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
+570
View File
@@ -0,0 +1,570 @@
/**
* BenchmarkPage — kbench storage benchmark runner + results display.
*
* The only write operation in the plugin.
* Creates PVC + Job, polls status, parses FIO log output.
*/
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { BenchmarkState, KbenchJobSummary, KbenchResult } from '../api/kbench';
import {
createJob,
createPvc,
deleteJob,
deletePvc,
fetchKbenchLogs,
formatBandwidth,
formatIops,
formatLatency,
generateJobName,
generatePvcName,
getJobPhase,
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { formatAge } from '../api/k8s';
// ---------------------------------------------------------------------------
// Result display components
// ---------------------------------------------------------------------------
interface MetricRowData {
label: string;
read: number;
write: number | null;
formatter: (v: number) => string;
note?: string;
}
function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: MetricRowData[]; higherIsBetter: boolean }) {
return (
<SectionBox title={title}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--mui-palette-divider, #e0e0e0)' }}>
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 600 }}>Metric</th>
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Read</th>
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Write</th>
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 400, color: 'var(--mui-palette-text-secondary)' }}>
{higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.label} style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}>
<td style={{ padding: '8px 4px' }}>{row.label}</td>
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
{row.formatter(row.read)}
</td>
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
{row.write !== null ? row.formatter(row.write) : '—'}
</td>
<td style={{ padding: '8px 4px', color: 'var(--mui-palette-text-secondary)' }}>
{row.note ?? ''}
</td>
</tr>
))}
</tbody>
</table>
</SectionBox>
);
}
function KbenchResultDisplay({ result }: { result: KbenchResult }) {
const iopsRows: MetricRowData[] = [
{ label: 'Random', read: result.iops.randomRead, write: result.iops.randomWrite, formatter: formatIops },
{ label: 'Sequential', read: result.iops.sequentialRead, write: result.iops.sequentialWrite, formatter: formatIops },
{ label: 'CPU Idleness', read: result.iops.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '' },
];
const bwRows: MetricRowData[] = [
{ label: 'Random', read: result.bandwidth.randomRead, write: result.bandwidth.randomWrite, formatter: formatBandwidth },
{ label: 'Sequential', read: result.bandwidth.sequentialRead, write: result.bandwidth.sequentialWrite, formatter: formatBandwidth },
{ label: 'CPU Idleness', read: result.bandwidth.cpuIdleness, write: null, formatter: v => `${v}%` },
];
const latRows: MetricRowData[] = [
{ label: 'Random', read: result.latency.randomRead, write: result.latency.randomWrite, formatter: formatLatency },
{ label: 'Sequential', read: result.latency.sequentialRead, write: result.latency.sequentialWrite, formatter: formatLatency },
{ label: 'CPU Idleness', read: result.latency.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '' },
];
return (
<>
<SectionBox title="Benchmark Metadata">
<NameValueTable
rows={[
{ name: 'Storage Class', value: result.metadata.storageClass || '—' },
{ name: 'Test Size', value: result.metadata.size },
{ name: 'Job', value: result.metadata.jobName || '—' },
{ name: 'Namespace', value: result.metadata.namespace || '—' },
{ name: 'Completed', value: result.metadata.completedAt ? new Date(result.metadata.completedAt).toLocaleString() : '—' },
]}
/>
</SectionBox>
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter={true} />
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter={true} />
<ResultTable title="Latency" rows={latRows} higherIsBetter={false} />
</>
);
}
// ---------------------------------------------------------------------------
// Benchmark form
// ---------------------------------------------------------------------------
interface RunFormProps {
storageClasses: string[];
onRun: (opts: { storageClass: string; namespace: string; size: string; mode: string }) => void;
disabled: boolean;
}
function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
const [storageClass, setStorageClass] = useState(storageClasses[0] ?? '');
const [namespace, setNamespace] = useState('default');
const [size, setSize] = useState('30G');
const [mode, setMode] = useState('full');
const [showConfirm, setShowConfirm] = useState(false);
useEffect(() => {
if (storageClasses.length > 0 && !storageClasses.includes(storageClass)) {
setStorageClass(storageClasses[0] ?? '');
}
}, [storageClasses, storageClass]);
function handleRunClick() {
setShowConfirm(true);
}
function handleConfirm() {
setShowConfirm(false);
onRun({ storageClass, namespace, size, mode });
}
return (
<SectionBox title="Run New Benchmark">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '12px 16px', alignItems: 'center', maxWidth: '600px' }}>
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>Storage Class *</label>
<select
id="kbench-sc"
value={storageClass}
onChange={e => setStorageClass(e.target.value)}
disabled={disabled || storageClasses.length === 0}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
aria-label="Select storage class for benchmark"
>
{storageClasses.length === 0 && <option value="">No tns-csi storage classes found</option>}
{storageClasses.map(sc => <option key={sc} value={sc}>{sc}</option>)}
</select>
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>Namespace</label>
<input
id="kbench-ns"
type="text"
value={namespace}
onChange={e => setNamespace(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
aria-label="Kubernetes namespace for benchmark job"
/>
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>Test Size</label>
<div>
<input
id="kbench-size"
type="text"
value={size}
onChange={e => setSize(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', width: '120px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
aria-label="FIO test size"
/>
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--mui-palette-text-secondary)' }}>
PVC will be ~10% larger (33Gi for 30G)
</span>
</div>
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>Mode</label>
<select
id="kbench-mode"
value={mode}
onChange={e => setMode(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
aria-label="Benchmark mode"
>
<option value="full">Full (~6 minutes)</option>
<option value="quick">Quick</option>
</select>
</div>
<div style={{ marginTop: '20px' }}>
<button
onClick={handleRunClick}
disabled={disabled || storageClasses.length === 0 || !storageClass}
aria-label="Start kbench storage benchmark"
style={{
padding: '8px 20px',
backgroundColor: disabled ? 'var(--mui-palette-action-disabled, #ccc)' : 'var(--mui-palette-primary-main, #1976d2)',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: disabled ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
Run Benchmark
</button>
</div>
{showConfirm && (
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000, backgroundColor: 'rgba(0,0,0,0.5)' }}
role="dialog"
aria-modal="true"
aria-labelledby="kbench-confirm-title"
>
<div style={{ backgroundColor: 'var(--mui-palette-background-paper, #fff)', borderRadius: '8px', padding: '24px', maxWidth: '480px', boxShadow: '0 4px 24px rgba(0,0,0,0.2)', color: 'var(--mui-palette-text-primary)' }}>
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>Confirm Benchmark</h3>
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark (
<strong>~6 minutes</strong>).
</p>
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
Storage class: <strong>{storageClass}</strong> · Namespace: <strong>{namespace}</strong>
</p>
<p style={{ margin: '0 0 16px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion.
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowConfirm(false)}
aria-label="Cancel benchmark"
style={{ padding: '8px 16px', border: '1px solid var(--mui-palette-divider)', borderRadius: '4px', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'var(--mui-palette-text-primary)' }}
>
Cancel
</button>
<button
onClick={handleConfirm}
aria-label="Confirm and start benchmark"
style={{ padding: '8px 16px', backgroundColor: 'var(--mui-palette-primary-main, #1976d2)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', fontWeight: 500 }}
>
Start Benchmark
</button>
</div>
</div>
</div>
)}
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Progress display
// ---------------------------------------------------------------------------
function BenchmarkProgress({ state }: { state: BenchmarkState }) {
if (state.status === 'idle') return null;
const labels: Record<BenchmarkState['status'], string> = {
idle: '',
'creating-pvc': 'Creating PVC...',
'waiting-pvc': 'Waiting for PVC to bind...',
running: 'Benchmark running...',
parsing: 'Parsing results...',
complete: 'Complete',
failed: 'Failed',
};
const statusColor: Record<BenchmarkState['status'], 'success' | 'warning' | 'error'> = {
idle: 'warning',
'creating-pvc': 'warning',
'waiting-pvc': 'warning',
running: 'warning',
parsing: 'warning',
complete: 'success',
failed: 'error',
};
return (
<SectionBox title="Benchmark Progress">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status={statusColor[state.status]}>
{labels[state.status]}
</StatusLabel>
),
},
...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []),
...('pvcName' in state && state.pvcName ? [{ name: 'PVC', value: state.pvcName }] : []),
...(state.status === 'failed' ? [{ name: 'Error', value: state.error }] : []),
]}
/>
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Past benchmarks
// ---------------------------------------------------------------------------
interface PastBenchmarksProps {
namespace: string;
}
function PastBenchmarks({ namespace }: PastBenchmarksProps) {
const [jobs, setJobs] = useState<KbenchJobSummary[]>([]);
const [jLoading, setJLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
const loadJobs = useCallback(async () => {
setJLoading(true);
try {
const result = await listKbenchJobs(namespace);
setJobs(result);
} catch {
setJobs([]);
} finally {
setJLoading(false);
}
}, [namespace]);
useEffect(() => { void loadJobs(); }, [loadJobs]);
async function handleDelete(job: KbenchJobSummary) {
if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return;
setDeleting(job.jobName);
try {
await deleteJob(job.jobName, job.namespace);
await deletePvc(`${job.jobName}-pvc`, job.namespace);
await loadJobs();
} catch (err: unknown) {
alert(`Error deleting: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setDeleting(null);
}
}
if (jLoading) return <Loader title="Loading past benchmarks..." />;
return (
<SectionBox title="Past Benchmarks">
<SimpleTable
columns={[
{ label: 'Job Name', getter: (j: KbenchJobSummary) => j.jobName },
{ label: 'Namespace', getter: (j: KbenchJobSummary) => j.namespace },
{ label: 'Storage Class', getter: (j: KbenchJobSummary) => j.storageClass },
{
label: 'Status',
getter: (j: KbenchJobSummary) => (
<StatusLabel status={j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'}>
{j.phase}
</StatusLabel>
),
},
{ label: 'Started', getter: (j: KbenchJobSummary) => formatAge(j.startedAt) },
{
label: 'Actions',
getter: (j: KbenchJobSummary) => (
<button
onClick={() => void handleDelete(j)}
disabled={deleting === j.jobName}
aria-label={`Delete benchmark job ${j.jobName}`}
style={{ padding: '4px 10px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}
>
{deleting === j.jobName ? 'Deleting...' : 'Delete'}
</button>
),
},
]}
data={jobs}
emptyMessage="No past benchmark jobs found."
/>
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
const POLL_INTERVAL_MS = 10_000;
const MAX_PVC_WAIT_MS = 120_000;
export default function BenchmarkPage() {
const { storageClasses, loading } = useTnsCsiContext();
const [benchState, setBenchState] = useState<BenchmarkState>({ status: 'idle' });
const [currentResult, setCurrentResult] = useState<KbenchResult | null>(null);
const [lastNamespace, setLastNamespace] = useState('default');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const scNames = storageClasses.map(sc => sc.metadata.name);
function stopPolling() {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}
async function runBenchmark(opts: { storageClass: string; namespace: string; size: string; mode: string }) {
stopPolling();
setCurrentResult(null);
setLastNamespace(opts.namespace);
const jobName = generateJobName();
const pvcName = generatePvcName(jobName);
const jobOpts = { jobName, pvcName, namespace: opts.namespace, storageClass: opts.storageClass, size: opts.size, mode: opts.mode };
// Step 1: Create PVC
setBenchState({ status: 'creating-pvc' });
try {
await createPvc(jobOpts);
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
return;
}
// Step 2: Wait for PVC to bind
setBenchState({ status: 'waiting-pvc', pvcName });
const pvcDeadline = Date.now() + MAX_PVC_WAIT_MS;
let pvcBound = false;
while (Date.now() < pvcDeadline) {
try {
const pvc = await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`) as { status?: { phase?: string } };
if (pvc.status?.phase === 'Bound') { pvcBound = true; break; }
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 5000));
}
if (!pvcBound) {
setBenchState({ status: 'failed', error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.', jobName, pvcName });
return;
}
// Step 3: Create Job
try {
await createJob(jobOpts);
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
return;
}
setBenchState({ status: 'running', jobName, pvcName, startedAt: new Date().toISOString() });
// Step 4: Poll job status
pollRef.current = setInterval(async () => {
try {
const { phase } = await getJobPhase(jobName, opts.namespace);
if (phase === 'Complete') {
stopPolling();
setBenchState({ status: 'parsing', jobName, pvcName });
try {
const logs = await fetchKbenchLogs(jobName, opts.namespace);
const result = parseKbenchLog(logs);
if (result) {
result.metadata.storageClass = opts.storageClass;
result.metadata.size = opts.size;
result.metadata.jobName = jobName;
result.metadata.namespace = opts.namespace;
result.metadata.completedAt = new Date().toISOString();
setCurrentResult(result);
setBenchState({ status: 'complete', result, jobName, pvcName });
} else {
setBenchState({ status: 'failed', error: 'Could not parse FIO output from pod logs.', jobName, pvcName });
}
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
}
} else if (phase === 'Failed') {
stopPolling();
setBenchState({ status: 'failed', error: 'kbench Job failed. Check pod logs for details.', jobName, pvcName });
}
} catch (err: unknown) {
stopPolling();
setBenchState({ status: 'failed', error: `Polling error: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
}
}, POLL_INTERVAL_MS);
}
// Clean up polling on unmount
useEffect(() => () => stopPolling(), []);
const isRunning = benchState.status !== 'idle' && benchState.status !== 'complete' && benchState.status !== 'failed';
if (loading) return <Loader title="Loading tns-csi data..." />;
return (
<>
<SectionHeader title="TNS-CSI — Benchmark" />
<SectionBox title="Benchmark Guide">
<NameValueTable
rows={[
{ name: 'Duration', value: 'Full benchmark takes ~6 minutes. Do not cancel mid-run.' },
{ name: 'Test Size', value: 'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).' },
{ name: 'Cache Warning', value: 'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.' },
{ name: 'CPU Idleness', value: 'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.' },
{ name: 'Interpretation', value: 'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.' },
]}
/>
</SectionBox>
<RunForm storageClasses={scNames} onRun={opts => void runBenchmark(opts)} disabled={isRunning} />
<BenchmarkProgress state={benchState} />
{currentResult && benchState.status === 'complete' && (
<>
<KbenchResultDisplay result={currentResult} />
<SectionBox title="Cleanup">
<NameValueTable
rows={[{
name: 'Resources',
value: (
<button
onClick={async () => {
const state = benchState;
if (state.status !== 'complete') return;
if (!window.confirm(`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`)) return;
try {
await deleteJob(state.jobName, lastNamespace);
await deletePvc(state.pvcName, lastNamespace);
setBenchState({ status: 'idle' });
setCurrentResult(null);
} catch (err: unknown) {
alert(`Cleanup error: ${err instanceof Error ? err.message : String(err)}`);
}
}}
aria-label="Delete benchmark job and PVC"
style={{ padding: '6px 14px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
>
Delete Job + PVC
</button>
),
}]}
/>
</SectionBox>
</>
)}
<PastBenchmarks namespace={lastNamespace} />
</>
);
}
+178
View File
@@ -0,0 +1,178 @@
/**
* DriverStatusCard — reusable component showing tns-csi driver health.
* Displays controller pods, node pods, CSIDriver capabilities, and
* WebSocket connection health from Prometheus metrics.
*/
import {
NameValueTable,
SectionBox,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import type { CSIDriver, TnsCsiPod } from '../api/k8s';
import { formatAge, getPodImage, getPodRestarts, isPodReady } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function WebSocketStatus({ metrics }: { metrics: TnsCsiMetrics | null }) {
if (!metrics) {
return <StatusLabel status="warning">Metrics unavailable</StatusLabel>;
}
const connected = metrics.websocketConnected;
if (connected === null) {
return <StatusLabel status="warning">Unknown</StatusLabel>;
}
return (
<StatusLabel status={connected === 1 ? 'success' : 'error'}>
{connected === 1 ? 'Connected' : 'Disconnected'}
</StatusLabel>
);
}
function PodStatusBadge({ pod }: { pod: TnsCsiPod }) {
const ready = isPodReady(pod);
const phase = pod.status?.phase ?? 'Unknown';
return (
<StatusLabel status={ready ? 'success' : 'error'}>
{phase}
</StatusLabel>
);
}
function PodRow({ pod }: { pod: TnsCsiPod }) {
const name = pod.metadata.name;
const node = pod.spec?.nodeName ?? '—';
const restarts = getPodRestarts(pod);
const image = getPodImage(pod);
const age = formatAge(pod.metadata.creationTimestamp);
return (
<NameValueTable
rows={[
{ name: 'Pod', value: name },
{ name: 'Node', value: node },
{ name: 'Status', value: <PodStatusBadge pod={pod} /> },
{ name: 'Restarts', value: String(restarts) },
{ name: 'Image', value: image },
{ name: 'Age', value: age },
]}
/>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
interface DriverStatusCardProps {
csiDriver: CSIDriver | null;
controllerPods: TnsCsiPod[];
nodePods: TnsCsiPod[];
metrics?: TnsCsiMetrics | null;
}
export default function DriverStatusCard({
csiDriver,
controllerPods,
nodePods,
metrics,
}: DriverStatusCardProps) {
const driverInstalled = csiDriver !== null;
const allPodsReady =
controllerPods.length > 0 &&
nodePods.length > 0 &&
[...controllerPods, ...nodePods].every(isPodReady);
return (
<>
<SectionBox title="Driver Status">
<NameValueTable
rows={[
{
name: 'Driver',
value: (
<StatusLabel status={driverInstalled ? 'success' : 'error'}>
{driverInstalled ? 'tns.csi.io installed' : 'Not detected'}
</StatusLabel>
),
},
{
name: 'Overall Health',
value: (
<StatusLabel status={allPodsReady ? 'success' : 'error'}>
{allPodsReady ? 'Healthy' : 'Degraded'}
</StatusLabel>
),
},
{
name: 'WebSocket',
value: <WebSocketStatus metrics={metrics ?? null} />,
},
...(metrics?.websocketReconnectsTotal !== null && metrics?.websocketReconnectsTotal !== undefined
? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }]
: []),
]}
/>
</SectionBox>
{csiDriver && (
<SectionBox title="CSI Driver Capabilities">
<NameValueTable
rows={[
{
name: 'Attach Required',
value: String(csiDriver.spec?.attachRequired ?? '—'),
},
{
name: 'Pod Info on Mount',
value: String(csiDriver.spec?.podInfoOnMount ?? '—'),
},
{
name: 'Volume Lifecycle Modes',
value: csiDriver.spec?.volumeLifecycleModes?.join(', ') ?? '—',
},
]}
/>
</SectionBox>
)}
{controllerPods.length > 0 && (
<SectionBox title={`Controller Pod${controllerPods.length > 1 ? 's' : ''}`}>
{controllerPods.map(pod => (
<PodRow key={pod.metadata.name} pod={pod} />
))}
</SectionBox>
)}
{controllerPods.length === 0 && (
<SectionBox title="Controller Pods">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No controller pod found</StatusLabel> }]}
/>
</SectionBox>
)}
{nodePods.length > 0 && (
<SectionBox title={`Node Pod${nodePods.length > 1 ? 's' : ''} (${nodePods.length})`}>
{nodePods.map(pod => (
<PodRow key={pod.metadata.name} pod={pod} />
))}
</SectionBox>
)}
{nodePods.length === 0 && (
<SectionBox title="Node Pods">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No node pods found</StatusLabel> }]}
/>
</SectionBox>
)}
</>
);
}
+214
View File
@@ -0,0 +1,214 @@
/**
* MetricsPage — Prometheus metrics from the tns-csi controller pod.
* Fetches metrics via API proxy and displays in structured cards.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiMetrics } from '../api/metrics';
import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics';
function formatAuditTime(iso: string): string {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} minute${mins > 1 ? 's' : ''} ago`;
const hours = Math.floor(mins / 60);
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
}
// ---------------------------------------------------------------------------
// Metrics cards
// ---------------------------------------------------------------------------
function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
const connected = metrics.websocketConnected;
const reconnects = metrics.websocketReconnectsTotal;
return (
<SectionBox title="WebSocket Health">
<NameValueTable
rows={[
{
name: 'Connection Status',
value: (
<StatusLabel status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}>
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
</StatusLabel>
),
},
{
name: 'Total Reconnects',
value: reconnects !== null ? String(reconnects) : '—',
},
{
name: 'Messages Total',
value: String(Math.round(sumSamples(metrics.websocketMessagesTotal))),
},
]}
/>
</SectionBox>
);
}
function VolumeOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byProtocol = groupByLabel(metrics.volumeOperationsTotal, 'protocol');
const totalOps = sumSamples(metrics.volumeOperationsTotal);
const totalCapacityBytes = sumSamples(metrics.volumeCapacityBytes);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total Operations', value: String(Math.round(totalOps)) },
{ name: 'Total Provisioned Capacity', value: formatBytes(totalCapacityBytes) },
];
for (const [protocol, count] of byProtocol.entries()) {
rows.push({ name: `Operations (${protocol})`, value: String(Math.round(count)) });
}
return (
<SectionBox title="Volume Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
function CsiOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byMethod = groupByLabel(metrics.csiOperationsTotal, 'method');
const totalOps = sumSamples(metrics.csiOperationsTotal);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total CSI Calls', value: String(Math.round(totalOps)) },
];
for (const [method, count] of byMethod.entries()) {
rows.push({ name: method, value: String(Math.round(count)) });
}
return (
<SectionBox title="CSI Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function MetricsPage() {
const { controllerPods, driverInstalled, loading } = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsLoading, setMetricsLoading] = useState(false);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
setMetricsLoading(true);
setMetricsError(null);
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setLastUpdated(new Date().toISOString());
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
} finally {
setMetricsLoading(false);
}
}, [controllerPods]);
useEffect(() => {
void fetchMetrics();
}, [fetchMetrics]);
if (loading) return <Loader title="Loading tns-csi data..." />;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<SectionHeader title="TNS-CSI — Metrics" />
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{lastUpdated && (
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
Updated: {formatAuditTime(lastUpdated)}
</span>
)}
<button
onClick={() => void fetchMetrics()}
disabled={metricsLoading}
aria-label="Refresh metrics"
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)',
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px',
cursor: metricsLoading ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
opacity: metricsLoading ? 0.6 : 1,
}}
>
{metricsLoading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
{!driverInstalled && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel> }]}
/>
</SectionBox>
)}
{controllerPods.length === 0 && driverInstalled && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{ name: 'Status', value: <StatusLabel status="warning">No controller pod found</StatusLabel> },
{ name: 'Note', value: 'Ensure controller pod is running with metrics enabled on port 8080.' },
{
name: 'Troubleshooting',
value: 'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
},
]}
/>
</SectionBox>
)}
{metricsError && (
<SectionBox title="Metrics Error">
<NameValueTable
rows={[
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
{ name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' },
]}
/>
</SectionBox>
)}
{metricsLoading && !metrics && <Loader title="Fetching metrics..." />}
{metrics && (
<>
<WebSocketCard metrics={metrics} />
<VolumeOperationsCard metrics={metrics} />
<CsiOperationsCard metrics={metrics} />
</>
)}
</>
);
}
+288
View File
@@ -0,0 +1,288 @@
/**
* OverviewPage — main dashboard for tns-csi plugin.
*
* Shows: driver health, storage summary (SC/PV/PVC counts + protocol breakdown),
* and any PVCs in non-Bound state.
*/
import {
Loader,
NameValueTable,
PercentageBar,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
// Protocol breakdown chart
// ---------------------------------------------------------------------------
const PROTOCOL_COLORS: Record<string, string> = {
NFS: '#1976d2',
'NVMe-oF': '#9c27b0',
iSCSI: '#f57c00',
Other: '#9e9e9e',
};
function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) {
const counts = new Map<string, number>();
for (const sc of storageClasses) {
const proto = formatProtocol(sc.parameters?.protocol);
counts.set(proto, (counts.get(proto) ?? 0) + 1);
}
return [...counts.entries()].map(([name, value]) => ({
name,
value,
fill: PROTOCOL_COLORS[name] ?? PROTOCOL_COLORS['Other'],
}));
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function OverviewPage() {
const {
csiDriver,
driverInstalled,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
controllerPods,
nodePods,
loading,
error,
refresh,
} = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setMetricsError(null);
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
}
}, [controllerPods]);
useEffect(() => {
void fetchMetrics();
}, [fetchMetrics]);
if (loading) {
return <Loader title="Loading TNS-CSI data..." />;
}
// Compute storage summary
const totalCapacityBytes = persistentVolumes.reduce((sum, pv) => {
const cap = pv.spec.capacity?.storage ?? '0';
return sum + parseStorageToBytes(cap);
}, 0);
const pvcStatusCounts = { Bound: 0, Pending: 0, Lost: 0, Other: 0 };
for (const pvc of persistentVolumeClaims) {
const phase = pvc.status?.phase ?? 'Other';
if (phase === 'Bound') pvcStatusCounts.Bound++;
else if (phase === 'Pending') pvcStatusCounts.Pending++;
else if (phase === 'Lost') pvcStatusCounts.Lost++;
else pvcStatusCounts.Other++;
}
const nonBoundPvcs = persistentVolumeClaims.filter(
pvc => pvc.status?.phase !== 'Bound'
);
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
aria-label="Refresh tns-csi data"
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)',
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Refresh
</button>
</div>
{/* Early development banner */}
<SectionBox title="Notice">
<NameValueTable
rows={[
{
name: 'Development Status',
value: (
<StatusLabel status="warning">
tns-csi is in active early development not production-ready
</StatusLabel>
),
},
]}
/>
</SectionBox>
{/* Driver not detected */}
{!driverInstalled && !loading && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>,
},
{
name: 'Install',
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
},
]}
/>
</SectionBox>
)}
{/* Error state */}
{error && (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
)}
{/* Driver status */}
<DriverStatusCard
csiDriver={csiDriver}
controllerPods={controllerPods}
nodePods={nodePods}
metrics={metrics}
/>
{metricsError && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="warning">{metricsError}</StatusLabel>,
},
{
name: 'Note',
value: 'Ensure controller pod is running with metrics enabled (port 8080).',
},
]}
/>
</SectionBox>
)}
{/* Storage summary */}
<SectionBox title="Storage Summary">
{totalScs > 0 && chartData.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
Protocol Distribution
</div>
<PercentageBar data={chartData} total={totalScs} />
</div>
)}
<NameValueTable
rows={[
{ name: 'Storage Classes', value: String(totalScs) },
{ name: 'Persistent Volumes', value: String(persistentVolumes.length) },
{ name: 'Total Capacity', value: formatBytes(totalCapacityBytes) },
{
name: 'PVCs (Bound)',
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
},
...(pvcStatusCounts.Pending > 0
? [{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
}]
: []),
...(pvcStatusCounts.Lost > 0
? [{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
}]
: []),
]}
/>
</SectionBox>
{/* Non-bound PVCs warning */}
{nonBoundPvcs.length > 0 && (
<SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable
columns={[
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
{
label: 'Status',
getter: (pvc) => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
]}
data={nonBoundPvcs}
/>
</SectionBox>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseStorageToBytes(storage: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
if (!match) return 0;
const value = parseFloat(match[1]);
const suffix = match[2] ?? '';
const multipliers: Record<string, number> = {
'': 1,
K: 1e3, Ki: 1024,
M: 1e6, Mi: 1024 ** 2,
G: 1e9, Gi: 1024 ** 3,
T: 1e12, Ti: 1024 ** 4,
P: 1e15, Pi: 1024 ** 5,
};
return value * (multipliers[suffix] ?? 1);
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${bytes} B`;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* PVCDetailSection — injected into Headlamp's PVC detail view.
*
* Shown only when the bound PV uses tns.csi.io as the CSI driver.
* Uses registerDetailsViewSection in index.tsx.
*/
import {
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { findBoundPv, formatProtocol } from '../api/k8s';
interface PVCDetailSectionProps {
resource: {
metadata?: { name?: string; namespace?: string };
spec?: { volumeName?: string; storageClassName?: string };
};
}
export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
const { persistentVolumes, persistentVolumeClaims, loading } = useTnsCsiContext();
if (loading) return null;
// Find this PVC in our filtered list
const pvcName = resource.metadata?.name;
const pvcNamespace = resource.metadata?.namespace;
const matchedPvc = persistentVolumeClaims.find(
pvc => pvc.metadata.name === pvcName && pvc.metadata.namespace === pvcNamespace
);
if (!matchedPvc) {
// Not a tns-csi PVC — render nothing
return null;
}
const boundPv = findBoundPv(matchedPvc, persistentVolumes);
if (!boundPv) return null;
const attrs = boundPv.spec.csi?.volumeAttributes ?? {};
const protocol = formatProtocol(attrs['protocol']);
return (
<SectionBox title="TNS-CSI Storage Details">
<NameValueTable
rows={[
{ name: 'Driver', value: 'tns.csi.io' },
{ name: 'Protocol', value: protocol },
{ name: 'Server', value: attrs['server'] ?? '—' },
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
...(Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
),
{
name: 'PV Name',
value: boundPv.metadata.name,
},
]}
/>
</SectionBox>
);
}
+124
View File
@@ -0,0 +1,124 @@
/**
* SnapshotsPage — lists VolumeSnapshots backed by tns-csi.
* Gracefully degrades when the snapshot CRD is not installed.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { VolumeSnapshot } from '../api/k8s';
import { formatAge } from '../api/k8s';
export default function SnapshotsPage() {
const { volumeSnapshots, volumeSnapshotClasses, snapshotCrdAvailable, loading, error } =
useTnsCsiContext();
if (loading) return <Loader title="Loading snapshots..." />;
if (error) {
return (
<>
<SectionHeader title="TNS-CSI — Snapshots" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
</SectionBox>
</>
);
}
if (!snapshotCrdAvailable) {
return (
<>
<SectionHeader title="TNS-CSI — Snapshots" />
<SectionBox title="Volume Snapshot CRDs Not Installed">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status="warning">
VolumeSnapshot CRDs (snapshot.storage.k8s.io/v1) not found on this cluster
</StatusLabel>
),
},
{
name: 'Documentation',
value: (
<a href="https://github.com/fenio/tns-csi" target="_blank" rel="noopener noreferrer">
See tns-csi documentation for snapshot setup instructions
</a>
),
},
]}
/>
</SectionBox>
</>
);
}
return (
<>
<SectionHeader title="TNS-CSI — Snapshots" />
{volumeSnapshotClasses.length > 0 && (
<SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: (vsc) => vsc.metadata.name },
{ label: 'Driver', getter: (vsc) => vsc.driver ?? '—' },
{ label: 'Deletion Policy', getter: (vsc) => vsc.deletionPolicy ?? '—' },
{ label: 'Age', getter: (vsc) => formatAge(vsc.metadata.creationTimestamp) },
]}
data={volumeSnapshotClasses}
/>
</SectionBox>
)}
<SectionBox>
<SimpleTable
columns={[
{ label: 'Name', getter: (s: VolumeSnapshot) => s.metadata.name },
{ label: 'Namespace', getter: (s: VolumeSnapshot) => s.metadata.namespace ?? '—' },
{
label: 'Source PVC',
getter: (s: VolumeSnapshot) => s.spec?.source?.persistentVolumeClaimName ?? '—',
},
{
label: 'Snapshot Class',
getter: (s: VolumeSnapshot) => s.spec?.volumeSnapshotClassName ?? '—',
},
{
label: 'Ready',
getter: (s: VolumeSnapshot) => {
const ready = s.status?.readyToUse;
if (ready === undefined) return <StatusLabel status="warning">Unknown</StatusLabel>;
return (
<StatusLabel status={ready ? 'success' : 'warning'}>
{ready ? 'Yes' : 'No'}
</StatusLabel>
);
},
},
{
label: 'Size',
getter: (s: VolumeSnapshot) => s.status?.restoreSize ?? '—',
},
{
label: 'Age',
getter: (s: VolumeSnapshot) => formatAge(s.metadata.creationTimestamp),
},
]}
data={volumeSnapshots}
emptyMessage="No tns-csi VolumeSnapshots found."
/>
</SectionBox>
</>
);
}
+259
View File
@@ -0,0 +1,259 @@
/**
* StorageClassesPage — lists tns-csi StorageClasses with a slide-in detail panel.
*
* Pattern mirrors headlamp-polaris-plugin's NamespacesListView:
* click row → detail drawer, Escape to close, URL hash state.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiStorageClass } from '../api/k8s';
import { formatProtocol } from '../api/k8s';
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
interface StorageClassDetailPanelProps {
sc: TnsCsiStorageClass;
pvCount: number;
onClose: () => void;
}
function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPanelProps) {
const [isMaximized, setIsMaximized] = React.useState(false);
const params = sc.parameters ?? {};
const protocol = formatProtocol(params.protocol);
const drawerClass = `tns-csi-sc-drawer-${sc.metadata.name}`;
return (
<>
<style>{`
.${drawerClass} {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: ${isMaximized ? 'calc(100vw - 240px)' : '900px'};
background-color: var(--mui-palette-background-default, #fafafa);
color: var(--mui-palette-text-primary);
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
overflow-y: auto;
z-index: 1200;
padding: 20px;
transition: width 0.3s ease;
}
`}</style>
<div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
{sc.metadata.name}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setIsMaximized(!isMaximized)}
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
title={isMaximized ? 'Minimize' : 'Maximize'}
style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
>
{isMaximized ? '⊟' : '⊡'}
</button>
<button
onClick={onClose}
aria-label="Close panel"
title="Close"
style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
>
×
</button>
</div>
</div>
<SectionBox title="StorageClass Details">
<NameValueTable
rows={[
{ name: 'Name', value: sc.metadata.name },
{ name: 'Protocol', value: protocol },
{ name: 'Pool', value: params.pool ?? '—' },
{ name: 'Server', value: params.server ?? '—' },
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
{
name: 'Allow Volume Expansion',
value: <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>,
},
{ name: 'Delete Strategy', value: params.deleteStrategy ?? '—' },
{
name: 'Encryption',
value: params.encryption === 'true'
? <StatusLabel status="success">Enabled</StatusLabel>
: <StatusLabel status="warning">Disabled</StatusLabel>,
},
{ name: 'Provisioner', value: sc.provisioner },
{ name: 'Bound PVs', value: String(pvCount) },
]}
/>
</SectionBox>
{/* Protocol-specific notes */}
{params.protocol && (
<SectionBox title="Protocol Notes">
<NameValueTable rows={protocolNotes(params.protocol)} />
</SectionBox>
)}
</div>
</>
);
}
function protocolNotes(protocol: string): Array<{ name: string; value: React.ReactNode }> {
const lower = protocol.toLowerCase();
if (lower === 'nfs') {
return [
{ name: 'Prerequisite', value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes' },
{ name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' },
];
}
if (lower === 'nvmeof') {
return [
{ name: 'Prerequisite', value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes' },
{ name: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
];
}
if (lower === 'iscsi') {
return [
{ name: 'Prerequisite', value: 'open-iscsi required on all nodes' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
];
}
return [];
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function StorageClassesPage() {
const location = useLocation();
const history = useHistory();
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>(
location.hash.slice(1) || null
);
useEffect(() => {
setSelectedName(location.hash.slice(1) || null);
}, [location.hash]);
const openSc = (name: string) => {
setSelectedName(name);
history.push(`${location.pathname}#${name}`);
};
const closeSc = () => {
setSelectedName(null);
history.push(location.pathname);
};
useEffect(() => {
if (!selectedName) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeSc();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName]);
if (loading) return <Loader title="Loading storage classes..." />;
if (error) {
return (
<>
<SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
</SectionBox>
</>
);
}
// Build PV count per StorageClass
const pvCountBySc = new Map<string, number>();
for (const pv of persistentVolumes) {
const scName = pv.spec.storageClassName ?? '';
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
}
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null;
return (
<>
<SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox>
<SimpleTable
columns={[
{
label: 'Name',
getter: (sc: TnsCsiStorageClass) => (
<button
onClick={() => openSc(sc.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
>
{sc.metadata.name}
</button>
),
},
{ label: 'Protocol', getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol) },
{ label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' },
{ label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' },
{ label: 'Reclaim Policy', getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—' },
{
label: 'Expansion',
getter: (sc: TnsCsiStorageClass) => (
<StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>
),
},
{
label: 'PVs',
getter: (sc: TnsCsiStorageClass) => String(pvCountBySc.get(sc.metadata.name) ?? 0),
},
]}
data={storageClasses}
emptyMessage="No tns-csi StorageClasses found."
/>
</SectionBox>
{selectedSc && (
<>
<div
onClick={closeSc}
aria-label="Close panel backdrop"
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
/>
<StorageClassDetailPanel
sc={selectedSc}
pvCount={pvCountBySc.get(selectedSc.metadata.name) ?? 0}
onClose={closeSc}
/>
</>
)}
</>
);
}
+250
View File
@@ -0,0 +1,250 @@
/**
* VolumesPage — lists tns-csi PersistentVolumes with PVC cross-reference.
* Slide-in detail panel shows full CSI attributes.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiPersistentVolume } from '../api/k8s';
import { findBoundPv, formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
// ---------------------------------------------------------------------------
// Detail panel
// ---------------------------------------------------------------------------
interface VolumeDetailPanelProps {
pv: TnsCsiPersistentVolume;
onClose: () => void;
}
function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
const [isMaximized, setIsMaximized] = React.useState(false);
const drawerClass = `tns-csi-pv-drawer-${pv.metadata.name}`;
const csi = pv.spec.csi;
const attrs = csi?.volumeAttributes ?? {};
const claim = pv.spec.claimRef;
return (
<>
<style>{`
.${drawerClass} {
position: fixed; right: 0; top: 0; bottom: 0;
width: ${isMaximized ? 'calc(100vw - 240px)' : '900px'};
background-color: var(--mui-palette-background-default, #fafafa);
color: var(--mui-palette-text-primary);
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
overflow-y: auto; z-index: 1200; padding: 20px;
transition: width 0.3s ease;
}
`}</style>
<div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>{pv.metadata.name}</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setIsMaximized(!isMaximized)} aria-label={isMaximized ? 'Minimize' : 'Maximize'} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
{isMaximized ? '⊟' : '⊡'}
</button>
<button onClick={onClose} aria-label="Close panel" style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
×
</button>
</div>
</div>
<SectionBox title="Volume Details">
<NameValueTable
rows={[
{ name: 'Name', value: pv.metadata.name },
{
name: 'Status',
value: (
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
{pv.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ name: 'Capacity', value: pv.spec.capacity?.storage ?? '—' },
{ name: 'Access Modes', value: formatAccessModes(pv.spec.accessModes) },
{ name: 'Reclaim Policy', value: pv.spec.persistentVolumeReclaimPolicy ?? '—' },
{ name: 'Storage Class', value: pv.spec.storageClassName ?? '—' },
{ name: 'Age', value: formatAge(pv.metadata.creationTimestamp) },
]}
/>
</SectionBox>
{claim && (
<SectionBox title="Bound PVC">
<NameValueTable
rows={[
{ name: 'PVC Name', value: claim.name },
{ name: 'Namespace', value: claim.namespace },
]}
/>
</SectionBox>
)}
<SectionBox title="CSI Attributes">
<NameValueTable
rows={[
{ name: 'Driver', value: csi?.driver ?? '—' },
{ name: 'Volume Handle', value: csi?.volumeHandle ?? '—' },
{ name: 'Protocol', value: formatProtocol(attrs['protocol']) },
{ name: 'Server', value: attrs['server'] ?? '—' },
...(Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
),
]}
/>
</SectionBox>
{/* Volume adoption note */}
{pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && (
<SectionBox title="Adoption">
<NameValueTable
rows={[{
name: 'Adoptable',
value: <StatusLabel status="success">This volume can be adopted cross-cluster</StatusLabel>,
}]}
/>
</SectionBox>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function VolumesPage() {
const location = useLocation();
const history = useHistory();
const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>(
location.hash.slice(1) || null
);
useEffect(() => {
setSelectedName(location.hash.slice(1) || null);
}, [location.hash]);
const openVolume = (name: string) => {
setSelectedName(name);
history.push(`${location.pathname}#${name}`);
};
const closeVolume = () => {
setSelectedName(null);
history.push(location.pathname);
};
useEffect(() => {
if (!selectedName) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeVolume();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName]);
if (loading) return <Loader title="Loading volumes..." />;
if (error) {
return (
<>
<SectionHeader title="TNS-CSI — Volumes" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
</SectionBox>
</>
);
}
const selectedPv = selectedName
? persistentVolumes.find(pv => pv.metadata.name === selectedName) ?? null
: null;
return (
<>
<SectionHeader title="TNS-CSI — Volumes" />
<SectionBox>
<SimpleTable
columns={[
{
label: 'PV Name',
getter: (pv: TnsCsiPersistentVolume) => (
<button
onClick={() => openVolume(pv.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
>
{pv.metadata.name}
</button>
),
},
{
label: 'PVC',
getter: (pv: TnsCsiPersistentVolume) => {
const claim = pv.spec.claimRef;
return claim ? `${claim.namespace}/${claim.name}` : '—';
},
},
{
label: 'Protocol',
getter: (pv: TnsCsiPersistentVolume) =>
formatProtocol(pv.spec.csi?.volumeAttributes?.['protocol']),
},
{
label: 'Capacity',
getter: (pv: TnsCsiPersistentVolume) => pv.spec.capacity?.storage ?? '—',
},
{
label: 'Access Modes',
getter: (pv: TnsCsiPersistentVolume) => formatAccessModes(pv.spec.accessModes),
},
{
label: 'Reclaim',
getter: (pv: TnsCsiPersistentVolume) => pv.spec.persistentVolumeReclaimPolicy ?? '—',
},
{
label: 'Status',
getter: (pv: TnsCsiPersistentVolume) => (
<StatusLabel status={phaseToStatus(pv.status?.phase)}>
{pv.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{
label: 'Age',
getter: (pv: TnsCsiPersistentVolume) => formatAge(pv.metadata.creationTimestamp),
},
]}
data={persistentVolumes}
emptyMessage="No tns-csi PersistentVolumes found."
/>
</SectionBox>
{selectedPv && (
<>
<div
onClick={closeVolume}
aria-label="Close panel backdrop"
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
/>
<VolumeDetailPanel pv={selectedPv} onClose={closeVolume} />
</>
)}
</>
);
}