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
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
filterTnsCsiPersistentVolumes,
filterTnsCsiPVCs,
filterTnsCsiStorageClasses,
filterTnsCsiVolumeSnapshots,
findBoundPv,
formatAccessModes,
formatAge,
formatProtocol,
isTnsCsiPersistentVolume,
isTnsCsiStorageClass,
phaseToStatus,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiStorageClass,
} from './k8s';
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
function makeSc(name: string, provisioner: string, protocol = 'nfs'): TnsCsiStorageClass {
return {
metadata: { name },
provisioner,
parameters: { protocol },
};
}
function makePv(name: string, driver: string, claimRef?: { name: string; namespace: string }): TnsCsiPersistentVolume {
return {
metadata: { name },
spec: {
csi: { driver, volumeAttributes: { protocol: 'nfs' } },
capacity: { storage: '10Gi' },
claimRef,
},
status: { phase: 'Bound' },
};
}
function makePvc(name: string, namespace: string): TnsCsiPersistentVolumeClaim {
return {
metadata: { name, namespace },
spec: {},
status: { phase: 'Bound' },
};
}
// ---------------------------------------------------------------------------
// StorageClass filtering
// ---------------------------------------------------------------------------
describe('isTnsCsiStorageClass', () => {
it('returns true for tns.csi.io provisioner', () => {
expect(isTnsCsiStorageClass(makeSc('sc1', 'tns.csi.io'))).toBe(true);
});
it('returns false for other provisioners', () => {
expect(isTnsCsiStorageClass(makeSc('sc1', 'kubernetes.io/nfs'))).toBe(false);
});
it('returns false for non-objects', () => {
expect(isTnsCsiStorageClass(null)).toBe(false);
expect(isTnsCsiStorageClass(undefined)).toBe(false);
expect(isTnsCsiStorageClass('string')).toBe(false);
});
});
describe('filterTnsCsiStorageClasses', () => {
it('filters to only tns-csi storage classes', () => {
const items = [
makeSc('tns-sc', 'tns.csi.io'),
makeSc('other-sc', 'kubernetes.io/aws-ebs'),
makeSc('another', 'rancher.io/local-path'),
];
const result = filterTnsCsiStorageClasses(items);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-sc');
});
it('returns empty array when no tns-csi classes', () => {
expect(filterTnsCsiStorageClasses([makeSc('x', 'other')])).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// PV filtering
// ---------------------------------------------------------------------------
describe('isTnsCsiPersistentVolume', () => {
it('returns true for tns.csi.io driver', () => {
expect(isTnsCsiPersistentVolume(makePv('pv1', 'tns.csi.io'))).toBe(true);
});
it('returns false for other drivers', () => {
expect(isTnsCsiPersistentVolume(makePv('pv1', 'ebs.csi.aws.com'))).toBe(false);
});
it('returns false for PVs without CSI spec', () => {
const pv = { metadata: { name: 'pv1' }, spec: {}, status: {} };
expect(isTnsCsiPersistentVolume(pv)).toBe(false);
});
});
describe('filterTnsCsiPersistentVolumes', () => {
it('filters to only tns-csi PVs', () => {
const items = [
makePv('tns-pv', 'tns.csi.io'),
makePv('other-pv', 'ebs.csi.aws.com'),
];
const result = filterTnsCsiPersistentVolumes(items);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-pv');
});
});
// ---------------------------------------------------------------------------
// PVC filtering
// ---------------------------------------------------------------------------
describe('filterTnsCsiPVCs', () => {
it('includes PVCs bound to tns-csi PVs via claimRef', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'my-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
const unrelatedPvc = makePvc('other-pvc', 'default');
const result = filterTnsCsiPVCs([pvc, unrelatedPvc], [pv]);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('my-pvc');
});
it('returns empty array when no PVs', () => {
const pvc = makePvc('my-pvc', 'default');
expect(filterTnsCsiPVCs([pvc], [])).toHaveLength(0);
});
});
describe('findBoundPv', () => {
it('finds the PV bound to a PVC', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'my-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
expect(findBoundPv(pvc, [pv])).toBe(pv);
});
it('returns undefined when no match', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'other-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
expect(findBoundPv(pvc, [pv])).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// VolumeSnapshot filtering
// ---------------------------------------------------------------------------
describe('filterTnsCsiVolumeSnapshots', () => {
it('filters snapshots to matching snapshot classes', () => {
const snapshots = [
{ metadata: { name: 's1' }, spec: { volumeSnapshotClassName: 'tns-vsc' } },
{ metadata: { name: 's2' }, spec: { volumeSnapshotClassName: 'other-vsc' } },
];
const result = filterTnsCsiVolumeSnapshots(snapshots, new Set(['tns-vsc']));
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('s1');
});
});
// ---------------------------------------------------------------------------
// Formatting utilities
// ---------------------------------------------------------------------------
describe('formatProtocol', () => {
it('maps nfs → NFS', () => expect(formatProtocol('nfs')).toBe('NFS'));
it('maps nvmeof → NVMe-oF', () => expect(formatProtocol('nvmeof')).toBe('NVMe-oF'));
it('maps iscsi → iSCSI', () => expect(formatProtocol('iscsi')).toBe('iSCSI'));
it('passes through unknown values', () => expect(formatProtocol('custom')).toBe('custom'));
it('returns — for undefined', () => expect(formatProtocol(undefined)).toBe('—'));
});
describe('formatAccessModes', () => {
it('abbreviates access modes', () => {
expect(formatAccessModes(['ReadWriteOnce', 'ReadWriteMany'])).toBe('RWO, RWX');
});
it('returns — for empty', () => expect(formatAccessModes([])).toBe('—'));
it('returns — for undefined', () => expect(formatAccessModes(undefined)).toBe('—'));
});
describe('formatAge', () => {
it('returns "unknown" for undefined', () => {
expect(formatAge(undefined)).toBe('unknown');
});
it('returns seconds for very recent timestamps', () => {
const ts = new Date(Date.now() - 30_000).toISOString();
expect(formatAge(ts)).toBe('30s');
});
it('returns minutes for ~5min ago', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(formatAge(ts)).toBe('5m');
});
});
describe('phaseToStatus', () => {
it('maps Bound → success', () => expect(phaseToStatus('Bound')).toBe('success'));
it('maps Pending → warning', () => expect(phaseToStatus('Pending')).toBe('warning'));
it('maps Failed → error', () => expect(phaseToStatus('Failed')).toBe('error'));
it('maps undefined → error', () => expect(phaseToStatus(undefined)).toBe('error'));
});