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:
@@ -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'));
|
||||
});
|
||||
Reference in New Issue
Block a user