Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions[bot] ae8d93aaa7 chore: release v0.2.4 2026-02-26 17:37:55 +00:00
Chris Farhood 680289fba4 fix(release): use correct tarball name (tns-csi, not headlamp-tns-csi-plugin)
headlamp-plugin package names the tarball from package.json "name" field
which is "tns-csi", producing tns-csi-VERSION.tar.gz.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:33:24 +00:00
Chris Farhood f7592d69db fix(ci): add eslint config, fix conditional hook, add checks permission
- Add .eslintrc.json (was missing, causing lint to always fail)
- Move useMemo above early return in OverviewPage to fix rules-of-hooks
- Remove unused imports in OverviewPage
- Add checks:write permission to test job for dorny/test-reporter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:20:19 +00:00
Chris Farhood fa401afecf ci: overhaul CI and Release workflows
Split CI into parallel lint/typecheck/test jobs with build gating on all
three. Add JUnit test reporter for PR visibility. Bump Node 20 to 22.
Replace inline npx commands with npm run scripts. Add CI gate and
concurrency control to Release workflow. Harden tarball validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:14:04 +00:00
Chris Farhood cdff1d1a07 test: add component tests for all 8 UI components
Add 92 new tests across 8 test files covering DriverStatusCard,
SnapshotsPage, PVCDetailSection, StorageClassesPage, VolumesPage,
MetricsPage, OverviewPage, and BenchmarkPage. Includes shared
test-helpers.tsx with fixtures and a lightweight CommonComponents
mock. Total tests: 67 → 159.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:08:09 +00:00
Chris Farhood 54a33d70b0 chore: remove tracked tarballs and add .gitignore
Release tarballs were committed to the repo. Remove them and add a
.gitignore to prevent it from happening again.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:45:25 +00:00
Chris Farhood 7922630fc3 chore: remove dead AppBarDriverBadge component
The registerAppBarAction call was removed in v0.2.1 (96ea9e1) but the
component file was left behind. Nothing imports it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:39:06 +00:00
20 changed files with 1800 additions and 141 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"env": {
"browser": true,
"es2021": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": { "jsx": true },
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"settings": {
"react": { "version": "detect" }
},
"rules": {
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "warn"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"@typescript-eslint/no-require-imports": "off"
}
}
]
}
+48 -20
View File
@@ -7,31 +7,59 @@ on:
branches: [main]
jobs:
lint-and-test:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- name: Install dependencies
run: npm ci
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run tsc
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
checks: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx vitest run --reporter=default --reporter=junit --outputFile=test-results.xml
- uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: test-results.xml
reporter: java-junit
- name: Lint
run: npx eslint --ext .ts,.tsx src/
- name: Type-check
run: npx tsc --noEmit
- name: Run unit tests
run: npm test
build:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
+33 -15
View File
@@ -8,9 +8,28 @@ on:
required: true
type: string
concurrency:
group: release
cancel-in-progress: false
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run tsc
- run: npm test
release:
runs-on: ubuntu-latest
needs: [ci]
permissions:
contents: write
steps:
@@ -37,7 +56,7 @@ jobs:
- name: Update artifacthub-pkg.yml version and URL
run: |
VERSION="${{ inputs.version }}"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-tns-csi-plugin-${VERSION}.tar.gz"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/tns-csi-${VERSION}.tar.gz"
sed -i "s|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
@@ -45,32 +64,31 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
run: npm run build
- name: Package plugin
run: npx @kinvolk/headlamp-plugin package
- name: Validate tarball name
- name: Validate tarball
run: |
EXPECTED="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
ACTUAL=$(ls *.tar.gz)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
EXPECTED="tns-csi-${{ inputs.version }}.tar.gz"
if [ ! -f "$EXPECTED" ]; then
echo "::error::Expected tarball not found: $EXPECTED"
exit 1
fi
echo "Tarball name validated: $ACTUAL"
echo "Tarball validated: $EXPECTED"
- name: Compute checksum
id: compute_checksum
run: |
TARBALL="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
TARBALL="tns-csi-${{ inputs.version }}.tar.gz"
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: sha256:${CHECKSUM}"
@@ -95,7 +113,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ inputs.version }}"
files: headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz
files: tns-csi-${{ inputs.version }}.tar.gz
fail_on_unmatched_files: true
draft: false
prerelease: false
@@ -105,7 +123,7 @@ jobs:
- name: Summary
run: |
echo "Version bumped to ${{ inputs.version }}"
echo "Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
echo "Tag v${{ inputs.version }} created"
echo "GitHub release published with tarball"
echo "Version bumped to ${{ inputs.version }}"
echo "Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
echo "Tag v${{ inputs.version }} created"
echo "GitHub release published with tarball"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tar.gz
+3 -3
View File
@@ -1,4 +1,4 @@
version: "0.2.3"
version: "0.2.4"
name: headlamp-tns-csi-plugin
displayName: TrueNAS CSI (tns-csi)
description: >-
@@ -51,7 +51,7 @@ changes:
description: "Package renamed to tns-csi so the plugin displays correctly in Headlamp's Plugins list"
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.3/tns-csi-0.2.3.tar.gz"
headlamp/plugin/archive-checksum: "sha256:af25fda9fb90a13155ff1e37feec320dee46c0350518e227506067e73f531f81"
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz"
headlamp/plugin/archive-checksum: "sha256:a72b8094a70dd127aa9edb388411b3506bf5f6a7071c266c2aaa477c27badf60"
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tns-csi",
"version": "0.2.3",
"version": "0.2.4",
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
"repository": {
"type": "git",
-80
View File
@@ -1,80 +0,0 @@
/**
* AppBarDriverBadge — registerAppBarAction driver health badge.
*
* Displays "tns-csi: N/N" in the Headlamp top nav bar showing
* ready controller + node pod counts. Color-coded:
* green = all pods ready
* orange = some pods degraded
* red = no pods ready or driver missing
*
* Returns null if the driver is not installed (no CSIDriver object) --
* no clutter in clusters where tns-csi is absent.
*
* Wrapped in TnsCsiDataProvider at registration time (index.tsx).
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { isPodReady, TnsCsiPod } from '../api/k8s';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
function countReady(pods: TnsCsiPod[]): number {
return pods.filter(isPodReady).length;
}
function getBadgeColor(ready: number, total: number): string {
if (total === 0) return '#9e9e9e';
if (ready === total) return '#4caf50';
if (ready > 0) return '#ff9800';
return '#f44336';
}
export default function AppBarDriverBadge() {
const { driverInstalled, controllerPods, nodePods, loading } = useTnsCsiContext();
const history = useHistory();
if (loading || !driverInstalled) {
return null;
}
const controllerReady = countReady(controllerPods);
const controllerTotal = controllerPods.length;
const nodeReady = countReady(nodePods);
const nodeTotal = nodePods.length;
const totalReady = controllerReady + nodeReady;
const totalPods = controllerTotal + nodeTotal;
const color = getBadgeColor(totalReady, totalPods);
const handleClick = () => {
history.push('/tns-csi');
};
const labelText = `tns-csi: ${controllerReady}/${controllerTotal}c ${nodeReady}/${nodeTotal}n`;
const ariaLabel = `TNS-CSI driver: ${controllerReady} of ${controllerTotal} controller pods ready, ${nodeReady} of ${nodeTotal} node pods ready`;
return (
<button
onClick={handleClick}
style={{
cursor: 'pointer',
marginRight: '8px',
padding: '4px 12px',
borderRadius: '16px',
border: 'none',
backgroundColor: color,
color: 'white',
fontSize: '13px',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
aria-label={ariaLabel}
title={ariaLabel}
>
<span>tns-csi: {controllerReady}/{controllerTotal}c {nodeReady}/{nodeTotal}n</span>
</button>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({}),
},
ConfigStore: class {
get() { return {}; }
set() {}
update() {}
useConfig() { return () => ({}); }
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/kbench')>();
return {
...actual,
createPvc: vi.fn().mockResolvedValue(undefined),
createJob: vi.fn().mockResolvedValue(undefined),
deleteJob: vi.fn().mockResolvedValue(undefined),
deletePvc: vi.fn().mockResolvedValue(undefined),
getJobPhase: vi.fn().mockResolvedValue({ phase: 'Active', job: {} }),
fetchKbenchLogs: vi.fn().mockResolvedValue(''),
listKbenchJobs: vi.fn().mockResolvedValue([]),
generateJobName: vi.fn().mockReturnValue('kbench-abc123'),
generatePvcName: vi.fn().mockReturnValue('kbench-abc123-pvc'),
};
});
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 { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('BenchmarkPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listKbenchJobs).mockResolvedValue([]);
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<BenchmarkPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('renders benchmark guide section', () => {
mockContext();
render(<BenchmarkPage />);
expect(screen.getByText('Benchmark Guide')).toBeInTheDocument();
expect(screen.getByText(/Do not cancel mid-run/)).toBeInTheDocument();
});
it('renders Run New Benchmark form', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
expect(screen.getByText('Run New Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Select storage class for benchmark')).toBeInTheDocument();
});
it('populates SC dropdown with storage class names', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
mockContext({ storageClasses: [sc1, sc2] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options.length).toBe(2);
expect(select.options[0].value).toBe('sc-a');
expect(select.options[1].value).toBe('sc-b');
});
it('shows "No tns-csi storage classes found" when empty', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options[0].text).toContain('No tns-csi storage classes');
});
it('shows confirmation dialog when Run Benchmark is clicked', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByText(/~33Gi PVC/)).toBeInTheDocument();
});
it('cancels confirmation dialog', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
fireEvent.click(screen.getByLabelText('Cancel benchmark'));
expect(screen.queryByText('Confirm Benchmark')).not.toBeInTheDocument();
});
it('starts benchmark on confirmation and calls createPvc', async () => {
vi.useFakeTimers();
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
// PVC bind check
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
expect(vi.mocked(createPvc)).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('shows failed state when PVC creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockRejectedValueOnce(new Error('quota exceeded'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/quota exceeded/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('renders past benchmarks section', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('Past Benchmarks')).toBeInTheDocument();
});
expect(screen.getByText('No past benchmark jobs found.')).toBeInTheDocument();
});
it('renders past benchmark jobs in table', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([
{
jobName: 'kbench-old',
namespace: 'default',
storageClass: 'tns-nfs',
phase: 'Complete',
startedAt: '2025-01-01T00:00:00Z',
},
]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('kbench-old')).toBeInTheDocument();
});
expect(screen.getByText('Complete')).toBeInTheDocument();
});
it('disables Run Benchmark button when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const btn = screen.getByLabelText('Start kbench storage benchmark');
expect(btn).toBeDisabled();
});
it('shows confirmation dialog with selected SC and namespace', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
// Change namespace
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'));
// Confirm dialog shows SC and namespace in <strong> tags
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm and start benchmark')).toBeInTheDocument();
// Namespace is shown in the dialog
const dialogText = screen.getByText(/bench-ns/);
expect(dialogText).toBeInTheDocument();
});
it('can change test size and mode', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
const sizeInput = screen.getByLabelText('FIO test size') as HTMLInputElement;
fireEvent.change(sizeInput, { target: { value: '10G' } });
expect(sizeInput.value).toBe('10G');
const modeSelect = screen.getByLabelText('Benchmark mode') as HTMLSelectElement;
fireEvent.change(modeSelect, { target: { value: 'quick' } });
expect(modeSelect.value).toBe('quick');
});
it('shows failed state when job creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockResolvedValueOnce(undefined);
// PVC binds immediately
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
vi.mocked(createJob).mockRejectedValueOnce(new Error('job already exists'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/job already exists/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
});
+183
View File
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
import DriverStatusCard from './DriverStatusCard';
import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpers';
describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => {
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={[]}
/>
);
expect(screen.getByText('Degraded')).toBeInTheDocument();
});
it('shows "Metrics unavailable" when no metrics provided', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
/>
);
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
});
it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 1 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Healthy')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io installed')).toBeInTheDocument();
});
it('shows "Disconnected" when WS is disconnected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 0 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('shows "Unknown" when websocketConnected is null', () => {
const metrics = makeSampleMetrics({ websocketConnected: null });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('renders CSI capabilities section when driver is present', () => {
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('Persistent')).toBeInTheDocument();
});
it('does not render CSI capabilities when no driver', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
});
it('renders pod rows with image, restarts, and ready status', () => {
const pod = makeSamplePod({
metadata: { name: 'ctrl-pod-1', creationTimestamp: '2025-01-01T00:00:00Z' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{ name: 'tns-csi', ready: true, restartCount: 2, image: 'fenio/tns-csi:v0.6.0' },
],
},
});
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
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows "No controller pod found" when controllerPods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
/>
);
expect(screen.getByText('No controller pod found')).toBeInTheDocument();
});
it('shows "No node pods found" when nodePods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[]}
/>
);
expect(screen.getByText('No node pods found')).toBeInTheDocument();
});
it('shows WS reconnects when available in metrics', () => {
const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
metrics={metrics}
/>
);
expect(screen.getByText('WS Reconnects')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('shows node pods count in section title', () => {
const node1 = makeSamplePod({ name: 'tns-csi-node-1' });
const node2 = makeSamplePod({ name: 'tns-csi-node-2' });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[node1, node2]}
/>
);
expect(screen.getByText('Node Pods (2)')).toBeInTheDocument();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import { defaultContext, makeSamplePod, makeSampleMetrics } from '../test-helpers';
import MetricsPage from './MetricsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('MetricsPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when context is loading', () => {
mockContext({ loading: true });
render(<MetricsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<MetricsPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/TNS-CSI driver not found/)).toBeInTheDocument();
});
it('shows "No controller pod found" when driver installed but no pods', () => {
mockContext({ driverInstalled: true, controllerPods: [] });
render(<MetricsPage />);
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
expect(screen.getByText(/No controller pod found/)).toBeInTheDocument();
});
it('shows metrics error when fetch fails', async () => {
const pod = makeSamplePod();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('connection refused'));
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('connection refused')).toBeInTheDocument();
});
});
it('renders three metric cards when fetch succeeds', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
expect(screen.getByText('Volume Operations')).toBeInTheDocument();
expect(screen.getByText('CSI Operations')).toBeInTheDocument();
});
it('displays correct WebSocket metric data', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
websocketConnected: 1,
websocketReconnectsTotal: 42,
websocketMessagesTotal: [{ labels: {}, value: 250 }],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
csiOperationsTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
expect(screen.getByText('42')).toBeInTheDocument(); // reconnects
expect(screen.getByText('250')).toBeInTheDocument(); // messages
});
it('displays CSI operations broken down by method', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 77 },
{ labels: { method: 'DeleteVolume' }, value: 13 },
],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
websocketMessagesTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('CreateVolume')).toBeInTheDocument();
});
expect(screen.getByText('77')).toBeInTheDocument();
expect(screen.getByText('DeleteVolume')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
});
it('refresh button triggers refetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValue(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
const initialCallCount = vi.mocked(fetchControllerMetrics).mock.calls.length;
fireEvent.click(screen.getByLabelText('Refresh metrics'));
await waitFor(() => {
expect(vi.mocked(fetchControllerMetrics).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('shows "Updated" timestamp after successful fetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
});
it('shows volume operations grouped by protocol', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 15 },
{ labels: { protocol: 'iscsi' }, value: 8 },
],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Operations (nfs)')).toBeInTheDocument();
});
expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument();
});
});
+276
View File
@@ -0,0 +1,276 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import {
defaultContext,
makeSamplePod,
makeSamplePV,
makeSamplePVC,
makeSampleStorageClass,
makeSampleMetrics,
sampleCSIDriver,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/CSIDriver tns.csi.io not found/)).toBeInTheDocument();
});
it('shows error section when error is present', () => {
mockContext({ error: 'cluster unavailable' });
render(<OverviewPage />);
expect(screen.getByText('cluster unavailable')).toBeInTheDocument();
});
it('always shows the development status notice', () => {
mockContext({ driverInstalled: true });
render(<OverviewPage />);
expect(screen.getByText(/active early development/)).toBeInTheDocument();
});
it('renders storage summary with SC/PV counts', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
const pvc = makeSamplePVC();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc],
persistentVolumes: [pv],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Storage Summary')).toBeInTheDocument();
expect(screen.getByText('Storage Classes')).toBeInTheDocument();
expect(screen.getByText('Persistent Volumes')).toBeInTheDocument();
});
it('renders capacity aggregation from PVs', () => {
const pv1 = makeSamplePV({
metadata: { name: 'pv-1' },
spec: { ...makeSamplePV().spec, capacity: { storage: '100Gi' } },
});
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, capacity: { storage: '50Gi' } },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv1, pv2],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
// 150 GiB total
expect(screen.getByText('150.0 GiB')).toBeInTheDocument();
});
it('renders protocol distribution bar', () => {
const sc1 = makeSampleStorageClass({ parameters: { protocol: 'nfs' } });
const sc2 = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc1, sc2],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Protocol Distribution')).toBeInTheDocument();
expect(screen.getByTestId('percentage-bar')).toBeInTheDocument();
});
it('renders pool capacity table when poolStats are present', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [
{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 },
],
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('ONLINE')).toBeInTheDocument();
});
it('shows pool stats error hint', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStatsError: 'API key invalid',
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity Unavailable')).toBeInTheDocument();
expect(screen.getByText('API key invalid')).toBeInTheDocument();
expect(screen.getByText(/TrueNAS API key/)).toBeInTheDocument();
});
it('shows Prometheus fallback capacity by pool when no poolStats and metrics available', async () => {
const pod = makeSamplePod();
const pv = makeSamplePV();
const metrics = makeSampleMetrics({
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [],
poolStatsError: null,
});
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Provisioned Capacity by Pool')).toBeInTheDocument();
});
});
it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({
metadata: { name: 'pending-pvc', namespace: 'test', creationTimestamp: '2025-01-01T00:00:00Z' },
status: { phase: 'Pending' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pendingPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Attention: Non-Bound PVCs')).toBeInTheDocument();
expect(screen.getByText('pending-pvc')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('does not show non-bound PVCs section when all PVCs are bound', () => {
const pvc = makeSamplePVC({ status: { phase: 'Bound' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.queryByText('Attention: Non-Bound PVCs')).not.toBeInTheDocument();
});
it('refresh button calls context.refresh()', () => {
const refreshFn = vi.fn();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
refresh: refreshFn,
});
render(<OverviewPage />);
fireEvent.click(screen.getByLabelText('Refresh tns-csi data'));
expect(refreshFn).toHaveBeenCalledTimes(1);
});
it('shows metrics unavailable when fetchControllerMetrics fails', async () => {
const pod = makeSamplePod();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('timeout'));
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
});
expect(screen.getByText('timeout')).toBeInTheDocument();
});
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' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [boundPvc, pendingPvc, lostPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument();
expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument();
});
});
+19 -22
View File
@@ -18,7 +18,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
import { fetchControllerMetrics } from '../api/metrics';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
@@ -85,6 +85,24 @@ export default function OverviewPage() {
void fetchMetrics();
}, [fetchMetrics]);
const capacityByPool: Map<string, number> = React.useMemo(() => {
const map = new Map<string, number>();
if (!metrics) return map;
const handleToPool = new Map<string, string>();
for (const pv of persistentVolumes) {
const handle = pv.spec.csi?.volumeHandle;
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
if (handle && pool) handleToPool.set(handle, pool);
}
for (const sample of metrics.volumeCapacityBytes) {
const volumeId = sample.labels['volume_id'];
if (!volumeId) continue;
const pool = handleToPool.get(volumeId) ?? 'unknown';
map.set(pool, (map.get(pool) ?? 0) + sample.value);
}
return map;
}, [metrics, persistentVolumes]);
if (loading) {
return <Loader title="Loading TNS-CSI data..." />;
}
@@ -111,27 +129,6 @@ export default function OverviewPage() {
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
// Capacity by pool: join volumeCapacityBytes samples (volume_id, protocol)
// with PV volumeHandle → pool name from volumeAttributes.
const capacityByPool: Map<string, number> = React.useMemo(() => {
const map = new Map<string, number>();
if (!metrics) return map;
// Build lookup: volumeHandle → pool name
const handleToPool = new Map<string, string>();
for (const pv of persistentVolumes) {
const handle = pv.spec.csi?.volumeHandle;
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
if (handle && pool) handleToPool.set(handle, pool);
}
for (const sample of metrics.volumeCapacityBytes) {
const volumeId = sample.labels['volume_id'];
if (!volumeId) continue;
const pool = handleToPool.get(volumeId) ?? 'unknown';
map.set(pool, (map.get(pool) ?? 0) + sample.value);
}
return map;
}, [metrics, persistentVolumes]);
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
+94
View File
@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV, makeSamplePVC } from '../test-helpers';
import PVCDetailSection from './PVCDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('PVCDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC is not in filtered list', () => {
mockContext({ persistentVolumeClaims: [] });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'other-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC has no bound PV', () => {
const pvc = makeSamplePVC({ metadata: { name: 'orphan-pvc', namespace: 'default' } });
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [], // no PVs to match
});
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'orphan-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('renders storage details when PVC and PV are found', () => {
const pvc = makeSamplePVC();
const pv = makeSamplePV();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
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();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('renders custom volume attributes (excluding protocol and server)', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
customAttr: 'customValue',
},
},
},
});
const pvc = makeSamplePVC();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument();
expect(screen.getByText('customValue')).toBeInTheDocument();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleSnapshot, makeSampleSnapshotClass } from '../test-helpers';
import SnapshotsPage from './SnapshotsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('SnapshotsPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<SnapshotsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...');
});
it('shows error state', () => {
mockContext({ error: 'something broke' });
render(<SnapshotsPage />);
expect(screen.getByText('something broke')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows notice when snapshot CRD is not available', () => {
mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect(
screen.getByText(/VolumeSnapshot CRDs.*not found/)
).toBeInTheDocument();
});
it('shows empty message when snapshots list is empty', () => {
mockContext({ snapshotCrdAvailable: true, volumeSnapshots: [] });
render(<SnapshotsPage />);
expect(screen.getByText('No tns-csi VolumeSnapshots found.')).toBeInTheDocument();
});
it('renders snapshot classes when available', () => {
const vsc = makeSampleSnapshotClass();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [vsc],
volumeSnapshots: [],
});
render(<SnapshotsPage />);
expect(screen.getByText('Snapshot Classes (1)')).toBeInTheDocument();
expect(screen.getByText('tns-snap-class')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
});
it('renders populated snapshots with readyToUse=true', () => {
const snap = makeSampleSnapshot();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('snap-001')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=false', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: false, restoreSize: '50Gi' },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('No')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=undefined as Unknown', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: undefined },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not render snapshot classes section when empty', () => {
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [],
volumeSnapshots: [makeSampleSnapshot()],
});
render(<SnapshotsPage />);
expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/storage-classes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleStorageClass, makeSamplePV } from '../test-helpers';
import StorageClassesPage from './StorageClassesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('StorageClassesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<StorageClassesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<StorageClassesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows empty message when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<StorageClassesPage />);
expect(screen.getByText('No tns-csi StorageClasses found.')).toBeInTheDocument();
});
it('renders table with all columns populated', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
mockContext({
storageClasses: [sc],
persistentVolumes: [pv],
});
render(<StorageClassesPage />);
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument(); // expansion
expect(screen.getByText('1')).toBeInTheDocument(); // PV count
});
it('opens detail panel when clicking SC name', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByText('tns-nfs'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes#tns-nfs');
});
it('renders detail panel when hash is set', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('StorageClass Details')).toBeInTheDocument();
});
it('closes panel via close button', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel via backdrop click', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel on Escape key', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs';
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();
expect(screen.getByText(/nfs-common/)).toBeInTheDocument();
});
it('shows NVMe-oF protocol notes', () => {
mockHash = '#tns-nvmeof';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/nvme-cli/)).toBeInTheDocument();
});
it('shows iSCSI protocol notes', () => {
mockHash = '#tns-iscsi';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-iscsi' },
parameters: { protocol: 'iscsi', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/open-iscsi/)).toBeInTheDocument();
});
it('shows PV count for each storage class', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
const pv1 = makeSamplePV({ spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' } });
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' },
});
mockContext({
storageClasses: [sc1, sc2],
persistentVolumes: [pv1, pv2],
});
render(<StorageClassesPage />);
const cells = screen.getAllByRole('cell');
const pvCells = cells.filter(c => c.textContent === '2');
expect(pvCells.length).toBeGreaterThanOrEqual(1);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/volumes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV } from '../test-helpers';
import VolumesPage from './VolumesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('VolumesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<VolumesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<VolumesPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows empty message when no PVs', () => {
mockContext({ persistentVolumes: [] });
render(<VolumesPage />);
expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument();
});
it('renders PV table with claim ref', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('pv-test-001')).toBeInTheDocument();
expect(screen.getByText('default/my-pvc')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
expect(screen.getByText('RWO')).toBeInTheDocument();
expect(screen.getByText('Bound')).toBeInTheDocument();
});
it('renders "—" for PV without claimRef', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
claimRef: undefined,
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const cells = screen.getAllByRole('cell');
const dashCells = cells.filter(c => c.textContent === '—');
expect(dashCells.length).toBeGreaterThanOrEqual(1);
});
it('opens detail panel when clicking PV name', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByText('pv-test-001'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes#pv-test-001');
});
it('renders detail panel with CSI attributes', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv], persistentVolumeClaims: [] });
render(<VolumesPage />);
expect(screen.getByText('Volume Details')).toBeInTheDocument();
expect(screen.getByText('CSI Attributes')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('shows Bound PVC section in detail panel when claimRef exists', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Bound PVC')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
});
it('shows Adoption section when annotation is present', () => {
mockHash = '#pv-adoptable';
const pv = makeSamplePV({
metadata: {
name: 'pv-adoptable',
annotations: { 'tns-csi.io/adoptable': 'true' },
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Adoption')).toBeInTheDocument();
expect(screen.getByText(/adopted cross-cluster/)).toBeInTheDocument();
});
it('closes panel on Escape key', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('closes panel via backdrop click', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('renders maximize/minimize button in panel', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const maxBtn = screen.getByLabelText('Maximize');
expect(maxBtn).toBeInTheDocument();
fireEvent.click(maxBtn);
expect(screen.getByLabelText('Minimize')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => commonComponentsMock).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
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 },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
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,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement('tbody', null,
data.map((item, i) =>
React.createElement('tr', { key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
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('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({
status,
children,
}: {
status: string;
children?: RC;
}) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
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}`)
)
);
+210
View File
@@ -0,0 +1,210 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type { TnsCsiContextValue } from './api/TnsCsiDataContext';
import type {
CSIDriver,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
VolumeSnapshot,
VolumeSnapshotClass,
} from './api/k8s';
import type { TnsCsiMetrics } from './api/metrics';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<TnsCsiContextValue>): TnsCsiContextValue {
return {
csiDriver: null,
driverInstalled: false,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
volumeSnapshots: [],
volumeSnapshotClasses: [],
snapshotCrdAvailable: false,
poolStats: [],
poolStatsError: null,
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export const sampleCSIDriver: CSIDriver = {
metadata: { name: 'tns.csi.io' },
spec: {
attachRequired: false,
podInfoOnMount: true,
volumeLifecycleModes: ['Persistent'],
},
};
export function makeSampleStorageClass(overrides?: Partial<TnsCsiStorageClass>): TnsCsiStorageClass {
return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io',
reclaimPolicy: 'Delete',
volumeBindingMode: 'Immediate',
allowVolumeExpansion: true,
parameters: {
protocol: 'nfs',
pool: 'tank',
server: '10.0.0.1',
},
...overrides,
};
}
export const sampleStorageClass = makeSampleStorageClass();
export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCsiPersistentVolume {
return {
metadata: {
name: 'pv-test-001',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
},
},
capacity: { storage: '100Gi' },
accessModes: ['ReadWriteOnce'],
persistentVolumeReclaimPolicy: 'Delete',
storageClassName: 'tns-nfs',
claimRef: { name: 'my-pvc', namespace: 'default' },
},
status: { phase: 'Bound' },
...overrides,
};
}
export const samplePV = makeSamplePV();
export function makeSamplePVC(overrides?: Partial<TnsCsiPersistentVolumeClaim>): TnsCsiPersistentVolumeClaim {
return {
metadata: {
name: 'my-pvc',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
storageClassName: 'tns-nfs',
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '100Gi' } },
volumeName: 'pv-test-001',
},
status: {
phase: 'Bound',
capacity: { storage: '100Gi' },
},
...overrides,
};
}
export const samplePVC = makeSamplePVC();
export function makeSamplePod(overrides?: Partial<TnsCsiPod> & { name?: string }): TnsCsiPod {
const name = overrides?.name ?? overrides?.metadata?.name ?? 'tns-csi-controller-abc';
return {
metadata: {
name,
creationTimestamp: '2025-01-01T00:00:00Z',
...overrides?.metadata,
},
spec: {
nodeName: 'node-1',
...overrides?.spec,
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'tns-csi',
ready: true,
restartCount: 0,
image: 'fenio/tns-csi:v0.5.0',
},
],
...overrides?.status,
},
};
}
export const samplePod = makeSamplePod();
export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeSnapshot {
return {
metadata: {
name: 'snap-001',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
source: { persistentVolumeClaimName: 'my-pvc' },
volumeSnapshotClassName: 'tns-snap-class',
},
status: {
readyToUse: true,
restoreSize: '100Gi',
},
...overrides,
};
}
export function makeSampleSnapshotClass(overrides?: Partial<VolumeSnapshotClass>): VolumeSnapshotClass {
return {
metadata: {
name: 'tns-snap-class',
creationTimestamp: '2025-01-01T00:00:00Z',
},
driver: 'tns.csi.io',
deletionPolicy: 'Delete',
...overrides,
};
}
export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMetrics {
return {
websocketConnected: 1,
websocketReconnectsTotal: 3,
websocketMessagesTotal: [{ labels: {}, value: 100 }],
websocketMessageDurationSeconds: [],
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 10 },
{ labels: { protocol: 'iscsi' }, value: 5 },
],
volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 },
],
csiOperationsDurationSeconds: [],
...overrides,
};
}
Binary file not shown.