diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc664c5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/api/TnsCsiDataContext.test.tsx b/src/api/TnsCsiDataContext.test.tsx new file mode 100644 index 0000000..d3266e1 --- /dev/null +++ b/src/api/TnsCsiDataContext.test.tsx @@ -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 }) => ( + {children} + ); + + 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'); + }); +}); diff --git a/src/api/TnsCsiDataContext.tsx b/src/api/TnsCsiDataContext.tsx new file mode 100644 index 0000000..012c0c1 --- /dev/null +++ b/src/api/TnsCsiDataContext.tsx @@ -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(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([]); + const [nodePods, setNodePods] = useState([]); + const [csiDriver, setCsiDriver] = useState(null); + const [volumeSnapshots, setVolumeSnapshots] = useState([]); + const [volumeSnapshotClasses, setVolumeSnapshotClasses] = useState([]); + const [snapshotCrdAvailable, setSnapshotCrdAvailable] = useState(false); + const [asyncLoading, setAsyncLoading] = useState(true); + const [asyncError, setAsyncError] = useState(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( + () => ({ + 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 {children}; +} diff --git a/src/api/k8s.test.ts b/src/api/k8s.test.ts new file mode 100644 index 0000000..048bc39 --- /dev/null +++ b/src/api/k8s.test.ts @@ -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')); +}); diff --git a/src/api/k8s.ts b/src/api/k8s.ts new file mode 100644 index 0000000..8f0d315 --- /dev/null +++ b/src/api/k8s.ts @@ -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; + annotations?: Record; + 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; + 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; + const spec = obj['spec'] as Record | undefined; + if (!spec) return false; + const csi = spec['csi'] as Record | 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(); + 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; + return obj['driver'] === TNS_CSI_PROVISIONER; +} + +export function filterTnsCsiVolumeSnapshots( + snapshots: VolumeSnapshot[], + tnsCsiSnapshotClassNames: Set +): VolumeSnapshot[] { + return snapshots.filter( + s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName) + ); +} + +// --------------------------------------------------------------------------- +// K8s API list response envelope +// --------------------------------------------------------------------------- + +export interface KubeList { + 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 { + if (!value || typeof value !== 'object') return false; + return Array.isArray((value as Record)['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 = { + 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 = { + 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'; + } +} diff --git a/src/api/kbench.test.ts b/src/api/kbench.test.ts new file mode 100644 index 0000000..071cd02 --- /dev/null +++ b/src/api/kbench.test.ts @@ -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; + expect(manifest['kind']).toBe('PersistentVolumeClaim'); + const spec = manifest['spec'] as Record; + expect(spec['storageClassName']).toBe('tns-nfs'); + const resources = spec['resources'] as Record; + const requests = resources['requests'] as Record; + expect(requests['storage']).toBe('33Gi'); + }); + + it('applies managed-by label', () => { + const manifest = buildPvcManifest(opts) as Record; + const meta = manifest['metadata'] as Record; + const labels = meta['labels'] as Record; + 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; + expect(manifest['kind']).toBe('Job'); + const spec = manifest['spec'] as Record; + expect(spec['backoffLimit']).toBe(0); + }); + + it('uses default size and mode when not specified', () => { + const manifest = buildJobManifest(opts) as Record; + const spec = manifest['spec'] as Record; + const template = spec['template'] as Record; + const podSpec = template['spec'] as Record; + const containers = podSpec['containers'] as Array>; + 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; + const spec = manifest['spec'] as Record; + const template = spec['template'] as Record; + const podSpec = template['spec'] as Record; + const containers = podSpec['containers'] as Array>; + 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'); + }); +}); diff --git a/src/api/kbench.ts b/src/api/kbench.ts new file mode 100644 index 0000000..2697313 --- /dev/null +++ b/src/api/kbench.ts @@ -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 { + 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 { + 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 { + 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 { + // Find pod with label kbench=fio and job-name= + 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 { + 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 { + 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 { + 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; 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`; +} diff --git a/src/api/metrics.test.ts b/src/api/metrics.test.ts new file mode 100644 index 0000000..9da7ff9 --- /dev/null +++ b/src/api/metrics.test.ts @@ -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')); +}); diff --git a/src/api/metrics.ts b/src/api/metrics.ts new file mode 100644 index 0000000..3adead8 --- /dev/null +++ b/src/api/metrics.ts @@ -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; + value: number; +} + +export interface MetricFamily { + name: string; + help: string; + type: string; + samples: MetricSample[]; +} + +export type ParsedMetrics = Map; + +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 { + const labels: Record = {}; + 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(); + 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; + 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 { + 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 { + const result = new Map(); + 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); +} diff --git a/src/components/BenchmarkPage.tsx b/src/components/BenchmarkPage.tsx new file mode 100644 index 0000000..4b12625 --- /dev/null +++ b/src/components/BenchmarkPage.tsx @@ -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 ( + + + + + + + + + + + + {rows.map(row => ( + + + + + + + ))} + +
MetricReadWrite + {higherIsBetter ? '↑ higher is better' : '↓ lower is better'} +
{row.label} + {row.formatter(row.read)} + + {row.write !== null ? row.formatter(row.write) : '—'} + + {row.note ?? ''} +
+
+ ); +} + +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 ( + <> + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +
+ + + + + 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" + /> + + +
+ 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" + /> + + PVC will be ~10% larger (33Gi for 30G) + +
+ + + +
+ +
+ +
+ + {showConfirm && ( +
+
+

Confirm Benchmark

+

+ This will create a ~33Gi PVC and run an FIO benchmark ( + ~6 minutes). +

+

+ Storage class: {storageClass} · Namespace: {namespace} +

+

+ The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion. +

+
+ + +
+
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Progress display +// --------------------------------------------------------------------------- + +function BenchmarkProgress({ state }: { state: BenchmarkState }) { + if (state.status === 'idle') return null; + + const labels: Record = { + 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 = { + idle: 'warning', + 'creating-pvc': 'warning', + 'waiting-pvc': 'warning', + running: 'warning', + parsing: 'warning', + complete: 'success', + failed: 'error', + }; + + return ( + + + {labels[state.status]} + + ), + }, + ...('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 }] : []), + ]} + /> + + ); +} + +// --------------------------------------------------------------------------- +// Past benchmarks +// --------------------------------------------------------------------------- + +interface PastBenchmarksProps { + namespace: string; +} + +function PastBenchmarks({ namespace }: PastBenchmarksProps) { + const [jobs, setJobs] = useState([]); + const [jLoading, setJLoading] = useState(true); + const [deleting, setDeleting] = useState(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 ; + + return ( + + j.jobName }, + { label: 'Namespace', getter: (j: KbenchJobSummary) => j.namespace }, + { label: 'Storage Class', getter: (j: KbenchJobSummary) => j.storageClass }, + { + label: 'Status', + getter: (j: KbenchJobSummary) => ( + + {j.phase} + + ), + }, + { label: 'Started', getter: (j: KbenchJobSummary) => formatAge(j.startedAt) }, + { + label: 'Actions', + getter: (j: KbenchJobSummary) => ( + + ), + }, + ]} + data={jobs} + emptyMessage="No past benchmark jobs found." + /> + + ); +} + +// --------------------------------------------------------------------------- +// 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({ status: 'idle' }); + const [currentResult, setCurrentResult] = useState(null); + const [lastNamespace, setLastNamespace] = useState('default'); + const pollRef = useRef | 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 ; + + return ( + <> + + + + + + + void runBenchmark(opts)} disabled={isRunning} /> + + + + {currentResult && benchState.status === 'complete' && ( + <> + + + { + 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 + + ), + }]} + /> + + + )} + + + + ); +} diff --git a/src/components/DriverStatusCard.tsx b/src/components/DriverStatusCard.tsx new file mode 100644 index 0000000..0198a61 --- /dev/null +++ b/src/components/DriverStatusCard.tsx @@ -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 Metrics unavailable; + } + + const connected = metrics.websocketConnected; + if (connected === null) { + return Unknown; + } + + return ( + + {connected === 1 ? 'Connected' : 'Disconnected'} + + ); +} + +function PodStatusBadge({ pod }: { pod: TnsCsiPod }) { + const ready = isPodReady(pod); + const phase = pod.status?.phase ?? 'Unknown'; + return ( + + {phase} + + ); +} + +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 ( + }, + { 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 ( + <> + + + {driverInstalled ? 'tns.csi.io installed' : 'Not detected'} + + ), + }, + { + name: 'Overall Health', + value: ( + + {allPodsReady ? 'Healthy' : 'Degraded'} + + ), + }, + { + name: 'WebSocket', + value: , + }, + ...(metrics?.websocketReconnectsTotal !== null && metrics?.websocketReconnectsTotal !== undefined + ? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }] + : []), + ]} + /> + + + {csiDriver && ( + + + + )} + + {controllerPods.length > 0 && ( + 1 ? 's' : ''}`}> + {controllerPods.map(pod => ( + + ))} + + )} + + {controllerPods.length === 0 && ( + + No controller pod found }]} + /> + + )} + + {nodePods.length > 0 && ( + 1 ? 's' : ''} (${nodePods.length})`}> + {nodePods.map(pod => ( + + ))} + + )} + + {nodePods.length === 0 && ( + + No node pods found }]} + /> + + )} + + ); +} diff --git a/src/components/MetricsPage.tsx b/src/components/MetricsPage.tsx new file mode 100644 index 0000000..2944082 --- /dev/null +++ b/src/components/MetricsPage.tsx @@ -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 ( + + + {connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'} + + ), + }, + { + name: 'Total Reconnects', + value: reconnects !== null ? String(reconnects) : '—', + }, + { + name: 'Messages Total', + value: String(Math.round(sumSamples(metrics.websocketMessagesTotal))), + }, + ]} + /> + + ); +} + +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 ( + + + + ); +} + +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 ( + + + + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function MetricsPage() { + const { controllerPods, driverInstalled, loading } = useTnsCsiContext(); + + const [metrics, setMetrics] = useState(null); + const [metricsLoading, setMetricsLoading] = useState(false); + const [metricsError, setMetricsError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(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 ; + + return ( + <> +
+ +
+ {lastUpdated && ( + + Updated: {formatAuditTime(lastUpdated)} + + )} + +
+
+ + {!driverInstalled && ( + + TNS-CSI driver not found on this cluster }]} + /> + + )} + + {controllerPods.length === 0 && driverInstalled && ( + + No controller pod found }, + { 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', + }, + ]} + /> + + )} + + {metricsError && ( + + {metricsError} }, + { name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' }, + ]} + /> + + )} + + {metricsLoading && !metrics && } + + {metrics && ( + <> + + + + + )} + + ); +} diff --git a/src/components/OverviewPage.tsx b/src/components/OverviewPage.tsx new file mode 100644 index 0000000..a13712a --- /dev/null +++ b/src/components/OverviewPage.tsx @@ -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 = { + NFS: '#1976d2', + 'NVMe-oF': '#9c27b0', + iSCSI: '#f57c00', + Other: '#9e9e9e', +}; + +function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) { + const counts = new Map(); + 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(null); + const [metricsError, setMetricsError] = useState(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 ; + } + + // 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 ( + <> +
+ + +
+ + {/* Early development banner */} + + + tns-csi is in active early development — not production-ready + + ), + }, + ]} + /> + + + {/* Driver not detected */} + {!driverInstalled && !loading && ( + + CSIDriver tns.csi.io not found on this cluster, + }, + { + name: 'Install', + value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system', + }, + ]} + /> + + )} + + {/* Error state */} + {error && ( + + {error} }]} + /> + + )} + + {/* Driver status */} + + + {metricsError && ( + + {metricsError}, + }, + { + name: 'Note', + value: 'Ensure controller pod is running with metrics enabled (port 8080).', + }, + ]} + /> + + )} + + {/* Storage summary */} + + {totalScs > 0 && chartData.length > 0 && ( +
+
+ Protocol Distribution +
+ +
+ )} + {pvcStatusCounts.Bound}, + }, + ...(pvcStatusCounts.Pending > 0 + ? [{ + name: 'PVCs (Pending)', + value: {pvcStatusCounts.Pending}, + }] + : []), + ...(pvcStatusCounts.Lost > 0 + ? [{ + name: 'PVCs (Lost)', + value: {pvcStatusCounts.Lost}, + }] + : []), + ]} + /> +
+ + {/* Non-bound PVCs warning */} + {nonBoundPvcs.length > 0 && ( + + pvc.metadata.name }, + { label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' }, + { + label: 'Status', + getter: (pvc) => ( + + {pvc.status?.phase ?? 'Unknown'} + + ), + }, + { label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) }, + ]} + data={nonBoundPvcs} + /> + + )} + + ); +} + +// --------------------------------------------------------------------------- +// 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 = { + '': 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`; +} diff --git a/src/components/PVCDetailSection.tsx b/src/components/PVCDetailSection.tsx new file mode 100644 index 0000000..e3fe243 --- /dev/null +++ b/src/components/PVCDetailSection.tsx @@ -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 ( + + !['protocol', 'server'].includes(k)) + .map(([k, v]) => ({ name: k, value: v ?? '—' })) + ), + { + name: 'PV Name', + value: boundPv.metadata.name, + }, + ]} + /> + + ); +} diff --git a/src/components/SnapshotsPage.tsx b/src/components/SnapshotsPage.tsx new file mode 100644 index 0000000..bd284c8 --- /dev/null +++ b/src/components/SnapshotsPage.tsx @@ -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 ; + + if (error) { + return ( + <> + + + {error} }]} /> + + + ); + } + + if (!snapshotCrdAvailable) { + return ( + <> + + + + VolumeSnapshot CRDs (snapshot.storage.k8s.io/v1) not found on this cluster + + ), + }, + { + name: 'Documentation', + value: ( + + See tns-csi documentation for snapshot setup instructions + + ), + }, + ]} + /> + + + ); + } + + return ( + <> + + + {volumeSnapshotClasses.length > 0 && ( + + 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} + /> + + )} + + + 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 Unknown; + return ( + + {ready ? 'Yes' : 'No'} + + ); + }, + }, + { + 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." + /> + + + ); +} diff --git a/src/components/StorageClassesPage.tsx b/src/components/StorageClassesPage.tsx new file mode 100644 index 0000000..3b0a52c --- /dev/null +++ b/src/components/StorageClassesPage.tsx @@ -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 ( + <> + +
+
+

+ {sc.metadata.name} +

+
+ + +
+
+ + + + {sc.allowVolumeExpansion ? 'Yes' : 'No'} + , + }, + { name: 'Delete Strategy', value: params.deleteStrategy ?? '—' }, + { + name: 'Encryption', + value: params.encryption === 'true' + ? Enabled + : Disabled, + }, + { name: 'Provisioner', value: sc.provisioner }, + { name: 'Bound PVs', value: String(pvCount) }, + ]} + /> + + + {/* Protocol-specific notes */} + {params.protocol && ( + + + + )} +
+ + ); +} + +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( + 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 ; + + if (error) { + return ( + <> + + + {error} }]} /> + + + ); + } + + // Build PV count per StorageClass + const pvCountBySc = new Map(); + 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 ( + <> + + + ( + + ), + }, + { 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) => ( + + {sc.allowVolumeExpansion ? 'Yes' : 'No'} + + ), + }, + { + label: 'PVs', + getter: (sc: TnsCsiStorageClass) => String(pvCountBySc.get(sc.metadata.name) ?? 0), + }, + ]} + data={storageClasses} + emptyMessage="No tns-csi StorageClasses found." + /> + + + {selectedSc && ( + <> +
+ + + )} + + ); +} diff --git a/src/components/VolumesPage.tsx b/src/components/VolumesPage.tsx new file mode 100644 index 0000000..ccfaf94 --- /dev/null +++ b/src/components/VolumesPage.tsx @@ -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 ( + <> + +
+
+

{pv.metadata.name}

+
+ + +
+
+ + + + {pv.status?.phase ?? 'Unknown'} + + ), + }, + { 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) }, + ]} + /> + + + {claim && ( + + + + )} + + + !['protocol', 'server'].includes(k)) + .map(([k, v]) => ({ name: k, value: v ?? '—' })) + ), + ]} + /> + + + {/* Volume adoption note */} + {pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && ( + + This volume can be adopted cross-cluster, + }]} + /> + + )} +
+ + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function VolumesPage() { + const location = useLocation(); + const history = useHistory(); + const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext(); + + const [selectedName, setSelectedName] = useState( + 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 ; + + if (error) { + return ( + <> + + + {error} }]} /> + + + ); + } + + const selectedPv = selectedName + ? persistentVolumes.find(pv => pv.metadata.name === selectedName) ?? null + : null; + + return ( + <> + + + ( + + ), + }, + { + 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) => ( + + {pv.status?.phase ?? 'Unknown'} + + ), + }, + { + label: 'Age', + getter: (pv: TnsCsiPersistentVolume) => formatAge(pv.metadata.creationTimestamp), + }, + ]} + data={persistentVolumes} + emptyMessage="No tns-csi PersistentVolumes found." + /> + + + {selectedPv && ( + <> +
+ + + )} + + ); +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..17b5ce6 --- /dev/null +++ b/src/index.tsx @@ -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: () => ( + + + + ), +}); + +registerRoute({ + path: '/tns-csi/storage-classes', + sidebar: 'tns-csi-storage-classes', + name: 'tns-csi-storage-classes', + exact: true, + component: () => ( + + + + ), +}); + +registerRoute({ + path: '/tns-csi/volumes', + sidebar: 'tns-csi-volumes', + name: 'tns-csi-volumes', + exact: true, + component: () => ( + + + + ), +}); + +registerRoute({ + path: '/tns-csi/snapshots', + sidebar: 'tns-csi-snapshots', + name: 'tns-csi-snapshots', + exact: true, + component: () => ( + + + + ), +}); + +registerRoute({ + path: '/tns-csi/metrics', + sidebar: 'tns-csi-metrics', + name: 'tns-csi-metrics', + exact: true, + component: () => ( + + + + ), +}); + +registerRoute({ + path: '/tns-csi/benchmark', + sidebar: 'tns-csi-benchmark', + name: 'tns-csi-benchmark', + exact: true, + component: () => ( + + + + ), +}); + +// --------------------------------------------------------------------------- +// PVC detail view injection +// --------------------------------------------------------------------------- + +registerDetailsViewSection(({ resource }) => { + if (resource?.kind !== 'PersistentVolumeClaim') return null; + + return ( + + + + ); +}); + +// --------------------------------------------------------------------------- +// Plugin settings +// --------------------------------------------------------------------------- + +function TnsCsiSettings() { + return ( +
+

+ TNS-CSI plugin settings. Configure defaults below. +

+ {/* Future: default namespace, metrics refresh interval, auto-cleanup setting */} +
+ ); +} + +registerPluginSettings('headlamp-tns-csi-plugin', TnsCsiSettings, true); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2eb0176 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..3a3739f --- /dev/null +++ b/vitest.config.mts @@ -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/**'], + }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 0000000..1fa97b6 --- /dev/null +++ b/vitest.setup.ts @@ -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(); + + 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, + }); + } +}