Implement headlamp-tns-csi-plugin

Full plugin implementation with 6 pages, K8s resource filtering,
Prometheus metrics parsing, kbench benchmark runner, and 67 unit tests.

## Pages
- Overview: driver health, storage summary, protocol distribution chart, non-Bound PVC alerts
- Storage Classes: tns-csi SC table with slide-in detail panel + protocol notes
- Volumes: PV table with full CSI attribute detail panel
- Snapshots: VolumeSnapshot CRDs with graceful degradation if not installed
- Metrics: Prometheus text format parser + WebSocket/Volume/CSI operation cards
- Benchmark: kbench Job+PVC lifecycle, FIO log parser, past benchmarks list

## API modules
- k8s.ts: typed resource shapes, filtering helpers, formatting utilities
- metrics.ts: Prometheus text format parser, tns-csi metric extraction
- kbench.ts: Job/PVC manifests, lifecycle management, FIO summary parser
- TnsCsiDataContext.tsx: shared React context with memoized filtered resources

## Quality
- TypeScript strict mode, zero any, discriminated union for benchmark state
- 67 tests passing (vitest + @testing-library/react)
- registerDetailsViewSection injects TNS-CSI details on PVC pages
- Graceful degradation for missing CSIDriver and VolumeSnapshot CRDs

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-18 07:45:19 -05:00
parent fd9db4f4a7
commit e5d1fcb11c
21 changed files with 4110 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { renderHook } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
// Mock headlamp plugin APIs before importing the module under test
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({ items: [] }),
},
K8s: {
ResourceClasses: {
StorageClass: {
useList: vi.fn(() => [[], null]),
},
PersistentVolume: {
useList: vi.fn(() => [[], null]),
},
PersistentVolumeClaim: {
useList: vi.fn(() => [[], null]),
},
},
},
}));
import { TnsCsiDataProvider, useTnsCsiContext } from './TnsCsiDataContext';
describe('useTnsCsiContext', () => {
it('throws when used outside TnsCsiDataProvider', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => {
renderHook(() => useTnsCsiContext());
}).toThrow('useTnsCsiContext must be used within a TnsCsiDataProvider');
spy.mockRestore();
});
it('returns context value when inside TnsCsiDataProvider', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TnsCsiDataProvider>{children}</TnsCsiDataProvider>
);
const { result } = renderHook(() => useTnsCsiContext(), { wrapper });
expect(result.current).toBeDefined();
expect(result.current.storageClasses).toBeInstanceOf(Array);
expect(result.current.persistentVolumes).toBeInstanceOf(Array);
expect(result.current.persistentVolumeClaims).toBeInstanceOf(Array);
expect(typeof result.current.refresh).toBe('function');
});
});
+249
View File
@@ -0,0 +1,249 @@
/**
* TnsCsiDataContext — shared data provider for tns-csi Kubernetes resources.
*
* Wraps the K8s hook calls and provides filtered tns-csi resources to all
* child pages through React context, avoiding prop drilling and duplicate
* API calls.
*/
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
CSIDriver,
filterTnsCsiPersistentVolumes,
filterTnsCsiPVCs,
filterTnsCsiStorageClasses,
isKubeList,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
TNS_CSI_PROVISIONER,
VolumeSnapshot,
VolumeSnapshotClass,
} from './k8s';
// ---------------------------------------------------------------------------
// Context shape
// ---------------------------------------------------------------------------
export interface TnsCsiContextValue {
// Driver presence
csiDriver: CSIDriver | null;
driverInstalled: boolean;
// Core resources (filtered to tns-csi only)
storageClasses: TnsCsiStorageClass[];
persistentVolumes: TnsCsiPersistentVolume[];
persistentVolumeClaims: TnsCsiPersistentVolumeClaim[];
// Driver pods
controllerPods: TnsCsiPod[];
nodePods: TnsCsiPod[];
// Snapshots (CRD — may be unavailable)
volumeSnapshots: VolumeSnapshot[];
volumeSnapshotClasses: VolumeSnapshotClass[];
snapshotCrdAvailable: boolean;
// Loading / error state
loading: boolean;
error: string | null;
// Manual refresh trigger
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const TnsCsiContext = createContext<TnsCsiContextValue | null>(null);
export function useTnsCsiContext(): TnsCsiContextValue {
const ctx = useContext(TnsCsiContext);
if (!ctx) {
throw new Error('useTnsCsiContext must be used within a TnsCsiDataProvider');
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) {
// K8s resource hooks — headlamp re-fetches on cluster changes automatically
const [allStorageClasses, scError] = K8s.ResourceClasses.StorageClass.useList();
const [allPvs, pvError] = K8s.ResourceClasses.PersistentVolume.useList();
const [allPvcs, pvcError] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
// Pods fetched via label selector through ApiProxy (useList doesn't support selectors easily)
const [controllerPods, setControllerPods] = useState<TnsCsiPod[]>([]);
const [nodePods, setNodePods] = useState<TnsCsiPod[]>([]);
const [csiDriver, setCsiDriver] = useState<CSIDriver | null>(null);
const [volumeSnapshots, setVolumeSnapshots] = useState<VolumeSnapshot[]>([]);
const [volumeSnapshotClasses, setVolumeSnapshotClasses] = useState<VolumeSnapshotClass[]>([]);
const [snapshotCrdAvailable, setSnapshotCrdAvailable] = useState(false);
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function fetchAsync() {
setAsyncLoading(true);
setAsyncError(null);
try {
// CSIDriver
try {
const driver = await ApiProxy.request(
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
) as CSIDriver;
if (!cancelled) setCsiDriver(driver);
} catch {
if (!cancelled) setCsiDriver(null);
}
// Controller pods
try {
const ctrlList = await ApiProxy.request(
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller'
)}`
);
if (!cancelled && isKubeList(ctrlList)) {
setControllerPods(ctrlList.items as TnsCsiPod[]);
}
} catch {
if (!cancelled) setControllerPods([]);
}
// Node pods
try {
const nodeList = await ApiProxy.request(
`/api/v1/namespaces/kube-system/pods?labelSelector=${encodeURIComponent(
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node'
)}`
);
if (!cancelled && isKubeList(nodeList)) {
setNodePods(nodeList.items as TnsCsiPod[]);
}
} catch {
if (!cancelled) setNodePods([]);
}
// VolumeSnapshots (CRD — graceful degradation)
try {
const vscList = await ApiProxy.request(
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
);
if (!cancelled && isKubeList(vscList)) {
setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]);
setSnapshotCrdAvailable(true);
const vsList = await ApiProxy.request(
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
);
if (!cancelled && isKubeList(vsList)) {
setVolumeSnapshots(vsList.items as VolumeSnapshot[]);
}
}
} catch {
if (!cancelled) {
setSnapshotCrdAvailable(false);
setVolumeSnapshotClasses([]);
setVolumeSnapshots([]);
}
}
} catch (err: unknown) {
if (!cancelled) {
setAsyncError(err instanceof Error ? err.message : String(err));
}
} finally {
if (!cancelled) setAsyncLoading(false);
}
}
void fetchAsync();
return () => { cancelled = true; };
}, [refreshKey]);
// ---------------------------------------------------------------------------
// Derived / filtered values — memoized to avoid recomputation on every render
// ---------------------------------------------------------------------------
const storageClasses = useMemo(() => {
if (!allStorageClasses) return [];
return filterTnsCsiStorageClasses(allStorageClasses as unknown[]);
}, [allStorageClasses]);
const persistentVolumes = useMemo(() => {
if (!allPvs) return [];
return filterTnsCsiPersistentVolumes(allPvs as unknown[]);
}, [allPvs]);
const persistentVolumeClaims = useMemo(() => {
if (!allPvcs || persistentVolumes.length === 0) return [];
return filterTnsCsiPVCs(allPvcs as TnsCsiPersistentVolumeClaim[], persistentVolumes);
}, [allPvcs, persistentVolumes]);
// ---------------------------------------------------------------------------
// Combined loading / error state
// ---------------------------------------------------------------------------
const loading = asyncLoading || !allStorageClasses || !allPvs || !allPvcs;
const errors: string[] = [];
if (scError) errors.push(String(scError));
if (pvError) errors.push(String(pvError));
if (pvcError) errors.push(String(pvcError));
if (asyncError) errors.push(asyncError);
const error = errors.length > 0 ? errors.join('; ') : null;
const driverInstalled = csiDriver !== null;
// ---------------------------------------------------------------------------
// Memoized context value to prevent unnecessary re-renders
// ---------------------------------------------------------------------------
const value = useMemo<TnsCsiContextValue>(
() => ({
csiDriver,
driverInstalled,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
controllerPods,
nodePods,
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
loading,
error,
refresh,
}),
[
csiDriver,
driverInstalled,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
controllerPods,
nodePods,
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
loading,
error,
refresh,
]
);
return <TnsCsiContext.Provider value={value}>{children}</TnsCsiContext.Provider>;
}
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import {
filterTnsCsiPersistentVolumes,
filterTnsCsiPVCs,
filterTnsCsiStorageClasses,
filterTnsCsiVolumeSnapshots,
findBoundPv,
formatAccessModes,
formatAge,
formatProtocol,
isTnsCsiPersistentVolume,
isTnsCsiStorageClass,
phaseToStatus,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiStorageClass,
} from './k8s';
// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------
function makeSc(name: string, provisioner: string, protocol = 'nfs'): TnsCsiStorageClass {
return {
metadata: { name },
provisioner,
parameters: { protocol },
};
}
function makePv(name: string, driver: string, claimRef?: { name: string; namespace: string }): TnsCsiPersistentVolume {
return {
metadata: { name },
spec: {
csi: { driver, volumeAttributes: { protocol: 'nfs' } },
capacity: { storage: '10Gi' },
claimRef,
},
status: { phase: 'Bound' },
};
}
function makePvc(name: string, namespace: string): TnsCsiPersistentVolumeClaim {
return {
metadata: { name, namespace },
spec: {},
status: { phase: 'Bound' },
};
}
// ---------------------------------------------------------------------------
// StorageClass filtering
// ---------------------------------------------------------------------------
describe('isTnsCsiStorageClass', () => {
it('returns true for tns.csi.io provisioner', () => {
expect(isTnsCsiStorageClass(makeSc('sc1', 'tns.csi.io'))).toBe(true);
});
it('returns false for other provisioners', () => {
expect(isTnsCsiStorageClass(makeSc('sc1', 'kubernetes.io/nfs'))).toBe(false);
});
it('returns false for non-objects', () => {
expect(isTnsCsiStorageClass(null)).toBe(false);
expect(isTnsCsiStorageClass(undefined)).toBe(false);
expect(isTnsCsiStorageClass('string')).toBe(false);
});
});
describe('filterTnsCsiStorageClasses', () => {
it('filters to only tns-csi storage classes', () => {
const items = [
makeSc('tns-sc', 'tns.csi.io'),
makeSc('other-sc', 'kubernetes.io/aws-ebs'),
makeSc('another', 'rancher.io/local-path'),
];
const result = filterTnsCsiStorageClasses(items);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-sc');
});
it('returns empty array when no tns-csi classes', () => {
expect(filterTnsCsiStorageClasses([makeSc('x', 'other')])).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// PV filtering
// ---------------------------------------------------------------------------
describe('isTnsCsiPersistentVolume', () => {
it('returns true for tns.csi.io driver', () => {
expect(isTnsCsiPersistentVolume(makePv('pv1', 'tns.csi.io'))).toBe(true);
});
it('returns false for other drivers', () => {
expect(isTnsCsiPersistentVolume(makePv('pv1', 'ebs.csi.aws.com'))).toBe(false);
});
it('returns false for PVs without CSI spec', () => {
const pv = { metadata: { name: 'pv1' }, spec: {}, status: {} };
expect(isTnsCsiPersistentVolume(pv)).toBe(false);
});
});
describe('filterTnsCsiPersistentVolumes', () => {
it('filters to only tns-csi PVs', () => {
const items = [
makePv('tns-pv', 'tns.csi.io'),
makePv('other-pv', 'ebs.csi.aws.com'),
];
const result = filterTnsCsiPersistentVolumes(items);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-pv');
});
});
// ---------------------------------------------------------------------------
// PVC filtering
// ---------------------------------------------------------------------------
describe('filterTnsCsiPVCs', () => {
it('includes PVCs bound to tns-csi PVs via claimRef', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'my-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
const unrelatedPvc = makePvc('other-pvc', 'default');
const result = filterTnsCsiPVCs([pvc, unrelatedPvc], [pv]);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('my-pvc');
});
it('returns empty array when no PVs', () => {
const pvc = makePvc('my-pvc', 'default');
expect(filterTnsCsiPVCs([pvc], [])).toHaveLength(0);
});
});
describe('findBoundPv', () => {
it('finds the PV bound to a PVC', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'my-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
expect(findBoundPv(pvc, [pv])).toBe(pv);
});
it('returns undefined when no match', () => {
const pv = makePv('tns-pv', 'tns.csi.io', { name: 'other-pvc', namespace: 'default' });
const pvc = makePvc('my-pvc', 'default');
expect(findBoundPv(pvc, [pv])).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// VolumeSnapshot filtering
// ---------------------------------------------------------------------------
describe('filterTnsCsiVolumeSnapshots', () => {
it('filters snapshots to matching snapshot classes', () => {
const snapshots = [
{ metadata: { name: 's1' }, spec: { volumeSnapshotClassName: 'tns-vsc' } },
{ metadata: { name: 's2' }, spec: { volumeSnapshotClassName: 'other-vsc' } },
];
const result = filterTnsCsiVolumeSnapshots(snapshots, new Set(['tns-vsc']));
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('s1');
});
});
// ---------------------------------------------------------------------------
// Formatting utilities
// ---------------------------------------------------------------------------
describe('formatProtocol', () => {
it('maps nfs → NFS', () => expect(formatProtocol('nfs')).toBe('NFS'));
it('maps nvmeof → NVMe-oF', () => expect(formatProtocol('nvmeof')).toBe('NVMe-oF'));
it('maps iscsi → iSCSI', () => expect(formatProtocol('iscsi')).toBe('iSCSI'));
it('passes through unknown values', () => expect(formatProtocol('custom')).toBe('custom'));
it('returns — for undefined', () => expect(formatProtocol(undefined)).toBe('—'));
});
describe('formatAccessModes', () => {
it('abbreviates access modes', () => {
expect(formatAccessModes(['ReadWriteOnce', 'ReadWriteMany'])).toBe('RWO, RWX');
});
it('returns — for empty', () => expect(formatAccessModes([])).toBe('—'));
it('returns — for undefined', () => expect(formatAccessModes(undefined)).toBe('—'));
});
describe('formatAge', () => {
it('returns "unknown" for undefined', () => {
expect(formatAge(undefined)).toBe('unknown');
});
it('returns seconds for very recent timestamps', () => {
const ts = new Date(Date.now() - 30_000).toISOString();
expect(formatAge(ts)).toBe('30s');
});
it('returns minutes for ~5min ago', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(formatAge(ts)).toBe('5m');
});
});
describe('phaseToStatus', () => {
it('maps Bound → success', () => expect(phaseToStatus('Bound')).toBe('success'));
it('maps Pending → warning', () => expect(phaseToStatus('Pending')).toBe('warning'));
it('maps Failed → error', () => expect(phaseToStatus('Failed')).toBe('error'));
it('maps undefined → error', () => expect(phaseToStatus(undefined)).toBe('error'));
});
+356
View File
@@ -0,0 +1,356 @@
/**
* Kubernetes type definitions and helper functions for tns-csi resources.
*
* All K8s resource types are typed at the fields we actually use.
* External data from the API is validated at the boundary before use.
*/
// ---------------------------------------------------------------------------
// Provisioner constant
// ---------------------------------------------------------------------------
export const TNS_CSI_PROVISIONER = 'tns.csi.io' as const;
// ---------------------------------------------------------------------------
// Label selectors
// ---------------------------------------------------------------------------
export const TNS_CSI_CONTROLLER_SELECTOR =
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller';
export const TNS_CSI_NODE_SELECTOR =
'app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node';
// ---------------------------------------------------------------------------
// Generic Kubernetes object base shapes
// ---------------------------------------------------------------------------
export interface KubeObjectMeta {
name: string;
namespace?: string;
creationTimestamp?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
uid?: string;
}
export interface KubeObject {
apiVersion?: string;
kind?: string;
metadata: KubeObjectMeta;
}
// ---------------------------------------------------------------------------
// StorageClass
// ---------------------------------------------------------------------------
export interface TnsCsiStorageClassParameters {
protocol?: 'nfs' | 'nvmeof' | 'iscsi' | string;
pool?: string;
server?: string;
deleteStrategy?: 'delete' | 'retain' | string;
encryption?: string; // "true" / "false" string from K8s params
}
export interface TnsCsiStorageClass extends KubeObject {
provisioner: string;
reclaimPolicy?: string;
volumeBindingMode?: string;
allowVolumeExpansion?: boolean;
parameters?: TnsCsiStorageClassParameters;
}
export function isTnsCsiStorageClass(sc: unknown): sc is TnsCsiStorageClass {
if (!sc || typeof sc !== 'object') return false;
const obj = sc as Record<string, unknown>;
return obj['provisioner'] === TNS_CSI_PROVISIONER;
}
export function filterTnsCsiStorageClasses(items: unknown[]): TnsCsiStorageClass[] {
return items.filter(isTnsCsiStorageClass);
}
// ---------------------------------------------------------------------------
// PersistentVolume
// ---------------------------------------------------------------------------
export interface TnsCsiVolumeAttributes {
protocol?: string;
server?: string;
pool?: string;
[key: string]: string | undefined;
}
export interface CsiSpec {
driver: string;
volumeHandle?: string;
volumeAttributes?: TnsCsiVolumeAttributes;
}
export interface ClaimRef {
name: string;
namespace: string;
}
export interface PersistentVolumeSpec {
csi?: CsiSpec;
capacity?: { storage?: string };
accessModes?: string[];
persistentVolumeReclaimPolicy?: string;
storageClassName?: string;
claimRef?: ClaimRef;
}
export interface TnsCsiPersistentVolume extends KubeObject {
spec: PersistentVolumeSpec;
status?: { phase?: string };
}
export function isTnsCsiPersistentVolume(pv: unknown): pv is TnsCsiPersistentVolume {
if (!pv || typeof pv !== 'object') return false;
const obj = pv as Record<string, unknown>;
const spec = obj['spec'] as Record<string, unknown> | undefined;
if (!spec) return false;
const csi = spec['csi'] as Record<string, unknown> | undefined;
return csi?.['driver'] === TNS_CSI_PROVISIONER;
}
export function filterTnsCsiPersistentVolumes(items: unknown[]): TnsCsiPersistentVolume[] {
return items.filter(isTnsCsiPersistentVolume);
}
// ---------------------------------------------------------------------------
// PersistentVolumeClaim
// ---------------------------------------------------------------------------
export interface PVCSpec {
storageClassName?: string;
accessModes?: string[];
resources?: { requests?: { storage?: string } };
volumeName?: string;
}
export interface TnsCsiPersistentVolumeClaim extends KubeObject {
spec: PVCSpec;
status?: {
phase?: string;
capacity?: { storage?: string };
accessModes?: string[];
};
}
/**
* Returns PVCs that are bound to a tns-csi PV (cross-reference by claimRef).
*/
export function filterTnsCsiPVCs(
pvcs: TnsCsiPersistentVolumeClaim[],
tnsPvs: TnsCsiPersistentVolume[]
): TnsCsiPersistentVolumeClaim[] {
const boundSet = new Set<string>();
for (const pv of tnsPvs) {
const ref = pv.spec.claimRef;
if (ref) {
boundSet.add(`${ref.namespace}/${ref.name}`);
}
}
return pvcs.filter(pvc => {
const ns = pvc.metadata.namespace ?? '';
return boundSet.has(`${ns}/${pvc.metadata.name}`);
});
}
/** Find the tns-csi PV bound to a given PVC. */
export function findBoundPv(
pvc: TnsCsiPersistentVolumeClaim,
tnsPvs: TnsCsiPersistentVolume[]
): TnsCsiPersistentVolume | undefined {
const ns = pvc.metadata.namespace ?? '';
const name = pvc.metadata.name;
return tnsPvs.find(
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
);
}
// ---------------------------------------------------------------------------
// CSIDriver
// ---------------------------------------------------------------------------
export interface CSIDriverSpec {
attachRequired?: boolean;
podInfoOnMount?: boolean;
volumeLifecycleModes?: string[];
}
export interface CSIDriver extends KubeObject {
spec?: CSIDriverSpec;
}
// ---------------------------------------------------------------------------
// Pod
// ---------------------------------------------------------------------------
export interface ContainerStatus {
name: string;
ready: boolean;
restartCount: number;
image?: string;
state?: {
running?: { startedAt?: string };
waiting?: { reason?: string; message?: string };
terminated?: { exitCode?: number; reason?: string };
};
}
export interface PodStatus {
phase?: string;
conditions?: Array<{ type: string; status: string }>;
containerStatuses?: ContainerStatus[];
}
export interface PodSpec {
nodeName?: string;
}
export interface TnsCsiPod extends KubeObject {
spec?: PodSpec;
status?: PodStatus;
}
export function isPodReady(pod: TnsCsiPod): boolean {
return (
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
);
}
export function getPodRestarts(pod: TnsCsiPod): number {
return (
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
);
}
export function getPodImage(pod: TnsCsiPod): string {
return pod.status?.containerStatuses?.[0]?.image ?? 'unknown';
}
// ---------------------------------------------------------------------------
// VolumeSnapshot (CRD: snapshot.storage.k8s.io/v1)
// ---------------------------------------------------------------------------
export interface VolumeSnapshotSpec {
source?: { persistentVolumeClaimName?: string; volumeSnapshotContentName?: string };
volumeSnapshotClassName?: string;
}
export interface VolumeSnapshotStatus {
readyToUse?: boolean;
restoreSize?: string;
error?: { message?: string };
}
export interface VolumeSnapshot extends KubeObject {
spec?: VolumeSnapshotSpec;
status?: VolumeSnapshotStatus;
}
export interface VolumeSnapshotClass extends KubeObject {
driver?: string;
deletionPolicy?: string;
}
export function isTnsCsiVolumeSnapshotClass(vsc: unknown): vsc is VolumeSnapshotClass {
if (!vsc || typeof vsc !== 'object') return false;
const obj = vsc as Record<string, unknown>;
return obj['driver'] === TNS_CSI_PROVISIONER;
}
export function filterTnsCsiVolumeSnapshots(
snapshots: VolumeSnapshot[],
tnsCsiSnapshotClassNames: Set<string>
): VolumeSnapshot[] {
return snapshots.filter(
s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName)
);
}
// ---------------------------------------------------------------------------
// K8s API list response envelope
// ---------------------------------------------------------------------------
export interface KubeList<T> {
items: T[];
metadata?: { resourceVersion?: string };
}
/**
* Type guard for a KubeList response from ApiProxy.request.
* Validates the minimal structure (items array) before consuming.
*/
export function isKubeList(value: unknown): value is KubeList<unknown> {
if (!value || typeof value !== 'object') return false;
return Array.isArray((value as Record<string, unknown>)['items']);
}
// ---------------------------------------------------------------------------
// Utility: human-readable age
// ---------------------------------------------------------------------------
export function formatAge(timestamp: string | undefined): string {
if (!timestamp) return 'unknown';
const diffMs = Date.now() - new Date(timestamp).getTime();
const secs = Math.floor(diffMs / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
// ---------------------------------------------------------------------------
// Utility: access modes display
// ---------------------------------------------------------------------------
const ACCESS_MODE_ABBREV: Record<string, string> = {
ReadWriteOnce: 'RWO',
ReadWriteMany: 'RWX',
ReadOnlyMany: 'ROX',
ReadWriteOncePod: 'RWOP',
};
export function formatAccessModes(modes: string[] | undefined): string {
if (!modes || modes.length === 0) return '—';
return modes.map(m => ACCESS_MODE_ABBREV[m] ?? m).join(', ');
}
// ---------------------------------------------------------------------------
// Utility: protocol display
// ---------------------------------------------------------------------------
export function formatProtocol(protocol: string | undefined): string {
if (!protocol) return '—';
const map: Record<string, string> = {
nfs: 'NFS',
nvmeof: 'NVMe-oF',
iscsi: 'iSCSI',
};
return map[protocol.toLowerCase()] ?? protocol;
}
// ---------------------------------------------------------------------------
// Phase → StatusLabel status mapping
// ---------------------------------------------------------------------------
export function phaseToStatus(phase: string | undefined): 'success' | 'warning' | 'error' {
switch (phase) {
case 'Bound':
case 'Available':
case 'Running':
case 'Succeeded':
return 'success';
case 'Pending':
case 'Released':
return 'warning';
default:
return 'error';
}
}
+195
View File
@@ -0,0 +1,195 @@
import { describe, expect, it } from 'vitest';
import {
buildJobManifest,
buildPvcManifest,
formatBandwidth,
formatIops,
formatLatency,
generateJobName,
generatePvcName,
KBENCH_FIO_LABEL,
KBENCH_FIO_VALUE,
KBENCH_MANAGED_BY_LABEL,
KBENCH_MANAGED_BY_VALUE,
parseKbenchLog,
} from './kbench';
const SAMPLE_LOG = `
=====================
FIO Benchmark Summary
For: test_device
SIZE: 30G
QUICK MODE: DISABLED
=====================
IOPS (Read/Write)
Random: 98368 / 89200
Sequential: 108513 / 107636
CPU Idleness: 68%
Bandwidth in KiB/sec (Read/Write)
Random: 542447 / 514487
Sequential: 552052 / 521330
CPU Idleness: 99%
Latency in ns (Read/Write)
Random: 97222 / 44548
Sequential: 40483 / 44690
CPU Idleness: 72%
`.trim();
const INCOMPLETE_LOG = 'Some other log output without FIO summary';
// ---------------------------------------------------------------------------
// Name generation
// ---------------------------------------------------------------------------
describe('generateJobName', () => {
it('generates names starting with kbench-', () => {
expect(generateJobName()).toMatch(/^kbench-[a-z0-9]+$/);
});
it('generates unique names', () => {
const names = new Set(Array.from({ length: 100 }, () => generateJobName()));
// Highly unlikely to collide in 100 attempts
expect(names.size).toBeGreaterThan(90);
});
});
describe('generatePvcName', () => {
it('derives PVC name from job name', () => {
expect(generatePvcName('kbench-abc123')).toBe('kbench-abc123-pvc');
});
});
// ---------------------------------------------------------------------------
// Manifest builders
// ---------------------------------------------------------------------------
describe('buildPvcManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
it('produces a valid PVC manifest with correct storage class', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>;
expect(manifest['kind']).toBe('PersistentVolumeClaim');
const spec = manifest['spec'] as Record<string, unknown>;
expect(spec['storageClassName']).toBe('tns-nfs');
const resources = spec['resources'] as Record<string, unknown>;
const requests = resources['requests'] as Record<string, unknown>;
expect(requests['storage']).toBe('33Gi');
});
it('applies managed-by label', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>;
const meta = manifest['metadata'] as Record<string, unknown>;
const labels = meta['labels'] as Record<string, string>;
expect(labels[KBENCH_MANAGED_BY_LABEL]).toBe(KBENCH_MANAGED_BY_VALUE);
expect(labels[KBENCH_FIO_LABEL]).toBe(KBENCH_FIO_VALUE);
});
});
describe('buildJobManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
it('produces a valid Job manifest', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>;
expect(manifest['kind']).toBe('Job');
const spec = manifest['spec'] as Record<string, unknown>;
expect(spec['backoffLimit']).toBe(0);
});
it('uses default size and mode when not specified', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>;
const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>;
const containers = podSpec['containers'] as Array<Record<string, unknown>>;
const env = containers[0]?.['env'] as Array<{ name: string; value: string }>;
expect(env?.find(e => e.name === 'SIZE')?.value).toBe('30G');
expect(env?.find(e => e.name === 'MODE')?.value).toBe('full');
});
it('uses custom size and mode when specified', () => {
const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<string, unknown>;
const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>;
const containers = podSpec['containers'] as Array<Record<string, unknown>>;
const env = containers[0]?.['env'] as Array<{ name: string; value: string }>;
expect(env?.find(e => e.name === 'SIZE')?.value).toBe('10G');
expect(env?.find(e => e.name === 'MODE')?.value).toBe('quick');
});
});
// ---------------------------------------------------------------------------
// Log parser
// ---------------------------------------------------------------------------
describe('parseKbenchLog', () => {
it('parses a complete FIO benchmark log', () => {
const result = parseKbenchLog(SAMPLE_LOG);
expect(result).not.toBeNull();
expect(result?.iops.randomRead).toBe(98368);
expect(result?.iops.randomWrite).toBe(89200);
expect(result?.iops.sequentialRead).toBe(108513);
expect(result?.iops.sequentialWrite).toBe(107636);
expect(result?.iops.cpuIdleness).toBe(68);
expect(result?.bandwidth.randomRead).toBe(542447);
expect(result?.bandwidth.randomWrite).toBe(514487);
expect(result?.bandwidth.cpuIdleness).toBe(99);
expect(result?.latency.randomRead).toBe(97222);
expect(result?.latency.randomWrite).toBe(44548);
expect(result?.latency.cpuIdleness).toBe(72);
});
it('returns null for unparseable log', () => {
expect(parseKbenchLog(INCOMPLETE_LOG)).toBeNull();
});
it('returns null for empty string', () => {
expect(parseKbenchLog('')).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
describe('formatIops', () => {
it('formats with thousands separator', () => {
expect(formatIops(98368)).toBe('98,368');
});
});
describe('formatBandwidth', () => {
it('formats GiB/s for large values', () => {
const result = formatBandwidth(2 * 1024 * 1024); // 2 GiB/s in KiB
expect(result).toBe('2.0 GiB/s');
});
it('formats MiB/s for medium values', () => {
const result = formatBandwidth(542447);
expect(result).toMatch(/MiB\/s/);
});
it('formats KiB/s for small values', () => {
const result = formatBandwidth(500);
expect(result).toBe('500 KiB/s');
});
});
describe('formatLatency', () => {
it('formats milliseconds for large values', () => {
expect(formatLatency(5_000_000)).toBe('5.00 ms');
});
it('formats microseconds for medium values', () => {
expect(formatLatency(97_222)).toBe('97.2 µs');
});
it('formats nanoseconds for small values', () => {
expect(formatLatency(500)).toBe('500 ns');
});
});
+435
View File
@@ -0,0 +1,435 @@
/**
* kbench integration: Job/PVC lifecycle management and FIO log parsing.
*
* kbench (https://github.com/longhorn/kbench) runs as a Kubernetes Job backed
* by a PVC. Results are parsed from pod logs after job completion.
*/
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface KbenchMetricGroup {
randomRead: number;
randomWrite: number;
sequentialRead: number;
sequentialWrite: number;
cpuIdleness: number;
}
export interface KbenchResult {
iops: KbenchMetricGroup;
bandwidth: KbenchMetricGroup; // KiB/s
latency: KbenchMetricGroup; // nanoseconds
metadata: KbenchResultMetadata;
}
export interface KbenchResultMetadata {
storageClass: string;
size: string;
startedAt: string;
completedAt: string;
jobName: string;
namespace: string;
}
export type BenchmarkStatus = 'idle' | 'creating-pvc' | 'waiting-pvc' | 'running' | 'parsing' | 'complete' | 'failed';
export type BenchmarkState =
| { status: 'idle' }
| { status: 'creating-pvc' }
| { status: 'waiting-pvc'; pvcName: string }
| { status: 'running'; jobName: string; pvcName: string; startedAt: string }
| { status: 'parsing'; jobName: string; pvcName: string }
| { status: 'complete'; result: KbenchResult; jobName: string; pvcName: string }
| { status: 'failed'; error: string; jobName: string; pvcName: string };
export interface KbenchJobSummary {
jobName: string;
namespace: string;
storageClass: string;
phase: 'Active' | 'Complete' | 'Failed' | 'Unknown';
startedAt: string;
completedAt?: string;
}
// ---------------------------------------------------------------------------
// Labels / annotations used for tracking
// ---------------------------------------------------------------------------
export const KBENCH_MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
export const KBENCH_MANAGED_BY_VALUE = 'headlamp-tns-csi-plugin';
export const KBENCH_FIO_LABEL = 'kbench';
export const KBENCH_FIO_VALUE = 'fio';
export const KBENCH_STORAGE_CLASS_ANNOTATION = 'tns-csi.headlamp/storage-class';
// ---------------------------------------------------------------------------
// Unique name generation
// ---------------------------------------------------------------------------
function shortId(): string {
return Math.random().toString(36).slice(2, 8);
}
export function generateJobName(): string {
return `kbench-${shortId()}`;
}
export function generatePvcName(jobName: string): string {
return `${jobName}-pvc`;
}
// ---------------------------------------------------------------------------
// Kubernetes manifest builders
// ---------------------------------------------------------------------------
export interface KbenchJobOptions {
jobName: string;
pvcName: string;
namespace: string;
storageClass: string;
size?: string; // default "30G"
mode?: string; // default "full"
}
export function buildPvcManifest(opts: KbenchJobOptions): object {
return {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: opts.pvcName,
namespace: opts.namespace,
labels: {
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
},
annotations: {
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
},
},
spec: {
storageClassName: opts.storageClass,
accessModes: ['ReadWriteOnce'],
resources: {
requests: {
// kbench needs ~33Gi for a 30G test (10% buffer rule)
storage: '33Gi',
},
},
},
};
}
export function buildJobManifest(opts: KbenchJobOptions): object {
return {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: opts.jobName,
namespace: opts.namespace,
labels: {
[KBENCH_MANAGED_BY_LABEL]: KBENCH_MANAGED_BY_VALUE,
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
},
annotations: {
[KBENCH_STORAGE_CLASS_ANNOTATION]: opts.storageClass,
},
},
spec: {
template: {
metadata: {
labels: {
[KBENCH_FIO_LABEL]: KBENCH_FIO_VALUE,
},
},
spec: {
containers: [
{
name: 'kbench',
image: 'yasker/kbench:latest',
env: [
{ name: 'MODE', value: opts.mode ?? 'full' },
{ name: 'FILE_NAME', value: '/volume/test' },
{ name: 'SIZE', value: opts.size ?? '30G' },
{ name: 'CPU_IDLE_PROF', value: 'disabled' },
],
volumeMounts: [
{ name: 'vol', mountPath: '/volume/' },
],
},
],
restartPolicy: 'Never',
volumes: [
{
name: 'vol',
persistentVolumeClaim: { claimName: opts.pvcName },
},
],
},
},
backoffLimit: 0,
},
};
}
// ---------------------------------------------------------------------------
// API operations
// ---------------------------------------------------------------------------
export async function createPvc(opts: KbenchJobOptions): Promise<void> {
await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims`, {
method: 'POST',
body: JSON.stringify(buildPvcManifest(opts)),
headers: { 'Content-Type': 'application/json' },
});
}
export async function createJob(opts: KbenchJobOptions): Promise<void> {
await ApiProxy.request(`/apis/batch/v1/namespaces/${opts.namespace}/jobs`, {
method: 'POST',
body: JSON.stringify(buildJobManifest(opts)),
headers: { 'Content-Type': 'application/json' },
});
}
interface K8sJobStatus {
active?: number;
succeeded?: number;
failed?: number;
completionTime?: string;
}
interface K8sJob {
status?: K8sJobStatus;
metadata?: { creationTimestamp?: string };
}
export type JobPhase = 'Active' | 'Complete' | 'Failed' | 'Unknown';
export async function getJobPhase(
jobName: string,
namespace: string
): Promise<{ phase: JobPhase; job: K8sJob }> {
const job = await ApiProxy.request(
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
) as K8sJob;
const status = job.status;
let phase: JobPhase = 'Unknown';
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
else if (status?.failed && status.failed > 0) phase = 'Failed';
else if (status?.active && status.active > 0) phase = 'Active';
return { phase, job };
}
export async function getPvcPhase(
pvcName: string,
namespace: string
): Promise<string> {
const pvc = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
) as { status?: { phase?: string } };
return pvc.status?.phase ?? 'Unknown';
}
/**
* Fetches the logs from the kbench pod (via the Job's pod selector).
* Uses the pod label selector to find the pod.
*/
export async function fetchKbenchLogs(
jobName: string,
namespace: string
): Promise<string> {
// Find pod with label kbench=fio and job-name=<jobName>
const podList = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(`job-name=${jobName}`)}`
) as { items?: Array<{ metadata?: { name?: string } }> };
const podName = podList.items?.[0]?.metadata?.name;
if (!podName) {
throw new Error(`No pod found for kbench job "${jobName}"`);
}
const logs = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
{ isJSON: false }
) as unknown;
if (typeof logs !== 'string') {
throw new Error('Pod logs were not returned as text');
}
return logs;
}
export async function deleteJob(jobName: string, namespace: string): Promise<void> {
await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, {
method: 'DELETE',
body: JSON.stringify({ propagationPolicy: 'Foreground' }),
headers: { 'Content-Type': 'application/json' },
});
}
export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
await ApiProxy.request(
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`,
{ method: 'DELETE' }
);
}
// ---------------------------------------------------------------------------
// FIO log parser
// ---------------------------------------------------------------------------
/**
* Parses a kbench FIO benchmark summary from pod log text.
*
* Expected format:
* =====================
* FIO Benchmark Summary
* ...
* IOPS (Read/Write)
* Random: 98368 / 89200
* Sequential: 108513 / 107636
* CPU Idleness: 68%
*
* Bandwidth in KiB/sec (Read/Write)
* Random: 542447 / 514487
* ...
*
* Latency in ns (Read/Write)
* ...
*/
export function parseKbenchLog(logText: string): KbenchResult | null {
const lines = logText.split('\n').map(l => l.trim());
function extractSection(header: string): string[] {
const idx = lines.findIndex(l => l.startsWith(header));
if (idx < 0) return [];
const section: string[] = [];
for (let i = idx + 1; i < lines.length && i < idx + 10; i++) {
const line = lines[i];
if (!line) break;
section.push(line);
}
return section;
}
function parseReadWrite(line: string): [number, number] | null {
const match = /(\d[\d,]*)\s*\/\s*(\d[\d,]*)/.exec(line);
if (!match) return null;
const read = parseInt(match[1].replace(/,/g, ''), 10);
const write = parseInt(match[2].replace(/,/g, ''), 10);
if (!Number.isFinite(read) || !Number.isFinite(write)) return null;
return [read, write];
}
function parseCpu(line: string): number {
const match = /(\d+)%/.exec(line);
return match ? parseInt(match[1], 10) : 0;
}
function parseSection(header: string): KbenchMetricGroup | null {
const section = extractSection(header);
if (section.length === 0) return null;
const randomLine = section.find(l => l.startsWith('Random:'));
const seqLine = section.find(l => l.startsWith('Sequential:'));
const cpuLine = section.find(l => l.startsWith('CPU Idleness:'));
const random = randomLine ? parseReadWrite(randomLine) : null;
const sequential = seqLine ? parseReadWrite(seqLine) : null;
const cpu = cpuLine ? parseCpu(cpuLine) : 0;
if (!random || !sequential) return null;
return {
randomRead: random[0],
randomWrite: random[1],
sequentialRead: sequential[0],
sequentialWrite: sequential[1],
cpuIdleness: cpu,
};
}
const iops = parseSection('IOPS (Read/Write)');
const bandwidth = parseSection('Bandwidth in KiB/sec (Read/Write)');
const latency = parseSection('Latency in ns (Read/Write)');
if (!iops || !bandwidth || !latency) return null;
return {
iops,
bandwidth,
latency,
metadata: {
storageClass: '', // filled in by the caller
size: '30G',
startedAt: '',
completedAt: new Date().toISOString(),
jobName: '',
namespace: '',
},
};
}
// ---------------------------------------------------------------------------
// List existing kbench Jobs (for Past Benchmarks view)
// ---------------------------------------------------------------------------
export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobSummary[]> {
const selector = encodeURIComponent(
`${KBENCH_MANAGED_BY_LABEL}=${KBENCH_MANAGED_BY_VALUE},${KBENCH_FIO_LABEL}=${KBENCH_FIO_VALUE}`
);
const path = namespace
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
: `/apis/batch/v1/jobs?labelSelector=${selector}`;
const list = await ApiProxy.request(path) as {
items?: Array<{
metadata?: { name?: string; namespace?: string; annotations?: Record<string, string>; creationTimestamp?: string };
status?: K8sJobStatus;
}>;
};
return (list.items ?? []).map(job => {
const status = job.status;
let phase: JobPhase = 'Unknown';
if (status?.succeeded && status.succeeded > 0) phase = 'Complete';
else if (status?.failed && status.failed > 0) phase = 'Failed';
else if (status?.active && status.active > 0) phase = 'Active';
return {
jobName: job.metadata?.name ?? '',
namespace: job.metadata?.namespace ?? namespace,
storageClass: job.metadata?.annotations?.[KBENCH_STORAGE_CLASS_ANNOTATION] ?? '—',
phase,
startedAt: job.metadata?.creationTimestamp ?? '',
completedAt: status?.completionTime,
};
});
}
// ---------------------------------------------------------------------------
// Formatting helpers for result display
// ---------------------------------------------------------------------------
export function formatIops(value: number): string {
return value.toLocaleString();
}
export function formatBandwidth(kib: number): string {
const mib = kib / 1024;
if (mib >= 1024) return `${(mib / 1024).toFixed(1)} GiB/s`;
if (mib >= 1) return `${mib.toFixed(0)} MiB/s`;
return `${kib.toFixed(0)} KiB/s`;
}
export function formatLatency(ns: number): string {
if (ns >= 1_000_000) return `${(ns / 1_000_000).toFixed(2)} ms`;
if (ns >= 1_000) return `${(ns / 1_000).toFixed(1)} µs`;
return `${ns} ns`;
}
+144
View File
@@ -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'));
});
+240
View File
@@ -0,0 +1,240 @@
/**
* Prometheus text format parser for tns-csi controller metrics.
*
* Fetches the raw metrics text via ApiProxy and parses the key metric families
* we expose in the Metrics page.
*/
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import type { TnsCsiPod } from './k8s';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MetricSample {
labels: Record<string, string>;
value: number;
}
export interface MetricFamily {
name: string;
help: string;
type: string;
samples: MetricSample[];
}
export type ParsedMetrics = Map<string, MetricFamily>;
export interface TnsCsiMetrics {
/** 1 = connected, 0 = disconnected */
websocketConnected: number | null;
websocketReconnectsTotal: number | null;
websocketMessagesTotal: MetricSample[];
websocketMessageDurationSeconds: MetricSample[];
volumeOperationsTotal: MetricSample[];
volumeOperationsDurationSeconds: MetricSample[];
volumeCapacityBytes: MetricSample[];
csiOperationsTotal: MetricSample[];
csiOperationsDurationSeconds: MetricSample[];
}
// ---------------------------------------------------------------------------
// Prometheus text format parser
// ---------------------------------------------------------------------------
const LABEL_PAIR_RE = /(\w+)="([^"]*)"/g;
function parseLabels(labelStr: string): Record<string, string> {
const labels: Record<string, string> = {};
let match: RegExpExecArray | null;
const re = new RegExp(LABEL_PAIR_RE.source, 'g');
while ((match = re.exec(labelStr)) !== null) {
const key = match[1];
const val = match[2];
if (key && val !== undefined) {
labels[key] = val;
}
}
return labels;
}
/**
* Parses Prometheus text exposition format into a Map of metric families.
* Only handles the subset used by tns-csi (gauge, counter, histogram summaries).
*/
export function parsePrometheusText(text: string): ParsedMetrics {
const families = new Map<string, MetricFamily>();
let currentName = '';
let currentHelp = '';
let currentType = '';
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('# HELP ')) {
const rest = line.slice(7);
const spaceIdx = rest.indexOf(' ');
currentName = spaceIdx >= 0 ? rest.slice(0, spaceIdx) : rest;
currentHelp = spaceIdx >= 0 ? rest.slice(spaceIdx + 1) : '';
continue;
}
if (line.startsWith('# TYPE ')) {
const rest = line.slice(7);
const spaceIdx = rest.indexOf(' ');
currentType = spaceIdx >= 0 ? rest.slice(spaceIdx + 1) : '';
continue;
}
if (line.startsWith('#')) continue;
// Sample line: metric_name{label="val"} 1.0
// or: metric_name 1.0
const openBrace = line.indexOf('{');
const closeBrace = line.lastIndexOf('}');
let metricName: string;
let labels: Record<string, string>;
let valuePart: string;
if (openBrace >= 0 && closeBrace > openBrace) {
metricName = line.slice(0, openBrace);
labels = parseLabels(line.slice(openBrace + 1, closeBrace));
valuePart = line.slice(closeBrace + 1).trim();
} else {
const spaceIdx = line.lastIndexOf(' ');
if (spaceIdx < 0) continue;
metricName = line.slice(0, spaceIdx);
labels = {};
valuePart = line.slice(spaceIdx + 1).trim();
}
// Strip timestamp if present (second space-separated token)
const valueTokens = valuePart.split(' ');
const valueStr = valueTokens[0] ?? '';
const value = parseFloat(valueStr);
if (!Number.isFinite(value)) continue;
// Determine the family name: for histogram/summary _bucket/_count/_sum
// strip the suffix but keep it as the family name key
const familyKey = metricName;
let family = families.get(familyKey);
if (!family) {
family = {
name: familyKey,
help: metricName === currentName ? currentHelp : '',
type: metricName === currentName ? currentType : '',
samples: [],
};
families.set(familyKey, family);
}
family.samples.push({ labels, value });
}
return families;
}
// ---------------------------------------------------------------------------
// Extract tns-csi-specific metrics from the parsed map
// ---------------------------------------------------------------------------
function scalarMetric(families: ParsedMetrics, name: string): number | null {
const family = families.get(name);
if (!family || family.samples.length === 0) return null;
return family.samples[0]?.value ?? null;
}
function samplesFor(families: ParsedMetrics, name: string): MetricSample[] {
return families.get(name)?.samples ?? [];
}
export function extractTnsCsiMetrics(families: ParsedMetrics): TnsCsiMetrics {
return {
websocketConnected: scalarMetric(families, 'tns_websocket_connected'),
websocketReconnectsTotal: scalarMetric(families, 'tns_websocket_reconnects_total'),
websocketMessagesTotal: samplesFor(families, 'tns_websocket_messages_total'),
websocketMessageDurationSeconds: samplesFor(families, 'tns_websocket_message_duration_seconds'),
volumeOperationsTotal: samplesFor(families, 'tns_volume_operations_total'),
volumeOperationsDurationSeconds: samplesFor(families, 'tns_volume_operations_duration_seconds'),
volumeCapacityBytes: samplesFor(families, 'tns_volume_capacity_bytes'),
csiOperationsTotal: samplesFor(families, 'tns_csi_operations_total'),
csiOperationsDurationSeconds: samplesFor(families, 'tns_csi_operations_duration_seconds'),
};
}
// ---------------------------------------------------------------------------
// Fetch metrics via Kubernetes API proxy
// ---------------------------------------------------------------------------
/**
* Fetches metrics from the tns-csi controller pod via the Kubernetes API proxy.
*
* The proxy path is:
* /api/v1/namespaces/{namespace}/pods/{podName}:{port}/proxy/metrics
*/
export async function fetchControllerMetrics(
controllerPod: TnsCsiPod,
namespace: string = 'kube-system'
): Promise<TnsCsiMetrics> {
const podName = controllerPod.metadata.name;
const path = `/api/v1/namespaces/${namespace}/pods/${podName}:8080/proxy/metrics`;
const raw: unknown = await ApiProxy.request(path, {
method: 'GET',
isJSON: false,
});
if (typeof raw !== 'string') {
throw new Error('Metrics endpoint did not return text');
}
const families = parsePrometheusText(raw);
return extractTnsCsiMetrics(families);
}
// ---------------------------------------------------------------------------
// Formatting helpers for display
// ---------------------------------------------------------------------------
/** Format a bytes value as a human-readable string (GB/MB/KB). */
export function formatBytes(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
return `${bytes} B`;
}
/** Sum all sample values for a given metric name. */
export function sumSamples(samples: MetricSample[]): number {
return samples.reduce((acc, s) => acc + s.value, 0);
}
/** Group samples by a label key, summing values per group. */
export function groupByLabel(
samples: MetricSample[],
labelKey: string
): Map<string, number> {
const result = new Map<string, number>();
for (const sample of samples) {
const key = sample.labels[labelKey] ?? 'unknown';
result.set(key, (result.get(key) ?? 0) + sample.value);
}
return result;
}
/** Filter samples where a specific label equals a value. */
export function filterByLabel(
samples: MetricSample[],
labelKey: string,
labelValue: string
): MetricSample[] {
return samples.filter(s => s.labels[labelKey] === labelValue);
}