diff --git a/src/api/TnsCsiDataContext.test.tsx b/src/api/TnsCsiDataContext.test.tsx index 4c158db..e19b54b 100644 --- a/src/api/TnsCsiDataContext.test.tsx +++ b/src/api/TnsCsiDataContext.test.tsx @@ -21,10 +21,14 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ }, }, ConfigStore: class { - get() { return {}; } + get() { + return {}; + } set() {} update() {} - useConfig() { return () => ({}); } + useConfig() { + return () => ({}); + } }, })); diff --git a/src/api/TnsCsiDataContext.tsx b/src/api/TnsCsiDataContext.tsx index 04ca028..428d0ea 100644 --- a/src/api/TnsCsiDataContext.tsx +++ b/src/api/TnsCsiDataContext.tsx @@ -109,9 +109,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) try { // CSIDriver try { - const driver = await ApiProxy.request( + const driver = (await ApiProxy.request( `/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}` - ) as CSIDriver; + )) as CSIDriver; if (!cancelled) setCsiDriver(driver); } catch { if (!cancelled) setCsiDriver(null); @@ -203,7 +203,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) } void fetchAsync(); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [refreshKey]); // --------------------------------------------------------------------------- diff --git a/src/api/k8s.test.ts b/src/api/k8s.test.ts index 048bc39..ede8a46 100644 --- a/src/api/k8s.test.ts +++ b/src/api/k8s.test.ts @@ -28,7 +28,11 @@ function makeSc(name: string, provisioner: string, protocol = 'nfs'): TnsCsiStor }; } -function makePv(name: string, driver: string, claimRef?: { name: string; namespace: string }): TnsCsiPersistentVolume { +function makePv( + name: string, + driver: string, + claimRef?: { name: string; namespace: string } +): TnsCsiPersistentVolume { return { metadata: { name }, spec: { @@ -106,10 +110,7 @@ describe('isTnsCsiPersistentVolume', () => { 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 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'); diff --git a/src/api/k8s.ts b/src/api/k8s.ts index 8f0d315..72ef57a 100644 --- a/src/api/k8s.ts +++ b/src/api/k8s.ts @@ -165,9 +165,7 @@ export function findBoundPv( ): 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 - ); + return tnsPvs.find(pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name); } // --------------------------------------------------------------------------- @@ -216,15 +214,11 @@ export interface TnsCsiPod extends KubeObject { } export function isPodReady(pod: TnsCsiPod): boolean { - return ( - pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false - ); + 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 - ); + return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0; } export function getPodImage(pod: TnsCsiPod): string { @@ -267,7 +261,9 @@ export function filterTnsCsiVolumeSnapshots( tnsCsiSnapshotClassNames: Set ): VolumeSnapshot[] { return snapshots.filter( - s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName) + s => + s.spec?.volumeSnapshotClassName && + tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName) ); } diff --git a/src/api/kbench.test.ts b/src/api/kbench.test.ts index 071cd02..89c33bb 100644 --- a/src/api/kbench.test.ts +++ b/src/api/kbench.test.ts @@ -66,7 +66,12 @@ describe('generatePvcName', () => { // --------------------------------------------------------------------------- describe('buildPvcManifest', () => { - const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' }; + 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; @@ -88,7 +93,12 @@ describe('buildPvcManifest', () => { }); describe('buildJobManifest', () => { - const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' }; + 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; @@ -109,7 +119,10 @@ describe('buildJobManifest', () => { }); it('uses custom size and mode when specified', () => { - const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record; + const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record< + string, + unknown + >; const spec = manifest['spec'] as Record; const template = spec['template'] as Record; const podSpec = template['spec'] as Record; diff --git a/src/api/kbench.ts b/src/api/kbench.ts index 2697313..ae00b55 100644 --- a/src/api/kbench.ts +++ b/src/api/kbench.ts @@ -22,7 +22,7 @@ export interface KbenchMetricGroup { export interface KbenchResult { iops: KbenchMetricGroup; bandwidth: KbenchMetricGroup; // KiB/s - latency: KbenchMetricGroup; // nanoseconds + latency: KbenchMetricGroup; // nanoseconds metadata: KbenchResultMetadata; } @@ -35,7 +35,14 @@ export interface KbenchResultMetadata { namespace: string; } -export type BenchmarkStatus = 'idle' | 'creating-pvc' | 'waiting-pvc' | 'running' | 'parsing' | 'complete' | 'failed'; +export type BenchmarkStatus = + | 'idle' + | 'creating-pvc' + | 'waiting-pvc' + | 'running' + | 'parsing' + | 'complete' + | 'failed'; export type BenchmarkState = | { status: 'idle' } @@ -90,8 +97,8 @@ export interface KbenchJobOptions { pvcName: string; namespace: string; storageClass: string; - size?: string; // default "30G" - mode?: string; // default "full" + size?: string; // default "30G" + mode?: string; // default "full" } export function buildPvcManifest(opts: KbenchJobOptions): object { @@ -155,9 +162,7 @@ export function buildJobManifest(opts: KbenchJobOptions): object { { name: 'SIZE', value: opts.size ?? '30G' }, { name: 'CPU_IDLE_PROF', value: 'disabled' }, ], - volumeMounts: [ - { name: 'vol', mountPath: '/volume/' }, - ], + volumeMounts: [{ name: 'vol', mountPath: '/volume/' }], }, ], restartPolicy: 'Never', @@ -212,9 +217,9 @@ export async function getJobPhase( jobName: string, namespace: string ): Promise<{ phase: JobPhase; job: K8sJob }> { - const job = await ApiProxy.request( + const job = (await ApiProxy.request( `/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}` - ) as K8sJob; + )) as K8sJob; const status = job.status; let phase: JobPhase = 'Unknown'; @@ -225,13 +230,10 @@ export async function getJobPhase( return { phase, job }; } -export async function getPvcPhase( - pvcName: string, - namespace: string -): Promise { - const pvc = await ApiProxy.request( +export async function getPvcPhase(pvcName: string, namespace: string): Promise { + const pvc = (await ApiProxy.request( `/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}` - ) as { status?: { phase?: string } }; + )) as { status?: { phase?: string } }; return pvc.status?.phase ?? 'Unknown'; } @@ -239,24 +241,23 @@ export async function getPvcPhase( * 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 { +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 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( + const logs = (await ApiProxy.request( `/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`, { isJSON: false } - ) as unknown; + )) as unknown; if (typeof logs !== 'string') { throw new Error('Pod logs were not returned as text'); @@ -274,10 +275,9 @@ export async function deleteJob(jobName: string, namespace: string): Promise { - await ApiProxy.request( - `/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, - { method: 'DELETE' } - ); + await ApiProxy.request(`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, { + method: 'DELETE', + }); } // --------------------------------------------------------------------------- @@ -366,7 +366,7 @@ export function parseKbenchLog(logText: string): KbenchResult | null { bandwidth, latency, metadata: { - storageClass: '', // filled in by the caller + storageClass: '', // filled in by the caller size: '30G', startedAt: '', completedAt: new Date().toISOString(), @@ -388,9 +388,14 @@ export async function listKbenchJobs(namespace: string = ''): Promise; creationTimestamp?: string }; + metadata?: { + name?: string; + namespace?: string; + annotations?: Record; + creationTimestamp?: string; + }; status?: K8sJobStatus; }>; }; diff --git a/src/api/metrics.ts b/src/api/metrics.ts index 3adead8..61fc391 100644 --- a/src/api/metrics.ts +++ b/src/api/metrics.ts @@ -218,10 +218,7 @@ export function sumSamples(samples: MetricSample[]): number { } /** Group samples by a label key, summing values per group. */ -export function groupByLabel( - samples: MetricSample[], - labelKey: string -): Map { +export function groupByLabel(samples: MetricSample[], labelKey: string): Map { const result = new Map(); for (const sample of samples) { const key = sample.labels[labelKey] ?? 'unknown'; diff --git a/src/api/truenas.ts b/src/api/truenas.ts index 8b1390b..cc68c69 100644 --- a/src/api/truenas.ts +++ b/src/api/truenas.ts @@ -70,10 +70,7 @@ export interface PoolStats { * @param apiKey - TrueNAS API key * @returns Array of pool stats */ -export function fetchTruenasPoolStats( - server: string, - apiKey: string -): Promise { +export function fetchTruenasPoolStats(server: string, apiKey: string): Promise { return new Promise((resolve, reject) => { // TrueNAS WebSocket endpoint — supports both SCALE and CORE const url = `wss://${server}/api/current`; @@ -99,12 +96,14 @@ export function fetchTruenasPoolStats( ws.onopen = () => { phase = 'authenticating'; - ws.send(JSON.stringify({ - id: msgId++, - msg: 'method', - method: 'auth.login_with_api_key', - params: [apiKey], - })); + ws.send( + JSON.stringify({ + id: msgId++, + msg: 'method', + method: 'auth.login_with_api_key', + params: [apiKey], + }) + ); }; ws.onmessage = (event: MessageEvent) => { @@ -124,12 +123,14 @@ export function fetchTruenasPoolStats( return; } phase = 'querying'; - ws.send(JSON.stringify({ - id: msgId++, - msg: 'method', - method: 'pool.query', - params: [], - })); + ws.send( + JSON.stringify({ + id: msgId++, + msg: 'method', + method: 'pool.query', + params: [], + }) + ); return; } @@ -162,7 +163,11 @@ export function fetchTruenasPoolStats( ws.onerror = () => { if (phase !== 'done') { clearTimeout(timeout); - reject(new Error(`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`)); + reject( + new Error( + `WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable` + ) + ); } }; diff --git a/src/components/BenchmarkPage.test.tsx b/src/components/BenchmarkPage.test.tsx index 424ef5d..0b28442 100644 --- a/src/components/BenchmarkPage.test.tsx +++ b/src/components/BenchmarkPage.test.tsx @@ -6,19 +6,24 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ request: vi.fn().mockResolvedValue({}), }, ConfigStore: class { - get() { return {}; } + get() { + return {}; + } set() {} update() {} - useConfig() { return () => ({}); } + useConfig() { + return () => ({}); + } }, })); -vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => - await import('./__mocks__/commonComponents') +vi.mock( + '@kinvolk/headlamp-plugin/lib/CommonComponents', + async () => await import('./__mocks__/commonComponents') ); vi.mock('../api/TnsCsiDataContext'); -vi.mock('../api/kbench', async (importOriginal) => { +vi.mock('../api/kbench', async importOriginal => { const actual = await importOriginal(); return { ...actual, @@ -36,16 +41,7 @@ vi.mock('../api/kbench', async (importOriginal) => { import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; -import { - createPvc, - createJob, - deleteJob, - deletePvc, - getJobPhase, - fetchKbenchLogs, - listKbenchJobs, - parseKbenchLog, -} from '../api/kbench'; +import { createPvc, createJob, listKbenchJobs } from '../api/kbench'; import { defaultContext, makeSampleStorageClass } from '../test-helpers'; import BenchmarkPage from './BenchmarkPage'; @@ -192,7 +188,9 @@ describe('BenchmarkPage', () => { render(); // Change namespace - const nsInput = screen.getByLabelText('Kubernetes namespace for benchmark job') as HTMLInputElement; + const nsInput = screen.getByLabelText( + 'Kubernetes namespace for benchmark job' + ) as HTMLInputElement; fireEvent.change(nsInput, { target: { value: 'bench-ns' } }); fireEvent.click(screen.getByLabelText('Start kbench storage benchmark')); diff --git a/src/components/BenchmarkPage.tsx b/src/components/BenchmarkPage.tsx index 4b12625..9d53687 100644 --- a/src/components/BenchmarkPage.tsx +++ b/src/components/BenchmarkPage.tsx @@ -46,7 +46,15 @@ interface MetricRowData { note?: string; } -function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: MetricRowData[]; higherIsBetter: boolean }) { +function ResultTable({ + title, + rows, + higherIsBetter, +}: { + title: string; + rows: MetricRowData[]; + higherIsBetter: boolean; +}) { return ( @@ -55,14 +63,24 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met - {rows.map(row => ( - +
Metric Read Write + {higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
{row.label} {row.formatter(row.read)} @@ -83,21 +101,69 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met 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' : '' }, + { + 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}%` }, + { + 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' : '' }, + { + 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 ( @@ -109,7 +175,12 @@ function KbenchResultDisplay({ result }: { result: KbenchResult }) { { name: 'Test Size', value: result.metadata.size }, { name: 'Job', value: result.metadata.jobName || '—' }, { name: 'Namespace', value: result.metadata.namespace || '—' }, - { name: 'Completed', value: result.metadata.completedAt ? new Date(result.metadata.completedAt).toLocaleString() : '—' }, + { + name: 'Completed', + value: result.metadata.completedAt + ? new Date(result.metadata.completedAt).toLocaleString() + : '—', + }, ]} /> @@ -154,32 +225,66 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) { 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)' }} + 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)' }} + 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)
- +