Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8d93aaa7 | |||
| 680289fba4 | |||
| f7592d69db | |||
| fa401afecf | |||
| cdff1d1a07 | |||
| 54a33d70b0 | |||
| 7922630fc3 | |||
| a20c20a4ec |
@@ -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]
|
branches: [main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-test:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: actions/setup-node@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
- name: Install dependencies
|
typecheck:
|
||||||
run: npm 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 tsc
|
||||||
|
|
||||||
- name: Build plugin
|
test:
|
||||||
run: npx @kinvolk/headlamp-plugin build
|
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
|
build:
|
||||||
run: npx eslint --ext .ts,.tsx src/
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
- name: Type-check
|
needs: [lint, typecheck, test]
|
||||||
run: npx tsc --noEmit
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
- name: Run unit tests
|
- uses: actions/setup-node@v4
|
||||||
run: npm test
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'npm'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
|||||||
@@ -8,9 +8,28 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
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:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ci]
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
@@ -37,7 +56,7 @@ jobs:
|
|||||||
- name: Update artifacthub-pkg.yml version and URL
|
- name: Update artifacthub-pkg.yml version and URL
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ inputs.version }}"
|
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|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
|
||||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" 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
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build plugin
|
- name: Build plugin
|
||||||
run: npx @kinvolk/headlamp-plugin build
|
run: npm run build
|
||||||
|
|
||||||
- name: Package plugin
|
- name: Package plugin
|
||||||
run: npx @kinvolk/headlamp-plugin package
|
run: npx @kinvolk/headlamp-plugin package
|
||||||
|
|
||||||
- name: Validate tarball name
|
- name: Validate tarball
|
||||||
run: |
|
run: |
|
||||||
EXPECTED="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
|
EXPECTED="tns-csi-${{ inputs.version }}.tar.gz"
|
||||||
ACTUAL=$(ls *.tar.gz)
|
if [ ! -f "$EXPECTED" ]; then
|
||||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
echo "::error::Expected tarball not found: $EXPECTED"
|
||||||
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✓ Tarball name validated: $ACTUAL"
|
echo "Tarball validated: $EXPECTED"
|
||||||
|
|
||||||
- name: Compute checksum
|
- name: Compute checksum
|
||||||
id: compute_checksum
|
id: compute_checksum
|
||||||
run: |
|
run: |
|
||||||
TARBALL="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
|
TARBALL="tns-csi-${{ inputs.version }}.tar.gz"
|
||||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||||
echo "Checksum: sha256:${CHECKSUM}"
|
echo "Checksum: sha256:${CHECKSUM}"
|
||||||
@@ -95,7 +113,7 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
tag_name: "v${{ inputs.version }}"
|
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
|
fail_on_unmatched_files: true
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
@@ -105,7 +123,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Summary
|
- name: Summary
|
||||||
run: |
|
run: |
|
||||||
echo "✓ Version bumped to ${{ inputs.version }}"
|
echo "Version bumped to ${{ inputs.version }}"
|
||||||
echo "✓ Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
|
echo "Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
|
||||||
echo "✓ Tag v${{ inputs.version }} created"
|
echo "Tag v${{ inputs.version }} created"
|
||||||
echo "✓ GitHub release published with tarball"
|
echo "GitHub release published with tarball"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tar.gz
|
||||||
+8
-1
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.2.2] - 2026-02-19
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -62,7 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- TypeScript strict mode with zero `any` types
|
- TypeScript strict mode with zero `any` types
|
||||||
- ESLint + Prettier code quality tooling
|
- ESLint + Prettier code quality tooling
|
||||||
|
|
||||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...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.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.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.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
|
||||||
|
|||||||
+5
-7
@@ -1,4 +1,4 @@
|
|||||||
version: "0.2.2"
|
version: "0.2.4"
|
||||||
name: headlamp-tns-csi-plugin
|
name: headlamp-tns-csi-plugin
|
||||||
displayName: TrueNAS CSI (tns-csi)
|
displayName: TrueNAS CSI (tns-csi)
|
||||||
description: >-
|
description: >-
|
||||||
@@ -47,13 +47,11 @@ links:
|
|||||||
url: https://github.com/longhorn/kbench
|
url: https://github.com/longhorn/kbench
|
||||||
|
|
||||||
changes:
|
changes:
|
||||||
- kind: fixed
|
- kind: changed
|
||||||
description: "Duplicate Protocol/Pool columns on mixed-driver clusters now merged into a single shared column"
|
description: "Package renamed to tns-csi so the plugin displays correctly in Headlamp's Plugins list"
|
||||||
- kind: fixed
|
|
||||||
description: "Plugin settings entry renamed from headlamp-tns-csi-plugin to tns-csi"
|
|
||||||
|
|
||||||
annotations:
|
annotations:
|
||||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.2/headlamp-tns-csi-plugin-0.2.2.tar.gz"
|
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:fa6506d581ea5a609598912596995f3dd2b2501ee4f66aff5f6cd93beb525549"
|
headlamp/plugin/archive-checksum: "sha256:a72b8094a70dd127aa9edb388411b3506bf5f6a7071c266c2aaa477c27badf60"
|
||||||
headlamp/plugin/version-compat: ">=0.20.0"
|
headlamp/plugin/version-compat: ">=0.20.0"
|
||||||
headlamp/plugin/distro-compat: "in-cluster,web,app"
|
headlamp/plugin/distro-compat: "in-cluster,web,app"
|
||||||
|
|||||||
Binary file not shown.
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "headlamp-tns-csi-plugin",
|
"name": "tns-csi",
|
||||||
"version": "0.2.2",
|
"version": "0.2.4",
|
||||||
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
|
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"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 { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||||
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||||
import type { TnsCsiMetrics } from '../api/metrics';
|
import type { TnsCsiMetrics } from '../api/metrics';
|
||||||
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
|
import { fetchControllerMetrics } from '../api/metrics';
|
||||||
import DriverStatusCard from './DriverStatusCard';
|
import DriverStatusCard from './DriverStatusCard';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -85,6 +85,24 @@ export default function OverviewPage() {
|
|||||||
void fetchMetrics();
|
void fetchMetrics();
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return <Loader title="Loading TNS-CSI data..." />;
|
return <Loader title="Loading TNS-CSI data..." />;
|
||||||
}
|
}
|
||||||
@@ -111,27 +129,6 @@ export default function OverviewPage() {
|
|||||||
const chartData = protocolChartData(storageClasses);
|
const chartData = protocolChartData(storageClasses);
|
||||||
const totalScs = storageClasses.length;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
<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}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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