/** * 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 { formatAge } from '../api/k8s'; 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 { useTnsCsiContext } from '../api/TnsCsiDataContext'; // --------------------------------------------------------------------------- // 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 => ( ))}
Metric Read Write {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 cancelledRef = useRef(false); const [cleaningUp, setCleaningUp] = useState(false); 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(); cancelledRef.current = false; 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 && !cancelledRef.current) { 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 (cancelledRef.current) return; 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 and cancel async loops on unmount useEffect( () => () => { cancelledRef.current = true; 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' || cleaningUp) return; setCleaningUp(true); try { await deleteJob(state.jobName, lastNamespace); await deletePvc(state.pvcName, lastNamespace); setBenchState({ status: 'idle' }); setCurrentResult(null); } catch (err: unknown) { setBenchState({ status: 'failed', error: `Cleanup error: ${ err instanceof Error ? err.message : String(err) }`, jobName: state.jobName, pvcName: state.pvcName, }); } finally { setCleaningUp(false); } }} disabled={cleaningUp} 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: cleaningUp ? 'not-allowed' : 'pointer', fontSize: '13px', opacity: cleaningUp ? 0.6 : 1, }} > {cleaningUp ? 'Deleting...' : 'Delete Job + PVC'} ), }, ]} /> )} ); }