Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8d93aaa7 | |||
| 680289fba4 | |||
| f7592d69db | |||
| fa401afecf | |||
| cdff1d1a07 | |||
| 54a33d70b0 | |||
| 7922630fc3 | |||
| a20c20a4ec | |||
| 3e757db799 | |||
| 81b0b35089 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tar.gz
|
||||
+25
-1
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.3] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Package name** — renamed from `headlamp-tns-csi-plugin` to `tns-csi` so the plugin displays correctly in Headlamp's Plugins list
|
||||
|
||||
## [0.2.2] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate columns** — Protocol and Pool columns on mixed-driver clusters (tns-csi + rook-ceph) are now merged into a single shared column rather than duplicated; whichever plugin loads first owns the column and the second merges into it
|
||||
- **Plugin settings name** — settings entry now registers as `tns-csi` instead of `headlamp-tns-csi-plugin`
|
||||
|
||||
## [0.2.1] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OverviewPage crash** — brace mismatch in `TnsCsiDataContext` placed TrueNAS pool stats fetch outside the outer try block, breaking the entire context provider
|
||||
- **PV Pool column** — tns-csi driver writes `datasetName` (e.g. `pool0/pvc-abc`), not `pool`, into `volumeAttributes`; Pool is now correctly derived from the first path segment
|
||||
- **App bar badge removed** — removed the colored tns-csi status bubble from the top nav bar
|
||||
|
||||
## [0.2.0] - 2026-02-18
|
||||
|
||||
### Added
|
||||
@@ -47,6 +68,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- TypeScript strict mode with zero `any` types
|
||||
- ESLint + Prettier code quality tooling
|
||||
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.3...HEAD
|
||||
[0.2.3]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/tag/v0.1.0
|
||||
|
||||
+5
-13
@@ -1,4 +1,4 @@
|
||||
version: "0.2.1"
|
||||
version: "0.2.4"
|
||||
name: headlamp-tns-csi-plugin
|
||||
displayName: TrueNAS CSI (tns-csi)
|
||||
description: >-
|
||||
@@ -47,19 +47,11 @@ links:
|
||||
url: https://github.com/longhorn/kbench
|
||||
|
||||
changes:
|
||||
- kind: added
|
||||
description: "Overview dashboard: driver health, storage summary, protocol distribution, non-Bound PVC alerts"
|
||||
- kind: added
|
||||
description: "Storage Classes, Volumes, Snapshots pages with slide-in detail panels"
|
||||
- kind: added
|
||||
description: "Metrics page: Prometheus WebSocket health, volume ops, CSI ops from controller pod"
|
||||
- kind: added
|
||||
description: "Benchmark page: kbench Job+PVC lifecycle, FIO log parser, results with IOPS/bandwidth/latency cards"
|
||||
- kind: added
|
||||
description: "PVC detail injection: TNS-CSI section on Headlamp PVC detail pages"
|
||||
- kind: changed
|
||||
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.1/headlamp-tns-csi-plugin-0.2.1.tar.gz"
|
||||
headlamp/plugin/archive-checksum: "sha256:9d53ae89996be6edbfbdb239c95d9d808fcab2de19c23b8f7b8d9abe1c51877b"
|
||||
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"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-tns-csi-plugin",
|
||||
"version": "0.2.1",
|
||||
"name": "tns-csi",
|
||||
"version": "0.2.4",
|
||||
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`)
|
||||
)
|
||||
);
|
||||
+31
-3
@@ -187,12 +187,40 @@ registerDetailsViewSection(({ resource }) => {
|
||||
// Table column processors — native StorageClass and PV tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Merges incoming columns into existing ones by label.
|
||||
// If a column with the same label already exists, the incoming getValue/render
|
||||
// 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 }>
|
||||
): 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;
|
||||
const result = [...existing];
|
||||
const toAppend: typeof incoming = [];
|
||||
for (const col of incoming) {
|
||||
const idx = result.findIndex(c => isObjCol(c) && (c as ObjCol).label === col.label);
|
||||
if (idx !== -1) {
|
||||
const prev = result[idx] as ObjCol;
|
||||
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),
|
||||
} as unknown as T;
|
||||
} else {
|
||||
toAppend.push(col);
|
||||
}
|
||||
}
|
||||
return [...result, ...(toAppend as unknown as T[])];
|
||||
}
|
||||
|
||||
registerResourceTableColumnsProcessor(({ id, columns }) => {
|
||||
if (id === 'headlamp-storageclasses') {
|
||||
return [...columns, ...buildStorageClassColumns()];
|
||||
return mergeColumns(columns, buildStorageClassColumns());
|
||||
}
|
||||
if (id === 'headlamp-persistentvolumes') {
|
||||
return [...columns, ...buildPVColumns()];
|
||||
return mergeColumns(columns, buildPVColumns());
|
||||
}
|
||||
return columns;
|
||||
});
|
||||
@@ -210,4 +238,4 @@ registerDetailsViewHeaderAction(({ resource }) => {
|
||||
// Plugin settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerPluginSettings('headlamp-tns-csi-plugin', TnsCsiSettings, true);
|
||||
registerPluginSettings('tns-csi', TnsCsiSettings, true);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user