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