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
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import {
buildJobManifest,
buildPvcManifest,
formatBandwidth,
formatIops,
formatLatency,
generateJobName,
generatePvcName,
KBENCH_FIO_LABEL,
KBENCH_FIO_VALUE,
KBENCH_MANAGED_BY_LABEL,
KBENCH_MANAGED_BY_VALUE,
parseKbenchLog,
} from './kbench';
const SAMPLE_LOG = `
=====================
FIO Benchmark Summary
For: test_device
SIZE: 30G
QUICK MODE: DISABLED
=====================
IOPS (Read/Write)
Random: 98368 / 89200
Sequential: 108513 / 107636
CPU Idleness: 68%
Bandwidth in KiB/sec (Read/Write)
Random: 542447 / 514487
Sequential: 552052 / 521330
CPU Idleness: 99%
Latency in ns (Read/Write)
Random: 97222 / 44548
Sequential: 40483 / 44690
CPU Idleness: 72%
`.trim();
const INCOMPLETE_LOG = 'Some other log output without FIO summary';
// ---------------------------------------------------------------------------
// Name generation
// ---------------------------------------------------------------------------
describe('generateJobName', () => {
it('generates names starting with kbench-', () => {
expect(generateJobName()).toMatch(/^kbench-[a-z0-9]+$/);
});
it('generates unique names', () => {
const names = new Set(Array.from({ length: 100 }, () => generateJobName()));
// Highly unlikely to collide in 100 attempts
expect(names.size).toBeGreaterThan(90);
});
});
describe('generatePvcName', () => {
it('derives PVC name from job name', () => {
expect(generatePvcName('kbench-abc123')).toBe('kbench-abc123-pvc');
});
});
// ---------------------------------------------------------------------------
// Manifest builders
// ---------------------------------------------------------------------------
describe('buildPvcManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
it('produces a valid PVC manifest with correct storage class', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>;
expect(manifest['kind']).toBe('PersistentVolumeClaim');
const spec = manifest['spec'] as Record<string, unknown>;
expect(spec['storageClassName']).toBe('tns-nfs');
const resources = spec['resources'] as Record<string, unknown>;
const requests = resources['requests'] as Record<string, unknown>;
expect(requests['storage']).toBe('33Gi');
});
it('applies managed-by label', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>;
const meta = manifest['metadata'] as Record<string, unknown>;
const labels = meta['labels'] as Record<string, string>;
expect(labels[KBENCH_MANAGED_BY_LABEL]).toBe(KBENCH_MANAGED_BY_VALUE);
expect(labels[KBENCH_FIO_LABEL]).toBe(KBENCH_FIO_VALUE);
});
});
describe('buildJobManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
it('produces a valid Job manifest', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>;
expect(manifest['kind']).toBe('Job');
const spec = manifest['spec'] as Record<string, unknown>;
expect(spec['backoffLimit']).toBe(0);
});
it('uses default size and mode when not specified', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>;
const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>;
const containers = podSpec['containers'] as Array<Record<string, unknown>>;
const env = containers[0]?.['env'] as Array<{ name: string; value: string }>;
expect(env?.find(e => e.name === 'SIZE')?.value).toBe('30G');
expect(env?.find(e => e.name === 'MODE')?.value).toBe('full');
});
it('uses custom size and mode when specified', () => {
const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<string, unknown>;
const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>;
const containers = podSpec['containers'] as Array<Record<string, unknown>>;
const env = containers[0]?.['env'] as Array<{ name: string; value: string }>;
expect(env?.find(e => e.name === 'SIZE')?.value).toBe('10G');
expect(env?.find(e => e.name === 'MODE')?.value).toBe('quick');
});
});
// ---------------------------------------------------------------------------
// Log parser
// ---------------------------------------------------------------------------
describe('parseKbenchLog', () => {
it('parses a complete FIO benchmark log', () => {
const result = parseKbenchLog(SAMPLE_LOG);
expect(result).not.toBeNull();
expect(result?.iops.randomRead).toBe(98368);
expect(result?.iops.randomWrite).toBe(89200);
expect(result?.iops.sequentialRead).toBe(108513);
expect(result?.iops.sequentialWrite).toBe(107636);
expect(result?.iops.cpuIdleness).toBe(68);
expect(result?.bandwidth.randomRead).toBe(542447);
expect(result?.bandwidth.randomWrite).toBe(514487);
expect(result?.bandwidth.cpuIdleness).toBe(99);
expect(result?.latency.randomRead).toBe(97222);
expect(result?.latency.randomWrite).toBe(44548);
expect(result?.latency.cpuIdleness).toBe(72);
});
it('returns null for unparseable log', () => {
expect(parseKbenchLog(INCOMPLETE_LOG)).toBeNull();
});
it('returns null for empty string', () => {
expect(parseKbenchLog('')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
describe('formatIops', () => {
it('formats with thousands separator', () => {
expect(formatIops(98368)).toBe('98,368');
});
});
describe('formatBandwidth', () => {
it('formats GiB/s for large values', () => {
const result = formatBandwidth(2 * 1024 * 1024); // 2 GiB/s in KiB
expect(result).toBe('2.0 GiB/s');
});
it('formats MiB/s for medium values', () => {
const result = formatBandwidth(542447);
expect(result).toMatch(/MiB\/s/);
});
it('formats KiB/s for small values', () => {
const result = formatBandwidth(500);
expect(result).toBe('500 KiB/s');
});
});
describe('formatLatency', () => {
it('formats milliseconds for large values', () => {
expect(formatLatency(5_000_000)).toBe('5.00 ms');
});
it('formats microseconds for medium values', () => {
expect(formatLatency(97_222)).toBe('97.2 µs');
});
it('formats nanoseconds for small values', () => {
expect(formatLatency(500)).toBe('500 ns');
});
});