Implement headlamp-tns-csi-plugin

Full plugin implementation with 6 pages, K8s resource filtering,
Prometheus metrics parsing, kbench benchmark runner, and 67 unit tests.

## Pages
- Overview: driver health, storage summary, protocol distribution chart, non-Bound PVC alerts
- Storage Classes: tns-csi SC table with slide-in detail panel + protocol notes
- Volumes: PV table with full CSI attribute detail panel
- Snapshots: VolumeSnapshot CRDs with graceful degradation if not installed
- Metrics: Prometheus text format parser + WebSocket/Volume/CSI operation cards
- Benchmark: kbench Job+PVC lifecycle, FIO log parser, past benchmarks list

## API modules
- k8s.ts: typed resource shapes, filtering helpers, formatting utilities
- metrics.ts: Prometheus text format parser, tns-csi metric extraction
- kbench.ts: Job/PVC manifests, lifecycle management, FIO summary parser
- TnsCsiDataContext.tsx: shared React context with memoized filtered resources

## Quality
- TypeScript strict mode, zero any, discriminated union for benchmark state
- 67 tests passing (vitest + @testing-library/react)
- registerDetailsViewSection injects TNS-CSI details on PVC pages
- Graceful degradation for missing CSIDriver and VolumeSnapshot CRDs

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-18 07:45:19 -05:00
parent fd9db4f4a7
commit e5d1fcb11c
21 changed files with 4110 additions and 0 deletions
+214
View File
@@ -0,0 +1,214 @@
/**
* MetricsPage — Prometheus metrics from the tns-csi controller pod.
* Fetches metrics via API proxy and displays in structured cards.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiMetrics } from '../api/metrics';
import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics';
function formatAuditTime(iso: string): string {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} minute${mins > 1 ? 's' : ''} ago`;
const hours = Math.floor(mins / 60);
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
}
// ---------------------------------------------------------------------------
// Metrics cards
// ---------------------------------------------------------------------------
function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
const connected = metrics.websocketConnected;
const reconnects = metrics.websocketReconnectsTotal;
return (
<SectionBox title="WebSocket Health">
<NameValueTable
rows={[
{
name: 'Connection Status',
value: (
<StatusLabel status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}>
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
</StatusLabel>
),
},
{
name: 'Total Reconnects',
value: reconnects !== null ? String(reconnects) : '—',
},
{
name: 'Messages Total',
value: String(Math.round(sumSamples(metrics.websocketMessagesTotal))),
},
]}
/>
</SectionBox>
);
}
function VolumeOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byProtocol = groupByLabel(metrics.volumeOperationsTotal, 'protocol');
const totalOps = sumSamples(metrics.volumeOperationsTotal);
const totalCapacityBytes = sumSamples(metrics.volumeCapacityBytes);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total Operations', value: String(Math.round(totalOps)) },
{ name: 'Total Provisioned Capacity', value: formatBytes(totalCapacityBytes) },
];
for (const [protocol, count] of byProtocol.entries()) {
rows.push({ name: `Operations (${protocol})`, value: String(Math.round(count)) });
}
return (
<SectionBox title="Volume Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
function CsiOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byMethod = groupByLabel(metrics.csiOperationsTotal, 'method');
const totalOps = sumSamples(metrics.csiOperationsTotal);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total CSI Calls', value: String(Math.round(totalOps)) },
];
for (const [method, count] of byMethod.entries()) {
rows.push({ name: method, value: String(Math.round(count)) });
}
return (
<SectionBox title="CSI Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function MetricsPage() {
const { controllerPods, driverInstalled, loading } = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsLoading, setMetricsLoading] = useState(false);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
setMetricsLoading(true);
setMetricsError(null);
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setLastUpdated(new Date().toISOString());
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
} finally {
setMetricsLoading(false);
}
}, [controllerPods]);
useEffect(() => {
void fetchMetrics();
}, [fetchMetrics]);
if (loading) return <Loader title="Loading tns-csi data..." />;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<SectionHeader title="TNS-CSI — Metrics" />
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{lastUpdated && (
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
Updated: {formatAuditTime(lastUpdated)}
</span>
)}
<button
onClick={() => void fetchMetrics()}
disabled={metricsLoading}
aria-label="Refresh metrics"
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)',
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px',
cursor: metricsLoading ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
opacity: metricsLoading ? 0.6 : 1,
}}
>
{metricsLoading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
{!driverInstalled && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel> }]}
/>
</SectionBox>
)}
{controllerPods.length === 0 && driverInstalled && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{ name: 'Status', value: <StatusLabel status="warning">No controller pod found</StatusLabel> },
{ name: 'Note', value: 'Ensure controller pod is running with metrics enabled on port 8080.' },
{
name: 'Troubleshooting',
value: 'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
},
]}
/>
</SectionBox>
)}
{metricsError && (
<SectionBox title="Metrics Error">
<NameValueTable
rows={[
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
{ name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' },
]}
/>
</SectionBox>
)}
{metricsLoading && !metrics && <Loader title="Fetching metrics..." />}
{metrics && (
<>
<WebSocketCard metrics={metrics} />
<VolumeOperationsCard metrics={metrics} />
<CsiOperationsCard metrics={metrics} />
</>
)}
</>
);
}