fix: remove unused imports and format source files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DevContainer User
2026-03-04 01:06:13 +00:00
parent 71abc6792d
commit 3cebde0673
32 changed files with 902 additions and 426 deletions
+6 -2
View File
@@ -21,10 +21,14 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
}, },
}, },
ConfigStore: class { ConfigStore: class {
get() { return {}; } get() {
return {};
}
set() {} set() {}
update() {} update() {}
useConfig() { return () => ({}); } useConfig() {
return () => ({});
}
}, },
})); }));
+5 -3
View File
@@ -109,9 +109,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
try { try {
// CSIDriver // CSIDriver
try { try {
const driver = await ApiProxy.request( const driver = (await ApiProxy.request(
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}` `/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
) as CSIDriver; )) as CSIDriver;
if (!cancelled) setCsiDriver(driver); if (!cancelled) setCsiDriver(driver);
} catch { } catch {
if (!cancelled) setCsiDriver(null); if (!cancelled) setCsiDriver(null);
@@ -203,7 +203,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
} }
void fetchAsync(); void fetchAsync();
return () => { cancelled = true; }; return () => {
cancelled = true;
};
}, [refreshKey]); }, [refreshKey]);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6 -5
View File
@@ -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 { return {
metadata: { name }, metadata: { name },
spec: { spec: {
@@ -106,10 +110,7 @@ describe('isTnsCsiPersistentVolume', () => {
describe('filterTnsCsiPersistentVolumes', () => { describe('filterTnsCsiPersistentVolumes', () => {
it('filters to only tns-csi PVs', () => { it('filters to only tns-csi PVs', () => {
const items = [ const items = [makePv('tns-pv', 'tns.csi.io'), makePv('other-pv', 'ebs.csi.aws.com')];
makePv('tns-pv', 'tns.csi.io'),
makePv('other-pv', 'ebs.csi.aws.com'),
];
const result = filterTnsCsiPersistentVolumes(items); const result = filterTnsCsiPersistentVolumes(items);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-pv'); expect(result[0]?.metadata.name).toBe('tns-pv');
+6 -10
View File
@@ -165,9 +165,7 @@ export function findBoundPv(
): TnsCsiPersistentVolume | undefined { ): TnsCsiPersistentVolume | undefined {
const ns = pvc.metadata.namespace ?? ''; const ns = pvc.metadata.namespace ?? '';
const name = pvc.metadata.name; const name = pvc.metadata.name;
return tnsPvs.find( return tnsPvs.find(pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name);
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 { export function isPodReady(pod: TnsCsiPod): boolean {
return ( return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
);
} }
export function getPodRestarts(pod: TnsCsiPod): number { export function getPodRestarts(pod: TnsCsiPod): number {
return ( return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
);
} }
export function getPodImage(pod: TnsCsiPod): string { export function getPodImage(pod: TnsCsiPod): string {
@@ -267,7 +261,9 @@ export function filterTnsCsiVolumeSnapshots(
tnsCsiSnapshotClassNames: Set<string> tnsCsiSnapshotClassNames: Set<string>
): VolumeSnapshot[] { ): VolumeSnapshot[] {
return snapshots.filter( return snapshots.filter(
s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName) s =>
s.spec?.volumeSnapshotClassName &&
tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName)
); );
} }
+16 -3
View File
@@ -66,7 +66,12 @@ describe('generatePvcName', () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe('buildPvcManifest', () => { 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', () => { it('produces a valid PVC manifest with correct storage class', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>; const manifest = buildPvcManifest(opts) as Record<string, unknown>;
@@ -88,7 +93,12 @@ describe('buildPvcManifest', () => {
}); });
describe('buildJobManifest', () => { 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', () => { it('produces a valid Job manifest', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>; const manifest = buildJobManifest(opts) as Record<string, unknown>;
@@ -109,7 +119,10 @@ describe('buildJobManifest', () => {
}); });
it('uses custom size and mode when specified', () => { it('uses custom size and mode when specified', () => {
const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<string, unknown>; const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<
string,
unknown
>;
const spec = manifest['spec'] as Record<string, unknown>; const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>; const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>; const podSpec = template['spec'] as Record<string, unknown>;
+36 -31
View File
@@ -22,7 +22,7 @@ export interface KbenchMetricGroup {
export interface KbenchResult { export interface KbenchResult {
iops: KbenchMetricGroup; iops: KbenchMetricGroup;
bandwidth: KbenchMetricGroup; // KiB/s bandwidth: KbenchMetricGroup; // KiB/s
latency: KbenchMetricGroup; // nanoseconds latency: KbenchMetricGroup; // nanoseconds
metadata: KbenchResultMetadata; metadata: KbenchResultMetadata;
} }
@@ -35,7 +35,14 @@ export interface KbenchResultMetadata {
namespace: string; 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 = export type BenchmarkState =
| { status: 'idle' } | { status: 'idle' }
@@ -90,8 +97,8 @@ export interface KbenchJobOptions {
pvcName: string; pvcName: string;
namespace: string; namespace: string;
storageClass: string; storageClass: string;
size?: string; // default "30G" size?: string; // default "30G"
mode?: string; // default "full" mode?: string; // default "full"
} }
export function buildPvcManifest(opts: KbenchJobOptions): object { export function buildPvcManifest(opts: KbenchJobOptions): object {
@@ -155,9 +162,7 @@ export function buildJobManifest(opts: KbenchJobOptions): object {
{ name: 'SIZE', value: opts.size ?? '30G' }, { name: 'SIZE', value: opts.size ?? '30G' },
{ name: 'CPU_IDLE_PROF', value: 'disabled' }, { name: 'CPU_IDLE_PROF', value: 'disabled' },
], ],
volumeMounts: [ volumeMounts: [{ name: 'vol', mountPath: '/volume/' }],
{ name: 'vol', mountPath: '/volume/' },
],
}, },
], ],
restartPolicy: 'Never', restartPolicy: 'Never',
@@ -212,9 +217,9 @@ export async function getJobPhase(
jobName: string, jobName: string,
namespace: string namespace: string
): Promise<{ phase: JobPhase; job: K8sJob }> { ): Promise<{ phase: JobPhase; job: K8sJob }> {
const job = await ApiProxy.request( const job = (await ApiProxy.request(
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}` `/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
) as K8sJob; )) as K8sJob;
const status = job.status; const status = job.status;
let phase: JobPhase = 'Unknown'; let phase: JobPhase = 'Unknown';
@@ -225,13 +230,10 @@ export async function getJobPhase(
return { phase, job }; return { phase, job };
} }
export async function getPvcPhase( export async function getPvcPhase(pvcName: string, namespace: string): Promise<string> {
pvcName: string, const pvc = (await ApiProxy.request(
namespace: string
): Promise<string> {
const pvc = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}` `/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
) as { status?: { phase?: string } }; )) as { status?: { phase?: string } };
return pvc.status?.phase ?? 'Unknown'; 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). * Fetches the logs from the kbench pod (via the Job's pod selector).
* Uses the pod label selector to find the pod. * Uses the pod label selector to find the pod.
*/ */
export async function fetchKbenchLogs( export async function fetchKbenchLogs(jobName: string, namespace: string): Promise<string> {
jobName: string,
namespace: string
): Promise<string> {
// Find pod with label kbench=fio and job-name=<jobName> // Find pod with label kbench=fio and job-name=<jobName>
const podList = await ApiProxy.request( const podList = (await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(`job-name=${jobName}`)}` `/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(
) as { items?: Array<{ metadata?: { name?: string } }> }; `job-name=${jobName}`
)}`
)) as { items?: Array<{ metadata?: { name?: string } }> };
const podName = podList.items?.[0]?.metadata?.name; const podName = podList.items?.[0]?.metadata?.name;
if (!podName) { if (!podName) {
throw new Error(`No pod found for kbench job "${jobName}"`); 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`, `/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
{ isJSON: false } { isJSON: false }
) as unknown; )) as unknown;
if (typeof logs !== 'string') { if (typeof logs !== 'string') {
throw new Error('Pod logs were not returned as text'); throw new Error('Pod logs were not returned as text');
@@ -274,10 +275,9 @@ export async function deleteJob(jobName: string, namespace: string): Promise<voi
} }
export async function deletePvc(pvcName: string, namespace: string): Promise<void> { export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
await ApiProxy.request( await ApiProxy.request(`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, {
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, method: 'DELETE',
{ method: 'DELETE' } });
);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -366,7 +366,7 @@ export function parseKbenchLog(logText: string): KbenchResult | null {
bandwidth, bandwidth,
latency, latency,
metadata: { metadata: {
storageClass: '', // filled in by the caller storageClass: '', // filled in by the caller
size: '30G', size: '30G',
startedAt: '', startedAt: '',
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
@@ -388,9 +388,14 @@ export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobS
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}` ? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
: `/apis/batch/v1/jobs?labelSelector=${selector}`; : `/apis/batch/v1/jobs?labelSelector=${selector}`;
const list = await ApiProxy.request(path) as { const list = (await ApiProxy.request(path)) as {
items?: Array<{ items?: Array<{
metadata?: { name?: string; namespace?: string; annotations?: Record<string, string>; creationTimestamp?: string }; metadata?: {
name?: string;
namespace?: string;
annotations?: Record<string, string>;
creationTimestamp?: string;
};
status?: K8sJobStatus; status?: K8sJobStatus;
}>; }>;
}; };
+1 -4
View File
@@ -218,10 +218,7 @@ export function sumSamples(samples: MetricSample[]): number {
} }
/** Group samples by a label key, summing values per group. */ /** Group samples by a label key, summing values per group. */
export function groupByLabel( export function groupByLabel(samples: MetricSample[], labelKey: string): Map<string, number> {
samples: MetricSample[],
labelKey: string
): Map<string, number> {
const result = new Map<string, number>(); const result = new Map<string, number>();
for (const sample of samples) { for (const sample of samples) {
const key = sample.labels[labelKey] ?? 'unknown'; const key = sample.labels[labelKey] ?? 'unknown';
+22 -17
View File
@@ -70,10 +70,7 @@ export interface PoolStats {
* @param apiKey - TrueNAS API key * @param apiKey - TrueNAS API key
* @returns Array of pool stats * @returns Array of pool stats
*/ */
export function fetchTruenasPoolStats( export function fetchTruenasPoolStats(server: string, apiKey: string): Promise<PoolStats[]> {
server: string,
apiKey: string
): Promise<PoolStats[]> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// TrueNAS WebSocket endpoint — supports both SCALE and CORE // TrueNAS WebSocket endpoint — supports both SCALE and CORE
const url = `wss://${server}/api/current`; const url = `wss://${server}/api/current`;
@@ -99,12 +96,14 @@ export function fetchTruenasPoolStats(
ws.onopen = () => { ws.onopen = () => {
phase = 'authenticating'; phase = 'authenticating';
ws.send(JSON.stringify({ ws.send(
id: msgId++, JSON.stringify({
msg: 'method', id: msgId++,
method: 'auth.login_with_api_key', msg: 'method',
params: [apiKey], method: 'auth.login_with_api_key',
})); params: [apiKey],
})
);
}; };
ws.onmessage = (event: MessageEvent) => { ws.onmessage = (event: MessageEvent) => {
@@ -124,12 +123,14 @@ export function fetchTruenasPoolStats(
return; return;
} }
phase = 'querying'; phase = 'querying';
ws.send(JSON.stringify({ ws.send(
id: msgId++, JSON.stringify({
msg: 'method', id: msgId++,
method: 'pool.query', msg: 'method',
params: [], method: 'pool.query',
})); params: [],
})
);
return; return;
} }
@@ -162,7 +163,11 @@ export function fetchTruenasPoolStats(
ws.onerror = () => { ws.onerror = () => {
if (phase !== 'done') { if (phase !== 'done') {
clearTimeout(timeout); 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`
)
);
} }
}; };
+14 -16
View File
@@ -6,19 +6,24 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
request: vi.fn().mockResolvedValue({}), request: vi.fn().mockResolvedValue({}),
}, },
ConfigStore: class { ConfigStore: class {
get() { return {}; } get() {
return {};
}
set() {} set() {}
update() {} update() {}
useConfig() { return () => ({}); } useConfig() {
return () => ({});
}
}, },
})); }));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
vi.mock('../api/TnsCsiDataContext'); vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async (importOriginal) => { vi.mock('../api/kbench', async importOriginal => {
const actual = await importOriginal<typeof import('../api/kbench')>(); const actual = await importOriginal<typeof import('../api/kbench')>();
return { return {
...actual, ...actual,
@@ -36,16 +41,7 @@ vi.mock('../api/kbench', async (importOriginal) => {
import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib'; import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { import { createPvc, createJob, listKbenchJobs } from '../api/kbench';
createPvc,
createJob,
deleteJob,
deletePvc,
getJobPhase,
fetchKbenchLogs,
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { defaultContext, makeSampleStorageClass } from '../test-helpers'; import { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage'; import BenchmarkPage from './BenchmarkPage';
@@ -192,7 +188,9 @@ describe('BenchmarkPage', () => {
render(<BenchmarkPage />); render(<BenchmarkPage />);
// Change namespace // 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.change(nsInput, { target: { value: 'bench-ns' } });
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark')); fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
+365 -82
View File
@@ -46,7 +46,15 @@ interface MetricRowData {
note?: string; 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 ( return (
<SectionBox title={title}> <SectionBox title={title}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
@@ -55,14 +63,24 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 600 }}>Metric</th> <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 }}>Read</th>
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Write</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)' }}> <th
style={{
textAlign: 'left',
padding: '8px 4px',
fontWeight: 400,
color: 'var(--mui-palette-text-secondary)',
}}
>
{higherIsBetter ? '↑ higher is better' : '↓ lower is better'} {higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map(row => ( {rows.map(row => (
<tr key={row.label} style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}> <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' }}>{row.label}</td>
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}> <td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
{row.formatter(row.read)} {row.formatter(row.read)}
@@ -83,21 +101,69 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
function KbenchResultDisplay({ result }: { result: KbenchResult }) { function KbenchResultDisplay({ result }: { result: KbenchResult }) {
const iopsRows: MetricRowData[] = [ 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: 'Random',
{ label: 'CPU Idleness', read: result.iops.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '' }, 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[] = [ 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: 'Random',
{ label: 'CPU Idleness', read: result.bandwidth.cpuIdleness, write: null, formatter: v => `${v}%` }, 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[] = [ 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: 'Random',
{ label: 'CPU Idleness', read: result.latency.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '' }, 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 ( return (
@@ -109,7 +175,12 @@ function KbenchResultDisplay({ result }: { result: KbenchResult }) {
{ name: 'Test Size', value: result.metadata.size }, { name: 'Test Size', value: result.metadata.size },
{ name: 'Job', value: result.metadata.jobName || '—' }, { name: 'Job', value: result.metadata.jobName || '—' },
{ name: 'Namespace', value: result.metadata.namespace || '—' }, { 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()
: '—',
},
]} ]}
/> />
</SectionBox> </SectionBox>
@@ -154,32 +225,66 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
return ( return (
<SectionBox title="Run New Benchmark"> <SectionBox title="Run New Benchmark">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '12px 16px', alignItems: 'center', maxWidth: '600px' }}> <div
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>Storage Class *</label> style={{
display: 'grid',
gridTemplateColumns: '200px 1fr',
gap: '12px 16px',
alignItems: 'center',
maxWidth: '600px',
}}
>
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>
Storage Class *
</label>
<select <select
id="kbench-sc" id="kbench-sc"
value={storageClass} value={storageClass}
onChange={e => setStorageClass(e.target.value)} onChange={e => setStorageClass(e.target.value)}
disabled={disabled || storageClasses.length === 0} 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)' }} 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" aria-label="Select storage class for benchmark"
> >
{storageClasses.length === 0 && <option value="">No tns-csi storage classes found</option>} {storageClasses.length === 0 && (
{storageClasses.map(sc => <option key={sc} value={sc}>{sc}</option>)} <option value="">No tns-csi storage classes found</option>
)}
{storageClasses.map(sc => (
<option key={sc} value={sc}>
{sc}
</option>
))}
</select> </select>
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>Namespace</label> <label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>
Namespace
</label>
<input <input
id="kbench-ns" id="kbench-ns"
type="text" type="text"
value={namespace} value={namespace}
onChange={e => setNamespace(e.target.value)} onChange={e => setNamespace(e.target.value)}
disabled={disabled} 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" aria-label="Kubernetes namespace for benchmark job"
/> />
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>Test Size</label> <label htmlFor="kbench-size" style={{ fontWeight: 500 }}>
Test Size
</label>
<div> <div>
<input <input
id="kbench-size" id="kbench-size"
@@ -187,21 +292,44 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
value={size} value={size}
onChange={e => setSize(e.target.value)} onChange={e => setSize(e.target.value)}
disabled={disabled} 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" aria-label="FIO test size"
/> />
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--mui-palette-text-secondary)' }}> <span
style={{
marginLeft: '8px',
fontSize: '12px',
color: 'var(--mui-palette-text-secondary)',
}}
>
PVC will be ~10% larger (33Gi for 30G) PVC will be ~10% larger (33Gi for 30G)
</span> </span>
</div> </div>
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>Mode</label> <label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>
Mode
</label>
<select <select
id="kbench-mode" id="kbench-mode"
value={mode} value={mode}
onChange={e => setMode(e.target.value)} onChange={e => setMode(e.target.value)}
disabled={disabled} 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="Benchmark mode" aria-label="Benchmark mode"
> >
<option value="full">Full (~6 minutes)</option> <option value="full">Full (~6 minutes)</option>
@@ -216,7 +344,9 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
aria-label="Start kbench storage benchmark" aria-label="Start kbench storage benchmark"
style={{ style={{
padding: '8px 20px', padding: '8px 20px',
backgroundColor: disabled ? 'var(--mui-palette-action-disabled, #ccc)' : 'var(--mui-palette-primary-main, #1976d2)', backgroundColor: disabled
? 'var(--mui-palette-action-disabled, #ccc)'
: 'var(--mui-palette-primary-main, #1976d2)',
color: '#fff', color: '#fff',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
@@ -231,35 +361,82 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
{showConfirm && ( {showConfirm && (
<div <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)' }} 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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="kbench-confirm-title" 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)' }}> <div
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>Confirm Benchmark</h3> 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' }}> <p style={{ margin: '0 0 8px', fontSize: '14px' }}>
This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark ( This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark (
<strong>~6 minutes</strong>). <strong>~6 minutes</strong>).
</p> </p>
<p style={{ margin: '0 0 8px', fontSize: '14px' }}> <p style={{ margin: '0 0 8px', fontSize: '14px' }}>
Storage class: <strong>{storageClass}</strong> · Namespace: <strong>{namespace}</strong> Storage class: <strong>{storageClass}</strong> · Namespace:{' '}
<strong>{namespace}</strong>
</p> </p>
<p style={{ margin: '0 0 16px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}> <p
The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion. 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> </p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button <button
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
aria-label="Cancel benchmark" 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)' }} 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 Cancel
</button> </button>
<button <button
onClick={handleConfirm} onClick={handleConfirm}
aria-label="Confirm and start benchmark" 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 }} 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 Start Benchmark
</button> </button>
@@ -305,9 +482,7 @@ function BenchmarkProgress({ state }: { state: BenchmarkState }) {
{ {
name: 'Status', name: 'Status',
value: ( value: (
<StatusLabel status={statusColor[state.status]}> <StatusLabel status={statusColor[state.status]}>{labels[state.status]}</StatusLabel>
{labels[state.status]}
</StatusLabel>
), ),
}, },
...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []), ...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []),
@@ -344,7 +519,9 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
} }
}, [namespace]); }, [namespace]);
useEffect(() => { void loadJobs(); }, [loadJobs]); useEffect(() => {
void loadJobs();
}, [loadJobs]);
async function handleDelete(job: KbenchJobSummary) { async function handleDelete(job: KbenchJobSummary) {
if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return; if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return;
@@ -372,7 +549,11 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
{ {
label: 'Status', label: 'Status',
getter: (j: KbenchJobSummary) => ( getter: (j: KbenchJobSummary) => (
<StatusLabel status={j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'}> <StatusLabel
status={
j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'
}
>
{j.phase} {j.phase}
</StatusLabel> </StatusLabel>
), ),
@@ -385,7 +566,15 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
onClick={() => void handleDelete(j)} onClick={() => void handleDelete(j)}
disabled={deleting === j.jobName} disabled={deleting === j.jobName}
aria-label={`Delete benchmark job ${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' }} 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'} {deleting === j.jobName ? 'Deleting...' : 'Delete'}
</button> </button>
@@ -422,21 +611,38 @@ export default function BenchmarkPage() {
} }
} }
async function runBenchmark(opts: { storageClass: string; namespace: string; size: string; mode: string }) { async function runBenchmark(opts: {
storageClass: string;
namespace: string;
size: string;
mode: string;
}) {
stopPolling(); stopPolling();
setCurrentResult(null); setCurrentResult(null);
setLastNamespace(opts.namespace); setLastNamespace(opts.namespace);
const jobName = generateJobName(); const jobName = generateJobName();
const pvcName = generatePvcName(jobName); const pvcName = generatePvcName(jobName);
const jobOpts = { jobName, pvcName, namespace: opts.namespace, storageClass: opts.storageClass, size: opts.size, mode: opts.mode }; const jobOpts = {
jobName,
pvcName,
namespace: opts.namespace,
storageClass: opts.storageClass,
size: opts.size,
mode: opts.mode,
};
// Step 1: Create PVC // Step 1: Create PVC
setBenchState({ status: 'creating-pvc' }); setBenchState({ status: 'creating-pvc' });
try { try {
await createPvc(jobOpts); await createPvc(jobOpts);
} catch (err: unknown) { } catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName }); setBenchState({
status: 'failed',
error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
return; return;
} }
@@ -446,13 +652,25 @@ export default function BenchmarkPage() {
let pvcBound = false; let pvcBound = false;
while (Date.now() < pvcDeadline) { while (Date.now() < pvcDeadline) {
try { try {
const pvc = await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`) as { status?: { phase?: string } }; const pvc = (await ApiProxy.request(
if (pvc.status?.phase === 'Bound') { pvcBound = true; break; } `/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`
} catch { /* retry */ } )) as { status?: { phase?: string } };
if (pvc.status?.phase === 'Bound') {
pvcBound = true;
break;
}
} catch {
/* retry */
}
await new Promise(r => setTimeout(r, 5000)); await new Promise(r => setTimeout(r, 5000));
} }
if (!pvcBound) { if (!pvcBound) {
setBenchState({ status: 'failed', error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.', jobName, pvcName }); setBenchState({
status: 'failed',
error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.',
jobName,
pvcName,
});
return; return;
} }
@@ -460,7 +678,12 @@ export default function BenchmarkPage() {
try { try {
await createJob(jobOpts); await createJob(jobOpts);
} catch (err: unknown) { } catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName }); setBenchState({
status: 'failed',
error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
return; return;
} }
@@ -487,18 +710,38 @@ export default function BenchmarkPage() {
setCurrentResult(result); setCurrentResult(result);
setBenchState({ status: 'complete', result, jobName, pvcName }); setBenchState({ status: 'complete', result, jobName, pvcName });
} else { } else {
setBenchState({ status: 'failed', error: 'Could not parse FIO output from pod logs.', jobName, pvcName }); setBenchState({
status: 'failed',
error: 'Could not parse FIO output from pod logs.',
jobName,
pvcName,
});
} }
} catch (err: unknown) { } catch (err: unknown) {
setBenchState({ status: 'failed', error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName }); setBenchState({
status: 'failed',
error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
} }
} else if (phase === 'Failed') { } else if (phase === 'Failed') {
stopPolling(); stopPolling();
setBenchState({ status: 'failed', error: 'kbench Job failed. Check pod logs for details.', jobName, pvcName }); setBenchState({
status: 'failed',
error: 'kbench Job failed. Check pod logs for details.',
jobName,
pvcName,
});
} }
} catch (err: unknown) { } catch (err: unknown) {
stopPolling(); stopPolling();
setBenchState({ status: 'failed', error: `Polling error: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName }); setBenchState({
status: 'failed',
error: `Polling error: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
} }
}, POLL_INTERVAL_MS); }, POLL_INTERVAL_MS);
} }
@@ -506,7 +749,10 @@ export default function BenchmarkPage() {
// Clean up polling on unmount // Clean up polling on unmount
useEffect(() => () => stopPolling(), []); useEffect(() => () => stopPolling(), []);
const isRunning = benchState.status !== 'idle' && benchState.status !== 'complete' && benchState.status !== 'failed'; const isRunning =
benchState.status !== 'idle' &&
benchState.status !== 'complete' &&
benchState.status !== 'failed';
if (loading) return <Loader title="Loading tns-csi data..." />; if (loading) return <Loader title="Loading tns-csi data..." />;
@@ -518,15 +764,35 @@ export default function BenchmarkPage() {
<NameValueTable <NameValueTable
rows={[ rows={[
{ name: 'Duration', value: 'Full benchmark takes ~6 minutes. Do not cancel mid-run.' }, { 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: 'Test Size',
{ name: 'CPU Idleness', value: 'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.' }, value:
{ 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.' }, '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> </SectionBox>
<RunForm storageClasses={scNames} onRun={opts => void runBenchmark(opts)} disabled={isRunning} /> <RunForm
storageClasses={scNames}
onRun={opts => void runBenchmark(opts)}
disabled={isRunning}
/>
<BenchmarkProgress state={benchState} /> <BenchmarkProgress state={benchState} />
@@ -535,30 +801,47 @@ export default function BenchmarkPage() {
<KbenchResultDisplay result={currentResult} /> <KbenchResultDisplay result={currentResult} />
<SectionBox title="Cleanup"> <SectionBox title="Cleanup">
<NameValueTable <NameValueTable
rows={[{ rows={[
name: 'Resources', {
value: ( name: 'Resources',
<button value: (
onClick={async () => { <button
const state = benchState; onClick={async () => {
if (state.status !== 'complete') return; const state = benchState;
if (!window.confirm(`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`)) return; if (state.status !== 'complete') return;
try { if (
await deleteJob(state.jobName, lastNamespace); !window.confirm(
await deletePvc(state.pvcName, lastNamespace); `Delete job "${state.jobName}" and PVC "${state.pvcName}"?`
setBenchState({ status: 'idle' }); )
setCurrentResult(null); )
} catch (err: unknown) { return;
alert(`Cleanup error: ${err instanceof Error ? err.message : String(err)}`); try {
} await deleteJob(state.jobName, lastNamespace);
}} await deletePvc(state.pvcName, lastNamespace);
aria-label="Delete benchmark job and PVC" setBenchState({ status: 'idle' });
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' }} setCurrentResult(null);
> } catch (err: unknown) {
Delete Job + PVC alert(
</button> `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> </SectionBox>
</> </>
+20 -14
View File
@@ -6,10 +6,7 @@
* Uses registerDetailsViewSection in index.tsx. * Uses registerDetailsViewSection in index.tsx.
*/ */
import { import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s'; import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s';
@@ -72,12 +69,14 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
// Extract from jsonData (KubeObject instance) or fall back to direct props. // Extract from jsonData (KubeObject instance) or fall back to direct props.
// jsonData.metadata has the full shape including name/namespace; resource.metadata // jsonData.metadata has the full shape including name/namespace; resource.metadata
// only exposes fields that the Headlamp class getter provides (labels, creationTimestamp). // only exposes fields that the Headlamp class getter provides (labels, creationTimestamp).
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as { const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as
name?: string; | {
namespace?: string; name?: string;
labels?: Record<string, string>; namespace?: string;
creationTimestamp?: string; labels?: Record<string, string>;
} | undefined; creationTimestamp?: string;
}
| undefined;
const spec = resource?.jsonData?.spec ?? resource?.spec; const spec = resource?.jsonData?.spec ?? resource?.spec;
const status = resource?.jsonData?.status ?? resource?.status; const status = resource?.jsonData?.status ?? resource?.status;
const labels = meta?.labels ?? {}; const labels = meta?.labels ?? {};
@@ -88,7 +87,8 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
} }
const component = labels['app.kubernetes.io/component'] ?? 'unknown'; const component = labels['app.kubernetes.io/component'] ?? 'unknown';
const roleLabel = component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component; const roleLabel =
component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
// Build a minimal pod shape that isPodReady / getPodRestarts can consume // Build a minimal pod shape that isPodReady / getPodRestarts can consume
const podShape: TnsCsiPod = { const podShape: TnsCsiPod = {
@@ -113,15 +113,21 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
const containerRows = containerStatuses.map(cs => { const containerRows = containerStatuses.map(cs => {
let stateText = 'Unknown'; let stateText = 'Unknown';
if (cs.state?.running) { if (cs.state?.running) {
stateText = `Running since ${cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'} ago`; stateText = `Running since ${
cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'
} ago`;
} else if (cs.state?.waiting) { } else if (cs.state?.waiting) {
stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`; stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`;
} else if (cs.state?.terminated) { } else if (cs.state?.terminated) {
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${cs.state.terminated.reason ?? ''}`; stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${
cs.state.terminated.reason ?? ''
}`;
} }
return { return {
name: cs.name, name: cs.name,
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'}${stateText}${cs.restartCount} restart(s)`, value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'}${stateText}${
cs.restartCount
} restart(s)`,
}; };
}); });
+9 -38
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
import DriverStatusCard from './DriverStatusCard'; import DriverStatusCard from './DriverStatusCard';
@@ -10,24 +11,12 @@ import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpe
describe('DriverStatusCard', () => { describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => { it('shows "Not detected" when no CSI driver is present', () => {
render( render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Not detected')).toBeInTheDocument(); expect(screen.getByText('Not detected')).toBeInTheDocument();
}); });
it('shows "Degraded" when no pods are present', () => { it('shows "Degraded" when no pods are present', () => {
render( render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Degraded')).toBeInTheDocument(); expect(screen.getByText('Degraded')).toBeInTheDocument();
}); });
@@ -84,27 +73,15 @@ describe('DriverStatusCard', () => {
}); });
it('renders CSI capabilities section when driver is present', () => { it('renders CSI capabilities section when driver is present', () => {
render( render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument(); expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('Persistent')).toBeInTheDocument(); expect(screen.getByText('Persistent')).toBeInTheDocument();
}); });
it('does not render CSI capabilities when no driver', () => { it('does not render CSI capabilities when no driver', () => {
render( render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument(); expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
}); });
@@ -119,13 +96,7 @@ describe('DriverStatusCard', () => {
], ],
}, },
}); });
render( render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[pod]} nodePods={[]} />);
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[pod]}
nodePods={[]}
/>
);
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument(); expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument(); expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // restarts expect(screen.getByText('2')).toBeInTheDocument(); // restarts
+15 -8
View File
@@ -38,11 +38,7 @@ function WebSocketStatus({ metrics }: { metrics: TnsCsiMetrics | null }) {
function PodStatusBadge({ pod }: { pod: TnsCsiPod }) { function PodStatusBadge({ pod }: { pod: TnsCsiPod }) {
const ready = isPodReady(pod); const ready = isPodReady(pod);
const phase = pod.status?.phase ?? 'Unknown'; const phase = pod.status?.phase ?? 'Unknown';
return ( return <StatusLabel status={ready ? 'success' : 'error'}>{phase}</StatusLabel>;
<StatusLabel status={ready ? 'success' : 'error'}>
{phase}
</StatusLabel>
);
} }
function PodRow({ pod }: { pod: TnsCsiPod }) { function PodRow({ pod }: { pod: TnsCsiPod }) {
@@ -114,7 +110,8 @@ export default function DriverStatusCard({
name: 'WebSocket', name: 'WebSocket',
value: <WebSocketStatus metrics={metrics ?? null} />, value: <WebSocketStatus metrics={metrics ?? null} />,
}, },
...(metrics?.websocketReconnectsTotal !== null && metrics?.websocketReconnectsTotal !== undefined ...(metrics?.websocketReconnectsTotal !== null &&
metrics?.websocketReconnectsTotal !== undefined
? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }] ? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }]
: []), : []),
]} ]}
@@ -153,7 +150,12 @@ export default function DriverStatusCard({
{controllerPods.length === 0 && ( {controllerPods.length === 0 && (
<SectionBox title="Controller Pods"> <SectionBox title="Controller Pods">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No controller pod found</StatusLabel> }]} rows={[
{
name: 'Status',
value: <StatusLabel status="error">No controller pod found</StatusLabel>,
},
]}
/> />
</SectionBox> </SectionBox>
)} )}
@@ -169,7 +171,12 @@ export default function DriverStatusCard({
{nodePods.length === 0 && ( {nodePods.length === 0 && (
<SectionBox title="Node Pods"> <SectionBox title="Node Pods">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No node pods found</StatusLabel> }]} rows={[
{
name: 'Status',
value: <StatusLabel status="error">No node pods found</StatusLabel>,
},
]}
/> />
</SectionBox> </SectionBox>
)} )}
+4 -3
View File
@@ -1,12 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
vi.mock('../api/TnsCsiDataContext'); vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => { vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>(); const actual = await importOriginal<typeof import('../api/metrics')>();
return { return {
...actual, ...actual,
+34 -7
View File
@@ -40,7 +40,9 @@ function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
{ {
name: 'Connection Status', name: 'Connection Status',
value: ( value: (
<StatusLabel status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}> <StatusLabel
status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}
>
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'} {connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
@@ -137,7 +139,14 @@ export default function MetricsPage() {
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Metrics" /> <SectionHeader title="TNS-CSI — Metrics" />
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{lastUpdated && ( {lastUpdated && (
@@ -169,7 +178,14 @@ export default function MetricsPage() {
{!driverInstalled && ( {!driverInstalled && (
<SectionBox title="Driver Not Detected"> <SectionBox title="Driver Not Detected">
<NameValueTable <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel> }]} rows={[
{
name: 'Status',
value: (
<StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel>
),
},
]}
/> />
</SectionBox> </SectionBox>
)} )}
@@ -178,11 +194,18 @@ export default function MetricsPage() {
<SectionBox title="Metrics Unavailable"> <SectionBox title="Metrics Unavailable">
<NameValueTable <NameValueTable
rows={[ 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: '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', name: 'Troubleshooting',
value: 'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller', value:
'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
}, },
]} ]}
/> />
@@ -194,7 +217,11 @@ export default function MetricsPage() {
<NameValueTable <NameValueTable
rows={[ rows={[
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> }, { name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
{ name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' }, {
name: 'Note',
value:
'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.',
},
]} ]}
/> />
</SectionBox> </SectionBox>
+23 -13
View File
@@ -1,12 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
vi.mock('../api/TnsCsiDataContext'); vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => { vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>(); const actual = await importOriginal<typeof import('../api/metrics')>();
return { return {
...actual, ...actual,
@@ -132,9 +133,7 @@ describe('OverviewPage', () => {
persistentVolumeClaims: [], persistentVolumeClaims: [],
controllerPods: [makeSamplePod()], controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })], nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [ poolStats: [{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 }],
{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 },
],
}); });
render(<OverviewPage />); render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument(); expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
@@ -163,9 +162,7 @@ describe('OverviewPage', () => {
const pod = makeSamplePod(); const pod = makeSamplePod();
const pv = makeSamplePV(); const pv = makeSamplePV();
const metrics = makeSampleMetrics({ const metrics = makeSampleMetrics({
volumeCapacityBytes: [ volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
}); });
mockContext({ mockContext({
driverInstalled: true, driverInstalled: true,
@@ -187,7 +184,11 @@ describe('OverviewPage', () => {
it('renders non-bound PVCs table', () => { it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({ const pendingPvc = makeSamplePVC({
metadata: { name: 'pending-pvc', namespace: 'test', creationTimestamp: '2025-01-01T00:00:00Z' }, metadata: {
name: 'pending-pvc',
namespace: 'test',
creationTimestamp: '2025-01-01T00:00:00Z',
},
status: { phase: 'Pending' }, status: { phase: 'Pending' },
}); });
mockContext({ mockContext({
@@ -257,9 +258,18 @@ describe('OverviewPage', () => {
}); });
it('shows PVC status breakdown with Pending and Lost counts', () => { it('shows PVC status breakdown with Pending and Lost counts', () => {
const boundPvc = makeSamplePVC({ metadata: { name: 'pvc-1', namespace: 'ns' }, status: { phase: 'Bound' } }); const boundPvc = makeSamplePVC({
const pendingPvc = makeSamplePVC({ metadata: { name: 'pvc-2', namespace: 'ns' }, status: { phase: 'Pending' } }); metadata: { name: 'pvc-1', namespace: 'ns' },
const lostPvc = makeSamplePVC({ metadata: { name: 'pvc-3', namespace: 'ns' }, status: { phase: 'Lost' } }); status: { phase: 'Bound' },
});
const pendingPvc = makeSamplePVC({
metadata: { name: 'pvc-2', namespace: 'ns' },
status: { phase: 'Pending' },
});
const lostPvc = makeSamplePVC({
metadata: { name: 'pvc-3', namespace: 'ns' },
status: { phase: 'Lost' },
});
mockContext({ mockContext({
driverInstalled: true, driverInstalled: true,
csiDriver: sampleCSIDriver, csiDriver: sampleCSIDriver,
+55 -32
View File
@@ -122,16 +122,21 @@ export default function OverviewPage() {
else pvcStatusCounts.Other++; else pvcStatusCounts.Other++;
} }
const nonBoundPvcs = persistentVolumeClaims.filter( const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
pvc => pvc.status?.phase !== 'Bound'
);
const chartData = protocolChartData(storageClasses); const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length; const totalScs = storageClasses.length;
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}> <div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Overview" /> <SectionHeader title="TNS-CSI — Overview" />
<button <button
onClick={refresh} onClick={refresh}
@@ -174,11 +179,16 @@ export default function OverviewPage() {
rows={[ rows={[
{ {
name: 'Status', name: 'Status',
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>, value: (
<StatusLabel status="error">
CSIDriver tns.csi.io not found on this cluster
</StatusLabel>
),
}, },
{ {
name: 'Install', name: 'Install',
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system', value:
'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
}, },
]} ]}
/> />
@@ -223,7 +233,13 @@ export default function OverviewPage() {
<SectionBox title="Storage Summary"> <SectionBox title="Storage Summary">
{totalScs > 0 && chartData.length > 0 && ( {totalScs > 0 && chartData.length > 0 && (
<div style={{ marginBottom: '16px' }}> <div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}> <div
style={{
marginBottom: '8px',
fontSize: '14px',
color: 'var(--mui-palette-text-secondary)',
}}
>
Protocol Distribution Protocol Distribution
</div> </div>
<PercentageBar data={chartData} total={totalScs} /> <PercentageBar data={chartData} total={totalScs} />
@@ -239,16 +255,20 @@ export default function OverviewPage() {
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>, value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
}, },
...(pvcStatusCounts.Pending > 0 ...(pvcStatusCounts.Pending > 0
? [{ ? [
name: 'PVCs (Pending)', {
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>, name: 'PVCs (Pending)',
}] value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
},
]
: []), : []),
...(pvcStatusCounts.Lost > 0 ...(pvcStatusCounts.Lost > 0
? [{ ? [
name: 'PVCs (Lost)', {
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>, name: 'PVCs (Lost)',
}] value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
},
]
: []), : []),
]} ]}
/> />
@@ -259,23 +279,21 @@ export default function OverviewPage() {
<SectionBox title="Pool Capacity"> <SectionBox title="Pool Capacity">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Pool', getter: (p) => p.name }, { label: 'Pool', getter: p => p.name },
{ {
label: 'Status', label: 'Status',
getter: (p) => ( getter: p => (
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}> <StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
{p.status} {p.status}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Total', getter: (p) => formatBytes(p.size) }, { label: 'Total', getter: p => formatBytes(p.size) },
{ label: 'Used', getter: (p) => formatBytes(p.allocated) }, { label: 'Used', getter: p => formatBytes(p.allocated) },
{ label: 'Free', getter: (p) => formatBytes(p.free) }, { label: 'Free', getter: p => formatBytes(p.free) },
{ {
label: 'Used %', label: 'Used %',
getter: (p) => p.size > 0 getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
? `${Math.round((p.allocated / p.size) * 100)}%`
: '—',
}, },
]} ]}
data={poolStats} data={poolStats}
@@ -319,17 +337,17 @@ export default function OverviewPage() {
<SectionBox title="Attention: Non-Bound PVCs"> <SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (pvc) => pvc.metadata.name }, { label: 'Name', getter: pvc => pvc.metadata.name },
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' }, { label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
{ {
label: 'Status', label: 'Status',
getter: (pvc) => ( getter: pvc => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}> <StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'} {pvc.status?.phase ?? 'Unknown'}
</StatusLabel> </StatusLabel>
), ),
}, },
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) }, { label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
]} ]}
data={nonBoundPvcs} data={nonBoundPvcs}
/> />
@@ -350,11 +368,16 @@ function parseStorageToBytes(storage: string): number {
const suffix = match[2] ?? ''; const suffix = match[2] ?? '';
const multipliers: Record<string, number> = { const multipliers: Record<string, number> = {
'': 1, '': 1,
K: 1e3, Ki: 1024, K: 1e3,
M: 1e6, Mi: 1024 ** 2, Ki: 1024,
G: 1e9, Gi: 1024 ** 3, M: 1e6,
T: 1e12, Ti: 1024 ** 4, Mi: 1024 ** 2,
P: 1e15, Pi: 1024 ** 5, G: 1e9,
Gi: 1024 ** 3,
T: 1e12,
Ti: 1024 ** 4,
P: 1e15,
Pi: 1024 ** 5,
}; };
return value * (multipliers[suffix] ?? 1); return value * (multipliers[suffix] ?? 1);
} }
+5 -8
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
vi.mock('../api/TnsCsiDataContext'); vi.mock('../api/TnsCsiDataContext');
@@ -51,9 +52,7 @@ describe('PVCDetailSection', () => {
persistentVolumeClaims: [pvc], persistentVolumeClaims: [pvc],
persistentVolumes: [pv], persistentVolumes: [pv],
}); });
render( render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument(); expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument(); expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument(); expect(screen.getByText('NFS')).toBeInTheDocument();
@@ -83,9 +82,7 @@ describe('PVCDetailSection', () => {
persistentVolumeClaims: [pvc], persistentVolumeClaims: [pvc],
persistentVolumes: [pv], persistentVolumes: [pv],
}); });
render( render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('pool')).toBeInTheDocument(); expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument(); expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument(); expect(screen.getByText('customAttr')).toBeInTheDocument();
+3 -7
View File
@@ -5,10 +5,7 @@
* Uses registerDetailsViewSection in index.tsx. * Uses registerDetailsViewSection in index.tsx.
*/ */
import { import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { findBoundPv, formatProtocol } from '../api/k8s'; import { findBoundPv, formatProtocol } from '../api/k8s';
@@ -52,10 +49,9 @@ export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
{ name: 'Server', value: attrs['server'] ?? '—' }, { name: 'Server', value: attrs['server'] ?? '—' },
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' }, { name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' }, { name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
...(Object.entries(attrs) ...Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k)) .filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' })) .map(([k, v]) => ({ name: k, value: v ?? '—' })),
),
{ {
name: 'PV Name', name: 'PV Name',
value: boundPv.metadata.name, value: boundPv.metadata.name,
+1 -4
View File
@@ -5,10 +5,7 @@
* Uses registerDetailsViewSection in index.tsx. * Uses registerDetailsViewSection in index.tsx.
*/ */
import { import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react'; import React from 'react';
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s'; import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
+4 -5
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
vi.mock('../api/TnsCsiDataContext'); vi.mock('../api/TnsCsiDataContext');
@@ -33,9 +34,7 @@ describe('SnapshotsPage', () => {
mockContext({ snapshotCrdAvailable: false }); mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />); render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument(); expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect( expect(screen.getByText(/VolumeSnapshot CRDs.*not found/)).toBeInTheDocument();
screen.getByText(/VolumeSnapshot CRDs.*not found/)
).toBeInTheDocument();
}); });
it('shows empty message when snapshots list is empty', () => { it('shows empty message when snapshots list is empty', () => {
+12 -6
View File
@@ -27,7 +27,9 @@ export default function SnapshotsPage() {
<> <>
<SectionHeader title="TNS-CSI — Snapshots" /> <SectionHeader title="TNS-CSI — Snapshots" />
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
</> </>
); );
@@ -51,7 +53,11 @@ export default function SnapshotsPage() {
{ {
name: 'Documentation', name: 'Documentation',
value: ( value: (
<a href="https://github.com/fenio/tns-csi" target="_blank" rel="noopener noreferrer"> <a
href="https://github.com/fenio/tns-csi"
target="_blank"
rel="noopener noreferrer"
>
See tns-csi documentation for snapshot setup instructions See tns-csi documentation for snapshot setup instructions
</a> </a>
), ),
@@ -71,10 +77,10 @@ export default function SnapshotsPage() {
<SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}> <SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}>
<SimpleTable <SimpleTable
columns={[ columns={[
{ label: 'Name', getter: (vsc) => vsc.metadata.name }, { label: 'Name', getter: vsc => vsc.metadata.name },
{ label: 'Driver', getter: (vsc) => vsc.driver ?? '—' }, { label: 'Driver', getter: vsc => vsc.driver ?? '—' },
{ label: 'Deletion Policy', getter: (vsc) => vsc.deletionPolicy ?? '—' }, { label: 'Deletion Policy', getter: vsc => vsc.deletionPolicy ?? '—' },
{ label: 'Age', getter: (vsc) => formatAge(vsc.metadata.creationTimestamp) }, { label: 'Age', getter: vsc => formatAge(vsc.metadata.creationTimestamp) },
]} ]}
data={volumeSnapshotClasses} data={volumeSnapshotClasses}
/> />
+6 -3
View File
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
let mockHash = ''; let mockHash = '';
@@ -109,7 +110,9 @@ describe('StorageClassesPage', () => {
it('shows NFS protocol notes in detail panel', () => { it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs'; mockHash = '#tns-nfs';
const sc = makeSampleStorageClass({ parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' } }); const sc = makeSampleStorageClass({
parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] }); mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />); render(<StorageClassesPage />);
expect(screen.getByText('Protocol Notes')).toBeInTheDocument(); expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
+78 -20
View File
@@ -55,7 +55,14 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
} }
`}</style> `}</style>
<div className={drawerClass}> <div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}> <h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
{sc.metadata.name} {sc.metadata.name}
</h2> </h2>
@@ -64,7 +71,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
onClick={() => setIsMaximized(!isMaximized)} onClick={() => setIsMaximized(!isMaximized)}
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'} aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
title={isMaximized ? 'Minimize' : 'Maximize'} 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' }} style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
> >
{isMaximized ? '⊟' : '⊡'} {isMaximized ? '⊟' : '⊡'}
</button> </button>
@@ -72,7 +87,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
onClick={onClose} onClick={onClose}
aria-label="Close panel" aria-label="Close panel"
title="Close" title="Close"
style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }} style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
> >
× ×
</button> </button>
@@ -90,16 +113,21 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' }, { name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
{ {
name: 'Allow Volume Expansion', name: 'Allow Volume Expansion',
value: <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}> value: (
{sc.allowVolumeExpansion ? 'Yes' : 'No'} <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
</StatusLabel>, {sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>
),
}, },
{ name: 'Delete Strategy', value: params.deleteStrategy ?? '—' }, { name: 'Delete Strategy', value: params.deleteStrategy ?? '—' },
{ {
name: 'Encryption', name: 'Encryption',
value: params.encryption === 'true' value:
? <StatusLabel status="success">Enabled</StatusLabel> params.encryption === 'true' ? (
: <StatusLabel status="warning">Disabled</StatusLabel>, <StatusLabel status="success">Enabled</StatusLabel>
) : (
<StatusLabel status="warning">Disabled</StatusLabel>
),
}, },
{ name: 'Provisioner', value: sc.provisioner }, { name: 'Provisioner', value: sc.provisioner },
{ name: 'Bound PVs', value: String(pvCount) }, { name: 'Bound PVs', value: String(pvCount) },
@@ -122,13 +150,19 @@ function protocolNotes(protocol: string): Array<{ name: string; value: React.Rea
const lower = protocol.toLowerCase(); const lower = protocol.toLowerCase();
if (lower === 'nfs') { if (lower === 'nfs') {
return [ return [
{ name: 'Prerequisite', value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes' }, {
name: 'Prerequisite',
value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes',
},
{ name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' }, { name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' },
]; ];
} }
if (lower === 'nvmeof') { if (lower === 'nvmeof') {
return [ return [
{ name: 'Prerequisite', value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes' }, {
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: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' }, { name: 'Access Modes', value: 'Supports RWO, RWOP' },
]; ];
@@ -151,9 +185,7 @@ export default function StorageClassesPage() {
const history = useHistory(); const history = useHistory();
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext(); const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>( const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
location.hash.slice(1) || null
);
useEffect(() => { useEffect(() => {
setSelectedName(location.hash.slice(1) || null); setSelectedName(location.hash.slice(1) || null);
@@ -186,7 +218,9 @@ export default function StorageClassesPage() {
<> <>
<SectionHeader title="TNS-CSI — Storage Classes" /> <SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
</> </>
); );
@@ -199,7 +233,9 @@ export default function StorageClassesPage() {
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1); pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
} }
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null; const selectedSc = selectedName
? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null
: null;
return ( return (
<> <>
@@ -212,16 +248,30 @@ export default function StorageClassesPage() {
getter: (sc: TnsCsiStorageClass) => ( getter: (sc: TnsCsiStorageClass) => (
<button <button
onClick={() => openSc(sc.metadata.name)} onClick={() => openSc(sc.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{sc.metadata.name} {sc.metadata.name}
</button> </button>
), ),
}, },
{ label: 'Protocol', getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol) }, {
label: 'Protocol',
getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol),
},
{ label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' }, { label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' },
{ label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' }, { label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' },
{ label: 'Reclaim Policy', getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—' }, {
label: 'Reclaim Policy',
getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—',
},
{ {
label: 'Expansion', label: 'Expansion',
getter: (sc: TnsCsiStorageClass) => ( getter: (sc: TnsCsiStorageClass) => (
@@ -245,7 +295,15 @@ export default function StorageClassesPage() {
<div <div
onClick={closeSc} onClick={closeSc}
aria-label="Close panel backdrop" 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 }} style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 1100,
}}
/> />
<StorageClassDetailPanel <StorageClassDetailPanel
sc={selectedSc} sc={selectedSc}
+4 -4
View File
@@ -119,8 +119,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
autoComplete="off" autoComplete="off"
/> />
<div style={HINT_STYLE}> <div style={HINT_STYLE}>
Generate in TrueNAS UI Credentials API Keys. Generate in TrueNAS UI Credentials API Keys. Required for real pool capacity
Required for real pool capacity data on the Overview page. data on the Overview page.
</div> </div>
</div> </div>
), ),
@@ -137,8 +137,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
style={INPUT_STYLE} style={INPUT_STYLE}
/> />
<div style={HINT_STYLE}> <div style={HINT_STYLE}>
TrueNAS host/IP. If blank, the plugin uses the{' '} TrueNAS host/IP. If blank, the plugin uses the <code>server</code> parameter from
<code>server</code> parameter from your tns-csi StorageClass. your tns-csi StorageClass.
</div> </div>
</div> </div>
), ),
+3 -2
View File
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', async () => vi.mock(
await import('./__mocks__/commonComponents') '@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
); );
let mockHash = ''; let mockHash = '';
+73 -19
View File
@@ -15,7 +15,7 @@ import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiPersistentVolume } from '../api/k8s'; import type { TnsCsiPersistentVolume } from '../api/k8s';
import { findBoundPv, formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s'; import { formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Detail panel // Detail panel
@@ -47,13 +47,46 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
} }
`}</style> `}</style>
<div className={drawerClass}> <div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>{pv.metadata.name}</h2> 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' }}> <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' }}> <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 ? '⊟' : '⊡'} {isMaximized ? '⊟' : '⊡'}
</button> </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
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> </button>
</div> </div>
@@ -98,10 +131,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
{ name: 'Volume Handle', value: csi?.volumeHandle ?? '—' }, { name: 'Volume Handle', value: csi?.volumeHandle ?? '—' },
{ name: 'Protocol', value: formatProtocol(attrs['protocol']) }, { name: 'Protocol', value: formatProtocol(attrs['protocol']) },
{ name: 'Server', value: attrs['server'] ?? '—' }, { name: 'Server', value: attrs['server'] ?? '—' },
...(Object.entries(attrs) ...Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k)) .filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' })) .map(([k, v]) => ({ name: k, value: v ?? '—' })),
),
]} ]}
/> />
</SectionBox> </SectionBox>
@@ -110,10 +142,16 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
{pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && ( {pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && (
<SectionBox title="Adoption"> <SectionBox title="Adoption">
<NameValueTable <NameValueTable
rows={[{ rows={[
name: 'Adoptable', {
value: <StatusLabel status="success">This volume can be adopted cross-cluster</StatusLabel>, name: 'Adoptable',
}]} value: (
<StatusLabel status="success">
This volume can be adopted cross-cluster
</StatusLabel>
),
},
]}
/> />
</SectionBox> </SectionBox>
)} )}
@@ -129,11 +167,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
export default function VolumesPage() { export default function VolumesPage() {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext(); const { persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>( const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
location.hash.slice(1) || null
);
useEffect(() => { useEffect(() => {
setSelectedName(location.hash.slice(1) || null); setSelectedName(location.hash.slice(1) || null);
@@ -166,7 +202,9 @@ export default function VolumesPage() {
<> <>
<SectionHeader title="TNS-CSI — Volumes" /> <SectionHeader title="TNS-CSI — Volumes" />
<SectionBox title="Error"> <SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} /> <NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox> </SectionBox>
</> </>
); );
@@ -187,7 +225,15 @@ export default function VolumesPage() {
getter: (pv: TnsCsiPersistentVolume) => ( getter: (pv: TnsCsiPersistentVolume) => (
<button <button
onClick={() => openVolume(pv.metadata.name)} onClick={() => openVolume(pv.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }} style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
> >
{pv.metadata.name} {pv.metadata.name}
</button> </button>
@@ -240,7 +286,15 @@ export default function VolumesPage() {
<div <div
onClick={closeVolume} onClick={closeVolume}
aria-label="Close panel backdrop" 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 }} 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} /> <VolumeDetailPanel pv={selectedPv} onClose={closeVolume} />
</> </>
+33 -25
View File
@@ -13,7 +13,9 @@ export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title); React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) => export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement('div', { 'data-testid': 'section-box', 'data-title': title }, React.createElement(
'div',
{ 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null, title ? React.createElement('h3', null, title) : null,
children children
); );
@@ -33,15 +35,25 @@ export const SimpleTable = ({
if (data.length === 0 && emptyMessage) { if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage); return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
} }
return React.createElement('table', { 'data-testid': 'simple-table' }, return React.createElement(
React.createElement('thead', null, 'table',
React.createElement('tr', null, { 'data-testid': 'simple-table' },
React.createElement(
'thead',
null,
React.createElement(
'tr',
null,
columns.map(col => React.createElement('th', { key: col.label }, col.label)) columns.map(col => React.createElement('th', { key: col.label }, col.label))
) )
), ),
React.createElement('tbody', null, React.createElement(
'tbody',
null,
data.map((item, i) => data.map((item, i) =>
React.createElement('tr', { key: i }, React.createElement(
'tr',
{ key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item))) columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
) )
) )
@@ -49,15 +61,17 @@ export const SimpleTable = ({
); );
}; };
export const NameValueTable = ({ export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
rows, React.createElement(
}: { 'table',
rows: Array<{ name: string; value: RC }>; { 'data-testid': 'name-value-table' },
}) => React.createElement(
React.createElement('table', { 'data-testid': 'name-value-table' }, 'tbody',
React.createElement('tbody', null, null,
rows.map(row => rows.map(row =>
React.createElement('tr', { key: row.name }, React.createElement(
'tr',
{ key: row.name },
React.createElement('td', null, row.name), React.createElement('td', null, row.name),
React.createElement('td', null, row.value) React.createElement('td', null, row.value)
) )
@@ -65,13 +79,7 @@ export const NameValueTable = ({
) )
); );
export const StatusLabel = ({ export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
status,
children,
}: {
status: string;
children?: RC;
}) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children); React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({ export const PercentageBar = ({
@@ -80,8 +88,8 @@ export const PercentageBar = ({
data: Array<{ name: string; value: number }>; data: Array<{ name: string; value: number }>;
total: number; total: number;
}) => }) =>
React.createElement('div', { 'data-testid': 'percentage-bar' }, React.createElement(
data.map(d => 'div',
React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`) { 'data-testid': 'percentage-bar' },
) data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
); );
@@ -22,23 +22,20 @@ interface StorageClassBenchmarkButtonProps {
}; };
} }
export default function StorageClassBenchmarkButton({ resource }: StorageClassBenchmarkButtonProps) { export default function StorageClassBenchmarkButton({
resource,
}: StorageClassBenchmarkButtonProps) {
const history = useHistory(); const history = useHistory();
// provisioner is one of the fields Headlamp's StorageClass class exposes as a getter, // provisioner is one of the fields Headlamp's StorageClass class exposes as a getter,
// so it's accessible directly. jsonData fallback for safety. // so it's accessible directly. jsonData fallback for safety.
const provisioner = const provisioner = resource?.provisioner ?? resource?.jsonData?.provisioner;
resource?.provisioner ??
resource?.jsonData?.provisioner;
if (provisioner !== TNS_CSI_PROVISIONER) { if (provisioner !== TNS_CSI_PROVISIONER) {
return null; return null;
} }
const scName = const scName = resource?.metadata?.name ?? resource?.jsonData?.metadata?.name ?? '';
resource?.metadata?.name ??
resource?.jsonData?.metadata?.name ??
'';
const handleClick = () => { const handleClick = () => {
// Navigate to benchmark page; user selects the SC in the benchmark form. // Navigate to benchmark page; user selects the SC in the benchmark form.
@@ -50,16 +50,14 @@ export function buildStorageClassColumns() {
label: 'Protocol', label: 'Protocol',
getValue: (sc: unknown): string | null => { getValue: (sc: unknown): string | null => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null; if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'protocol'); const p = getField(sc, 'parameters', 'protocol');
return typeof p === 'string' ? formatProtocol(p) : null; return typeof p === 'string' ? formatProtocol(p) : null;
}, },
render: (sc: unknown) => { render: (sc: unknown) => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>; if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined; const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
return <span>{formatProtocol(protocol)}</span>; return <span>{formatProtocol(protocol)}</span>;
@@ -69,16 +67,14 @@ export function buildStorageClassColumns() {
label: 'Pool', label: 'Pool',
getValue: (sc: unknown): string | null => { getValue: (sc: unknown): string | null => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null; if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'pool'); const p = getField(sc, 'parameters', 'pool');
return typeof p === 'string' ? p : null; return typeof p === 'string' ? p : null;
}, },
render: (sc: unknown) => { render: (sc: unknown) => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>; if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const pool = getField(sc, 'parameters', 'pool') as string | undefined; const pool = getField(sc, 'parameters', 'pool') as string | undefined;
return <span>{pool ?? '—'}</span>; return <span>{pool ?? '—'}</span>;
@@ -88,16 +84,14 @@ export function buildStorageClassColumns() {
label: 'Server', label: 'Server',
getValue: (sc: unknown): string | null => { getValue: (sc: unknown): string | null => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null; if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'server'); const p = getField(sc, 'parameters', 'server');
return typeof p === 'string' ? p : null; return typeof p === 'string' ? p : null;
}, },
render: (sc: unknown) => { render: (sc: unknown) => {
const provisioner = const provisioner =
getField(sc, 'provisioner') ?? getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>; if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const server = getField(sc, 'parameters', 'server') as string | undefined; const server = getField(sc, 'parameters', 'server') as string | undefined;
return <span>{server ?? '—'}</span>; return <span>{server ?? '—'}</span>;
@@ -127,7 +121,9 @@ export function buildPVColumns() {
render: (pv: unknown) => { render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined; const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>; if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as string | undefined; const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as
| string
| undefined;
return <span>{formatProtocol(protocol)}</span>; return <span>{formatProtocol(protocol)}</span>;
}, },
}, },
@@ -144,7 +140,9 @@ export function buildPVColumns() {
render: (pv: unknown) => { render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined; const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>; if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as string | undefined; const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as
| string
| undefined;
const pool = dataset?.split('/')[0]; const pool = dataset?.split('/')[0];
return <span>{pool ?? '—'}</span>; return <span>{pool ?? '—'}</span>;
}, },
+16 -6
View File
@@ -18,7 +18,10 @@ import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
import TnsCsiSettings from './components/TnsCsiSettings'; import TnsCsiSettings from './components/TnsCsiSettings';
import BenchmarkPage from './components/BenchmarkPage'; import BenchmarkPage from './components/BenchmarkPage';
import DriverPodDetailSection from './components/DriverPodDetailSection'; import DriverPodDetailSection from './components/DriverPodDetailSection';
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns'; import {
buildPVColumns,
buildStorageClassColumns,
} from './components/integrations/StorageClassColumns';
import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton'; import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
import MetricsPage from './components/MetricsPage'; import MetricsPage from './components/MetricsPage';
import OverviewPage from './components/OverviewPage'; import OverviewPage from './components/OverviewPage';
@@ -192,11 +195,18 @@ registerDetailsViewSection(({ resource }) => {
// takes priority and falls back to the existing one (for mixed-driver tables). // takes priority and falls back to the existing one (for mixed-driver tables).
function mergeColumns<T>( function mergeColumns<T>(
existing: T[], existing: T[],
incoming: Array<{ label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode }> incoming: Array<{
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
}>
): T[] { ): T[] {
type ObjCol = { label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode }; type ObjCol = {
const isObjCol = (c: unknown): c is ObjCol => label: string;
typeof c === 'object' && c !== null && 'label' in c; getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
};
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
const result = [...existing]; const result = [...existing];
const toAppend: typeof incoming = []; const toAppend: typeof incoming = [];
for (const col of incoming) { for (const col of incoming) {
@@ -206,7 +216,7 @@ function mergeColumns<T>(
result[idx] = { result[idx] = {
label: col.label, label: col.label,
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r), getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
render: (r: unknown) => col.getValue(r) !== null ? col.render(r) : prev.render(r), render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
} as unknown as T; } as unknown as T;
} else { } else {
toAppend.push(col); toAppend.push(col);
+10 -7
View File
@@ -54,7 +54,9 @@ export const sampleCSIDriver: CSIDriver = {
}, },
}; };
export function makeSampleStorageClass(overrides?: Partial<TnsCsiStorageClass>): TnsCsiStorageClass { export function makeSampleStorageClass(
overrides?: Partial<TnsCsiStorageClass>
): TnsCsiStorageClass {
return { return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' }, metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io', provisioner: 'tns.csi.io',
@@ -101,7 +103,9 @@ export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCs
export const samplePV = makeSamplePV(); export const samplePV = makeSamplePV();
export function makeSamplePVC(overrides?: Partial<TnsCsiPersistentVolumeClaim>): TnsCsiPersistentVolumeClaim { export function makeSamplePVC(
overrides?: Partial<TnsCsiPersistentVolumeClaim>
): TnsCsiPersistentVolumeClaim {
return { return {
metadata: { metadata: {
name: 'my-pvc', name: 'my-pvc',
@@ -173,7 +177,9 @@ export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeS
}; };
} }
export function makeSampleSnapshotClass(overrides?: Partial<VolumeSnapshotClass>): VolumeSnapshotClass { export function makeSampleSnapshotClass(
overrides?: Partial<VolumeSnapshotClass>
): VolumeSnapshotClass {
return { return {
metadata: { metadata: {
name: 'tns-snap-class', name: 'tns-snap-class',
@@ -196,9 +202,7 @@ export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMet
{ labels: { protocol: 'iscsi' }, value: 5 }, { labels: { protocol: 'iscsi' }, value: 5 },
], ],
volumeOperationsDurationSeconds: [], volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [ volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
csiOperationsTotal: [ csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 }, { labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 }, { labels: { method: 'DeleteVolume' }, value: 2 },
@@ -207,4 +211,3 @@ export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMet
...overrides, ...overrides,
}; };
} }