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,30 @@
|
||||
{
|
||||
"name": "headlamp-tns-csi-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues"
|
||||
},
|
||||
"homepage": "https://github.com/privilegedescalation/headlamp-tns-csi-plugin#readme",
|
||||
"author": "privilegedescalation",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
"build": "headlamp-plugin build",
|
||||
"package": "headlamp-plugin package",
|
||||
"tsc": "tsc --noEmit",
|
||||
"lint": "eslint --ext .ts,.tsx src/",
|
||||
"lint:fix": "eslint --ext .ts,.tsx --fix src/",
|
||||
"format": "prettier --write src/",
|
||||
"format:check": "prettier --check src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock headlamp plugin APIs before importing the module under test
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: {
|
||||
request: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
StorageClass: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolume: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolumeClaim: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { TnsCsiDataProvider, useTnsCsiContext } from './TnsCsiDataContext';
|
||||
|
||||
describe('useTnsCsiContext', () => {
|
||||
it('throws when used outside TnsCsiDataProvider', () => {
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useTnsCsiContext());
|
||||
}).toThrow('useTnsCsiContext must be used within a TnsCsiDataProvider');
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns context value when inside TnsCsiDataProvider', async () => {
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<TnsCsiDataProvider>{children}</TnsCsiDataProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useTnsCsiContext(), { wrapper });
|
||||
|
||||
expect(result.current).toBeDefined();
|
||||
expect(result.current.storageClasses).toBeInstanceOf(Array);
|
||||
expect(result.current.persistentVolumes).toBeInstanceOf(Array);
|
||||
expect(result.current.persistentVolumeClaims).toBeInstanceOf(Array);
|
||||
expect(typeof result.current.refresh).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* TnsCsiDataContext — shared data provider for tns-csi Kubernetes resources.
|
||||
*
|
||||
* Wraps the K8s hook calls and provides filtered tns-csi resources to all
|
||||
* child pages through React context, avoiding prop drilling and duplicate
|
||||
* API calls.
|
||||
*/
|
||||
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
CSIDriver,
|
||||
filterTnsCsiPersistentVolumes,
|
||||
filterTnsCsiPVCs,
|
||||
filterTnsCsiStorageClasses,
|
||||
isKubeList,
|
||||
TnsCsiPersistentVolume,
|
||||
TnsCsiPersistentVolumeClaim,
|
||||
TnsCsiPod,
|
||||
TnsCsiStorageClass,
|
||||
TNS_CSI_PROVISIONER,
|
||||
VolumeSnapshot,
|
||||
VolumeSnapshotClass,
|
||||
} from './k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TnsCsiContextValue {
|
||||
// Driver presence
|
||||
csiDriver: CSIDriver | null;
|
||||
driverInstalled: boolean;
|
||||
|
||||
// Core resources (filtered to tns-csi only)
|
||||
storageClasses: TnsCsiStorageClass[];
|
||||
persistentVolumes: TnsCsiPersistentVolume[];
|
||||
persistentVolumeClaims: TnsCsiPersistentVolumeClaim[];
|
||||
|
||||
// Driver pods
|
||||
controllerPods: TnsCsiPod[];
|
||||
nodePods: TnsCsiPod[];
|
||||
|
||||
// Snapshots (CRD — may be unavailable)
|
||||
volumeSnapshots: VolumeSnapshot[];
|
||||
volumeSnapshotClasses: VolumeSnapshotClass[];
|
||||
snapshotCrdAvailable: boolean;
|
||||
|
||||
// Loading / error state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Manual refresh trigger
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TnsCsiContext = createContext<TnsCsiContextValue | null>(null);
|
||||
|
||||
export function useTnsCsiContext(): TnsCsiContextValue {
|
||||
const ctx = useContext(TnsCsiContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTnsCsiContext must be used within a TnsCsiDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) {
|
||||
// K8s resource hooks — headlamp re-fetches on cluster changes automatically
|
||||
const [allStorageClasses, scError] = K8s.ResourceClasses.StorageClass.useList();
|
||||
const [allPvs, pvError] = K8s.ResourceClasses.PersistentVolume.useList();
|
||||
const [allPvcs, pvcError] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
|
||||
|
||||
// Pods fetched via label selector through ApiProxy (useList doesn't support selectors easily)
|
||||
const [controllerPods, setControllerPods] = useState<TnsCsiPod[]>([]);
|
||||
const [nodePods, setNodePods] = useState<TnsCsiPod[]>([]);
|
||||
const [csiDriver, setCsiDriver] = useState<CSIDriver | null>(null);
|
||||
const [volumeSnapshots, setVolumeSnapshots] = useState<VolumeSnapshot[]>([]);
|
||||
const [volumeSnapshotClasses, setVolumeSnapshotClasses] = useState<VolumeSnapshotClass[]>([]);
|
||||
const [snapshotCrdAvailable, setSnapshotCrdAvailable] = useState(false);
|
||||
const [asyncLoading, setAsyncLoading] = useState(true);
|
||||
const [asyncError, setAsyncError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchAsync() {
|
||||
setAsyncLoading(true);
|
||||
setAsyncError(null);
|
||||
try {
|
||||
// CSIDriver
|
||||
try {
|
||||
const driver = await ApiProxy.request(
|
||||
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
|
||||
) as CSIDriver;
|
||||
if (!cancelled) setCsiDriver(driver);
|
||||
} catch {
|
||||
if (!cancelled) setCsiDriver(null);
|
||||
}
|
||||
|
||||
// Controller pods
|
||||
try {
|
||||
const ctrlList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
|
||||
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller'
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(ctrlList)) {
|
||||
setControllerPods(ctrlList.items as TnsCsiPod[]);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setControllerPods([]);
|
||||
}
|
||||
|
||||
// Node pods
|
||||
try {
|
||||
const nodeList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
|
||||
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node'
|
||||
)}`
|
||||
);
|
||||
if (!cancelled && isKubeList(nodeList)) {
|
||||
setNodePods(nodeList.items as TnsCsiPod[]);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setNodePods([]);
|
||||
}
|
||||
|
||||
// VolumeSnapshots (CRD — graceful degradation)
|
||||
try {
|
||||
const vscList = await ApiProxy.request(
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
|
||||
);
|
||||
if (!cancelled && isKubeList(vscList)) {
|
||||
setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]);
|
||||
setSnapshotCrdAvailable(true);
|
||||
|
||||
const vsList = await ApiProxy.request(
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
|
||||
);
|
||||
if (!cancelled && isKubeList(vsList)) {
|
||||
setVolumeSnapshots(vsList.items as VolumeSnapshot[]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSnapshotCrdAvailable(false);
|
||||
setVolumeSnapshotClasses([]);
|
||||
setVolumeSnapshots([]);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setAsyncError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setAsyncLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void fetchAsync();
|
||||
return () => { cancelled = true; };
|
||||
}, [refreshKey]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / filtered values — memoized to avoid recomputation on every render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const storageClasses = useMemo(() => {
|
||||
if (!allStorageClasses) return [];
|
||||
return filterTnsCsiStorageClasses(allStorageClasses as unknown[]);
|
||||
}, [allStorageClasses]);
|
||||
|
||||
const persistentVolumes = useMemo(() => {
|
||||
if (!allPvs) return [];
|
||||
return filterTnsCsiPersistentVolumes(allPvs as unknown[]);
|
||||
}, [allPvs]);
|
||||
|
||||
const persistentVolumeClaims = useMemo(() => {
|
||||
if (!allPvcs || persistentVolumes.length === 0) return [];
|
||||
return filterTnsCsiPVCs(allPvcs as TnsCsiPersistentVolumeClaim[], persistentVolumes);
|
||||
}, [allPvcs, persistentVolumes]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combined loading / error state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loading = asyncLoading || !allStorageClasses || !allPvs || !allPvcs;
|
||||
|
||||
const errors: string[] = [];
|
||||
if (scError) errors.push(String(scError));
|
||||
if (pvError) errors.push(String(pvError));
|
||||
if (pvcError) errors.push(String(pvcError));
|
||||
if (asyncError) errors.push(asyncError);
|
||||
const error = errors.length > 0 ? errors.join('; ') : null;
|
||||
|
||||
const driverInstalled = csiDriver !== null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memoized context value to prevent unnecessary re-renders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const value = useMemo<TnsCsiContextValue>(
|
||||
() => ({
|
||||
csiDriver,
|
||||
driverInstalled,
|
||||
storageClasses,
|
||||
persistentVolumes,
|
||||
persistentVolumeClaims,
|
||||
controllerPods,
|
||||
nodePods,
|
||||
volumeSnapshots,
|
||||
volumeSnapshotClasses,
|
||||
snapshotCrdAvailable,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
}),
|
||||
[
|
||||
csiDriver,
|
||||
driverInstalled,
|
||||
storageClasses,
|
||||
persistentVolumes,
|
||||
persistentVolumeClaims,
|
||||
controllerPods,
|
||||
nodePods,
|
||||
volumeSnapshots,
|
||||
volumeSnapshotClasses,
|
||||
snapshotCrdAvailable,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
]
|
||||
);
|
||||
|
||||
return <TnsCsiContext.Provider value={value}>{children}</TnsCsiContext.Provider>;
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Kubernetes type definitions and helper functions for tns-csi resources.
|
||||
*
|
||||
* All K8s resource types are typed at the fields we actually use.
|
||||
* External data from the API is validated at the boundary before use.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provisioner constant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TNS_CSI_PROVISIONER = 'tns.csi.io' as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TNS_CSI_CONTROLLER_SELECTOR =
|
||||
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller';
|
||||
export const TNS_CSI_NODE_SELECTOR =
|
||||
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic Kubernetes object base shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeObjectMeta {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
creationTimestamp?: string;
|
||||
labels?: Record<string, string>;
|
||||
annotations?: Record<string, string>;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface KubeObject {
|
||||
apiVersion?: string;
|
||||
kind?: string;
|
||||
metadata: KubeObjectMeta;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StorageClass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TnsCsiStorageClassParameters {
|
||||
protocol?: 'nfs' | 'nvmeof' | 'iscsi' | string;
|
||||
pool?: string;
|
||||
server?: string;
|
||||
deleteStrategy?: 'delete' | 'retain' | string;
|
||||
encryption?: string; // "true" / "false" string from K8s params
|
||||
}
|
||||
|
||||
export interface TnsCsiStorageClass extends KubeObject {
|
||||
provisioner: string;
|
||||
reclaimPolicy?: string;
|
||||
volumeBindingMode?: string;
|
||||
allowVolumeExpansion?: boolean;
|
||||
parameters?: TnsCsiStorageClassParameters;
|
||||
}
|
||||
|
||||
export function isTnsCsiStorageClass(sc: unknown): sc is TnsCsiStorageClass {
|
||||
if (!sc || typeof sc !== 'object') return false;
|
||||
const obj = sc as Record<string, unknown>;
|
||||
return obj['provisioner'] === TNS_CSI_PROVISIONER;
|
||||
}
|
||||
|
||||
export function filterTnsCsiStorageClasses(items: unknown[]): TnsCsiStorageClass[] {
|
||||
return items.filter(isTnsCsiStorageClass);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PersistentVolume
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TnsCsiVolumeAttributes {
|
||||
protocol?: string;
|
||||
server?: string;
|
||||
pool?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
|
||||
export interface CsiSpec {
|
||||
driver: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: TnsCsiVolumeAttributes;
|
||||
}
|
||||
|
||||
export interface ClaimRef {
|
||||
name: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export interface PersistentVolumeSpec {
|
||||
csi?: CsiSpec;
|
||||
capacity?: { storage?: string };
|
||||
accessModes?: string[];
|
||||
persistentVolumeReclaimPolicy?: string;
|
||||
storageClassName?: string;
|
||||
claimRef?: ClaimRef;
|
||||
}
|
||||
|
||||
export interface TnsCsiPersistentVolume extends KubeObject {
|
||||
spec: PersistentVolumeSpec;
|
||||
status?: { phase?: string };
|
||||
}
|
||||
|
||||
export function isTnsCsiPersistentVolume(pv: unknown): pv is TnsCsiPersistentVolume {
|
||||
if (!pv || typeof pv !== 'object') return false;
|
||||
const obj = pv as Record<string, unknown>;
|
||||
const spec = obj['spec'] as Record<string, unknown> | undefined;
|
||||
if (!spec) return false;
|
||||
const csi = spec['csi'] as Record<string, unknown> | undefined;
|
||||
return csi?.['driver'] === TNS_CSI_PROVISIONER;
|
||||
}
|
||||
|
||||
export function filterTnsCsiPersistentVolumes(items: unknown[]): TnsCsiPersistentVolume[] {
|
||||
return items.filter(isTnsCsiPersistentVolume);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PersistentVolumeClaim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PVCSpec {
|
||||
storageClassName?: string;
|
||||
accessModes?: string[];
|
||||
resources?: { requests?: { storage?: string } };
|
||||
volumeName?: string;
|
||||
}
|
||||
|
||||
export interface TnsCsiPersistentVolumeClaim extends KubeObject {
|
||||
spec: PVCSpec;
|
||||
status?: {
|
||||
phase?: string;
|
||||
capacity?: { storage?: string };
|
||||
accessModes?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns PVCs that are bound to a tns-csi PV (cross-reference by claimRef).
|
||||
*/
|
||||
export function filterTnsCsiPVCs(
|
||||
pvcs: TnsCsiPersistentVolumeClaim[],
|
||||
tnsPvs: TnsCsiPersistentVolume[]
|
||||
): TnsCsiPersistentVolumeClaim[] {
|
||||
const boundSet = new Set<string>();
|
||||
for (const pv of tnsPvs) {
|
||||
const ref = pv.spec.claimRef;
|
||||
if (ref) {
|
||||
boundSet.add(`${ref.namespace}/${ref.name}`);
|
||||
}
|
||||
}
|
||||
return pvcs.filter(pvc => {
|
||||
const ns = pvc.metadata.namespace ?? '';
|
||||
return boundSet.has(`${ns}/${pvc.metadata.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Find the tns-csi PV bound to a given PVC. */
|
||||
export function findBoundPv(
|
||||
pvc: TnsCsiPersistentVolumeClaim,
|
||||
tnsPvs: TnsCsiPersistentVolume[]
|
||||
): TnsCsiPersistentVolume | undefined {
|
||||
const ns = pvc.metadata.namespace ?? '';
|
||||
const name = pvc.metadata.name;
|
||||
return tnsPvs.find(
|
||||
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSIDriver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CSIDriverSpec {
|
||||
attachRequired?: boolean;
|
||||
podInfoOnMount?: boolean;
|
||||
volumeLifecycleModes?: string[];
|
||||
}
|
||||
|
||||
export interface CSIDriver extends KubeObject {
|
||||
spec?: CSIDriverSpec;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pod
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ContainerStatus {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string; message?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface PodStatus {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: ContainerStatus[];
|
||||
}
|
||||
|
||||
export interface PodSpec {
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface TnsCsiPod extends KubeObject {
|
||||
spec?: PodSpec;
|
||||
status?: PodStatus;
|
||||
}
|
||||
|
||||
export function isPodReady(pod: TnsCsiPod): boolean {
|
||||
return (
|
||||
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export function getPodRestarts(pod: TnsCsiPod): number {
|
||||
return (
|
||||
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
export function getPodImage(pod: TnsCsiPod): string {
|
||||
return pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VolumeSnapshot (CRD: snapshot.storage.k8s.io/v1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface VolumeSnapshotSpec {
|
||||
source?: { persistentVolumeClaimName?: string; volumeSnapshotContentName?: string };
|
||||
volumeSnapshotClassName?: string;
|
||||
}
|
||||
|
||||
export interface VolumeSnapshotStatus {
|
||||
readyToUse?: boolean;
|
||||
restoreSize?: string;
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
export interface VolumeSnapshot extends KubeObject {
|
||||
spec?: VolumeSnapshotSpec;
|
||||
status?: VolumeSnapshotStatus;
|
||||
}
|
||||
|
||||
export interface VolumeSnapshotClass extends KubeObject {
|
||||
driver?: string;
|
||||
deletionPolicy?: string;
|
||||
}
|
||||
|
||||
export function isTnsCsiVolumeSnapshotClass(vsc: unknown): vsc is VolumeSnapshotClass {
|
||||
if (!vsc || typeof vsc !== 'object') return false;
|
||||
const obj = vsc as Record<string, unknown>;
|
||||
return obj['driver'] === TNS_CSI_PROVISIONER;
|
||||
}
|
||||
|
||||
export function filterTnsCsiVolumeSnapshots(
|
||||
snapshots: VolumeSnapshot[],
|
||||
tnsCsiSnapshotClassNames: Set<string>
|
||||
): VolumeSnapshot[] {
|
||||
return snapshots.filter(
|
||||
s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// K8s API list response envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KubeList<T> {
|
||||
items: T[];
|
||||
metadata?: { resourceVersion?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for a KubeList response from ApiProxy.request.
|
||||
* Validates the minimal structure (items array) before consuming.
|
||||
*/
|
||||
export function isKubeList(value: unknown): value is KubeList<unknown> {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
return Array.isArray((value as Record<string, unknown>)['items']);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: human-readable age
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatAge(timestamp: string | undefined): string {
|
||||
if (!timestamp) return 'unknown';
|
||||
const diffMs = Date.now() - new Date(timestamp).getTime();
|
||||
const secs = Math.floor(diffMs / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: access modes display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACCESS_MODE_ABBREV: Record<string, string> = {
|
||||
ReadWriteOnce: 'RWO',
|
||||
ReadWriteMany: 'RWX',
|
||||
ReadOnlyMany: 'ROX',
|
||||
ReadWriteOncePod: 'RWOP',
|
||||
};
|
||||
|
||||
export function formatAccessModes(modes: string[] | undefined): string {
|
||||
if (!modes || modes.length === 0) return '—';
|
||||
return modes.map(m => ACCESS_MODE_ABBREV[m] ?? m).join(', ');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: protocol display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatProtocol(protocol: string | undefined): string {
|
||||
if (!protocol) return '—';
|
||||
const map: Record<string, string> = {
|
||||
nfs: 'NFS',
|
||||
nvmeof: 'NVMe-oF',
|
||||
iscsi: 'iSCSI',
|
||||
};
|
||||
return map[protocol.toLowerCase()] ?? protocol;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase → StatusLabel status mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
|
||||
switch (phase) {
|
||||
case 'Bound':
|
||||
case 'Available':
|
||||
case 'Running':
|
||||
case 'Succeeded':
|
||||
return 'success';
|
||||
case 'Pending':
|
||||
case 'Released':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* kbench integration: Job/PVC lifecycle management and FIO log parsing.
|
||||
*
|
||||
* kbench (https://github.com/longhorn/kbench) runs as a Kubernetes Job backed
|
||||
* by a PVC. Results are parsed from pod logs after job completion.
|
||||
*/
|
||||
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KbenchMetricGroup {
|
||||
randomRead: number;
|
||||
randomWrite: number;
|
||||
sequentialRead: number;
|
||||
sequentialWrite: number;
|
||||
cpuIdleness: number;
|
||||
}
|
||||
|
||||
export interface KbenchResult {
|
||||
iops: KbenchMetricGroup;
|
||||
bandwidth: KbenchMetricGroup; // KiB/s
|
||||
latency: KbenchMetricGroup; // nanoseconds
|
||||
metadata: KbenchResultMetadata;
|
||||
}
|
||||
|
||||
export interface KbenchResultMetadata {
|
||||
storageClass: string;
|
||||
size: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
jobName: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export type BenchmarkStatus = 'idle' | 'creating-pvc' | 'waiting-pvc' | 'running' | 'parsing' | 'complete' | 'failed';
|
||||
|
||||
export type BenchmarkState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'creating-pvc' }
|
||||
| { status: 'waiting-pvc'; pvcName: string }
|
||||
| { status: 'running'; jobName: string; pvcName: string; startedAt: string }
|
||||
| { status: 'parsing'; jobName: string; pvcName: string }
|
||||
| { status: 'complete'; result: KbenchResult; jobName: string; pvcName: string }
|
||||
| { status: 'failed'; error: string; jobName: string; pvcName: string };
|
||||
|
||||
export interface KbenchJobSummary {
|
||||
jobName: string;
|
||||
namespace: string;
|
||||
storageClass: string;
|
||||
phase: 'Active' | 'Complete' | 'Failed' | 'Unknown';
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Labels / annotations used for tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const KBENCH_MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||
export const KBENCH_MANAGED_BY_VALUE = 'headlamp-tns-csi-plugin';
|
||||
export const KBENCH_FIO_LABEL = 'kbench';
|
||||
export const KBENCH_FIO_VALUE = 'fio';
|
||||
export const KBENCH_STORAGE_CLASS_ANNOTATION = 'tns-csi.headlamp/storage-class';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unique name generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shortId(): string {
|
||||
return Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
export function generateJobName(): string {
|
||||
return `kbench-${shortId()}`;
|
||||
}
|
||||
|
||||
export function generatePvcName(jobName: string): string {
|
||||
return `${jobName}-pvc`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kubernetes manifest builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KbenchJobOptions {
|
||||
jobName: string;
|
||||
pvcName: string;
|
||||
namespace: string;
|
||||
storageClass: string;
|
||||
size?: string; // default "30G"
|
||||
mode?: string; // default "full"
|
||||
}
|
||||
|
||||
export function buildPvcManifest(opts: KbenchJobOptions): object {
|
||||
return {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: {
|
||||
name: opts.pvcName,
|
||||
namespace: opts.namespace,
|
||||
labels: {
|
||||
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
|
||||
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
||||
},
|
||||
annotations: {
|
||||
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
storageClassName: opts.storageClass,
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: {
|
||||
requests: {
|
||||
// kbench needs ~33Gi for a 30G test (10% buffer rule)
|
||||
storage: '33Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildJobManifest(opts: KbenchJobOptions): object {
|
||||
return {
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
metadata: {
|
||||
name: opts.jobName,
|
||||
namespace: opts.namespace,
|
||||
labels: {
|
||||
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
|
||||
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
||||
},
|
||||
annotations: {
|
||||
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'kbench',
|
||||
image: 'yasker/kbench:latest',
|
||||
env: [
|
||||
{ name: 'MODE', value: opts.mode ?? 'full' },
|
||||
{ name: 'FILE_NAME', value: '/volume/test' },
|
||||
{ name: 'SIZE', value: opts.size ?? '30G' },
|
||||
{ name: 'CPU_IDLE_PROF', value: 'disabled' },
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'vol', mountPath: '/volume/' },
|
||||
],
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
volumes: [
|
||||
{
|
||||
name: 'vol',
|
||||
persistentVolumeClaim: { claimName: opts.pvcName },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
backoffLimit: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createPvc(opts: KbenchJobOptions): Promise<void> {
|
||||
await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildPvcManifest(opts)),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function createJob(opts: KbenchJobOptions): Promise<void> {
|
||||
await ApiProxy.request(`/apis/batch/v1/namespaces/${opts.namespace}/jobs`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildJobManifest(opts)),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
interface K8sJobStatus {
|
||||
active?: number;
|
||||
succeeded?: number;
|
||||
failed?: number;
|
||||
completionTime?: string;
|
||||
}
|
||||
|
||||
interface K8sJob {
|
||||
status?: K8sJobStatus;
|
||||
metadata?: { creationTimestamp?: string };
|
||||
}
|
||||
|
||||
export type JobPhase = 'Active' | 'Complete' | 'Failed' | 'Unknown';
|
||||
|
||||
export async function getJobPhase(
|
||||
jobName: string,
|
||||
namespace: string
|
||||
): Promise<{ phase: JobPhase; job: K8sJob }> {
|
||||
const job = await ApiProxy.request(
|
||||
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
|
||||
) as K8sJob;
|
||||
|
||||
const status = job.status;
|
||||
let phase: JobPhase = 'Unknown';
|
||||
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
|
||||
else if (status?.failed && status.failed > 0) phase = 'Failed';
|
||||
else if (status?.active && status.active > 0) phase = 'Active';
|
||||
|
||||
return { phase, job };
|
||||
}
|
||||
|
||||
export async function getPvcPhase(
|
||||
pvcName: string,
|
||||
namespace: string
|
||||
): Promise<string> {
|
||||
const pvc = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
|
||||
) as { status?: { phase?: string } };
|
||||
return pvc.status?.phase ?? 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the logs from the kbench pod (via the Job's pod selector).
|
||||
* Uses the pod label selector to find the pod.
|
||||
*/
|
||||
export async function fetchKbenchLogs(
|
||||
jobName: string,
|
||||
namespace: string
|
||||
): Promise<string> {
|
||||
// Find pod with label kbench=fio and job-name=<jobName>
|
||||
const podList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(`job-name=${jobName}`)}`
|
||||
) as { items?: Array<{ metadata?: { name?: string } }> };
|
||||
|
||||
const podName = podList.items?.[0]?.metadata?.name;
|
||||
if (!podName) {
|
||||
throw new Error(`No pod found for kbench job "${jobName}"`);
|
||||
}
|
||||
|
||||
const logs = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
|
||||
{ isJSON: false }
|
||||
) as unknown;
|
||||
|
||||
if (typeof logs !== 'string') {
|
||||
throw new Error('Pod logs were not returned as text');
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
export async function deleteJob(jobName: string, namespace: string): Promise<void> {
|
||||
await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ propagationPolicy: 'Foreground' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
|
||||
await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FIO log parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses a kbench FIO benchmark summary from pod log text.
|
||||
*
|
||||
* Expected format:
|
||||
* =====================
|
||||
* FIO Benchmark Summary
|
||||
* ...
|
||||
* IOPS (Read/Write)
|
||||
* Random: 98368 / 89200
|
||||
* Sequential: 108513 / 107636
|
||||
* CPU Idleness: 68%
|
||||
*
|
||||
* Bandwidth in KiB/sec (Read/Write)
|
||||
* Random: 542447 / 514487
|
||||
* ...
|
||||
*
|
||||
* Latency in ns (Read/Write)
|
||||
* ...
|
||||
*/
|
||||
export function parseKbenchLog(logText: string): KbenchResult | null {
|
||||
const lines = logText.split('\n').map(l => l.trim());
|
||||
|
||||
function extractSection(header: string): string[] {
|
||||
const idx = lines.findIndex(l => l.startsWith(header));
|
||||
if (idx < 0) return [];
|
||||
const section: string[] = [];
|
||||
for (let i = idx + 1; i < lines.length && i < idx + 10; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) break;
|
||||
section.push(line);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
function parseReadWrite(line: string): [number, number] | null {
|
||||
const match = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(line);
|
||||
if (!match) return null;
|
||||
const read = parseInt(match[1].replace(/,/g, ''), 10);
|
||||
const write = parseInt(match[2].replace(/,/g, ''), 10);
|
||||
if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
|
||||
return [read, write];
|
||||
}
|
||||
|
||||
function parseCpu(line: string): number {
|
||||
const match = /(\d+)%/.exec(line);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
function parseSection(header: string): KbenchMetricGroup | null {
|
||||
const section = extractSection(header);
|
||||
if (section.length === 0) return null;
|
||||
|
||||
const randomLine = section.find(l => l.startsWith('Random:'));
|
||||
const seqLine = section.find(l => l.startsWith('Sequential:'));
|
||||
const cpuLine = section.find(l => l.startsWith('CPU Idleness:'));
|
||||
|
||||
const random = randomLine ? parseReadWrite(randomLine) : null;
|
||||
const sequential = seqLine ? parseReadWrite(seqLine) : null;
|
||||
const cpu = cpuLine ? parseCpu(cpuLine) : 0;
|
||||
|
||||
if (!random || !sequential) return null;
|
||||
|
||||
return {
|
||||
randomRead: random[0],
|
||||
randomWrite: random[1],
|
||||
sequentialRead: sequential[0],
|
||||
sequentialWrite: sequential[1],
|
||||
cpuIdleness: cpu,
|
||||
};
|
||||
}
|
||||
|
||||
const iops = parseSection('IOPS (Read/Write)');
|
||||
const bandwidth = parseSection('Bandwidth in KiB/sec (Read/Write)');
|
||||
const latency = parseSection('Latency in ns (Read/Write)');
|
||||
|
||||
if (!iops || !bandwidth || !latency) return null;
|
||||
|
||||
return {
|
||||
iops,
|
||||
bandwidth,
|
||||
latency,
|
||||
metadata: {
|
||||
storageClass: '', // filled in by the caller
|
||||
size: '30G',
|
||||
startedAt: '',
|
||||
completedAt: new Date().toISOString(),
|
||||
jobName: '',
|
||||
namespace: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List existing kbench Jobs (for Past Benchmarks view)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobSummary[]> {
|
||||
const selector = encodeURIComponent(
|
||||
`${KBENCH_MANAGED_BY_LABEL}=${KBENCH_MANAGED_BY_VALUE},${KBENCH_FIO_LABEL}=${KBENCH_FIO_VALUE}`
|
||||
);
|
||||
const path = namespace
|
||||
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
|
||||
: `/apis/batch/v1/jobs?labelSelector=${selector}`;
|
||||
|
||||
const list = await ApiProxy.request(path) as {
|
||||
items?: Array<{
|
||||
metadata?: { name?: string; namespace?: string; annotations?: Record<string, string>; creationTimestamp?: string };
|
||||
status?: K8sJobStatus;
|
||||
}>;
|
||||
};
|
||||
|
||||
return (list.items ?? []).map(job => {
|
||||
const status = job.status;
|
||||
let phase: JobPhase = 'Unknown';
|
||||
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
|
||||
else if (status?.failed && status.failed > 0) phase = 'Failed';
|
||||
else if (status?.active && status.active > 0) phase = 'Active';
|
||||
|
||||
return {
|
||||
jobName: job.metadata?.name ?? '',
|
||||
namespace: job.metadata?.namespace ?? namespace,
|
||||
storageClass: job.metadata?.annotations?.[KBENCH_STORAGE_CLASS_ANNOTATION] ?? '—',
|
||||
phase,
|
||||
startedAt: job.metadata?.creationTimestamp ?? '',
|
||||
completedAt: status?.completionTime,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers for result display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatIops(value: number): string {
|
||||
return value.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatBandwidth(kib: number): string {
|
||||
const mib = kib / 1024;
|
||||
if (mib >= 1024) return `${(mib / 1024).toFixed(1)} GiB/s`;
|
||||
if (mib >= 1) return `${mib.toFixed(0)} MiB/s`;
|
||||
return `${kib.toFixed(0)} KiB/s`;
|
||||
}
|
||||
|
||||
export function formatLatency(ns: number): string {
|
||||
if (ns >= 1_000_000) return `${(ns / 1_000_000).toFixed(2)} ms`;
|
||||
if (ns >= 1_000) return `${(ns / 1_000).toFixed(1)} µs`;
|
||||
return `${ns} ns`;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractTnsCsiMetrics,
|
||||
filterByLabel,
|
||||
formatBytes,
|
||||
groupByLabel,
|
||||
parsePrometheusText,
|
||||
sumSamples,
|
||||
} from './metrics';
|
||||
|
||||
const SAMPLE_METRICS = `
|
||||
# HELP tns_websocket_connected WebSocket connection status
|
||||
# TYPE tns_websocket_connected gauge
|
||||
tns_websocket_connected 1
|
||||
|
||||
# HELP tns_websocket_reconnects_total Total WebSocket reconnects
|
||||
# TYPE tns_websocket_reconnects_total counter
|
||||
tns_websocket_reconnects_total 3
|
||||
|
||||
# HELP tns_volume_operations_total Total volume operations
|
||||
# TYPE tns_volume_operations_total counter
|
||||
tns_volume_operations_total{protocol="nfs",operation="create",status="success"} 42
|
||||
tns_volume_operations_total{protocol="nfs",operation="delete",status="success"} 10
|
||||
tns_volume_operations_total{protocol="iscsi",operation="create",status="success"} 5
|
||||
|
||||
# HELP tns_volume_capacity_bytes Total provisioned capacity
|
||||
# TYPE tns_volume_capacity_bytes gauge
|
||||
tns_volume_capacity_bytes{volume_id="vol1",protocol="nfs"} 10737418240
|
||||
tns_volume_capacity_bytes{volume_id="vol2",protocol="nfs"} 21474836480
|
||||
|
||||
# HELP tns_csi_operations_total CSI gRPC operations
|
||||
# TYPE tns_csi_operations_total counter
|
||||
tns_csi_operations_total{method="CreateVolume",grpc_status_code="OK"} 42
|
||||
tns_csi_operations_total{method="DeleteVolume",grpc_status_code="OK"} 10
|
||||
`.trim();
|
||||
|
||||
describe('parsePrometheusText', () => {
|
||||
it('parses scalar gauges', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ws = families.get('tns_websocket_connected');
|
||||
expect(ws).toBeDefined();
|
||||
expect(ws?.samples).toHaveLength(1);
|
||||
expect(ws?.samples[0]?.value).toBe(1);
|
||||
});
|
||||
|
||||
it('parses counters with labels', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ops = families.get('tns_volume_operations_total');
|
||||
expect(ops?.samples).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('parses labels correctly', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ops = families.get('tns_volume_operations_total');
|
||||
const firstSample = ops?.samples[0];
|
||||
expect(firstSample?.labels['protocol']).toBe('nfs');
|
||||
expect(firstSample?.labels['operation']).toBe('create');
|
||||
expect(firstSample?.labels['status']).toBe('success');
|
||||
expect(firstSample?.value).toBe(42);
|
||||
});
|
||||
|
||||
it('handles empty input gracefully', () => {
|
||||
const families = parsePrometheusText('');
|
||||
expect(families.size).toBe(0);
|
||||
});
|
||||
|
||||
it('skips comment lines', () => {
|
||||
const families = parsePrometheusText('# HELP foo bar\n# TYPE foo gauge\n');
|
||||
expect(families.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTnsCsiMetrics', () => {
|
||||
it('extracts websocket connected status', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const metrics = extractTnsCsiMetrics(families);
|
||||
expect(metrics.websocketConnected).toBe(1);
|
||||
});
|
||||
|
||||
it('extracts reconnect total', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const metrics = extractTnsCsiMetrics(families);
|
||||
expect(metrics.websocketReconnectsTotal).toBe(3);
|
||||
});
|
||||
|
||||
it('extracts volume operations samples', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const metrics = extractTnsCsiMetrics(families);
|
||||
expect(metrics.volumeOperationsTotal).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('extracts CSI operations samples', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const metrics = extractTnsCsiMetrics(families);
|
||||
expect(metrics.csiOperationsTotal).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns null for missing metrics', () => {
|
||||
const families = parsePrometheusText('');
|
||||
const metrics = extractTnsCsiMetrics(families);
|
||||
expect(metrics.websocketConnected).toBeNull();
|
||||
expect(metrics.websocketReconnectsTotal).toBeNull();
|
||||
expect(metrics.volumeOperationsTotal).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sumSamples', () => {
|
||||
it('sums all sample values', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
|
||||
expect(sumSamples(ops)).toBe(57); // 42 + 10 + 5
|
||||
});
|
||||
|
||||
it('returns 0 for empty array', () => {
|
||||
expect(sumSamples([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByLabel', () => {
|
||||
it('groups samples by label key and sums values', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
|
||||
const byProtocol = groupByLabel(ops, 'protocol');
|
||||
expect(byProtocol.get('nfs')).toBe(52); // 42 + 10
|
||||
expect(byProtocol.get('iscsi')).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByLabel', () => {
|
||||
it('filters samples by label value', () => {
|
||||
const families = parsePrometheusText(SAMPLE_METRICS);
|
||||
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
|
||||
const iscsiOps = filterByLabel(ops, 'protocol', 'iscsi');
|
||||
expect(iscsiOps).toHaveLength(1);
|
||||
expect(iscsiOps[0]?.value).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('formats GB', () => expect(formatBytes(1e9)).toBe('1.0 GB'));
|
||||
it('formats MB', () => expect(formatBytes(1.5e6)).toBe('1.5 MB'));
|
||||
it('formats KB', () => expect(formatBytes(2e3)).toBe('2.0 KB'));
|
||||
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Prometheus text format parser for tns-csi controller metrics.
|
||||
*
|
||||
* Fetches the raw metrics text via ApiProxy and parses the key metric families
|
||||
* we expose in the Metrics page.
|
||||
*/
|
||||
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import type { TnsCsiPod } from './k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MetricSample {
|
||||
labels: Record<string, string>;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface MetricFamily {
|
||||
name: string;
|
||||
help: string;
|
||||
type: string;
|
||||
samples: MetricSample[];
|
||||
}
|
||||
|
||||
export type ParsedMetrics = Map<string, MetricFamily>;
|
||||
|
||||
export interface TnsCsiMetrics {
|
||||
/** 1 = connected, 0 = disconnected */
|
||||
websocketConnected: number | null;
|
||||
websocketReconnectsTotal: number | null;
|
||||
websocketMessagesTotal: MetricSample[];
|
||||
websocketMessageDurationSeconds: MetricSample[];
|
||||
|
||||
volumeOperationsTotal: MetricSample[];
|
||||
volumeOperationsDurationSeconds: MetricSample[];
|
||||
volumeCapacityBytes: MetricSample[];
|
||||
|
||||
csiOperationsTotal: MetricSample[];
|
||||
csiOperationsDurationSeconds: MetricSample[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prometheus text format parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LABEL_PAIR_RE = /(\w+)="([^"]*)"/g;
|
||||
|
||||
function parseLabels(labelStr: string): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
let match: RegExpExecArray | null;
|
||||
const re = new RegExp(LABEL_PAIR_RE.source, 'g');
|
||||
while ((match = re.exec(labelStr)) !== null) {
|
||||
const key = match[1];
|
||||
const val = match[2];
|
||||
if (key && val !== undefined) {
|
||||
labels[key] = val;
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Prometheus text exposition format into a Map of metric families.
|
||||
* Only handles the subset used by tns-csi (gauge, counter, histogram summaries).
|
||||
*/
|
||||
export function parsePrometheusText(text: string): ParsedMetrics {
|
||||
const families = new Map<string, MetricFamily>();
|
||||
let currentName = '';
|
||||
let currentHelp = '';
|
||||
let currentType = '';
|
||||
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
if (line.startsWith('# HELP ')) {
|
||||
const rest = line.slice(7);
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
currentName = spaceIdx >= 0 ? rest.slice(0, spaceIdx) : rest;
|
||||
currentHelp = spaceIdx >= 0 ? rest.slice(spaceIdx + 1) : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('# TYPE ')) {
|
||||
const rest = line.slice(7);
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
currentType = spaceIdx >= 0 ? rest.slice(spaceIdx + 1) : '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('#')) continue;
|
||||
|
||||
// Sample line: metric_name{label="val"} 1.0
|
||||
// or: metric_name 1.0
|
||||
const openBrace = line.indexOf('{');
|
||||
const closeBrace = line.lastIndexOf('}');
|
||||
|
||||
let metricName: string;
|
||||
let labels: Record<string, string>;
|
||||
let valuePart: string;
|
||||
|
||||
if (openBrace >= 0 && closeBrace > openBrace) {
|
||||
metricName = line.slice(0, openBrace);
|
||||
labels = parseLabels(line.slice(openBrace + 1, closeBrace));
|
||||
valuePart = line.slice(closeBrace + 1).trim();
|
||||
} else {
|
||||
const spaceIdx = line.lastIndexOf(' ');
|
||||
if (spaceIdx < 0) continue;
|
||||
metricName = line.slice(0, spaceIdx);
|
||||
labels = {};
|
||||
valuePart = line.slice(spaceIdx + 1).trim();
|
||||
}
|
||||
|
||||
// Strip timestamp if present (second space-separated token)
|
||||
const valueTokens = valuePart.split(' ');
|
||||
const valueStr = valueTokens[0] ?? '';
|
||||
const value = parseFloat(valueStr);
|
||||
if (!Number.isFinite(value)) continue;
|
||||
|
||||
// Determine the family name: for histogram/summary _bucket/_count/_sum
|
||||
// strip the suffix but keep it as the family name key
|
||||
const familyKey = metricName;
|
||||
|
||||
let family = families.get(familyKey);
|
||||
if (!family) {
|
||||
family = {
|
||||
name: familyKey,
|
||||
help: metricName === currentName ? currentHelp : '',
|
||||
type: metricName === currentName ? currentType : '',
|
||||
samples: [],
|
||||
};
|
||||
families.set(familyKey, family);
|
||||
}
|
||||
|
||||
family.samples.push({ labels, value });
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract tns-csi-specific metrics from the parsed map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function scalarMetric(families: ParsedMetrics, name: string): number | null {
|
||||
const family = families.get(name);
|
||||
if (!family || family.samples.length === 0) return null;
|
||||
return family.samples[0]?.value ?? null;
|
||||
}
|
||||
|
||||
function samplesFor(families: ParsedMetrics, name: string): MetricSample[] {
|
||||
return families.get(name)?.samples ?? [];
|
||||
}
|
||||
|
||||
export function extractTnsCsiMetrics(families: ParsedMetrics): TnsCsiMetrics {
|
||||
return {
|
||||
websocketConnected: scalarMetric(families, 'tns_websocket_connected'),
|
||||
websocketReconnectsTotal: scalarMetric(families, 'tns_websocket_reconnects_total'),
|
||||
websocketMessagesTotal: samplesFor(families, 'tns_websocket_messages_total'),
|
||||
websocketMessageDurationSeconds: samplesFor(families, 'tns_websocket_message_duration_seconds'),
|
||||
|
||||
volumeOperationsTotal: samplesFor(families, 'tns_volume_operations_total'),
|
||||
volumeOperationsDurationSeconds: samplesFor(families, 'tns_volume_operations_duration_seconds'),
|
||||
volumeCapacityBytes: samplesFor(families, 'tns_volume_capacity_bytes'),
|
||||
|
||||
csiOperationsTotal: samplesFor(families, 'tns_csi_operations_total'),
|
||||
csiOperationsDurationSeconds: samplesFor(families, 'tns_csi_operations_duration_seconds'),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fetch metrics via Kubernetes API proxy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches metrics from the tns-csi controller pod via the Kubernetes API proxy.
|
||||
*
|
||||
* The proxy path is:
|
||||
* /api/v1/namespaces/{namespace}/pods/{podName}:{port}/proxy/metrics
|
||||
*/
|
||||
export async function fetchControllerMetrics(
|
||||
controllerPod: TnsCsiPod,
|
||||
namespace: string = 'kube-system'
|
||||
): Promise<TnsCsiMetrics> {
|
||||
const podName = controllerPod.metadata.name;
|
||||
const path = `/api/v1/namespaces/${namespace}/pods/${podName}:8080/proxy/metrics`;
|
||||
|
||||
const raw: unknown = await ApiProxy.request(path, {
|
||||
method: 'GET',
|
||||
isJSON: false,
|
||||
});
|
||||
|
||||
if (typeof raw !== 'string') {
|
||||
throw new Error('Metrics endpoint did not return text');
|
||||
}
|
||||
|
||||
const families = parsePrometheusText(raw);
|
||||
return extractTnsCsiMetrics(families);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting helpers for display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Format a bytes value as a human-readable string (GB/MB/KB). */
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** Sum all sample values for a given metric name. */
|
||||
export function sumSamples(samples: MetricSample[]): number {
|
||||
return samples.reduce((acc, s) => acc + s.value, 0);
|
||||
}
|
||||
|
||||
/** Group samples by a label key, summing values per group. */
|
||||
export function groupByLabel(
|
||||
samples: MetricSample[],
|
||||
labelKey: string
|
||||
): Map<string, number> {
|
||||
const result = new Map<string, number>();
|
||||
for (const sample of samples) {
|
||||
const key = sample.labels[labelKey] ?? 'unknown';
|
||||
result.set(key, (result.get(key) ?? 0) + sample.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Filter samples where a specific label equals a value. */
|
||||
export function filterByLabel(
|
||||
samples: MetricSample[],
|
||||
labelKey: string,
|
||||
labelValue: string
|
||||
): MetricSample[] {
|
||||
return samples.filter(s => s.labels[labelKey] === labelValue);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* BenchmarkPage — kbench storage benchmark runner + results display.
|
||||
*
|
||||
* The only write operation in the plugin.
|
||||
* Creates PVC + Job, polls status, parses FIO log output.
|
||||
*/
|
||||
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { BenchmarkState, KbenchJobSummary, KbenchResult } from '../api/kbench';
|
||||
import {
|
||||
createJob,
|
||||
createPvc,
|
||||
deleteJob,
|
||||
deletePvc,
|
||||
fetchKbenchLogs,
|
||||
formatBandwidth,
|
||||
formatIops,
|
||||
formatLatency,
|
||||
generateJobName,
|
||||
generatePvcName,
|
||||
getJobPhase,
|
||||
listKbenchJobs,
|
||||
parseKbenchLog,
|
||||
} from '../api/kbench';
|
||||
import { formatAge } from '../api/k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result display components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MetricRowData {
|
||||
label: string;
|
||||
read: number;
|
||||
write: number | null;
|
||||
formatter: (v: number) => string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: MetricRowData[]; higherIsBetter: boolean }) {
|
||||
return (
|
||||
<SectionBox title={title}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--mui-palette-divider, #e0e0e0)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 600 }}>Metric</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Read</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Write</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 400, color: 'var(--mui-palette-text-secondary)' }}>
|
||||
{higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.label} style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}>
|
||||
<td style={{ padding: '8px 4px' }}>{row.label}</td>
|
||||
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||||
{row.formatter(row.read)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||||
{row.write !== null ? row.formatter(row.write) : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 4px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
{row.note ?? ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
function KbenchResultDisplay({ result }: { result: KbenchResult }) {
|
||||
const iopsRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.iops.randomRead, write: result.iops.randomWrite, formatter: formatIops },
|
||||
{ label: 'Sequential', read: result.iops.sequentialRead, write: result.iops.sequentialWrite, formatter: formatIops },
|
||||
{ label: 'CPU Idleness', read: result.iops.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '' },
|
||||
];
|
||||
|
||||
const bwRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.bandwidth.randomRead, write: result.bandwidth.randomWrite, formatter: formatBandwidth },
|
||||
{ label: 'Sequential', read: result.bandwidth.sequentialRead, write: result.bandwidth.sequentialWrite, formatter: formatBandwidth },
|
||||
{ label: 'CPU Idleness', read: result.bandwidth.cpuIdleness, write: null, formatter: v => `${v}%` },
|
||||
];
|
||||
|
||||
const latRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.latency.randomRead, write: result.latency.randomWrite, formatter: formatLatency },
|
||||
{ label: 'Sequential', read: result.latency.sequentialRead, write: result.latency.sequentialWrite, formatter: formatLatency },
|
||||
{ label: 'CPU Idleness', read: result.latency.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Benchmark Metadata">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Storage Class', value: result.metadata.storageClass || '—' },
|
||||
{ name: 'Test Size', value: result.metadata.size },
|
||||
{ name: 'Job', value: result.metadata.jobName || '—' },
|
||||
{ name: 'Namespace', value: result.metadata.namespace || '—' },
|
||||
{ name: 'Completed', value: result.metadata.completedAt ? new Date(result.metadata.completedAt).toLocaleString() : '—' },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter={true} />
|
||||
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter={true} />
|
||||
<ResultTable title="Latency" rows={latRows} higherIsBetter={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RunFormProps {
|
||||
storageClasses: string[];
|
||||
onRun: (opts: { storageClass: string; namespace: string; size: string; mode: string }) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
|
||||
const [storageClass, setStorageClass] = useState(storageClasses[0] ?? '');
|
||||
const [namespace, setNamespace] = useState('default');
|
||||
const [size, setSize] = useState('30G');
|
||||
const [mode, setMode] = useState('full');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageClasses.length > 0 && !storageClasses.includes(storageClass)) {
|
||||
setStorageClass(storageClasses[0] ?? '');
|
||||
}
|
||||
}, [storageClasses, storageClass]);
|
||||
|
||||
function handleRunClick() {
|
||||
setShowConfirm(true);
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
setShowConfirm(false);
|
||||
onRun({ storageClass, namespace, size, mode });
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title="Run New Benchmark">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '12px 16px', alignItems: 'center', maxWidth: '600px' }}>
|
||||
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>Storage Class *</label>
|
||||
<select
|
||||
id="kbench-sc"
|
||||
value={storageClass}
|
||||
onChange={e => setStorageClass(e.target.value)}
|
||||
disabled={disabled || storageClasses.length === 0}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
aria-label="Select storage class for benchmark"
|
||||
>
|
||||
{storageClasses.length === 0 && <option value="">No tns-csi storage classes found</option>}
|
||||
{storageClasses.map(sc => <option key={sc} value={sc}>{sc}</option>)}
|
||||
</select>
|
||||
|
||||
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>Namespace</label>
|
||||
<input
|
||||
id="kbench-ns"
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={e => setNamespace(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
aria-label="Kubernetes namespace for benchmark job"
|
||||
/>
|
||||
|
||||
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>Test Size</label>
|
||||
<div>
|
||||
<input
|
||||
id="kbench-size"
|
||||
type="text"
|
||||
value={size}
|
||||
onChange={e => setSize(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', width: '120px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
aria-label="FIO test size"
|
||||
/>
|
||||
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
PVC will be ~10% larger (33Gi for 30G)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>Mode</label>
|
||||
<select
|
||||
id="kbench-mode"
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
aria-label="Benchmark mode"
|
||||
>
|
||||
<option value="full">Full (~6 minutes)</option>
|
||||
<option value="quick">Quick</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<button
|
||||
onClick={handleRunClick}
|
||||
disabled={disabled || storageClasses.length === 0 || !storageClass}
|
||||
aria-label="Start kbench storage benchmark"
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
backgroundColor: disabled ? 'var(--mui-palette-action-disabled, #ccc)' : 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Run Benchmark
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<div
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000, backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="kbench-confirm-title"
|
||||
>
|
||||
<div style={{ backgroundColor: 'var(--mui-palette-background-paper, #fff)', borderRadius: '8px', padding: '24px', maxWidth: '480px', boxShadow: '0 4px 24px rgba(0,0,0,0.2)', color: 'var(--mui-palette-text-primary)' }}>
|
||||
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>Confirm Benchmark</h3>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
|
||||
This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark (
|
||||
<strong>~6 minutes</strong>).
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
|
||||
Storage class: <strong>{storageClass}</strong> · Namespace: <strong>{namespace}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
aria-label="Cancel benchmark"
|
||||
style={{ padding: '8px 16px', border: '1px solid var(--mui-palette-divider)', borderRadius: '4px', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'var(--mui-palette-text-primary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm and start benchmark"
|
||||
style={{ padding: '8px 16px', backgroundColor: 'var(--mui-palette-primary-main, #1976d2)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', fontWeight: 500 }}
|
||||
>
|
||||
Start Benchmark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BenchmarkProgress({ state }: { state: BenchmarkState }) {
|
||||
if (state.status === 'idle') return null;
|
||||
|
||||
const labels: Record<BenchmarkState['status'], string> = {
|
||||
idle: '',
|
||||
'creating-pvc': 'Creating PVC...',
|
||||
'waiting-pvc': 'Waiting for PVC to bind...',
|
||||
running: 'Benchmark running...',
|
||||
parsing: 'Parsing results...',
|
||||
complete: 'Complete',
|
||||
failed: 'Failed',
|
||||
};
|
||||
|
||||
const statusColor: Record<BenchmarkState['status'], 'success' | 'warning' | 'error'> = {
|
||||
idle: 'warning',
|
||||
'creating-pvc': 'warning',
|
||||
'waiting-pvc': 'warning',
|
||||
running: 'warning',
|
||||
parsing: 'warning',
|
||||
complete: 'success',
|
||||
failed: 'error',
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBox title="Benchmark Progress">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: (
|
||||
<StatusLabel status={statusColor[state.status]}>
|
||||
{labels[state.status]}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []),
|
||||
...('pvcName' in state && state.pvcName ? [{ name: 'PVC', value: state.pvcName }] : []),
|
||||
...(state.status === 'failed' ? [{ name: 'Error', value: state.error }] : []),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Past benchmarks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PastBenchmarksProps {
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
function PastBenchmarks({ namespace }: PastBenchmarksProps) {
|
||||
const [jobs, setJobs] = useState<KbenchJobSummary[]>([]);
|
||||
const [jLoading, setJLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
setJLoading(true);
|
||||
try {
|
||||
const result = await listKbenchJobs(namespace);
|
||||
setJobs(result);
|
||||
} catch {
|
||||
setJobs([]);
|
||||
} finally {
|
||||
setJLoading(false);
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
useEffect(() => { void loadJobs(); }, [loadJobs]);
|
||||
|
||||
async function handleDelete(job: KbenchJobSummary) {
|
||||
if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return;
|
||||
setDeleting(job.jobName);
|
||||
try {
|
||||
await deleteJob(job.jobName, job.namespace);
|
||||
await deletePvc(`${job.jobName}-pvc`, job.namespace);
|
||||
await loadJobs();
|
||||
} catch (err: unknown) {
|
||||
alert(`Error deleting: ${err instanceof Error ? err.message : String(err)}`);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (jLoading) return <Loader title="Loading past benchmarks..." />;
|
||||
|
||||
return (
|
||||
<SectionBox title="Past Benchmarks">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Job Name', getter: (j: KbenchJobSummary) => j.jobName },
|
||||
{ label: 'Namespace', getter: (j: KbenchJobSummary) => j.namespace },
|
||||
{ label: 'Storage Class', getter: (j: KbenchJobSummary) => j.storageClass },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (j: KbenchJobSummary) => (
|
||||
<StatusLabel status={j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'}>
|
||||
{j.phase}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Started', getter: (j: KbenchJobSummary) => formatAge(j.startedAt) },
|
||||
{
|
||||
label: 'Actions',
|
||||
getter: (j: KbenchJobSummary) => (
|
||||
<button
|
||||
onClick={() => void handleDelete(j)}
|
||||
disabled={deleting === j.jobName}
|
||||
aria-label={`Delete benchmark job ${j.jobName}`}
|
||||
style={{ padding: '4px 10px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}
|
||||
>
|
||||
{deleting === j.jobName ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
data={jobs}
|
||||
emptyMessage="No past benchmark jobs found."
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const MAX_PVC_WAIT_MS = 120_000;
|
||||
|
||||
export default function BenchmarkPage() {
|
||||
const { storageClasses, loading } = useTnsCsiContext();
|
||||
const [benchState, setBenchState] = useState<BenchmarkState>({ status: 'idle' });
|
||||
const [currentResult, setCurrentResult] = useState<KbenchResult | null>(null);
|
||||
const [lastNamespace, setLastNamespace] = useState('default');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const scNames = storageClasses.map(sc => sc.metadata.name);
|
||||
|
||||
function stopPolling() {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runBenchmark(opts: { storageClass: string; namespace: string; size: string; mode: string }) {
|
||||
stopPolling();
|
||||
setCurrentResult(null);
|
||||
setLastNamespace(opts.namespace);
|
||||
|
||||
const jobName = generateJobName();
|
||||
const pvcName = generatePvcName(jobName);
|
||||
const jobOpts = { jobName, pvcName, namespace: opts.namespace, storageClass: opts.storageClass, size: opts.size, mode: opts.mode };
|
||||
|
||||
// Step 1: Create PVC
|
||||
setBenchState({ status: 'creating-pvc' });
|
||||
try {
|
||||
await createPvc(jobOpts);
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Wait for PVC to bind
|
||||
setBenchState({ status: 'waiting-pvc', pvcName });
|
||||
const pvcDeadline = Date.now() + MAX_PVC_WAIT_MS;
|
||||
let pvcBound = false;
|
||||
while (Date.now() < pvcDeadline) {
|
||||
try {
|
||||
const pvc = await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`) as { status?: { phase?: string } };
|
||||
if (pvc.status?.phase === 'Bound') { pvcBound = true; break; }
|
||||
} catch { /* retry */ }
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
}
|
||||
if (!pvcBound) {
|
||||
setBenchState({ status: 'failed', error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.', jobName, pvcName });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Create Job
|
||||
try {
|
||||
await createJob(jobOpts);
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
return;
|
||||
}
|
||||
|
||||
setBenchState({ status: 'running', jobName, pvcName, startedAt: new Date().toISOString() });
|
||||
|
||||
// Step 4: Poll job status
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const { phase } = await getJobPhase(jobName, opts.namespace);
|
||||
|
||||
if (phase === 'Complete') {
|
||||
stopPolling();
|
||||
setBenchState({ status: 'parsing', jobName, pvcName });
|
||||
|
||||
try {
|
||||
const logs = await fetchKbenchLogs(jobName, opts.namespace);
|
||||
const result = parseKbenchLog(logs);
|
||||
if (result) {
|
||||
result.metadata.storageClass = opts.storageClass;
|
||||
result.metadata.size = opts.size;
|
||||
result.metadata.jobName = jobName;
|
||||
result.metadata.namespace = opts.namespace;
|
||||
result.metadata.completedAt = new Date().toISOString();
|
||||
setCurrentResult(result);
|
||||
setBenchState({ status: 'complete', result, jobName, pvcName });
|
||||
} else {
|
||||
setBenchState({ status: 'failed', error: 'Could not parse FIO output from pod logs.', jobName, pvcName });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
}
|
||||
} else if (phase === 'Failed') {
|
||||
stopPolling();
|
||||
setBenchState({ status: 'failed', error: 'kbench Job failed. Check pod logs for details.', jobName, pvcName });
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stopPolling();
|
||||
setBenchState({ status: 'failed', error: `Polling error: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Clean up polling on unmount
|
||||
useEffect(() => () => stopPolling(), []);
|
||||
|
||||
const isRunning = benchState.status !== 'idle' && benchState.status !== 'complete' && benchState.status !== 'failed';
|
||||
|
||||
if (loading) return <Loader title="Loading tns-csi data..." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Benchmark" />
|
||||
|
||||
<SectionBox title="Benchmark Guide">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Duration', value: 'Full benchmark takes ~6 minutes. Do not cancel mid-run.' },
|
||||
{ name: 'Test Size', value: 'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).' },
|
||||
{ name: 'Cache Warning', value: 'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.' },
|
||||
{ name: 'CPU Idleness', value: 'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.' },
|
||||
{ name: 'Interpretation', value: 'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.' },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<RunForm storageClasses={scNames} onRun={opts => void runBenchmark(opts)} disabled={isRunning} />
|
||||
|
||||
<BenchmarkProgress state={benchState} />
|
||||
|
||||
{currentResult && benchState.status === 'complete' && (
|
||||
<>
|
||||
<KbenchResultDisplay result={currentResult} />
|
||||
<SectionBox title="Cleanup">
|
||||
<NameValueTable
|
||||
rows={[{
|
||||
name: 'Resources',
|
||||
value: (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const state = benchState;
|
||||
if (state.status !== 'complete') return;
|
||||
if (!window.confirm(`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`)) return;
|
||||
try {
|
||||
await deleteJob(state.jobName, lastNamespace);
|
||||
await deletePvc(state.pvcName, lastNamespace);
|
||||
setBenchState({ status: 'idle' });
|
||||
setCurrentResult(null);
|
||||
} catch (err: unknown) {
|
||||
alert(`Cleanup error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}}
|
||||
aria-label="Delete benchmark job and PVC"
|
||||
style={{ padding: '6px 14px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
|
||||
>
|
||||
Delete Job + PVC
|
||||
</button>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PastBenchmarks namespace={lastNamespace} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* DriverStatusCard — reusable component showing tns-csi driver health.
|
||||
* Displays controller pods, node pods, CSIDriver capabilities, and
|
||||
* WebSocket connection health from Prometheus metrics.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import type { CSIDriver, TnsCsiPod } from '../api/k8s';
|
||||
import { formatAge, getPodImage, getPodRestarts, isPodReady } from '../api/k8s';
|
||||
import type { TnsCsiMetrics } from '../api/metrics';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WebSocketStatus({ metrics }: { metrics: TnsCsiMetrics | null }) {
|
||||
if (!metrics) {
|
||||
return <StatusLabel status="warning">Metrics unavailable</StatusLabel>;
|
||||
}
|
||||
|
||||
const connected = metrics.websocketConnected;
|
||||
if (connected === null) {
|
||||
return <StatusLabel status="warning">Unknown</StatusLabel>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusLabel status={connected === 1 ? 'success' : 'error'}>
|
||||
{connected === 1 ? 'Connected' : 'Disconnected'}
|
||||
</StatusLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function PodStatusBadge({ pod }: { pod: TnsCsiPod }) {
|
||||
const ready = isPodReady(pod);
|
||||
const phase = pod.status?.phase ?? 'Unknown';
|
||||
return (
|
||||
<StatusLabel status={ready ? 'success' : 'error'}>
|
||||
{phase}
|
||||
</StatusLabel>
|
||||
);
|
||||
}
|
||||
|
||||
function PodRow({ pod }: { pod: TnsCsiPod }) {
|
||||
const name = pod.metadata.name;
|
||||
const node = pod.spec?.nodeName ?? '—';
|
||||
const restarts = getPodRestarts(pod);
|
||||
const image = getPodImage(pod);
|
||||
const age = formatAge(pod.metadata.creationTimestamp);
|
||||
|
||||
return (
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Pod', value: name },
|
||||
{ name: 'Node', value: node },
|
||||
{ name: 'Status', value: <PodStatusBadge pod={pod} /> },
|
||||
{ name: 'Restarts', value: String(restarts) },
|
||||
{ name: 'Image', value: image },
|
||||
{ name: 'Age', value: age },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DriverStatusCardProps {
|
||||
csiDriver: CSIDriver | null;
|
||||
controllerPods: TnsCsiPod[];
|
||||
nodePods: TnsCsiPod[];
|
||||
metrics?: TnsCsiMetrics | null;
|
||||
}
|
||||
|
||||
export default function DriverStatusCard({
|
||||
csiDriver,
|
||||
controllerPods,
|
||||
nodePods,
|
||||
metrics,
|
||||
}: DriverStatusCardProps) {
|
||||
const driverInstalled = csiDriver !== null;
|
||||
const allPodsReady =
|
||||
controllerPods.length > 0 &&
|
||||
nodePods.length > 0 &&
|
||||
[...controllerPods, ...nodePods].every(isPodReady);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionBox title="Driver Status">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Driver',
|
||||
value: (
|
||||
<StatusLabel status={driverInstalled ? 'success' : 'error'}>
|
||||
{driverInstalled ? 'tns.csi.io installed' : 'Not detected'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Overall Health',
|
||||
value: (
|
||||
<StatusLabel status={allPodsReady ? 'success' : 'error'}>
|
||||
{allPodsReady ? 'Healthy' : 'Degraded'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'WebSocket',
|
||||
value: <WebSocketStatus metrics={metrics ?? null} />,
|
||||
},
|
||||
...(metrics?.websocketReconnectsTotal !== null && metrics?.websocketReconnectsTotal !== undefined
|
||||
? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{csiDriver && (
|
||||
<SectionBox title="CSI Driver Capabilities">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Attach Required',
|
||||
value: String(csiDriver.spec?.attachRequired ?? '—'),
|
||||
},
|
||||
{
|
||||
name: 'Pod Info on Mount',
|
||||
value: String(csiDriver.spec?.podInfoOnMount ?? '—'),
|
||||
},
|
||||
{
|
||||
name: 'Volume Lifecycle Modes',
|
||||
value: csiDriver.spec?.volumeLifecycleModes?.join(', ') ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{controllerPods.length > 0 && (
|
||||
<SectionBox title={`Controller Pod${controllerPods.length > 1 ? 's' : ''}`}>
|
||||
{controllerPods.map(pod => (
|
||||
<PodRow key={pod.metadata.name} pod={pod} />
|
||||
))}
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{controllerPods.length === 0 && (
|
||||
<SectionBox title="Controller Pods">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">No controller pod found</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{nodePods.length > 0 && (
|
||||
<SectionBox title={`Node Pod${nodePods.length > 1 ? 's' : ''} (${nodePods.length})`}>
|
||||
{nodePods.map(pod => (
|
||||
<PodRow key={pod.metadata.name} pod={pod} />
|
||||
))}
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{nodePods.length === 0 && (
|
||||
<SectionBox title="Node Pods">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">No node pods found</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* PVCDetailSection — injected into Headlamp's PVC detail view.
|
||||
*
|
||||
* Shown only when the bound PV uses tns.csi.io as the CSI driver.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { findBoundPv, formatProtocol } from '../api/k8s';
|
||||
|
||||
interface PVCDetailSectionProps {
|
||||
resource: {
|
||||
metadata?: { name?: string; namespace?: string };
|
||||
spec?: { volumeName?: string; storageClassName?: string };
|
||||
};
|
||||
}
|
||||
|
||||
export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
|
||||
const { persistentVolumes, persistentVolumeClaims, loading } = useTnsCsiContext();
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
// Find this PVC in our filtered list
|
||||
const pvcName = resource.metadata?.name;
|
||||
const pvcNamespace = resource.metadata?.namespace;
|
||||
const matchedPvc = persistentVolumeClaims.find(
|
||||
pvc => pvc.metadata.name === pvcName && pvc.metadata.namespace === pvcNamespace
|
||||
);
|
||||
|
||||
if (!matchedPvc) {
|
||||
// Not a tns-csi PVC — render nothing
|
||||
return null;
|
||||
}
|
||||
|
||||
const boundPv = findBoundPv(matchedPvc, persistentVolumes);
|
||||
if (!boundPv) return null;
|
||||
|
||||
const attrs = boundPv.spec.csi?.volumeAttributes ?? {};
|
||||
const protocol = formatProtocol(attrs['protocol']);
|
||||
|
||||
return (
|
||||
<SectionBox title="TNS-CSI Storage Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: 'tns.csi.io' },
|
||||
{ name: 'Protocol', value: protocol },
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
|
||||
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
|
||||
...(Object.entries(attrs)
|
||||
.filter(([k]) => !['protocol', 'server'].includes(k))
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
|
||||
),
|
||||
{
|
||||
name: 'PV Name',
|
||||
value: boundPv.metadata.name,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SnapshotsPage — lists VolumeSnapshots backed by tns-csi.
|
||||
* Gracefully degrades when the snapshot CRD is not installed.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { VolumeSnapshot } from '../api/k8s';
|
||||
import { formatAge } from '../api/k8s';
|
||||
|
||||
export default function SnapshotsPage() {
|
||||
const { volumeSnapshots, volumeSnapshotClasses, snapshotCrdAvailable, loading, error } =
|
||||
useTnsCsiContext();
|
||||
|
||||
if (loading) return <Loader title="Loading snapshots..." />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Snapshots" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshotCrdAvailable) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Snapshots" />
|
||||
<SectionBox title="Volume Snapshot CRDs Not Installed">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: (
|
||||
<StatusLabel status="warning">
|
||||
VolumeSnapshot CRDs (snapshot.storage.k8s.io/v1) not found on this cluster
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Documentation',
|
||||
value: (
|
||||
<a href="https://github.com/fenio/tns-csi" target="_blank" rel="noopener noreferrer">
|
||||
See tns-csi documentation for snapshot setup instructions
|
||||
</a>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Snapshots" />
|
||||
|
||||
{volumeSnapshotClasses.length > 0 && (
|
||||
<SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (vsc) => vsc.metadata.name },
|
||||
{ label: 'Driver', getter: (vsc) => vsc.driver ?? '—' },
|
||||
{ label: 'Deletion Policy', getter: (vsc) => vsc.deletionPolicy ?? '—' },
|
||||
{ label: 'Age', getter: (vsc) => formatAge(vsc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={volumeSnapshotClasses}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
<SectionBox>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (s: VolumeSnapshot) => s.metadata.name },
|
||||
{ label: 'Namespace', getter: (s: VolumeSnapshot) => s.metadata.namespace ?? '—' },
|
||||
{
|
||||
label: 'Source PVC',
|
||||
getter: (s: VolumeSnapshot) => s.spec?.source?.persistentVolumeClaimName ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Snapshot Class',
|
||||
getter: (s: VolumeSnapshot) => s.spec?.volumeSnapshotClassName ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Ready',
|
||||
getter: (s: VolumeSnapshot) => {
|
||||
const ready = s.status?.readyToUse;
|
||||
if (ready === undefined) return <StatusLabel status="warning">Unknown</StatusLabel>;
|
||||
return (
|
||||
<StatusLabel status={ready ? 'success' : 'warning'}>
|
||||
{ready ? 'Yes' : 'No'}
|
||||
</StatusLabel>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Size',
|
||||
getter: (s: VolumeSnapshot) => s.status?.restoreSize ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (s: VolumeSnapshot) => formatAge(s.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={volumeSnapshots}
|
||||
emptyMessage="No tns-csi VolumeSnapshots found."
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* StorageClassesPage — lists tns-csi StorageClasses with a slide-in detail panel.
|
||||
*
|
||||
* Pattern mirrors headlamp-polaris-plugin's NamespacesListView:
|
||||
* click row → detail drawer, Escape to close, URL hash state.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { TnsCsiStorageClass } from '../api/k8s';
|
||||
import { formatProtocol } from '../api/k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface StorageClassDetailPanelProps {
|
||||
sc: TnsCsiStorageClass;
|
||||
pvCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPanelProps) {
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
const params = sc.parameters ?? {};
|
||||
const protocol = formatProtocol(params.protocol);
|
||||
|
||||
const drawerClass = `tns-csi-sc-drawer-${sc.metadata.name}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.${drawerClass} {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: ${isMaximized ? 'calc(100vw - 240px)' : '900px'};
|
||||
background-color: var(--mui-palette-background-default, #fafafa);
|
||||
color: var(--mui-palette-text-primary);
|
||||
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
|
||||
overflow-y: auto;
|
||||
z-index: 1200;
|
||||
padding: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
`}</style>
|
||||
<div className={drawerClass}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
|
||||
{sc.metadata.name}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
title={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
|
||||
>
|
||||
{isMaximized ? '⊟' : '⊡'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
title="Close"
|
||||
style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionBox title="StorageClass Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Name', value: sc.metadata.name },
|
||||
{ name: 'Protocol', value: protocol },
|
||||
{ name: 'Pool', value: params.pool ?? '—' },
|
||||
{ name: 'Server', value: params.server ?? '—' },
|
||||
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
|
||||
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
|
||||
{
|
||||
name: 'Allow Volume Expansion',
|
||||
value: <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
|
||||
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
|
||||
</StatusLabel>,
|
||||
},
|
||||
{ name: 'Delete Strategy', value: params.deleteStrategy ?? '—' },
|
||||
{
|
||||
name: 'Encryption',
|
||||
value: params.encryption === 'true'
|
||||
? <StatusLabel status="success">Enabled</StatusLabel>
|
||||
: <StatusLabel status="warning">Disabled</StatusLabel>,
|
||||
},
|
||||
{ name: 'Provisioner', value: sc.provisioner },
|
||||
{ name: 'Bound PVs', value: String(pvCount) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{/* Protocol-specific notes */}
|
||||
{params.protocol && (
|
||||
<SectionBox title="Protocol Notes">
|
||||
<NameValueTable rows={protocolNotes(params.protocol)} />
|
||||
</SectionBox>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function protocolNotes(protocol: string): Array<{ name: string; value: React.ReactNode }> {
|
||||
const lower = protocol.toLowerCase();
|
||||
if (lower === 'nfs') {
|
||||
return [
|
||||
{ name: 'Prerequisite', value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes' },
|
||||
{ name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' },
|
||||
];
|
||||
}
|
||||
if (lower === 'nvmeof') {
|
||||
return [
|
||||
{ name: 'Prerequisite', value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes' },
|
||||
{ name: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
|
||||
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
|
||||
];
|
||||
}
|
||||
if (lower === 'iscsi') {
|
||||
return [
|
||||
{ name: 'Prerequisite', value: 'open-iscsi required on all nodes' },
|
||||
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function StorageClassesPage() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
|
||||
|
||||
const [selectedName, setSelectedName] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(location.hash.slice(1) || null);
|
||||
}, [location.hash]);
|
||||
|
||||
const openSc = (name: string) => {
|
||||
setSelectedName(name);
|
||||
history.push(`${location.pathname}#${name}`);
|
||||
};
|
||||
|
||||
const closeSc = () => {
|
||||
setSelectedName(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedName) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeSc();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedName]);
|
||||
|
||||
if (loading) return <Loader title="Loading storage classes..." />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Storage Classes" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Build PV count per StorageClass
|
||||
const pvCountBySc = new Map<string, number>();
|
||||
for (const pv of persistentVolumes) {
|
||||
const scName = pv.spec.storageClassName ?? '';
|
||||
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Storage Classes" />
|
||||
<SectionBox>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'Name',
|
||||
getter: (sc: TnsCsiStorageClass) => (
|
||||
<button
|
||||
onClick={() => openSc(sc.metadata.name)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
{sc.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ label: 'Protocol', getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol) },
|
||||
{ label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' },
|
||||
{ label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' },
|
||||
{ label: 'Reclaim Policy', getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—' },
|
||||
{
|
||||
label: 'Expansion',
|
||||
getter: (sc: TnsCsiStorageClass) => (
|
||||
<StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
|
||||
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'PVs',
|
||||
getter: (sc: TnsCsiStorageClass) => String(pvCountBySc.get(sc.metadata.name) ?? 0),
|
||||
},
|
||||
]}
|
||||
data={storageClasses}
|
||||
emptyMessage="No tns-csi StorageClasses found."
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{selectedSc && (
|
||||
<>
|
||||
<div
|
||||
onClick={closeSc}
|
||||
aria-label="Close panel backdrop"
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
|
||||
/>
|
||||
<StorageClassDetailPanel
|
||||
sc={selectedSc}
|
||||
pvCount={pvCountBySc.get(selectedSc.metadata.name) ?? 0}
|
||||
onClose={closeSc}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* VolumesPage — lists tns-csi PersistentVolumes with PVC cross-reference.
|
||||
* Slide-in detail panel shows full CSI attributes.
|
||||
*/
|
||||
|
||||
import {
|
||||
Loader,
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
SectionHeader,
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { TnsCsiPersistentVolume } from '../api/k8s';
|
||||
import { findBoundPv, formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface VolumeDetailPanelProps {
|
||||
pv: TnsCsiPersistentVolume;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
|
||||
const [isMaximized, setIsMaximized] = React.useState(false);
|
||||
const drawerClass = `tns-csi-pv-drawer-${pv.metadata.name}`;
|
||||
const csi = pv.spec.csi;
|
||||
const attrs = csi?.volumeAttributes ?? {};
|
||||
const claim = pv.spec.claimRef;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.${drawerClass} {
|
||||
position: fixed; right: 0; top: 0; bottom: 0;
|
||||
width: ${isMaximized ? 'calc(100vw - 240px)' : '900px'};
|
||||
background-color: var(--mui-palette-background-default, #fafafa);
|
||||
color: var(--mui-palette-text-primary);
|
||||
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
|
||||
overflow-y: auto; z-index: 1200; padding: 20px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
`}</style>
|
||||
<div className={drawerClass}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>{pv.metadata.name}</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => setIsMaximized(!isMaximized)} aria-label={isMaximized ? 'Minimize' : 'Maximize'} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
|
||||
{isMaximized ? '⊟' : '⊡'}
|
||||
</button>
|
||||
<button onClick={onClose} aria-label="Close panel" style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionBox title="Volume Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Name', value: pv.metadata.name },
|
||||
{
|
||||
name: 'Status',
|
||||
value: (
|
||||
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||
{pv.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ name: 'Capacity', value: pv.spec.capacity?.storage ?? '—' },
|
||||
{ name: 'Access Modes', value: formatAccessModes(pv.spec.accessModes) },
|
||||
{ name: 'Reclaim Policy', value: pv.spec.persistentVolumeReclaimPolicy ?? '—' },
|
||||
{ name: 'Storage Class', value: pv.spec.storageClassName ?? '—' },
|
||||
{ name: 'Age', value: formatAge(pv.metadata.creationTimestamp) },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{claim && (
|
||||
<SectionBox title="Bound PVC">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'PVC Name', value: claim.name },
|
||||
{ name: 'Namespace', value: claim.namespace },
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
<SectionBox title="CSI Attributes">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: csi?.driver ?? '—' },
|
||||
{ name: 'Volume Handle', value: csi?.volumeHandle ?? '—' },
|
||||
{ name: 'Protocol', value: formatProtocol(attrs['protocol']) },
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
...(Object.entries(attrs)
|
||||
.filter(([k]) => !['protocol', 'server'].includes(k))
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{/* Volume adoption note */}
|
||||
{pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && (
|
||||
<SectionBox title="Adoption">
|
||||
<NameValueTable
|
||||
rows={[{
|
||||
name: 'Adoptable',
|
||||
value: <StatusLabel status="success">This volume can be adopted cross-cluster</StatusLabel>,
|
||||
}]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function VolumesPage() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext();
|
||||
|
||||
const [selectedName, setSelectedName] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(location.hash.slice(1) || null);
|
||||
}, [location.hash]);
|
||||
|
||||
const openVolume = (name: string) => {
|
||||
setSelectedName(name);
|
||||
history.push(`${location.pathname}#${name}`);
|
||||
};
|
||||
|
||||
const closeVolume = () => {
|
||||
setSelectedName(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedName) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeVolume();
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedName]);
|
||||
|
||||
if (loading) return <Loader title="Loading volumes..." />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Volumes" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedPv = selectedName
|
||||
? persistentVolumes.find(pv => pv.metadata.name === selectedName) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Volumes" />
|
||||
<SectionBox>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{
|
||||
label: 'PV Name',
|
||||
getter: (pv: TnsCsiPersistentVolume) => (
|
||||
<button
|
||||
onClick={() => openVolume(pv.metadata.name)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
>
|
||||
{pv.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'PVC',
|
||||
getter: (pv: TnsCsiPersistentVolume) => {
|
||||
const claim = pv.spec.claimRef;
|
||||
return claim ? `${claim.namespace}/${claim.name}` : '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Protocol',
|
||||
getter: (pv: TnsCsiPersistentVolume) =>
|
||||
formatProtocol(pv.spec.csi?.volumeAttributes?.['protocol']),
|
||||
},
|
||||
{
|
||||
label: 'Capacity',
|
||||
getter: (pv: TnsCsiPersistentVolume) => pv.spec.capacity?.storage ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Access Modes',
|
||||
getter: (pv: TnsCsiPersistentVolume) => formatAccessModes(pv.spec.accessModes),
|
||||
},
|
||||
{
|
||||
label: 'Reclaim',
|
||||
getter: (pv: TnsCsiPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (pv: TnsCsiPersistentVolume) => (
|
||||
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
|
||||
{pv.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Age',
|
||||
getter: (pv: TnsCsiPersistentVolume) => formatAge(pv.metadata.creationTimestamp),
|
||||
},
|
||||
]}
|
||||
data={persistentVolumes}
|
||||
emptyMessage="No tns-csi PersistentVolumes found."
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{selectedPv && (
|
||||
<>
|
||||
<div
|
||||
onClick={closeVolume}
|
||||
aria-label="Close panel backdrop"
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
|
||||
/>
|
||||
<VolumeDetailPanel pv={selectedPv} onClose={closeVolume} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* headlamp-tns-csi-plugin — entry point.
|
||||
*
|
||||
* Registers sidebar entries, routes, detail view section, and plugin settings
|
||||
* for the tns-csi CSI driver Headlamp plugin.
|
||||
*/
|
||||
|
||||
import {
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
|
||||
import BenchmarkPage from './components/BenchmarkPage';
|
||||
import MetricsPage from './components/MetricsPage';
|
||||
import OverviewPage from './components/OverviewPage';
|
||||
import PVCDetailSection from './components/PVCDetailSection';
|
||||
import SnapshotsPage from './components/SnapshotsPage';
|
||||
import StorageClassesPage from './components/StorageClassesPage';
|
||||
import VolumesPage from './components/VolumesPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar entries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: null,
|
||||
name: 'tns-csi',
|
||||
label: 'TNS CSI',
|
||||
url: '/tns-csi',
|
||||
icon: 'mdi:database-cog',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-overview',
|
||||
label: 'Overview',
|
||||
url: '/tns-csi',
|
||||
icon: 'mdi:view-dashboard',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-storage-classes',
|
||||
label: 'Storage Classes',
|
||||
url: '/tns-csi/storage-classes',
|
||||
icon: 'mdi:database',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-volumes',
|
||||
label: 'Volumes',
|
||||
url: '/tns-csi/volumes',
|
||||
icon: 'mdi:harddisk',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-snapshots',
|
||||
label: 'Snapshots',
|
||||
url: '/tns-csi/snapshots',
|
||||
icon: 'mdi:camera',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-metrics',
|
||||
label: 'Metrics',
|
||||
url: '/tns-csi/metrics',
|
||||
icon: 'mdi:chart-line',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-benchmark',
|
||||
label: 'Benchmark',
|
||||
url: '/tns-csi/benchmark',
|
||||
icon: 'mdi:speedometer',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi',
|
||||
sidebar: 'tns-csi-overview',
|
||||
name: 'tns-csi-overview',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<OverviewPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/storage-classes',
|
||||
sidebar: 'tns-csi-storage-classes',
|
||||
name: 'tns-csi-storage-classes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<StorageClassesPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/volumes',
|
||||
sidebar: 'tns-csi-volumes',
|
||||
name: 'tns-csi-volumes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<VolumesPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/snapshots',
|
||||
sidebar: 'tns-csi-snapshots',
|
||||
name: 'tns-csi-snapshots',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<SnapshotsPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/metrics',
|
||||
sidebar: 'tns-csi-metrics',
|
||||
name: 'tns-csi-metrics',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<MetricsPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/benchmark',
|
||||
sidebar: 'tns-csi-benchmark',
|
||||
name: 'tns-csi-benchmark',
|
||||
exact: true,
|
||||
component: () => (
|
||||
<TnsCsiDataProvider>
|
||||
<BenchmarkPage />
|
||||
</TnsCsiDataProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PVC detail view injection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'PersistentVolumeClaim') return null;
|
||||
|
||||
return (
|
||||
<TnsCsiDataProvider>
|
||||
<PVCDetailSection resource={resource} />
|
||||
</TnsCsiDataProvider>
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TnsCsiSettings() {
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<p style={{ color: 'var(--mui-palette-text-secondary)' }}>
|
||||
TNS-CSI plugin settings. Configure defaults below.
|
||||
</p>
|
||||
{/* Future: default namespace, metrics refresh interval, auto-cleanup setting */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerPluginSettings('headlamp-tns-csi-plugin', TnsCsiSettings, true);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
exclude: ['e2e/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Node 22+ ships a minimal built-in `localStorage` global (property-bag only,
|
||||
// no getItem/setItem/removeItem/clear) that shadows jsdom's Web Storage
|
||||
// implementation. Provide a spec-compliant shim so code under test works.
|
||||
if (typeof localStorage !== 'undefined' && typeof localStorage.getItem !== 'function') {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
const storage = {
|
||||
getItem(key: string): string | null {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string): void {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
store.delete(key);
|
||||
},
|
||||
clear(): void {
|
||||
store.clear();
|
||||
},
|
||||
get length(): number {
|
||||
return store.size;
|
||||
},
|
||||
key(index: number): string | null {
|
||||
return [...store.keys()][index] ?? null;
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: storage,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: storage,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user