fix: remove unused imports and format source files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,10 +21,14 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
},
|
||||
},
|
||||
ConfigStore: class {
|
||||
get() { return {}; }
|
||||
get() {
|
||||
return {};
|
||||
}
|
||||
set() {}
|
||||
update() {}
|
||||
useConfig() { return () => ({}); }
|
||||
useConfig() {
|
||||
return () => ({});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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)`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user