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
+288
View File
@@ -0,0 +1,288 @@
/**
* OverviewPage — main dashboard for tns-csi plugin.
*
* Shows: driver health, storage summary (SC/PV/PVC counts + protocol breakdown),
* and any PVCs in non-Bound state.
*/
import {
Loader,
NameValueTable,
PercentageBar,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
// Protocol breakdown chart
// ---------------------------------------------------------------------------
const PROTOCOL_COLORS: Record<string, string> = {
NFS: '#1976d2',
'NVMe-oF': '#9c27b0',
iSCSI: '#f57c00',
Other: '#9e9e9e',
};
function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) {
const counts = new Map<string, number>();
for (const sc of storageClasses) {
const proto = formatProtocol(sc.parameters?.protocol);
counts.set(proto, (counts.get(proto) ?? 0) + 1);
}
return [...counts.entries()].map(([name, value]) => ({
name,
value,
fill: PROTOCOL_COLORS[name] ?? PROTOCOL_COLORS['Other'],
}));
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function OverviewPage() {
const {
csiDriver,
driverInstalled,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
controllerPods,
nodePods,
loading,
error,
refresh,
} = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setMetricsError(null);
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
}
}, [controllerPods]);
useEffect(() => {
void fetchMetrics();
}, [fetchMetrics]);
if (loading) {
return <Loader title="Loading TNS-CSI data..." />;
}
// Compute storage summary
const totalCapacityBytes = persistentVolumes.reduce((sum, pv) => {
const cap = pv.spec.capacity?.storage ?? '0';
return sum + parseStorageToBytes(cap);
}, 0);
const pvcStatusCounts = { Bound: 0, Pending: 0, Lost: 0, Other: 0 };
for (const pvc of persistentVolumeClaims) {
const phase = pvc.status?.phase ?? 'Other';
if (phase === 'Bound') pvcStatusCounts.Bound++;
else if (phase === 'Pending') pvcStatusCounts.Pending++;
else if (phase === 'Lost') pvcStatusCounts.Lost++;
else pvcStatusCounts.Other++;
}
const nonBoundPvcs = persistentVolumeClaims.filter(
pvc => pvc.status?.phase !== 'Bound'
);
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
aria-label="Refresh tns-csi data"
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: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Refresh
</button>
</div>
{/* Early development banner */}
<SectionBox title="Notice">
<NameValueTable
rows={[
{
name: 'Development Status',
value: (
<StatusLabel status="warning">
tns-csi is in active early development not production-ready
</StatusLabel>
),
},
]}
/>
</SectionBox>
{/* Driver not detected */}
{!driverInstalled && !loading && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>,
},
{
name: 'Install',
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
},
]}
/>
</SectionBox>
)}
{/* Error state */}
{error && (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
)}
{/* Driver status */}
<DriverStatusCard
csiDriver={csiDriver}
controllerPods={controllerPods}
nodePods={nodePods}
metrics={metrics}
/>
{metricsError && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="warning">{metricsError}</StatusLabel>,
},
{
name: 'Note',
value: 'Ensure controller pod is running with metrics enabled (port 8080).',
},
]}
/>
</SectionBox>
)}
{/* Storage summary */}
<SectionBox title="Storage Summary">
{totalScs > 0 && chartData.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
Protocol Distribution
</div>
<PercentageBar data={chartData} total={totalScs} />
</div>
)}
<NameValueTable
rows={[
{ name: 'Storage Classes', value: String(totalScs) },
{ name: 'Persistent Volumes', value: String(persistentVolumes.length) },
{ name: 'Total Capacity', value: formatBytes(totalCapacityBytes) },
{
name: 'PVCs (Bound)',
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
},
...(pvcStatusCounts.Pending > 0
? [{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
}]
: []),
...(pvcStatusCounts.Lost > 0
? [{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
}]
: []),
]}
/>
</SectionBox>
{/* Non-bound PVCs warning */}
{nonBoundPvcs.length > 0 && (
<SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable
columns={[
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
{
label: 'Status',
getter: (pvc) => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
]}
data={nonBoundPvcs}
/>
</SectionBox>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseStorageToBytes(storage: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
if (!match) return 0;
const value = parseFloat(match[1]);
const suffix = match[2] ?? '';
const multipliers: Record<string, number> = {
'': 1,
K: 1e3, Ki: 1024,
M: 1e6, Mi: 1024 ** 2,
G: 1e9, Gi: 1024 ** 3,
T: 1e12, Ti: 1024 ** 4,
P: 1e15, Pi: 1024 ** 5,
};
return value * (multipliers[suffix] ?? 1);
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${bytes} B`;
}