Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot] 6b0b9bc9ea release: v0.2.5 2026-03-04 02:14:12 +00:00
DevContainer User 50ed43f3a2 fix: add --allow-same-version to npm version in release
Prevents failure when re-running a release for the same version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:11:07 +00:00
DevContainer User e54c76e7cd fix: use softprops/action-gh-release instead of gh CLI
The self-hosted runner does not have gh CLI installed, causing
the Create GitHub Release step to fail with 'command not found'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 02:06:07 +00:00
github-actions[bot] 1f6677e2f6 release: v0.2.5 2026-03-04 02:03:33 +00:00
DevContainer User 0882451c67 fix: handle same-name tarball in release workflow
headlamp-plugin package already produces {name}-{version}.tar.gz,
so mv fails when source and destination are the same file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:45:37 +00:00
DevContainer User 2988af9926 fix: move Node.js setup before npm version in release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:09:28 +00:00
DevContainer User 3cebde0673 fix: remove unused imports and format source files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 01:06:13 +00:00
DevContainer User 71abc6792d ci: standardize CI/CD workflows and add Renovate
- CI: single sequential job, local-ubuntu-latest runner, Node 22, workflow_call trigger, npm run commands
- Release: CI gate via reusable workflow, concurrency protection, dynamic package name, tarball validation, gh CLI
- Retain tns-csi-specific appVersion sync from upstream releases
- Add renovate.json with recommended config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:41:36 +00:00
DevContainer User 5960cc521e fix: add module=esnext to tsconfig for dynamic import support
The async import() in vi.mock factories requires module=esnext in
tsconfig. Without this, tsc reports TS1323 for all component test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 00:18:17 +00:00
DevContainer User 06d18a3eb3 chore: standardize config and fix component test mocks
- Replace .eslintrc.json with .eslintrc.js (standard @headlamp-k8s/eslint-config)
- Create .prettierrc.js (standard Headlamp prettier config)
- Add .headlamp-plugin/, .env, .env.local, .eslintcache to .gitignore
- Remove .claude/settings.json and .claude/settings.local.json from .gitignore
- Create .claude/settings.local.json with enabled MCP servers
- Remove "Subagent guidance" section and hardcoded test count from CLAUDE.md
- Fix 8 component test files: replace require('./__mocks__/commonComponents.ts')
  with await import('./__mocks__/commonComponents') in vi.mock factories —
  require() cannot process TypeScript in hoisted mock context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 21:30:55 +00:00
Chris Farhood e2512ec500 docs: clean up repo cruft and update all documentation for v0.2.4
- Remove PROMPT.md (AI scaffolding artifact)
- Add .claude/settings* to .gitignore
- Commit .mcp.json (MCP server config)
- Fix ArtifactHub URLs (headlamp/tns-csi path)
- Fix tarball name (tns-csi-VERSION.tar.gz) in all install docs
- Update version URLs from v0.1.0/v0.2.0 to v0.2.4
- Update test count from 67 to 159 across 12 files
- Update Node.js version from 20 to 22
- Add CHANGELOG entry for v0.2.4
- Update testing.md with full test file inventory and CI description

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 18:04:43 +00:00
Chris Farhood e955cf80fb docs: update artifacthub changelog for v0.2.4
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:55:07 +00:00
Chris Farhood 50c280d1df fix: track tns-csi appVersion in artifacthub metadata
Update appVersion to 0.12.0 (current latest tns-csi release). Add a
release workflow step that fetches the latest fenio/tns-csi release tag
and updates appVersion automatically on each plugin release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:53:34 +00:00
54 changed files with 1130 additions and 1538 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"enabledMcpjsonServers": [
"github",
"kubernetes",
"flux",
"playwright"
]
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@headlamp-k8s/eslint-config'],
};
-34
View File
@@ -1,34 +0,0 @@
{
"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"
}
}
]
}
+24 -48
View File
@@ -5,61 +5,37 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_call:
jobs:
lint:
runs-on: ubuntu-latest
ci:
runs-on: local-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
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: Checkout
uses: actions/checkout@v4
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
checks: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Setup Node.js
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
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
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Lint
run: npm run lint
- name: Type-check
run: npm run tsc
- name: Format check
run: npm run format:check
- name: Run tests
run: npm test
+64 -73
View File
@@ -4,126 +4,117 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (without v prefix, e.g., 0.2.0)'
description: 'Release version (e.g. 1.0.0)'
required: true
type: string
permissions:
contents: write
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
uses: ./.github/workflows/ci.yaml
release:
runs-on: ubuntu-latest
needs: [ci]
permissions:
contents: write
needs: ci
runs-on: local-ubuntu-latest
timeout-minutes: 10
steps:
- name: Validate version format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)"
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in X.Y.Z format"
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update package.json version
run: |
jq --arg version "${{ inputs.version }}" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
- name: Update artifacthub-pkg.yml version and URL
run: |
VERSION="${{ inputs.version }}"
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
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update version in package.json
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
- name: Update artifacthub-pkg.yml
run: |
VERSION="${{ inputs.version }}"
PKG_NAME=$(jq -r .name package.json)
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${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
- name: Update appVersion from latest tns-csi release
run: |
APP_VERSION=$(curl -sf https://api.github.com/repos/fenio/tns-csi/releases/latest | jq -r '.tag_name | ltrimstr("v")')
if [ -z "$APP_VERSION" ] || [ "$APP_VERSION" = "null" ]; then
echo "::warning::Could not fetch latest tns-csi release, skipping appVersion update"
else
sed -i "s|^appVersion:.*|appVersion: \"${APP_VERSION}\"|" artifacthub-pkg.yml
echo "appVersion set to ${APP_VERSION}"
fi
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
run: npx @kinvolk/headlamp-plugin build
- name: Package plugin
run: npx @kinvolk/headlamp-plugin package
- name: Prepare release tarball
run: |
VERSION="${{ inputs.version }}"
PKG_NAME=$(jq -r .name package.json)
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
# Rename tarball if headlamp-plugin produced a different name
for f in *.tar.gz; do
[ "$f" != "$TARBALL" ] && mv "$f" "$TARBALL"
done
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
- name: Validate tarball
run: |
EXPECTED="tns-csi-${{ inputs.version }}.tar.gz"
if [ ! -f "$EXPECTED" ]; then
echo "::error::Expected tarball not found: $EXPECTED"
exit 1
fi
echo "Tarball validated: $EXPECTED"
echo "Tarball: ${{ env.TARBALL }}"
ls -lh "${{ env.TARBALL }}"
tar -tzf "${{ env.TARBALL }}" | head -20
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
- name: Compute checksum
id: compute_checksum
run: |
TARBALL="tns-csi-${{ inputs.version }}.tar.gz"
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: sha256:${CHECKSUM}"
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
- name: Update checksum in metadata
- name: Commit and tag
run: |
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}"
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml
- name: Commit version bump and metadata
run: |
git add package.json artifacthub-pkg.yml
git commit -m "chore: release v${{ inputs.version }}"
git push origin main
- name: Create and push tag
run: |
git tag "v${{ inputs.version }}"
git push origin "v${{ inputs.version }}"
VERSION="${{ inputs.version }}"
git add package.json package-lock.json artifacthub-pkg.yml
git commit -m "release: v${VERSION}"
git tag "v${VERSION}"
git push origin main --tags
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ inputs.version }}"
files: tns-csi-${{ inputs.version }}.tar.gz
files: ${{ env.TARBALL }}
fail_on_unmatched_files: true
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- 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"
+4
View File
@@ -1,3 +1,7 @@
node_modules/
dist/
.headlamp-plugin/
*.tar.gz
.env
.env.local
.eslintcache
+24
View File
@@ -0,0 +1,24 @@
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
},
"kubernetes": {
"type": "sse",
"url": "http://localhost:8080/sse"
},
"flux": {
"type": "sse",
"url": "http://localhost:8081/sse"
},
"playwright": {
"type": "sse",
"url": "http://localhost:8086/sse"
}
}
}
+1
View File
@@ -0,0 +1 @@
module.exports = require('@headlamp-k8s/eslint-config/prettier-config');
+23 -1
View File
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.2.4] - 2026-02-26
### Added
- **Component tests** — 159 unit tests across 12 test files (up from 67 across 4)
- **ESLint configuration** — added `.eslintrc.json` so `npm run lint` works
- **JUnit test reporter** — CI posts test summary directly on PRs via `dorny/test-reporter`
### Changed
- **CI workflow** — split into 4 parallel jobs (lint, typecheck, test, build) with build gating on the other three
- **Release workflow** — CI gate (`needs: [ci]`) prevents releases when checks fail; concurrency control prevents racing releases
- **Node.js** — upgraded from 20 to 22 (current LTS) in all workflows
- **CI scripts** — replaced inline `npx` commands with `npm run` scripts
- **appVersion tracking** — `artifacthub-pkg.yml` now tracks the latest upstream tns-csi release (0.12.0); release workflow auto-fetches it
### Fixed
- **Conditional hook** — moved `React.useMemo` above early return in OverviewPage to fix `react-hooks/rules-of-hooks` violation
- **Tarball name** — release workflow now uses correct name `tns-csi-VERSION.tar.gz` (matching package.json `name` field)
## [0.2.3] - 2026-02-19
### Changed
@@ -68,7 +89,8 @@ 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.3...HEAD
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.4...HEAD
[0.2.4]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.3...v0.2.4
[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
+1 -25
View File
@@ -63,36 +63,12 @@ src/
- Context provider (`TnsCsiDataProvider`) wraps each route component in `index.tsx`
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
## Subagent guidance
When launching subagents for tasks in this repo:
- **Research tasks** (reading files, searching code, exploring GitHub): use `subagent_type: Explore`
with tools: Read, Glob, Grep, Bash, WebFetch, GitHub MCP
- **Implementation tasks** (writing/editing files): use `subagent_type: general-purpose`
- **Debugging**: use `subagent_type: debugger`
- **Avoid** launching background agents for open-ended research — do research in the main session
using Glob, Grep, Read, and GitHub MCP directly, then delegate scoped write tasks to agents
- The main session has broader tool approvals than subagent sandboxes; use it for exploration
### Local agents (`.claude/agents/`)
Three meta-orchestration agents are installed for this project:
| Agent | Model | Use when |
|---|---|---|
| `agent-organizer` | sonnet | Decomposing a large task into subtasks and selecting the right agent for each |
| `multi-agent-coordinator` | opus | Running multiple concurrent agents that need to share state and synchronize |
| `agent-installer` | haiku | Browsing or installing additional agents from awesome-claude-code-subagents |
Use `agent-organizer` first when a task is large enough to require multiple agents. It will plan the team composition and hand off to `multi-agent-coordinator` for execution.
## Testing
All tests must pass before committing:
```bash
npm test # 67 tests across 4 test files
npm test
npm run tsc # must exit 0
```
+6 -6
View File
@@ -27,7 +27,7 @@ This project follows a standard code of conduct:
### Prerequisites
- Node.js 20 or later
- Node.js 22 or later
- npm
- Access to a Kubernetes cluster with Headlamp and tns-csi installed (for end-to-end testing)
- Git
@@ -53,7 +53,7 @@ This project follows a standard code of conduct:
4. **Run tests:**
```bash
npm test # 67 unit tests
npm test # 159 unit tests
npm run tsc # TypeScript type-check
```
@@ -196,7 +196,7 @@ Add `Co-Authored-By` for pair programming or AI assistance:
```
feat: add NVMe-oF protocol notes to StorageClass detail panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
```
@@ -209,7 +209,7 @@ Co-Authored-By: Happy <yesreply@happy.engineering>
npm run build # Verify build succeeds
npm run lint # Check for linting errors
npm run tsc # Type-check TypeScript
npm test # Run 67 unit tests
npm test # Run 159 unit tests
```
2. **Update documentation:**
@@ -219,7 +219,7 @@ Co-Authored-By: Happy <yesreply@happy.engineering>
3. **Write/update tests:**
- Add unit tests for new functions/components
- Ensure all 67 tests (plus yours) pass
- Ensure all 159 tests (plus yours) pass
### Creating a PR
@@ -319,7 +319,7 @@ npm run tsc # TypeScript check
### Unit Tests (Required)
All 67 tests must pass before committing:
All 159 tests must pass before committing:
```bash
npm test # vitest run
-872
View File
@@ -1,872 +0,0 @@
# Headlamp TNS-CSI Plugin — Implementation Prompt
## Overview
You are an expert Kubernetes storage engineer, staff TypeScript engineer, and React engineer with deep experience in headlamp plugin development. Your task is to implement a headlamp plugin for the **tns-csi** CSI driver (https://github.com/fenio/tns-csi) that surfaces storage visibility into the Headlamp Kubernetes UI.
The plugin is **read-only** with a single interactive exception: triggering a **kbench** storage benchmark job and displaying its results.
---
## Role Context
You are a composite of three specialist personas working in concert.
### Kubernetes Specialist
You are a senior Kubernetes specialist with deep expertise in designing, deploying, and managing production Kubernetes clusters. For this plugin, your K8s mastery covers:
- **Storage orchestration**: StorageClasses, PersistentVolumes, dynamic provisioning, volume snapshots, CSI drivers, backup strategies, performance tuning
- **Custom resources**: CSIDriver, VolumeSnapshot/VolumeSnapshotClass CRDs (graceful degradation when absent), proper CRD API version detection
- **Observability**: Prometheus metrics collection, Kubernetes events, pod log retrieval via API proxy
- **Workload orchestration**: Job management (creation, status polling, log retrieval, cleanup), PVC lifecycle
- **Production patterns**: Design for failure, health checks, readiness probes, graceful degradation
- **Troubleshooting expertise**: Understand tns-csi label selectors, pod states, CSI driver registration, metrics endpoint configuration
Apply this mindset: before surfacing any Kubernetes data, verify the resource/CRD exists and handle absence gracefully with actionable user messaging.
### TypeScript Professional
You are a senior TypeScript developer with mastery of TypeScript 5.0+ specializing in advanced type safety and correctness. For this plugin:
- **Strict mode**: All compiler flags enabled, zero `any` usage (use `unknown` + type guards where truly opaque)
- **Type-first development**: Define all interfaces before implementing — `KbenchResult`, `TnsCsiStorageClass`, `PrometheusMetrics`, etc.
- **Branded types**: Use branded types for identifiers where appropriate (e.g., `type JobName = string & { __brand: 'JobName' }`)
- **Discriminated unions**: Model states as discriminated unions — e.g., `BenchmarkState = { status: 'idle' } | { status: 'running'; jobName: string } | { status: 'complete'; result: KbenchResult } | { status: 'failed'; error: string }`
- **Type guards**: Write explicit type guard functions for API responses (K8s objects, Prometheus text parsing output)
- **No runtime surprises**: Validate all external data (K8s API responses, pod log text) at the boundary before passing into typed domain objects
- **Type-only imports**: Use `import type` for type-only imports to minimize bundle impact
TypeScript quality bar: 100% type coverage on all public APIs, zero `@ts-ignore` or `@ts-expect-error` without comment justification.
### React Specialist
You are a senior React specialist with expertise in React 18+ and the modern React ecosystem. For this plugin:
- **Functional components only**: No class components, no legacy lifecycle methods
- **Hooks mastery**: `useState`, `useEffect`, `useMemo`, `useCallback`, `useRef`, `useContext` — used correctly with proper dependency arrays (no stale closures)
- **Context optimization**: Avoid unnecessary re-renders by splitting context when needed; memoize context values
- **Performance**: `useMemo` for expensive computations (filtering PV lists, parsing metrics), `useCallback` for stable event handlers passed to children
- **Component composition**: Small, focused components; compound component pattern for complex UI like the benchmark result cards
- **Accessibility**: Proper ARIA labels on all interactive elements (benchmark runner buttons, drawer close buttons, dropdown selects); keyboard navigation (Escape to close panels, as established in polaris plugin)
- **Error boundaries**: Loading/error/empty guards at every data boundary — match the exact pattern from `headlamp-polaris-plugin`
- **URL state**: Use `useHistory`/`useLocation` from `react-router-dom` for detail panel state (hash-based), matching polaris pattern
React quality bar: No prop drilling beyond 2 levels (use context), no inline function definitions in JSX that cause unnecessary re-renders on hot paths.
---
## Target Project: tns-csi
**tns-csi** (https://github.com/fenio/tns-csi) is a Kubernetes CSI driver for **TrueNAS Scale 25.10+** that provisions NFS, NVMe-oF, and iSCSI persistent volumes. It is in active early development (not production-ready).
### Key Architecture Details
- **Driver name / provisioner**: `tns.csi.io`
- **Namespace**: `kube-system` (default Helm install)
- **Label selectors**:
- Controller pod: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller`
- Node pod: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node`
- **Protocols supported**: NFS (RWX/RWO/RWOP), NVMe-oF (RWO/RWOP), iSCSI (RWO/RWOP)
- **StorageClass `provisioner`**: `tns.csi.io`
- **Prometheus metrics endpoint**: `http://<controller-pod>:8080/metrics`
### ZFS Volume Metadata (on TrueNAS)
Volumes are tagged with ZFS user properties (`tns-csi:*`). While these aren't directly queryable from Kubernetes, the plugin should surface equivalent Kubernetes-native data:
- `tns-csi:protocol` → visible in PV `.spec.csi.volumeAttributes.protocol`
- `tns-csi:managed_by` = `"tns-csi"` (ownership marker)
- `tns-csi:schema_version` = `"1"`
### Kubernetes Resources to Surface
The plugin should query and display the following:
**StorageClasses** (filtered where `provisioner == "tns.csi.io"`):
- Name, protocol (from `parameters.protocol`), pool, server
- `allowVolumeExpansion`, `reclaimPolicy`, `volumeBindingMode`
**PersistentVolumes** (filtered where `spec.csi.driver == "tns.csi.io"`):
- Name, capacity, status, reclaim policy, access modes
- CSI attributes: `protocol`, `server`
- Bound PVC reference
**PersistentVolumeClaims** (cross-referenced with tns-csi PVs):
- Name, namespace, status, requested/allocated storage
- Access modes, StorageClass name
- Bound PV
**VolumeSnapshots** (`snapshot.storage.k8s.io/v1`):
- Filtered by `spec.volumeSnapshotClassName` matching tns-csi snapshot classes
- Name, namespace, source PVC, size, readyToUse, creation time
**CSI Driver** resource (`storage.k8s.io/v1` CSIDriver where `name == "tns.csi.io"`):
- Capabilities: volumeLifecycleModes, podInfoOnMount, attachRequired
**Controller and Node Pods** (via label selector):
- Status, restarts, age, image version
- Ready/not-ready state
### Prometheus Metrics (Available from Controller)
The controller exposes `/metrics` on port `8080`. Key metrics to display:
```
# Volume operations
tns_volume_operations_total{protocol, operation, status}
tns_volume_operations_duration_seconds{protocol, operation, status}
tns_volume_capacity_bytes{volume_id, protocol}
# WebSocket connection health
tns_websocket_connected # gauge: 1=connected, 0=disconnected
tns_websocket_reconnects_total # counter
tns_websocket_message_duration_seconds{method}
# CSI operations
tns_csi_operations_total{method, grpc_status_code}
tns_csi_operations_duration_seconds{method, grpc_status_code}
```
These should be fetched via the Kubernetes API proxy (not direct pod access), using `ApiProxy.request` from `@kinvolk/headlamp-plugin/lib`.
---
## kbench Integration
**kbench** (https://github.com/longhorn/kbench) is a Kubernetes-native FIO storage benchmark tool.
### How kbench Works
kbench runs as a Kubernetes **Job** backed by a **PersistentVolumeClaim**. When the Job completes (~6 minutes), results are captured from pod logs.
### Kubernetes YAML to Deploy
```yaml
# PVC
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: kbench-pvc-<uuid>
namespace: default
labels:
app.kubernetes.io/managed-by: headlamp-tns-csi-plugin
spec:
storageClassName: <user-selected-storage-class>
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 33Gi # kbench needs ~33Gi minimum for 30G test
---
apiVersion: batch/v1
kind: Job
metadata:
name: kbench-<uuid>
namespace: default
labels:
app.kubernetes.io/managed-by: headlamp-tns-csi-plugin
kbench: fio
spec:
template:
metadata:
labels:
kbench: fio
spec:
containers:
- name: kbench
image: yasker/kbench:latest
env:
- name: MODE
value: "full"
- name: FILE_NAME
value: "/volume/test"
- name: SIZE
value: "30G"
- name: CPU_IDLE_PROF
value: "disabled"
volumeMounts:
- name: vol
mountPath: /volume/
restartPolicy: Never
volumes:
- name: vol
persistentVolumeClaim:
claimName: kbench-pvc-<uuid>
backoffLimit: 0
```
### Result Format
kbench outputs a structured summary to stdout:
```
=====================
FIO Benchmark Summary
For: test_device
SIZE: 30G
QUICK MODE: DISABLED
=====================
IOPS (Read/Write)
Random: 98368 / 89200
Sequential: 108513 / 107636
CPU Idleness: 68%
Bandwidth in KiB/sec (Read/Write)
Random: 542447 / 514487
Sequential: 552052 / 521330
CPU Idleness: 99%
Latency in ns (Read/Write)
Random: 97222 / 44548
Sequential: 40483 / 44690
CPU Idleness: 72%
```
The plugin must:
1. Parse this text output from pod logs
2. Display it in a structured, readable table/card format
3. Distinguish IOPS, Bandwidth, and Latency sections
4. Show Read/Write separately
5. Indicate "higher is better" for IOPS/Bandwidth/CPU Idleness and "lower is better" for Latency
### kbench UX Flow
1. User navigates to the "Benchmark" section of the plugin
2. User selects a tns-csi StorageClass from a dropdown
3. User optionally configures: size (default 30G), namespace (default: `default`), mode (default: `full`)
4. User clicks "Run Benchmark" — shows confirmation dialog explaining duration (~6 min) and resource requirements
5. Plugin creates PVC + Job via `ApiProxy.request` (POST to Kubernetes API)
6. Plugin polls Job status every 10 seconds, showing progress (Pending → Running → Complete/Failed)
7. When Job completes, plugin fetches logs and parses the FIO summary
8. Results displayed in a structured card with sections for IOPS, Bandwidth, Latency
9. User can dismiss results or run another benchmark
10. Past benchmark results are listed (fetched from existing kbench Jobs with label `app.kubernetes.io/managed-by: headlamp-tns-csi-plugin`)
11. Cleanup: offer a button to delete the Job + PVC when done
---
## Headlamp Plugin Development Guide
### Project Bootstrap
```bash
npx @kinvolk/headlamp-plugin create headlamp-tns-csi-plugin
cd headlamp-tns-csi-plugin
npm install
npm start # dev server with hot reload
```
### package.json
```json
{
"name": "headlamp-tns-csi-plugin",
"version": "0.1.0",
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
"license": "Apache-2.0",
"scripts": {
"start": "headlamp-plugin start",
"build": "headlamp-plugin build",
"package": "headlamp-plugin package",
"tsc": "tsc --noEmit",
"lint": "eslint --ext .ts,.tsx src/",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
}
}
```
### Key Registration APIs
All imports from `@kinvolk/headlamp-plugin/lib`:
```typescript
import {
registerRoute,
registerSidebarEntry,
registerDetailsViewSection,
registerAppBarAction,
registerPluginSettings,
} from '@kinvolk/headlamp-plugin/lib';
```
**Sidebar Entry:**
```typescript
registerSidebarEntry({
parent: null,
name: 'tns-csi',
label: 'TNS CSI',
url: '/tns-csi',
icon: 'mdi:database', // MDI icon name
});
registerSidebarEntry({
parent: 'tns-csi',
name: 'tns-csi-overview',
label: 'Overview',
url: '/tns-csi',
icon: 'mdi:view-dashboard',
});
```
**Route:**
```typescript
registerRoute({
path: '/tns-csi',
sidebar: 'tns-csi-overview',
name: 'tns-csi-overview',
exact: true,
component: () => <OverviewPage />,
});
```
**Details View Section** (to inject tns-csi info on PVC/PV detail pages):
```typescript
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'PersistentVolumeClaim') return null;
// Only for tns-csi PVCs (check storageClassName or bound PV driver)
return <TnsCsiPVCDetail resource={resource} />;
});
```
### K8s Resource Hooks
```typescript
import { K8s } from '@kinvolk/headlamp-plugin/lib';
// List StorageClasses
const [storageClasses, error] = K8s.ResourceClasses.StorageClass.useList();
// List PVCs
const [pvcs, error] = K8s.ResourceClasses.PersistentVolumeClaim.useList({ namespace: '' });
// List PVs (cluster-scoped)
const [pvs, error] = K8s.ResourceClasses.PersistentVolume.useList();
// List Jobs
const [jobs, error] = K8s.ResourceClasses.Job.useList({ namespace: 'default' });
// Custom Resources (VolumeSnapshots)
// Use K8s.makeCustomResourceClass or ApiProxy.request for CRDs
```
### ApiProxy for Custom Requests
```typescript
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
// Fetch pod logs
const logs = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench&timestamps=false`
);
// Create a Job
await ApiProxy.request('/apis/batch/v1/namespaces/default/jobs', {
method: 'POST',
body: JSON.stringify(jobManifest),
headers: { 'Content-Type': 'application/json' },
});
// Fetch metrics (via proxy to controller pod)
const metricsText = await ApiProxy.request(
`/api/v1/namespaces/kube-system/pods/${controllerPodName}:8080/proxy/metrics`
);
```
### Common UI Components
All from `@kinvolk/headlamp-plugin/lib/CommonComponents`:
```typescript
import {
SectionBox,
SectionHeader,
SimpleTable,
NameValueTable,
StatusLabel,
Loader,
PercentageBar,
PercentageCircle,
Link,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
```
Usage patterns (from existing headlamp-polaris-plugin):
- `<SectionBox title="...">` — card-style container with title
- `<SectionHeader title="..." />` — page header
- `<SimpleTable columns={[{label, getter}]} data={rows} />` — sortable data table
- `<NameValueTable rows={[{name, value}]} />` — two-column key-value display
- `<StatusLabel status="success|warning|error">text</StatusLabel>` — colored badge
- `<Loader title="..." />` — loading spinner
- `<PercentageCircle data={[]} total={n} label="..." />` — donut chart
- `<PercentageBar data={[]} total={n} />` — horizontal bar breakdown
### Data Pattern: Context + Hook
Follow the pattern from headlamp-polaris-plugin:
```typescript
// src/api/TnsCsiDataContext.tsx
import React, { createContext, useContext, useState } from 'react';
interface TnsCsiContextType {
storageClasses: StorageClass[] | null;
pvs: PV[] | null;
pvcs: PVC[] | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
const TnsCsiContext = createContext<TnsCsiContextType | null>(null);
export function TnsCsiDataProvider({ children }: { children: React.ReactNode }) {
// ... fetch and provide data
}
export function useTnsCsiContext() {
const ctx = useContext(TnsCsiContext);
if (!ctx) throw new Error('useTnsCsiContext must be used within TnsCsiDataProvider');
return ctx;
}
```
### Testing
Use **vitest** + **@testing-library/react** (as in headlamp-polaris-plugin):
```typescript
// vitest.config.ts (auto-configured by headlamp-plugin)
// src/components/Overview.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn() },
K8s: { ResourceClasses: { StorageClass: { useList: vi.fn(() => [[], null]) } } },
}));
```
---
## Plugin Architecture
### File Structure
```
headlamp-tns-csi-plugin/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.tsx # Plugin entry: register routes, sidebar, detail sections
│ ├── api/
│ │ ├── k8s.ts # Helper functions: filter tns-csi resources, parse CSI attrs
│ │ ├── metrics.ts # Prometheus metrics parsing (text format)
│ │ ├── kbench.ts # kbench Job/PVC creation, log parsing, result types
│ │ └── TnsCsiDataContext.tsx # React context + provider for shared data
│ └── components/
│ ├── OverviewPage.tsx # Main dashboard: driver health, stats summary
│ ├── StorageClassesPage.tsx # List of tns-csi StorageClasses
│ ├── VolumesPage.tsx # List of tns-csi PVs with PVC cross-reference
│ ├── SnapshotsPage.tsx # VolumeSnapshot list (tns-csi)
│ ├── MetricsPage.tsx # Prometheus metrics visualization
│ ├── BenchmarkPage.tsx # kbench trigger + results
│ ├── DriverStatusCard.tsx # Reusable: controller/node pod health
│ └── PVCDetailSection.tsx # Injected into PVC detail view
```
### Sidebar Navigation
```
TNS CSI (top-level, icon: mdi:database-cog)
├── Overview (/tns-csi)
├── Storage Classes (/tns-csi/storage-classes)
├── Volumes (/tns-csi/volumes)
├── Snapshots (/tns-csi/snapshots)
├── Metrics (/tns-csi/metrics)
└── Benchmark (/tns-csi/benchmark)
```
---
## Page Specifications
### 1. Overview Page (`/tns-csi`)
**Sections:**
**Driver Status Card:**
- CSIDriver resource: name, attached, capabilities
- Controller pod(s): status, restarts, image version
- Node pod(s): status per node, restarts
- WebSocket connection health (from Prometheus `tns_websocket_connected`)
**Storage Summary:**
- Total StorageClasses managed by tns-csi
- Breakdown by protocol (NFS / NVMe-oF / iSCSI) — using `PercentageBar`
- Total PVs, total capacity (sum of `spec.capacity.storage`)
- PVC status breakdown: Bound / Pending / Lost
**Recent Activity:**
- Last N volume operations (inferred from recent PV creation timestamps)
- Any PVCs in non-Bound state (highlighted as warnings)
### 2. Storage Classes Page (`/tns-csi/storage-classes`)
**Filter**: `storageClass.provisioner === 'tns.csi.io'`
**Table columns:**
| Column | Source |
|--------|--------|
| Name | `.metadata.name` |
| Protocol | `.parameters.protocol` (nfs/nvmeof/iscsi) |
| Pool | `.parameters.pool` |
| Server | `.parameters.server` |
| Reclaim Policy | `.reclaimPolicy` |
| Volume Binding | `.volumeBindingMode` |
| Allow Expansion | `.allowVolumeExpansion` |
| Delete Strategy | `.parameters.deleteStrategy` (retain/delete) |
| Encryption | `.parameters.encryption` (bool) |
| PV Count | (cross-ref from PV list) |
Click row → detail panel showing all parameters
### 3. Volumes Page (`/tns-csi/volumes`)
**Filter**: `pv.spec.csi.driver === 'tns.csi.io'`
**Table columns:**
| Column | Source |
|--------|--------|
| PVC Name | `.spec.claimRef.name` |
| Namespace | `.spec.claimRef.namespace` |
| Protocol | `.spec.csi.volumeAttributes.protocol` |
| Server | `.spec.csi.volumeAttributes.server` |
| Capacity | `.spec.capacity.storage` |
| Access Modes | `.spec.accessModes` |
| Reclaim Policy | `.spec.persistentVolumeReclaimPolicy` |
| Status | `.status.phase` (color-coded) |
| StorageClass | `.spec.storageClassName` |
| Age | `.metadata.creationTimestamp` |
Click row → detail panel showing full CSI attributes and linked snapshot list
### 4. Snapshots Page (`/tns-csi/snapshots`)
**Resource**: `snapshot.storage.k8s.io/v1` VolumeSnapshot
**Filter**: VolumeSnapshotClass's `driver === 'tns.csi.io'`
(fetch VolumeSnapshotClasses first, then filter VolumeSnapshots by snapshotClassName)
Use `ApiProxy.request('/apis/snapshot.storage.k8s.io/v1/volumesnapshots')` since VolumeSnapshot is a CRD.
**Table columns:**
| Column | Source |
|--------|--------|
| Name | `.metadata.name` |
| Namespace | `.metadata.namespace` |
| Source PVC | `.spec.source.persistentVolumeClaimName` |
| Snapshot Class | `.spec.volumeSnapshotClassName` |
| Ready | `.status.readyToUse` (boolean badge) |
| Size | `.status.restoreSize` |
| Age | `.metadata.creationTimestamp` |
### 5. Metrics Page (`/tns-csi/metrics`)
Fetch Prometheus metrics text via ApiProxy from the controller pod metrics endpoint.
Display in cards:
**WebSocket Health:**
- Connection status (green/red indicator from `tns_websocket_connected`)
- Total reconnects (`tns_websocket_reconnects_total`)
- Messages sent/received (`tns_websocket_messages_total`)
**Volume Operations:**
- Operations by protocol (`tns_volume_operations_total`)
- Error rate per protocol/operation
- Total provisioned capacity (from `tns_volume_capacity_bytes`)
**CSI Operations:**
- Operation counts by method (`tns_csi_operations_total`)
- Error rates
Include a "Refresh" button and last-updated timestamp.
Note: If the controller pod cannot be found or metrics are unavailable, display a helpful message explaining how metrics are configured.
### 6. Benchmark Page (`/tns-csi/benchmark`)
#### Run New Benchmark Section
**Form:**
- **Storage Class** (required): dropdown of tns-csi StorageClasses
- **Namespace**: text input, default `default`
- **Test Size**: text input, default `30G` (with note: must be ~10% smaller than PVC)
- **Mode**: select — `full` (default), `quick`, or specific modes (random-read-iops, etc.)
**Run Button** → opens confirmation dialog:
> "This will create a ~33Gi PVC and run FIO benchmark (~6 minutes). The Job and PVC will remain until manually deleted. Continue?"
After confirmation:
1. Generate unique suffix (short UUID)
2. Create PVC via POST to `/apis/v1/namespaces/{ns}/persistentvolumeclaims`
3. Create Job via POST to `/apis/batch/v1/namespaces/{ns}/jobs`
4. Show status: "Creating PVC... → Waiting for PVC to bind... → Job running... → Parsing results..."
**Progress Polling** (every 10 seconds):
- Fetch Job status
- Show phase: `Pending` / `Active` / `Succeeded` / `Failed`
- Show pod status if available
#### Results Display
When Job succeeds, fetch logs and parse the FIO summary text:
```typescript
interface KbenchResult {
iops: {
randomRead: number;
randomWrite: number;
sequentialRead: number;
sequentialWrite: number;
cpuIdleness: number;
};
bandwidth: {
randomRead: number;
randomWrite: number;
sequentialRead: number;
sequentialWrite: number;
cpuIdleness: number;
};
latency: {
randomRead: number;
randomWrite: number;
sequentialRead: number;
sequentialWrite: number;
cpuIdleness: number;
};
metadata: {
storageClass: string;
size: string;
startedAt: string;
completedAt: string;
jobName: string;
namespace: string;
};
}
```
Display results in three cards (IOPS, Bandwidth, Latency), each with a table:
| Metric | Read | Write | Note |
|--------|------|-------|------|
| Random | ... | ... | Higher is better |
| Sequential | ... | ... | Higher is better |
| CPU Idleness | ... | - | Higher is better |
For Latency: "Lower is better" note instead.
Format values:
- IOPS: thousands separator (e.g., `98,368`)
- Bandwidth: human-readable (e.g., `529 MB/s`)
- Latency: microseconds or milliseconds (e.g., `97 µs`)
#### Past Benchmarks List
List existing Jobs with label `app.kubernetes.io/managed-by: headlamp-tns-csi-plugin` and `kbench: fio`:
| Column | Value |
|--------|-------|
| Job Name | link to Job detail |
| Namespace | namespace |
| Storage Class | (from Job annotations or labels) |
| Status | Active/Complete/Failed |
| Started | creation timestamp |
| Actions | "View Results" / "Delete" |
**Delete** action removes both the Job and the PVC.
---
## PVC Detail Section Injection
Register a `registerDetailsViewSection` that injects a "TNS-CSI Storage Details" section on PVC detail pages when the bound PV uses `tns.csi.io` as the CSI driver.
Display:
- Protocol (NFS/NVMe-oF/iSCSI) — with icon
- Server (TrueNAS IP)
- ZFS pool
- StorageClass parameters relevant to this volume
- Link to Volumes page filtered to this PVC
---
## Implementation Requirements
### Filtering
**StorageClass filter**: `sc.spec.provisioner === 'tns.csi.io'`
**PV filter**: `pv.spec.csi?.driver === 'tns.csi.io'`
**PVC cross-reference**: For each tns-csi PV, find the PVC via `pv.spec.claimRef.{name,namespace}`
**VolumeSnapshot filter**:
1. Get all VolumeSnapshotClasses: `GET /apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses`
2. Filter where `.driver === 'tns.csi.io'`
3. Get all VolumeSnapshots: `GET /apis/snapshot.storage.k8s.io/v1/volumesnapshots`
4. Filter where `.spec.volumeSnapshotClassName` is in the tns-csi snapshot class names
### Error Handling
- **Driver not installed**: If no CSIDriver `tns.csi.io` exists, show a clear banner: "TNS-CSI driver not detected on this cluster. Install via Helm..."
- **No snapshots CRD**: If VolumeSnapshot CRDs are not present, show: "Volume snapshot CRDs not installed. See tns-csi documentation."
- **Metrics unavailable**: If controller pod not found or metrics request fails, show: "Metrics unavailable. Ensure controller pod is running with metrics enabled (port 8080)."
- **kbench Job fails**: Show job logs, offer to re-run or cleanup
### Important Developer Notes from tns-csi
Based on the upstream documentation:
1. **Early development warning**: The driver is NOT production-ready. The plugin UI should prominently note this on the Overview page.
2. **NVMe-oF requires static IP**: Display a note on the NVMe-oF StorageClass detail that DHCP is not supported.
3. **Protocol-specific prerequisites**: Display prerequisite notes per protocol:
- NFS: `nfs-common` / `nfs-utils` on nodes
- NVMe-oF: `nvme-cli`, kernel modules `nvme-tcp`/`nvme-fabrics`
- iSCSI: `open-iscsi` on nodes
4. **WebSocket API dependency**: The driver uses TrueNAS WebSocket API (`wss://`). Connection health is critical — the Metrics page `tns_websocket_connected` gauge is the primary health indicator.
5. **Volume adoption**: Volumes tagged with `tns-csi:adoptable=true` can be adopted cross-cluster. This is surfaced as metadata on the PV detail section.
6. **Provisioner ID**: Always use `tns.csi.io` (not `tns-csi` or variations).
7. **Controller logs command** (show in troubleshooting section):
```
kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller
```
### kbench Important Notes
From the kbench documentation:
- **Test SIZE must be at least 10% smaller than PVC size** (default: 30G test in 33Gi PVC)
- For accurate results, **SIZE should be at least 25× the read/write bandwidth** to avoid cache effects
- A full benchmark takes **~6 minutes**; do not cancel mid-run
- Always test local storage baseline first for comparison
- **CPU Idleness for Latency benchmark should be ≥40%** — if lower, the result may be CPU-starved
- Lower read latency than local storage is a red flag (likely caching)
- Better write performance than local storage is almost impossible for distributed storage without cache
Display these notes as info tooltips or a "Benchmark Guide" info panel.
---
## Code Quality Requirements
### TypeScript Checklist
- [ ] `strict: true` in `tsconfig.json` with all compiler flags (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, etc.)
- [ ] Zero `any` — use `unknown` + type guards for external data (API responses, log parsing)
- [ ] All public APIs have 100% type coverage
- [ ] `import type` used for type-only imports
- [ ] All K8s resource shapes typed — use `KubeObject` base type from headlamp where available
- [ ] Discriminated unions for all state machines (benchmark flow, snapshot CRD availability)
- [ ] Type guards at every external data boundary (API response parsing, Prometheus text parsing, pod log parsing)
- [ ] No `@ts-ignore` without inline explanation comment
### React Checklist
- [ ] Functional components with hooks only — no class components
- [ ] All `useEffect` dependency arrays correct — no stale closures, no missing deps
- [ ] `useMemo` on expensive filtering (tns-csi PV/PVC cross-reference computation)
- [ ] `useCallback` for stable event handlers passed as props (open/close panel, refresh)
- [ ] Context values memoized to prevent unnecessary re-renders
- [ ] ARIA labels on all interactive elements (buttons, selects, drawer controls)
- [ ] Keyboard navigation: Escape closes detail panels
- [ ] URL hash state for detail panel (matching polaris plugin pattern)
- [ ] Use headlamp's built-in component library exclusively — **do NOT add MUI, Ant Design, or other UI libraries**
### Error Boundary Pattern
Wrap each page with the exact loading/error pattern from `headlamp-polaris-plugin`:
```typescript
if (loading) return <Loader title="Loading TNS-CSI data..." />;
if (error) return (
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
</SectionBox>
);
if (!data) return (
<SectionBox title="No Data">
<NameValueTable rows={[{ name: 'Status', value: 'TNS-CSI driver not detected on this cluster.' }]} />
</SectionBox>
);
```
### Kubernetes Checklist
- [ ] Check CSIDriver `tns.csi.io` existence before rendering any pages — show install banner if absent
- [ ] VolumeSnapshot CRD availability checked before Snapshots page renders — show degraded state if absent
- [ ] Metrics endpoint access via API proxy (`/api/v1/namespaces/kube-system/pods/<pod>:8080/proxy/metrics`) — handle 404/timeout
- [ ] kbench Job/PVC labeled with `app.kubernetes.io/managed-by: headlamp-tns-csi-plugin` for tracking
- [ ] kbench PVC cleanup offered after benchmark completion — never auto-delete without user confirmation
- [ ] Use correct label selectors for tns-csi pods:
- Controller: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller`
- Node: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node`
### Plugin Settings
Register plugin settings for configurable options:
- Default namespace for kbench jobs
- Metrics refresh interval (default: 60s)
- Automatically cleanup completed kbench jobs (bool, default: false)
```typescript
registerPluginSettings('headlamp-tns-csi-plugin', SettingsComponent, true);
```
---
## Reference: Existing Plugin Patterns
Study the `headlamp-polaris-plugin` at `../headlamp-polaris-plugin/` for patterns:
**index.tsx**: `registerSidebarEntry`, `registerRoute`, `registerDetailsViewSection`, `registerAppBarAction`, `registerPluginSettings`
**Data context pattern**: `PolarisDataProvider` → `usePolarisDataContext()` — replicate this for tns-csi data
**Component patterns**:
- `DashboardView.tsx`: `SectionHeader` + multiple `SectionBox` + `PercentageCircle` + `PercentageBar` + `SimpleTable`
- `NamespacesListView.tsx`: `SimpleTable` with click handlers, slide-in detail panel, keyboard navigation (Escape to close), URL hash state
**API pattern**: `ApiProxy.request(url)` for all Kubernetes API calls, including CRDs
**Testing pattern**: `vitest` + `vi.mock('@kinvolk/headlamp-plugin/lib', ...)` for mocking K8s APIs
---
## Deliverables
Implement the complete plugin with:
1. **`src/index.tsx`** — entry point with all registrations
2. **`src/api/k8s.ts`** — K8s helper functions and type definitions
3. **`src/api/metrics.ts`** — Prometheus text format parser
4. **`src/api/kbench.ts`** — kbench Job management and log parser
5. **`src/api/TnsCsiDataContext.tsx`** — React context provider
6. **`src/components/OverviewPage.tsx`**
7. **`src/components/StorageClassesPage.tsx`**
8. **`src/components/VolumesPage.tsx`**
9. **`src/components/SnapshotsPage.tsx`**
10. **`src/components/MetricsPage.tsx`**
11. **`src/components/BenchmarkPage.tsx`**
12. **`src/components/DriverStatusCard.tsx`**
13. **`src/components/PVCDetailSection.tsx`**
14. **Unit tests** for all API modules and key components
15. **`package.json`** with correct headlamp-plugin dependency
The plugin must be buildable with `npm run build` and loadable by headlamp without errors.
+10 -10
View File
@@ -1,6 +1,6 @@
# Headlamp TNS-CSI Plugin
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/package/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/package/headlamp/tns-csi/headlamp-tns-csi-plugin)](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)
[![CI](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/actions/workflows/ci.yaml/badge.svg)](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/actions/workflows/ci.yaml)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
@@ -49,7 +49,7 @@ The tns-csi driver must be deployed in `kube-system` with the standard `app.kube
### Option 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin). Configure Headlamp via Helm:
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin). Configure Headlamp via Helm:
```yaml
config:
@@ -57,8 +57,8 @@ config:
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
Or install via the Headlamp UI:
@@ -73,8 +73,8 @@ Or install via the Headlamp UI:
Download the `.tar.gz` from the [GitHub releases page](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases), then extract into Headlamp's plugin directory:
```bash
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
tar xzf headlamp-tns-csi-plugin-0.2.0.tar.gz -C /headlamp/plugins/
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
tar xzf tns-csi-0.2.4.tar.gz -C /headlamp/plugins/
```
### Option 3: Build from Source
@@ -185,10 +185,10 @@ npm start # Opens Headlamp at http://localhost:4466
# Build for production
npm run build # outputs dist/main.js
npm run package # creates headlamp-tns-csi-plugin-<version>.tar.gz
npm run package # creates tns-csi-<version>.tar.gz
# Run tests
npm test # 67 unit tests
npm test # 159 unit tests
npm run test:watch # watch mode
# Code quality
@@ -264,7 +264,7 @@ Releases are automated via **GitHub Actions**. To cut a release:
This triggers the **GitHub Actions** release workflow (`.github/workflows/release.yaml`):
1. Build the plugin in a `node:20` container
1. Build the plugin in a `node:22` container
2. Update `package.json` and `artifacthub-pkg.yml` with the new version
3. Package a `.tar.gz` tarball
4. Compute SHA256 checksum and update `artifacthub-pkg.yml`
@@ -286,7 +286,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
## Links
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)** — Source code, issues, releases
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)** — Plugin catalog listing
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)** — Plugin catalog listing
- **[Headlamp](https://headlamp.dev/)** — Kubernetes web UI
- **[tns-csi driver](https://github.com/fenio/tns-csi)** — TrueNAS CSI driver
- **[kbench](https://github.com/longhorn/kbench)** — Storage benchmark tool
+17 -5
View File
@@ -1,4 +1,4 @@
version: "0.2.4"
version: "0.2.5"
name: headlamp-tns-csi-plugin
displayName: TrueNAS CSI (tns-csi)
description: >-
@@ -13,7 +13,7 @@ license: Apache-2.0
category: storage
homeURL: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
appVersion: "0.1.0"
appVersion: "0.15.0"
keywords:
- headlamp
@@ -47,11 +47,23 @@ links:
url: https://github.com/longhorn/kbench
changes:
- kind: added
description: "Component tests for all 8 UI components"
- kind: changed
description: "Package renamed to tns-csi so the plugin displays correctly in Headlamp's Plugins list"
description: "CI workflow split into parallel lint, typecheck, test jobs with build gating on all three"
- kind: changed
description: "JUnit test reporter for PR test result visibility"
- kind: changed
description: "Node.js 20 upgraded to 22 (current LTS) in all workflows"
- kind: changed
description: "Release workflow gated on CI passing with concurrency protection"
- kind: fixed
description: "Conditional React hook call in OverviewPage"
- kind: fixed
description: "appVersion now tracks upstream tns-csi driver release (0.12.0)"
annotations:
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/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.5/tns-csi-0.2.5.tar.gz"
headlamp/plugin/archive-checksum: sha256:3f5812d5081081c43b00f1da2da8de894db0477e765a1c69e55885adad2c0fcb
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
+6 -6
View File
@@ -12,8 +12,8 @@ helm install headlamp headlamp/headlamp \
--namespace kube-system \
--create-namespace \
--set config.pluginsDir=/headlamp/plugins \
--set pluginsManager.sources[0].name=headlamp-tns-csi-plugin \
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
--set pluginsManager.sources[0].name=tns-csi \
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
## Complete values.yaml Example
@@ -26,8 +26,8 @@ config:
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
serviceAccount:
name: headlamp
@@ -80,8 +80,8 @@ spec:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
## RBAC Manifest (Apply Separately)
+11 -11
View File
@@ -2,7 +2,7 @@
## Test Suite Overview
The plugin has 67 unit tests across 4 test files:
The plugin has **159 unit tests** across 12 test files:
| File | Tests | Coverage |
| ---- | ----- | -------- |
@@ -10,6 +10,14 @@ The plugin has 67 unit tests across 4 test files:
| `src/api/metrics.test.ts` | Prometheus text format parser | metrics.ts |
| `src/api/kbench.test.ts` | FIO log parser, manifest builders, format helpers | kbench.ts |
| `src/api/TnsCsiDataContext.test.tsx` | Context provider integration | TnsCsiDataContext.tsx |
| `src/components/OverviewPage.test.tsx` | Overview dashboard rendering | OverviewPage.tsx |
| `src/components/StorageClassesPage.test.tsx` | StorageClass list and detail panel | StorageClassesPage.tsx |
| `src/components/VolumesPage.test.tsx` | PV list and detail panel | VolumesPage.tsx |
| `src/components/SnapshotsPage.test.tsx` | VolumeSnapshot list | SnapshotsPage.tsx |
| `src/components/MetricsPage.test.tsx` | Prometheus metrics display | MetricsPage.tsx |
| `src/components/BenchmarkPage.test.tsx` | kbench runner UI | BenchmarkPage.tsx |
| `src/components/DriverStatusCard.test.tsx` | Driver health card | DriverStatusCard.tsx |
| `src/components/PVCDetailSection.test.tsx` | PVC detail injection | PVCDetailSection.tsx |
## Running Tests
@@ -136,14 +144,6 @@ if (typeof localStorage === 'undefined') {
## CI Test Enforcement
The GitHub Actions CI workflow runs tests on every push and pull request:
The GitHub Actions CI workflow runs lint, typecheck, and test as three parallel jobs on every push and PR. A fourth `build` job gates on all three passing. The test job uses a JUnit reporter that posts test summaries directly on PRs.
```yaml
- name: Run unit tests
run: npm test
- name: Type-check
run: npx tsc --noEmit
```
Both must pass for the PR to merge.
All three checks must pass for the PR to merge.
+14 -14
View File
@@ -4,7 +4,7 @@
### Method 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin).
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin).
**Via Headlamp UI:**
@@ -21,8 +21,8 @@ config:
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
**Via FluxCD HelmRelease:**
@@ -45,8 +45,8 @@ spec:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
### Method 2: Manual Tarball Install
@@ -55,16 +55,16 @@ Download and extract the plugin directly:
```bash
# Download the release tarball
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
# Verify the checksum
echo "14a3e8c13d0b894a41aa1cfccbcb1f6af09dcbb8fd95c7040a540987ea2096a7 headlamp-tns-csi-plugin-0.1.0.tar.gz" | sha256sum --check
echo "14a3e8c13d0b894a41aa1cfccbcb1f6af09dcbb8fd95c7040a540987ea2096a7 tns-csi-0.2.4.tar.gz" | sha256sum --check
# Extract into your Headlamp plugins directory
tar xzf headlamp-tns-csi-plugin-0.1.0.tar.gz -C /headlamp/plugins/
tar xzf tns-csi-0.2.4.tar.gz -C /headlamp/plugins/
```
The plugin directory should appear as `/headlamp/plugins/headlamp-tns-csi-plugin/`.
The plugin directory should appear as `/headlamp/plugins/tns-csi/`.
Restart Headlamp (or the pod) after extracting.
@@ -81,7 +81,7 @@ initContainers:
- -c
- |
wget -O /tmp/plugin.tar.gz \
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
tar xzf /tmp/plugin.tar.gz -C /headlamp/plugins/
volumeMounts:
- name: plugins
@@ -98,10 +98,10 @@ cd headlamp-tns-csi-plugin
npm install
npm run build
npm run package
# Produces headlamp-tns-csi-plugin-0.1.0.tar.gz
# Produces tns-csi-0.2.4.tar.gz
# Extract to your Headlamp plugins directory
tar xzf headlamp-tns-csi-plugin-0.1.0.tar.gz -C /headlamp/plugins/
tar xzf tns-csi-0.2.4.tar.gz -C /headlamp/plugins/
```
Or use `headlamp-plugin extract` for automatic placement:
@@ -129,7 +129,7 @@ For Plugin Manager installs, the catalog will show available updates.
Remove the plugin directory from your Headlamp plugins directory:
```bash
rm -rf /headlamp/plugins/headlamp-tns-csi-plugin/
rm -rf /headlamp/plugins/tns-csi/
```
Or via the Headlamp UI: **Settings → Plugins → headlamp-tns-csi-plugin → Uninstall**.
Or via the Headlamp UI: **Settings → Plugins → tns-csi → Uninstall**.
+2 -2
View File
@@ -27,8 +27,8 @@ config:
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz
```
Then upgrade your Headlamp release:
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "headlamp-tns-csi-plugin",
"version": "0.1.0",
"name": "tns-csi",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "headlamp-tns-csi-plugin",
"version": "0.1.0",
"name": "tns-csi",
"version": "0.2.5",
"license": "Apache-2.0",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "tns-csi",
"version": "0.2.4",
"version": "0.2.5",
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
"repository": {
"type": "git",
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
}
+6 -2
View File
@@ -21,10 +21,14 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
},
},
ConfigStore: class {
get() { return {}; }
get() {
return {};
}
set() {}
update() {}
useConfig() { return () => ({}); }
useConfig() {
return () => ({});
}
},
}));
+5 -3
View File
@@ -109,9 +109,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
try {
// CSIDriver
try {
const driver = await ApiProxy.request(
const driver = (await ApiProxy.request(
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
) as CSIDriver;
)) as CSIDriver;
if (!cancelled) setCsiDriver(driver);
} catch {
if (!cancelled) setCsiDriver(null);
@@ -203,7 +203,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
}
void fetchAsync();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [refreshKey]);
// ---------------------------------------------------------------------------
+6 -5
View File
@@ -28,7 +28,11 @@ function makeSc(name: string, provisioner: string, protocol = 'nfs'): TnsCsiStor
};
}
function makePv(name: string, driver: string, claimRef?: { name: string; namespace: string }): TnsCsiPersistentVolume {
function makePv(
name: string,
driver: string,
claimRef?: { name: string; namespace: string }
): TnsCsiPersistentVolume {
return {
metadata: { name },
spec: {
@@ -106,10 +110,7 @@ describe('isTnsCsiPersistentVolume', () => {
describe('filterTnsCsiPersistentVolumes', () => {
it('filters to only tns-csi PVs', () => {
const items = [
makePv('tns-pv', 'tns.csi.io'),
makePv('other-pv', 'ebs.csi.aws.com'),
];
const items = [makePv('tns-pv', 'tns.csi.io'), makePv('other-pv', 'ebs.csi.aws.com')];
const result = filterTnsCsiPersistentVolumes(items);
expect(result).toHaveLength(1);
expect(result[0]?.metadata.name).toBe('tns-pv');
+6 -10
View File
@@ -165,9 +165,7 @@ export function findBoundPv(
): TnsCsiPersistentVolume | undefined {
const ns = pvc.metadata.namespace ?? '';
const name = pvc.metadata.name;
return tnsPvs.find(
pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name
);
return tnsPvs.find(pv => pv.spec.claimRef?.namespace === ns && pv.spec.claimRef?.name === name);
}
// ---------------------------------------------------------------------------
@@ -216,15 +214,11 @@ export interface TnsCsiPod extends KubeObject {
}
export function isPodReady(pod: TnsCsiPod): boolean {
return (
pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false
);
return pod.status?.conditions?.some(c => c.type === 'Ready' && c.status === 'True') ?? false;
}
export function getPodRestarts(pod: TnsCsiPod): number {
return (
pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0
);
return pod.status?.containerStatuses?.reduce((sum, c) => sum + c.restartCount, 0) ?? 0;
}
export function getPodImage(pod: TnsCsiPod): string {
@@ -267,7 +261,9 @@ export function filterTnsCsiVolumeSnapshots(
tnsCsiSnapshotClassNames: Set<string>
): VolumeSnapshot[] {
return snapshots.filter(
s => s.spec?.volumeSnapshotClassName && tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName)
s =>
s.spec?.volumeSnapshotClassName &&
tnsCsiSnapshotClassNames.has(s.spec.volumeSnapshotClassName)
);
}
+16 -3
View File
@@ -66,7 +66,12 @@ describe('generatePvcName', () => {
// ---------------------------------------------------------------------------
describe('buildPvcManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
const opts = {
jobName: 'kbench-test',
pvcName: 'kbench-test-pvc',
namespace: 'default',
storageClass: 'tns-nfs',
};
it('produces a valid PVC manifest with correct storage class', () => {
const manifest = buildPvcManifest(opts) as Record<string, unknown>;
@@ -88,7 +93,12 @@ describe('buildPvcManifest', () => {
});
describe('buildJobManifest', () => {
const opts = { jobName: 'kbench-test', pvcName: 'kbench-test-pvc', namespace: 'default', storageClass: 'tns-nfs' };
const opts = {
jobName: 'kbench-test',
pvcName: 'kbench-test-pvc',
namespace: 'default',
storageClass: 'tns-nfs',
};
it('produces a valid Job manifest', () => {
const manifest = buildJobManifest(opts) as Record<string, unknown>;
@@ -109,7 +119,10 @@ describe('buildJobManifest', () => {
});
it('uses custom size and mode when specified', () => {
const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<string, unknown>;
const manifest = buildJobManifest({ ...opts, size: '10G', mode: 'quick' }) as Record<
string,
unknown
>;
const spec = manifest['spec'] as Record<string, unknown>;
const template = spec['template'] as Record<string, unknown>;
const podSpec = template['spec'] as Record<string, unknown>;
+36 -31
View File
@@ -22,7 +22,7 @@ export interface KbenchMetricGroup {
export interface KbenchResult {
iops: KbenchMetricGroup;
bandwidth: KbenchMetricGroup; // KiB/s
latency: KbenchMetricGroup; // nanoseconds
latency: KbenchMetricGroup; // nanoseconds
metadata: KbenchResultMetadata;
}
@@ -35,7 +35,14 @@ export interface KbenchResultMetadata {
namespace: string;
}
export type BenchmarkStatus = 'idle' | 'creating-pvc' | 'waiting-pvc' | 'running' | 'parsing' | 'complete' | 'failed';
export type BenchmarkStatus =
| 'idle'
| 'creating-pvc'
| 'waiting-pvc'
| 'running'
| 'parsing'
| 'complete'
| 'failed';
export type BenchmarkState =
| { status: 'idle' }
@@ -90,8 +97,8 @@ export interface KbenchJobOptions {
pvcName: string;
namespace: string;
storageClass: string;
size?: string; // default "30G"
mode?: string; // default "full"
size?: string; // default "30G"
mode?: string; // default "full"
}
export function buildPvcManifest(opts: KbenchJobOptions): object {
@@ -155,9 +162,7 @@ export function buildJobManifest(opts: KbenchJobOptions): object {
{ name: 'SIZE', value: opts.size ?? '30G' },
{ name: 'CPU_IDLE_PROF', value: 'disabled' },
],
volumeMounts: [
{ name: 'vol', mountPath: '/volume/' },
],
volumeMounts: [{ name: 'vol', mountPath: '/volume/' }],
},
],
restartPolicy: 'Never',
@@ -212,9 +217,9 @@ export async function getJobPhase(
jobName: string,
namespace: string
): Promise<{ phase: JobPhase; job: K8sJob }> {
const job = await ApiProxy.request(
const job = (await ApiProxy.request(
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
) as K8sJob;
)) as K8sJob;
const status = job.status;
let phase: JobPhase = 'Unknown';
@@ -225,13 +230,10 @@ export async function getJobPhase(
return { phase, job };
}
export async function getPvcPhase(
pvcName: string,
namespace: string
): Promise<string> {
const pvc = await ApiProxy.request(
export async function getPvcPhase(pvcName: string, namespace: string): Promise<string> {
const pvc = (await ApiProxy.request(
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
) as { status?: { phase?: string } };
)) as { status?: { phase?: string } };
return pvc.status?.phase ?? 'Unknown';
}
@@ -239,24 +241,23 @@ export async function getPvcPhase(
* Fetches the logs from the kbench pod (via the Job's pod selector).
* Uses the pod label selector to find the pod.
*/
export async function fetchKbenchLogs(
jobName: string,
namespace: string
): Promise<string> {
export async function fetchKbenchLogs(jobName: string, namespace: string): Promise<string> {
// Find pod with label kbench=fio and job-name=<jobName>
const podList = await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(`job-name=${jobName}`)}`
) as { items?: Array<{ metadata?: { name?: string } }> };
const podList = (await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(
`job-name=${jobName}`
)}`
)) as { items?: Array<{ metadata?: { name?: string } }> };
const podName = podList.items?.[0]?.metadata?.name;
if (!podName) {
throw new Error(`No pod found for kbench job "${jobName}"`);
}
const logs = await ApiProxy.request(
const logs = (await ApiProxy.request(
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
{ isJSON: false }
) as unknown;
)) as unknown;
if (typeof logs !== 'string') {
throw new Error('Pod logs were not returned as text');
@@ -274,10 +275,9 @@ export async function deleteJob(jobName: string, namespace: string): Promise<voi
}
export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
await ApiProxy.request(
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`,
{ method: 'DELETE' }
);
await ApiProxy.request(`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, {
method: 'DELETE',
});
}
// ---------------------------------------------------------------------------
@@ -366,7 +366,7 @@ export function parseKbenchLog(logText: string): KbenchResult | null {
bandwidth,
latency,
metadata: {
storageClass: '', // filled in by the caller
storageClass: '', // filled in by the caller
size: '30G',
startedAt: '',
completedAt: new Date().toISOString(),
@@ -388,9 +388,14 @@ export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobS
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
: `/apis/batch/v1/jobs?labelSelector=${selector}`;
const list = await ApiProxy.request(path) as {
const list = (await ApiProxy.request(path)) as {
items?: Array<{
metadata?: { name?: string; namespace?: string; annotations?: Record<string, string>; creationTimestamp?: string };
metadata?: {
name?: string;
namespace?: string;
annotations?: Record<string, string>;
creationTimestamp?: string;
};
status?: K8sJobStatus;
}>;
};
+1 -4
View File
@@ -218,10 +218,7 @@ export function sumSamples(samples: MetricSample[]): number {
}
/** Group samples by a label key, summing values per group. */
export function groupByLabel(
samples: MetricSample[],
labelKey: string
): Map<string, number> {
export function groupByLabel(samples: MetricSample[], labelKey: string): Map<string, number> {
const result = new Map<string, number>();
for (const sample of samples) {
const key = sample.labels[labelKey] ?? 'unknown';
+22 -17
View File
@@ -70,10 +70,7 @@ export interface PoolStats {
* @param apiKey - TrueNAS API key
* @returns Array of pool stats
*/
export function fetchTruenasPoolStats(
server: string,
apiKey: string
): Promise<PoolStats[]> {
export function fetchTruenasPoolStats(server: string, apiKey: string): Promise<PoolStats[]> {
return new Promise((resolve, reject) => {
// TrueNAS WebSocket endpoint — supports both SCALE and CORE
const url = `wss://${server}/api/current`;
@@ -99,12 +96,14 @@ export function fetchTruenasPoolStats(
ws.onopen = () => {
phase = 'authenticating';
ws.send(JSON.stringify({
id: msgId++,
msg: 'method',
method: 'auth.login_with_api_key',
params: [apiKey],
}));
ws.send(
JSON.stringify({
id: msgId++,
msg: 'method',
method: 'auth.login_with_api_key',
params: [apiKey],
})
);
};
ws.onmessage = (event: MessageEvent) => {
@@ -124,12 +123,14 @@ export function fetchTruenasPoolStats(
return;
}
phase = 'querying';
ws.send(JSON.stringify({
id: msgId++,
msg: 'method',
method: 'pool.query',
params: [],
}));
ws.send(
JSON.stringify({
id: msgId++,
msg: 'method',
method: 'pool.query',
params: [],
})
);
return;
}
@@ -162,7 +163,11 @@ export function fetchTruenasPoolStats(
ws.onerror = () => {
if (phase !== 'done') {
clearTimeout(timeout);
reject(new Error(`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`));
reject(
new Error(
`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`
)
);
}
};
+14 -16
View File
@@ -6,19 +6,24 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
request: vi.fn().mockResolvedValue({}),
},
ConfigStore: class {
get() { return {}; }
get() {
return {};
}
set() {}
update() {}
useConfig() { return () => ({}); }
useConfig() {
return () => ({});
}
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async (importOriginal) => {
vi.mock('../api/kbench', async importOriginal => {
const actual = await importOriginal<typeof import('../api/kbench')>();
return {
...actual,
@@ -36,16 +41,7 @@ vi.mock('../api/kbench', async (importOriginal) => {
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import {
createPvc,
createJob,
deleteJob,
deletePvc,
getJobPhase,
fetchKbenchLogs,
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { createPvc, createJob, listKbenchJobs } from '../api/kbench';
import { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage';
@@ -192,7 +188,9 @@ describe('BenchmarkPage', () => {
render(<BenchmarkPage />);
// Change namespace
const nsInput = screen.getByLabelText('Kubernetes namespace for benchmark job') as HTMLInputElement;
const nsInput = screen.getByLabelText(
'Kubernetes namespace for benchmark job'
) as HTMLInputElement;
fireEvent.change(nsInput, { target: { value: 'bench-ns' } });
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
+365 -82
View File
@@ -46,7 +46,15 @@ interface MetricRowData {
note?: string;
}
function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: MetricRowData[]; higherIsBetter: boolean }) {
function ResultTable({
title,
rows,
higherIsBetter,
}: {
title: string;
rows: MetricRowData[];
higherIsBetter: boolean;
}) {
return (
<SectionBox title={title}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
@@ -55,14 +63,24 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 600 }}>Metric</th>
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Read</th>
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Write</th>
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 400, color: 'var(--mui-palette-text-secondary)' }}>
<th
style={{
textAlign: 'left',
padding: '8px 4px',
fontWeight: 400,
color: 'var(--mui-palette-text-secondary)',
}}
>
{higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.label} style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}>
<tr
key={row.label}
style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}
>
<td style={{ padding: '8px 4px' }}>{row.label}</td>
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
{row.formatter(row.read)}
@@ -83,21 +101,69 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
function KbenchResultDisplay({ result }: { result: KbenchResult }) {
const iopsRows: MetricRowData[] = [
{ label: 'Random', read: result.iops.randomRead, write: result.iops.randomWrite, formatter: formatIops },
{ label: 'Sequential', read: result.iops.sequentialRead, write: result.iops.sequentialWrite, formatter: formatIops },
{ label: 'CPU Idleness', read: result.iops.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '' },
{
label: 'Random',
read: result.iops.randomRead,
write: result.iops.randomWrite,
formatter: formatIops,
},
{
label: 'Sequential',
read: result.iops.sequentialRead,
write: result.iops.sequentialWrite,
formatter: formatIops,
},
{
label: 'CPU Idleness',
read: result.iops.cpuIdleness,
write: null,
formatter: v => `${v}%`,
note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '',
},
];
const bwRows: MetricRowData[] = [
{ label: 'Random', read: result.bandwidth.randomRead, write: result.bandwidth.randomWrite, formatter: formatBandwidth },
{ label: 'Sequential', read: result.bandwidth.sequentialRead, write: result.bandwidth.sequentialWrite, formatter: formatBandwidth },
{ label: 'CPU Idleness', read: result.bandwidth.cpuIdleness, write: null, formatter: v => `${v}%` },
{
label: 'Random',
read: result.bandwidth.randomRead,
write: result.bandwidth.randomWrite,
formatter: formatBandwidth,
},
{
label: 'Sequential',
read: result.bandwidth.sequentialRead,
write: result.bandwidth.sequentialWrite,
formatter: formatBandwidth,
},
{
label: 'CPU Idleness',
read: result.bandwidth.cpuIdleness,
write: null,
formatter: v => `${v}%`,
},
];
const latRows: MetricRowData[] = [
{ label: 'Random', read: result.latency.randomRead, write: result.latency.randomWrite, formatter: formatLatency },
{ label: 'Sequential', read: result.latency.sequentialRead, write: result.latency.sequentialWrite, formatter: formatLatency },
{ label: 'CPU Idleness', read: result.latency.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '' },
{
label: 'Random',
read: result.latency.randomRead,
write: result.latency.randomWrite,
formatter: formatLatency,
},
{
label: 'Sequential',
read: result.latency.sequentialRead,
write: result.latency.sequentialWrite,
formatter: formatLatency,
},
{
label: 'CPU Idleness',
read: result.latency.cpuIdleness,
write: null,
formatter: v => `${v}%`,
note:
result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '',
},
];
return (
@@ -109,7 +175,12 @@ function KbenchResultDisplay({ result }: { result: KbenchResult }) {
{ name: 'Test Size', value: result.metadata.size },
{ name: 'Job', value: result.metadata.jobName || '—' },
{ name: 'Namespace', value: result.metadata.namespace || '—' },
{ name: 'Completed', value: result.metadata.completedAt ? new Date(result.metadata.completedAt).toLocaleString() : '—' },
{
name: 'Completed',
value: result.metadata.completedAt
? new Date(result.metadata.completedAt).toLocaleString()
: '—',
},
]}
/>
</SectionBox>
@@ -154,32 +225,66 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
return (
<SectionBox title="Run New Benchmark">
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '12px 16px', alignItems: 'center', maxWidth: '600px' }}>
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>Storage Class *</label>
<div
style={{
display: 'grid',
gridTemplateColumns: '200px 1fr',
gap: '12px 16px',
alignItems: 'center',
maxWidth: '600px',
}}
>
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>
Storage Class *
</label>
<select
id="kbench-sc"
value={storageClass}
onChange={e => setStorageClass(e.target.value)}
disabled={disabled || storageClasses.length === 0}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
style={{
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--mui-palette-divider, #ccc)',
fontSize: '14px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
}}
aria-label="Select storage class for benchmark"
>
{storageClasses.length === 0 && <option value="">No tns-csi storage classes found</option>}
{storageClasses.map(sc => <option key={sc} value={sc}>{sc}</option>)}
{storageClasses.length === 0 && (
<option value="">No tns-csi storage classes found</option>
)}
{storageClasses.map(sc => (
<option key={sc} value={sc}>
{sc}
</option>
))}
</select>
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>Namespace</label>
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>
Namespace
</label>
<input
id="kbench-ns"
type="text"
value={namespace}
onChange={e => setNamespace(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
style={{
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--mui-palette-divider, #ccc)',
fontSize: '14px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
}}
aria-label="Kubernetes namespace for benchmark job"
/>
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>Test Size</label>
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>
Test Size
</label>
<div>
<input
id="kbench-size"
@@ -187,21 +292,44 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
value={size}
onChange={e => setSize(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', width: '120px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
style={{
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--mui-palette-divider, #ccc)',
fontSize: '14px',
width: '120px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
}}
aria-label="FIO test size"
/>
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--mui-palette-text-secondary)' }}>
<span
style={{
marginLeft: '8px',
fontSize: '12px',
color: 'var(--mui-palette-text-secondary)',
}}
>
PVC will be ~10% larger (33Gi for 30G)
</span>
</div>
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>Mode</label>
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>
Mode
</label>
<select
id="kbench-mode"
value={mode}
onChange={e => setMode(e.target.value)}
disabled={disabled}
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
style={{
padding: '6px 8px',
borderRadius: '4px',
border: '1px solid var(--mui-palette-divider, #ccc)',
fontSize: '14px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
}}
aria-label="Benchmark mode"
>
<option value="full">Full (~6 minutes)</option>
@@ -216,7 +344,9 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
aria-label="Start kbench storage benchmark"
style={{
padding: '8px 20px',
backgroundColor: disabled ? 'var(--mui-palette-action-disabled, #ccc)' : 'var(--mui-palette-primary-main, #1976d2)',
backgroundColor: disabled
? 'var(--mui-palette-action-disabled, #ccc)'
: 'var(--mui-palette-primary-main, #1976d2)',
color: '#fff',
border: 'none',
borderRadius: '4px',
@@ -231,35 +361,82 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
{showConfirm && (
<div
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000, backgroundColor: 'rgba(0,0,0,0.5)' }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
backgroundColor: 'rgba(0,0,0,0.5)',
}}
role="dialog"
aria-modal="true"
aria-labelledby="kbench-confirm-title"
>
<div style={{ backgroundColor: 'var(--mui-palette-background-paper, #fff)', borderRadius: '8px', padding: '24px', maxWidth: '480px', boxShadow: '0 4px 24px rgba(0,0,0,0.2)', color: 'var(--mui-palette-text-primary)' }}>
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>Confirm Benchmark</h3>
<div
style={{
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
borderRadius: '8px',
padding: '24px',
maxWidth: '480px',
boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
color: 'var(--mui-palette-text-primary)',
}}
>
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>
Confirm Benchmark
</h3>
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark (
<strong>~6 minutes</strong>).
</p>
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
Storage class: <strong>{storageClass}</strong> · Namespace: <strong>{namespace}</strong>
Storage class: <strong>{storageClass}</strong> · Namespace:{' '}
<strong>{namespace}</strong>
</p>
<p style={{ margin: '0 0 16px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion.
<p
style={{
margin: '0 0 16px',
fontSize: '14px',
color: 'var(--mui-palette-text-secondary)',
}}
>
The Job and PVC will remain until manually deleted. You will be prompted to clean up
after completion.
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowConfirm(false)}
aria-label="Cancel benchmark"
style={{ padding: '8px 16px', border: '1px solid var(--mui-palette-divider)', borderRadius: '4px', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'var(--mui-palette-text-primary)' }}
style={{
padding: '8px 16px',
border: '1px solid var(--mui-palette-divider)',
borderRadius: '4px',
background: 'transparent',
cursor: 'pointer',
fontSize: '14px',
color: 'var(--mui-palette-text-primary)',
}}
>
Cancel
</button>
<button
onClick={handleConfirm}
aria-label="Confirm and start benchmark"
style={{ padding: '8px 16px', backgroundColor: 'var(--mui-palette-primary-main, #1976d2)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', fontWeight: 500 }}
style={{
padding: '8px 16px',
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
Start Benchmark
</button>
@@ -305,9 +482,7 @@ function BenchmarkProgress({ state }: { state: BenchmarkState }) {
{
name: 'Status',
value: (
<StatusLabel status={statusColor[state.status]}>
{labels[state.status]}
</StatusLabel>
<StatusLabel status={statusColor[state.status]}>{labels[state.status]}</StatusLabel>
),
},
...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []),
@@ -344,7 +519,9 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
}
}, [namespace]);
useEffect(() => { void loadJobs(); }, [loadJobs]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
async function handleDelete(job: KbenchJobSummary) {
if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return;
@@ -372,7 +549,11 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
{
label: 'Status',
getter: (j: KbenchJobSummary) => (
<StatusLabel status={j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'}>
<StatusLabel
status={
j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'
}
>
{j.phase}
</StatusLabel>
),
@@ -385,7 +566,15 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
onClick={() => void handleDelete(j)}
disabled={deleting === j.jobName}
aria-label={`Delete benchmark job ${j.jobName}`}
style={{ padding: '4px 10px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}
style={{
padding: '4px 10px',
border: '1px solid var(--mui-palette-error-main, #d32f2f)',
color: 'var(--mui-palette-error-main, #d32f2f)',
background: 'transparent',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
}}
>
{deleting === j.jobName ? 'Deleting...' : 'Delete'}
</button>
@@ -422,21 +611,38 @@ export default function BenchmarkPage() {
}
}
async function runBenchmark(opts: { storageClass: string; namespace: string; size: string; mode: string }) {
async function runBenchmark(opts: {
storageClass: string;
namespace: string;
size: string;
mode: string;
}) {
stopPolling();
setCurrentResult(null);
setLastNamespace(opts.namespace);
const jobName = generateJobName();
const pvcName = generatePvcName(jobName);
const jobOpts = { jobName, pvcName, namespace: opts.namespace, storageClass: opts.storageClass, size: opts.size, mode: opts.mode };
const jobOpts = {
jobName,
pvcName,
namespace: opts.namespace,
storageClass: opts.storageClass,
size: opts.size,
mode: opts.mode,
};
// Step 1: Create PVC
setBenchState({ status: 'creating-pvc' });
try {
await createPvc(jobOpts);
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
setBenchState({
status: 'failed',
error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
return;
}
@@ -446,13 +652,25 @@ export default function BenchmarkPage() {
let pvcBound = false;
while (Date.now() < pvcDeadline) {
try {
const pvc = await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`) as { status?: { phase?: string } };
if (pvc.status?.phase === 'Bound') { pvcBound = true; break; }
} catch { /* retry */ }
const pvc = (await ApiProxy.request(
`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`
)) as { status?: { phase?: string } };
if (pvc.status?.phase === 'Bound') {
pvcBound = true;
break;
}
} catch {
/* retry */
}
await new Promise(r => setTimeout(r, 5000));
}
if (!pvcBound) {
setBenchState({ status: 'failed', error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.', jobName, pvcName });
setBenchState({
status: 'failed',
error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.',
jobName,
pvcName,
});
return;
}
@@ -460,7 +678,12 @@ export default function BenchmarkPage() {
try {
await createJob(jobOpts);
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
setBenchState({
status: 'failed',
error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
return;
}
@@ -487,18 +710,38 @@ export default function BenchmarkPage() {
setCurrentResult(result);
setBenchState({ status: 'complete', result, jobName, pvcName });
} else {
setBenchState({ status: 'failed', error: 'Could not parse FIO output from pod logs.', jobName, pvcName });
setBenchState({
status: 'failed',
error: 'Could not parse FIO output from pod logs.',
jobName,
pvcName,
});
}
} catch (err: unknown) {
setBenchState({ status: 'failed', error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
setBenchState({
status: 'failed',
error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
}
} else if (phase === 'Failed') {
stopPolling();
setBenchState({ status: 'failed', error: 'kbench Job failed. Check pod logs for details.', jobName, pvcName });
setBenchState({
status: 'failed',
error: 'kbench Job failed. Check pod logs for details.',
jobName,
pvcName,
});
}
} catch (err: unknown) {
stopPolling();
setBenchState({ status: 'failed', error: `Polling error: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
setBenchState({
status: 'failed',
error: `Polling error: ${err instanceof Error ? err.message : String(err)}`,
jobName,
pvcName,
});
}
}, POLL_INTERVAL_MS);
}
@@ -506,7 +749,10 @@ export default function BenchmarkPage() {
// Clean up polling on unmount
useEffect(() => () => stopPolling(), []);
const isRunning = benchState.status !== 'idle' && benchState.status !== 'complete' && benchState.status !== 'failed';
const isRunning =
benchState.status !== 'idle' &&
benchState.status !== 'complete' &&
benchState.status !== 'failed';
if (loading) return <Loader title="Loading tns-csi data..." />;
@@ -518,15 +764,35 @@ export default function BenchmarkPage() {
<NameValueTable
rows={[
{ name: 'Duration', value: 'Full benchmark takes ~6 minutes. Do not cancel mid-run.' },
{ name: 'Test Size', value: 'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).' },
{ name: 'Cache Warning', value: 'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.' },
{ name: 'CPU Idleness', value: 'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.' },
{ name: 'Interpretation', value: 'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.' },
{
name: 'Test Size',
value:
'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).',
},
{
name: 'Cache Warning',
value:
'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.',
},
{
name: 'CPU Idleness',
value:
'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.',
},
{
name: 'Interpretation',
value:
'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.',
},
]}
/>
</SectionBox>
<RunForm storageClasses={scNames} onRun={opts => void runBenchmark(opts)} disabled={isRunning} />
<RunForm
storageClasses={scNames}
onRun={opts => void runBenchmark(opts)}
disabled={isRunning}
/>
<BenchmarkProgress state={benchState} />
@@ -535,30 +801,47 @@ export default function BenchmarkPage() {
<KbenchResultDisplay result={currentResult} />
<SectionBox title="Cleanup">
<NameValueTable
rows={[{
name: 'Resources',
value: (
<button
onClick={async () => {
const state = benchState;
if (state.status !== 'complete') return;
if (!window.confirm(`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`)) return;
try {
await deleteJob(state.jobName, lastNamespace);
await deletePvc(state.pvcName, lastNamespace);
setBenchState({ status: 'idle' });
setCurrentResult(null);
} catch (err: unknown) {
alert(`Cleanup error: ${err instanceof Error ? err.message : String(err)}`);
}
}}
aria-label="Delete benchmark job and PVC"
style={{ padding: '6px 14px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
>
Delete Job + PVC
</button>
),
}]}
rows={[
{
name: 'Resources',
value: (
<button
onClick={async () => {
const state = benchState;
if (state.status !== 'complete') return;
if (
!window.confirm(
`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`
)
)
return;
try {
await deleteJob(state.jobName, lastNamespace);
await deletePvc(state.pvcName, lastNamespace);
setBenchState({ status: 'idle' });
setCurrentResult(null);
} catch (err: unknown) {
alert(
`Cleanup error: ${err instanceof Error ? err.message : String(err)}`
);
}
}}
aria-label="Delete benchmark job and PVC"
style={{
padding: '6px 14px',
border: '1px solid var(--mui-palette-error-main, #d32f2f)',
color: 'var(--mui-palette-error-main, #d32f2f)',
background: 'transparent',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
}}
>
Delete Job + PVC
</button>
),
},
]}
/>
</SectionBox>
</>
+20 -14
View File
@@ -6,10 +6,7 @@
* Uses registerDetailsViewSection in index.tsx.
*/
import {
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s';
@@ -72,12 +69,14 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
// Extract from jsonData (KubeObject instance) or fall back to direct props.
// jsonData.metadata has the full shape including name/namespace; resource.metadata
// only exposes fields that the Headlamp class getter provides (labels, creationTimestamp).
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as {
name?: string;
namespace?: string;
labels?: Record<string, string>;
creationTimestamp?: string;
} | undefined;
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as
| {
name?: string;
namespace?: string;
labels?: Record<string, string>;
creationTimestamp?: string;
}
| undefined;
const spec = resource?.jsonData?.spec ?? resource?.spec;
const status = resource?.jsonData?.status ?? resource?.status;
const labels = meta?.labels ?? {};
@@ -88,7 +87,8 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
}
const component = labels['app.kubernetes.io/component'] ?? 'unknown';
const roleLabel = component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
const roleLabel =
component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
// Build a minimal pod shape that isPodReady / getPodRestarts can consume
const podShape: TnsCsiPod = {
@@ -113,15 +113,21 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
const containerRows = containerStatuses.map(cs => {
let stateText = 'Unknown';
if (cs.state?.running) {
stateText = `Running since ${cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'} ago`;
stateText = `Running since ${
cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'
} ago`;
} else if (cs.state?.waiting) {
stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`;
} else if (cs.state?.terminated) {
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${cs.state.terminated.reason ?? ''}`;
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${
cs.state.terminated.reason ?? ''
}`;
}
return {
name: cs.name,
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'}${stateText}${cs.restartCount} restart(s)`,
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'}${stateText}${
cs.restartCount
} restart(s)`,
};
});
+9 -38
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
import DriverStatusCard from './DriverStatusCard';
@@ -10,24 +11,12 @@ import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpe
describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('Not detected')).toBeInTheDocument();
});
it('shows "Degraded" when no pods are present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('Degraded')).toBeInTheDocument();
});
@@ -84,27 +73,15 @@ describe('DriverStatusCard', () => {
});
it('renders CSI capabilities section when driver is present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('Persistent')).toBeInTheDocument();
});
it('does not render CSI capabilities when no driver', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
});
@@ -119,13 +96,7 @@ describe('DriverStatusCard', () => {
],
},
});
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[pod]}
nodePods={[]}
/>
);
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[pod]} nodePods={[]} />);
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // restarts
+15 -8
View File
@@ -38,11 +38,7 @@ function WebSocketStatus({ metrics }: { metrics: TnsCsiMetrics | null }) {
function PodStatusBadge({ pod }: { pod: TnsCsiPod }) {
const ready = isPodReady(pod);
const phase = pod.status?.phase ?? 'Unknown';
return (
<StatusLabel status={ready ? 'success' : 'error'}>
{phase}
</StatusLabel>
);
return <StatusLabel status={ready ? 'success' : 'error'}>{phase}</StatusLabel>;
}
function PodRow({ pod }: { pod: TnsCsiPod }) {
@@ -114,7 +110,8 @@ export default function DriverStatusCard({
name: 'WebSocket',
value: <WebSocketStatus metrics={metrics ?? null} />,
},
...(metrics?.websocketReconnectsTotal !== null && metrics?.websocketReconnectsTotal !== undefined
...(metrics?.websocketReconnectsTotal !== null &&
metrics?.websocketReconnectsTotal !== undefined
? [{ name: 'WS Reconnects', value: String(metrics.websocketReconnectsTotal) }]
: []),
]}
@@ -153,7 +150,12 @@ export default function DriverStatusCard({
{controllerPods.length === 0 && (
<SectionBox title="Controller Pods">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No controller pod found</StatusLabel> }]}
rows={[
{
name: 'Status',
value: <StatusLabel status="error">No controller pod found</StatusLabel>,
},
]}
/>
</SectionBox>
)}
@@ -169,7 +171,12 @@ export default function DriverStatusCard({
{nodePods.length === 0 && (
<SectionBox title="Node Pods">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">No node pods found</StatusLabel> }]}
rows={[
{
name: 'Status',
value: <StatusLabel status="error">No node pods found</StatusLabel>,
},
]}
/>
</SectionBox>
)}
+4 -3
View File
@@ -1,12 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
+34 -7
View File
@@ -40,7 +40,9 @@ function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
{
name: 'Connection Status',
value: (
<StatusLabel status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}>
<StatusLabel
status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}
>
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
</StatusLabel>
),
@@ -137,7 +139,14 @@ export default function MetricsPage() {
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Metrics" />
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{lastUpdated && (
@@ -169,7 +178,14 @@ export default function MetricsPage() {
{!driverInstalled && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel> }]}
rows={[
{
name: 'Status',
value: (
<StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel>
),
},
]}
/>
</SectionBox>
)}
@@ -178,11 +194,18 @@ export default function MetricsPage() {
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{ name: 'Status', value: <StatusLabel status="warning">No controller pod found</StatusLabel> },
{ name: 'Note', value: 'Ensure controller pod is running with metrics enabled on port 8080.' },
{
name: 'Status',
value: <StatusLabel status="warning">No controller pod found</StatusLabel>,
},
{
name: 'Note',
value: 'Ensure controller pod is running with metrics enabled on port 8080.',
},
{
name: 'Troubleshooting',
value: 'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
value:
'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
},
]}
/>
@@ -194,7 +217,11 @@ export default function MetricsPage() {
<NameValueTable
rows={[
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
{ name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' },
{
name: 'Note',
value:
'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.',
},
]}
/>
</SectionBox>
+23 -13
View File
@@ -1,12 +1,13 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
@@ -132,9 +133,7 @@ describe('OverviewPage', () => {
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [
{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 },
],
poolStats: [{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 }],
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
@@ -163,9 +162,7 @@ describe('OverviewPage', () => {
const pod = makeSamplePod();
const pv = makeSamplePV();
const metrics = makeSampleMetrics({
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
});
mockContext({
driverInstalled: true,
@@ -187,7 +184,11 @@ describe('OverviewPage', () => {
it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({
metadata: { name: 'pending-pvc', namespace: 'test', creationTimestamp: '2025-01-01T00:00:00Z' },
metadata: {
name: 'pending-pvc',
namespace: 'test',
creationTimestamp: '2025-01-01T00:00:00Z',
},
status: { phase: 'Pending' },
});
mockContext({
@@ -257,9 +258,18 @@ describe('OverviewPage', () => {
});
it('shows PVC status breakdown with Pending and Lost counts', () => {
const boundPvc = makeSamplePVC({ metadata: { name: 'pvc-1', namespace: 'ns' }, status: { phase: 'Bound' } });
const pendingPvc = makeSamplePVC({ metadata: { name: 'pvc-2', namespace: 'ns' }, status: { phase: 'Pending' } });
const lostPvc = makeSamplePVC({ metadata: { name: 'pvc-3', namespace: 'ns' }, status: { phase: 'Lost' } });
const boundPvc = makeSamplePVC({
metadata: { name: 'pvc-1', namespace: 'ns' },
status: { phase: 'Bound' },
});
const pendingPvc = makeSamplePVC({
metadata: { name: 'pvc-2', namespace: 'ns' },
status: { phase: 'Pending' },
});
const lostPvc = makeSamplePVC({
metadata: { name: 'pvc-3', namespace: 'ns' },
status: { phase: 'Lost' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
+55 -32
View File
@@ -122,16 +122,21 @@ export default function OverviewPage() {
else pvcStatusCounts.Other++;
}
const nonBoundPvcs = persistentVolumeClaims.filter(
pvc => pvc.status?.phase !== 'Bound'
);
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
@@ -174,11 +179,16 @@ export default function OverviewPage() {
rows={[
{
name: 'Status',
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>,
value: (
<StatusLabel status="error">
CSIDriver tns.csi.io not found on this cluster
</StatusLabel>
),
},
{
name: 'Install',
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
value:
'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
},
]}
/>
@@ -223,7 +233,13 @@ export default function OverviewPage() {
<SectionBox title="Storage Summary">
{totalScs > 0 && chartData.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
<div
style={{
marginBottom: '8px',
fontSize: '14px',
color: 'var(--mui-palette-text-secondary)',
}}
>
Protocol Distribution
</div>
<PercentageBar data={chartData} total={totalScs} />
@@ -239,16 +255,20 @@ export default function OverviewPage() {
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
},
...(pvcStatusCounts.Pending > 0
? [{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
}]
? [
{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
},
]
: []),
...(pvcStatusCounts.Lost > 0
? [{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
}]
? [
{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
},
]
: []),
]}
/>
@@ -259,23 +279,21 @@ export default function OverviewPage() {
<SectionBox title="Pool Capacity">
<SimpleTable
columns={[
{ label: 'Pool', getter: (p) => p.name },
{ label: 'Pool', getter: p => p.name },
{
label: 'Status',
getter: (p) => (
getter: p => (
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
{p.status}
</StatusLabel>
),
},
{ label: 'Total', getter: (p) => formatBytes(p.size) },
{ label: 'Used', getter: (p) => formatBytes(p.allocated) },
{ label: 'Free', getter: (p) => formatBytes(p.free) },
{ label: 'Total', getter: p => formatBytes(p.size) },
{ label: 'Used', getter: p => formatBytes(p.allocated) },
{ label: 'Free', getter: p => formatBytes(p.free) },
{
label: 'Used %',
getter: (p) => p.size > 0
? `${Math.round((p.allocated / p.size) * 100)}%`
: '—',
getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
},
]}
data={poolStats}
@@ -319,17 +337,17 @@ export default function OverviewPage() {
<SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable
columns={[
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
{ label: 'Name', getter: pvc => pvc.metadata.name },
{ label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
{
label: 'Status',
getter: (pvc) => (
getter: pvc => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
{ label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
]}
data={nonBoundPvcs}
/>
@@ -350,11 +368,16 @@ function parseStorageToBytes(storage: string): number {
const suffix = match[2] ?? '';
const multipliers: Record<string, number> = {
'': 1,
K: 1e3, Ki: 1024,
M: 1e6, Mi: 1024 ** 2,
G: 1e9, Gi: 1024 ** 3,
T: 1e12, Ti: 1024 ** 4,
P: 1e15, Pi: 1024 ** 5,
K: 1e3,
Ki: 1024,
M: 1e6,
Mi: 1024 ** 2,
G: 1e9,
Gi: 1024 ** 3,
T: 1e12,
Ti: 1024 ** 4,
P: 1e15,
Pi: 1024 ** 5,
};
return value * (multipliers[suffix] ?? 1);
}
+5 -8
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
@@ -51,9 +52,7 @@ describe('PVCDetailSection', () => {
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
@@ -83,9 +82,7 @@ describe('PVCDetailSection', () => {
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument();
+3 -7
View File
@@ -5,10 +5,7 @@
* Uses registerDetailsViewSection in index.tsx.
*/
import {
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { findBoundPv, formatProtocol } from '../api/k8s';
@@ -52,10 +49,9 @@ export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
{ name: 'Server', value: attrs['server'] ?? '—' },
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
...(Object.entries(attrs)
...Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
),
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
{
name: 'PV Name',
value: boundPv.metadata.name,
+1 -4
View File
@@ -5,10 +5,7 @@
* Uses registerDetailsViewSection in index.tsx.
*/
import {
NameValueTable,
SectionBox,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
+4 -5
View File
@@ -1,8 +1,9 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
@@ -33,9 +34,7 @@ describe('SnapshotsPage', () => {
mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect(
screen.getByText(/VolumeSnapshot CRDs.*not found/)
).toBeInTheDocument();
expect(screen.getByText(/VolumeSnapshot CRDs.*not found/)).toBeInTheDocument();
});
it('shows empty message when snapshots list is empty', () => {
+12 -6
View File
@@ -27,7 +27,9 @@ export default function SnapshotsPage() {
<>
<SectionHeader title="TNS-CSI — Snapshots" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
</>
);
@@ -51,7 +53,11 @@ export default function SnapshotsPage() {
{
name: 'Documentation',
value: (
<a href="https://github.com/fenio/tns-csi" target="_blank" rel="noopener noreferrer">
<a
href="https://github.com/fenio/tns-csi"
target="_blank"
rel="noopener noreferrer"
>
See tns-csi documentation for snapshot setup instructions
</a>
),
@@ -71,10 +77,10 @@ export default function SnapshotsPage() {
<SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}>
<SimpleTable
columns={[
{ label: 'Name', getter: (vsc) => vsc.metadata.name },
{ label: 'Driver', getter: (vsc) => vsc.driver ?? '—' },
{ label: 'Deletion Policy', getter: (vsc) => vsc.deletionPolicy ?? '—' },
{ label: 'Age', getter: (vsc) => formatAge(vsc.metadata.creationTimestamp) },
{ label: 'Name', getter: vsc => vsc.metadata.name },
{ label: 'Driver', getter: vsc => vsc.driver ?? '—' },
{ label: 'Deletion Policy', getter: vsc => vsc.deletionPolicy ?? '—' },
{ label: 'Age', getter: vsc => formatAge(vsc.metadata.creationTimestamp) },
]}
data={volumeSnapshotClasses}
/>
+6 -3
View File
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
@@ -109,7 +110,9 @@ describe('StorageClassesPage', () => {
it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass({ parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' } });
const sc = makeSampleStorageClass({
parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
+78 -20
View File
@@ -55,7 +55,14 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
}
`}</style>
<div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
{sc.metadata.name}
</h2>
@@ -64,7 +71,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
onClick={() => setIsMaximized(!isMaximized)}
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
title={isMaximized ? 'Minimize' : 'Maximize'}
style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
>
{isMaximized ? '⊟' : '⊡'}
</button>
@@ -72,7 +87,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
onClick={onClose}
aria-label="Close panel"
title="Close"
style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
>
×
</button>
@@ -90,16 +113,21 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
{
name: 'Allow Volume Expansion',
value: <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>,
value: (
<StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>
),
},
{ name: 'Delete Strategy', value: params.deleteStrategy ?? '—' },
{
name: 'Encryption',
value: params.encryption === 'true'
? <StatusLabel status="success">Enabled</StatusLabel>
: <StatusLabel status="warning">Disabled</StatusLabel>,
value:
params.encryption === 'true' ? (
<StatusLabel status="success">Enabled</StatusLabel>
) : (
<StatusLabel status="warning">Disabled</StatusLabel>
),
},
{ name: 'Provisioner', value: sc.provisioner },
{ name: 'Bound PVs', value: String(pvCount) },
@@ -122,13 +150,19 @@ function protocolNotes(protocol: string): Array<{ name: string; value: React.Rea
const lower = protocol.toLowerCase();
if (lower === 'nfs') {
return [
{ name: 'Prerequisite', value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes' },
{
name: 'Prerequisite',
value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes',
},
{ name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' },
];
}
if (lower === 'nvmeof') {
return [
{ name: 'Prerequisite', value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes' },
{
name: 'Prerequisite',
value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes',
},
{ name: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
];
@@ -151,9 +185,7 @@ export default function StorageClassesPage() {
const history = useHistory();
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>(
location.hash.slice(1) || null
);
const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
useEffect(() => {
setSelectedName(location.hash.slice(1) || null);
@@ -186,7 +218,9 @@ export default function StorageClassesPage() {
<>
<SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
</>
);
@@ -199,7 +233,9 @@ export default function StorageClassesPage() {
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
}
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null;
const selectedSc = selectedName
? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null
: null;
return (
<>
@@ -212,16 +248,30 @@ export default function StorageClassesPage() {
getter: (sc: TnsCsiStorageClass) => (
<button
onClick={() => openSc(sc.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
>
{sc.metadata.name}
</button>
),
},
{ label: 'Protocol', getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol) },
{
label: 'Protocol',
getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol),
},
{ label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' },
{ label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' },
{ label: 'Reclaim Policy', getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—' },
{
label: 'Reclaim Policy',
getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—',
},
{
label: 'Expansion',
getter: (sc: TnsCsiStorageClass) => (
@@ -245,7 +295,15 @@ export default function StorageClassesPage() {
<div
onClick={closeSc}
aria-label="Close panel backdrop"
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 1100,
}}
/>
<StorageClassDetailPanel
sc={selectedSc}
+4 -4
View File
@@ -119,8 +119,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
autoComplete="off"
/>
<div style={HINT_STYLE}>
Generate in TrueNAS UI Credentials API Keys.
Required for real pool capacity data on the Overview page.
Generate in TrueNAS UI Credentials API Keys. Required for real pool capacity
data on the Overview page.
</div>
</div>
),
@@ -137,8 +137,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
style={INPUT_STYLE}
/>
<div style={HINT_STYLE}>
TrueNAS host/IP. If blank, the plugin uses the{' '}
<code>server</code> parameter from your tns-csi StorageClass.
TrueNAS host/IP. If blank, the plugin uses the <code>server</code> parameter from
your tns-csi StorageClass.
</div>
</div>
),
+3 -2
View File
@@ -1,8 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
+73 -19
View File
@@ -15,7 +15,7 @@ import React, { useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiPersistentVolume } from '../api/k8s';
import { findBoundPv, formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import { formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
// ---------------------------------------------------------------------------
// Detail panel
@@ -47,13 +47,46 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
}
`}</style>
<div className={drawerClass}>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>{pv.metadata.name}</h2>
<div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
{pv.metadata.name}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setIsMaximized(!isMaximized)} aria-label={isMaximized ? 'Minimize' : 'Maximize'} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
<button
onClick={() => setIsMaximized(!isMaximized)}
aria-label={isMaximized ? 'Minimize' : 'Maximize'}
style={{
border: 'none',
background: 'transparent',
fontSize: '20px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
>
{isMaximized ? '⊟' : '⊡'}
</button>
<button onClick={onClose} aria-label="Close panel" style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
<button
onClick={onClose}
aria-label="Close panel"
style={{
border: 'none',
background: 'transparent',
fontSize: '24px',
cursor: 'pointer',
padding: '4px 8px',
color: 'var(--mui-palette-text-secondary, #666)',
borderRadius: '4px',
}}
>
×
</button>
</div>
@@ -98,10 +131,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
{ name: 'Volume Handle', value: csi?.volumeHandle ?? '—' },
{ name: 'Protocol', value: formatProtocol(attrs['protocol']) },
{ name: 'Server', value: attrs['server'] ?? '—' },
...(Object.entries(attrs)
...Object.entries(attrs)
.filter(([k]) => !['protocol', 'server'].includes(k))
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
),
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
]}
/>
</SectionBox>
@@ -110,10 +142,16 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
{pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && (
<SectionBox title="Adoption">
<NameValueTable
rows={[{
name: 'Adoptable',
value: <StatusLabel status="success">This volume can be adopted cross-cluster</StatusLabel>,
}]}
rows={[
{
name: 'Adoptable',
value: (
<StatusLabel status="success">
This volume can be adopted cross-cluster
</StatusLabel>
),
},
]}
/>
</SectionBox>
)}
@@ -129,11 +167,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
export default function VolumesPage() {
const location = useLocation();
const history = useHistory();
const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext();
const { persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>(
location.hash.slice(1) || null
);
const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
useEffect(() => {
setSelectedName(location.hash.slice(1) || null);
@@ -166,7 +202,9 @@ export default function VolumesPage() {
<>
<SectionHeader title="TNS-CSI — Volumes" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
</>
);
@@ -187,7 +225,15 @@ export default function VolumesPage() {
getter: (pv: TnsCsiPersistentVolume) => (
<button
onClick={() => openVolume(pv.metadata.name)}
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
style={{
border: 'none',
background: 'transparent',
color: 'var(--link-color, #1976d2)',
cursor: 'pointer',
textDecoration: 'underline',
padding: 0,
font: 'inherit',
}}
>
{pv.metadata.name}
</button>
@@ -240,7 +286,15 @@ export default function VolumesPage() {
<div
onClick={closeVolume}
aria-label="Close panel backdrop"
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
zIndex: 1100,
}}
/>
<VolumeDetailPanel pv={selectedPv} onClose={closeVolume} />
</>
+33 -25
View File
@@ -13,7 +13,9 @@ export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement('div', { 'data-testid': 'section-box', 'data-title': title },
React.createElement(
'div',
{ 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
@@ -33,15 +35,25 @@ export const SimpleTable = ({
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement('table', { 'data-testid': 'simple-table' },
React.createElement('thead', null,
React.createElement('tr', null,
return React.createElement(
'table',
{ 'data-testid': 'simple-table' },
React.createElement(
'thead',
null,
React.createElement(
'tr',
null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement('tbody', null,
React.createElement(
'tbody',
null,
data.map((item, i) =>
React.createElement('tr', { key: i },
React.createElement(
'tr',
{ key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
@@ -49,15 +61,17 @@ export const SimpleTable = ({
);
};
export const NameValueTable = ({
rows,
}: {
rows: Array<{ name: string; value: RC }>;
}) =>
React.createElement('table', { 'data-testid': 'name-value-table' },
React.createElement('tbody', null,
export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
React.createElement(
'table',
{ 'data-testid': 'name-value-table' },
React.createElement(
'tbody',
null,
rows.map(row =>
React.createElement('tr', { key: row.name },
React.createElement(
'tr',
{ key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
@@ -65,13 +79,7 @@ export const NameValueTable = ({
)
);
export const StatusLabel = ({
status,
children,
}: {
status: string;
children?: RC;
}) =>
export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
@@ -80,8 +88,8 @@ export const PercentageBar = ({
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement('div', { 'data-testid': 'percentage-bar' },
data.map(d =>
React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`)
)
React.createElement(
'div',
{ 'data-testid': 'percentage-bar' },
data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
);
@@ -22,23 +22,20 @@ interface StorageClassBenchmarkButtonProps {
};
}
export default function StorageClassBenchmarkButton({ resource }: StorageClassBenchmarkButtonProps) {
export default function StorageClassBenchmarkButton({
resource,
}: StorageClassBenchmarkButtonProps) {
const history = useHistory();
// provisioner is one of the fields Headlamp's StorageClass class exposes as a getter,
// so it's accessible directly. jsonData fallback for safety.
const provisioner =
resource?.provisioner ??
resource?.jsonData?.provisioner;
const provisioner = resource?.provisioner ?? resource?.jsonData?.provisioner;
if (provisioner !== TNS_CSI_PROVISIONER) {
return null;
}
const scName =
resource?.metadata?.name ??
resource?.jsonData?.metadata?.name ??
'';
const scName = resource?.metadata?.name ?? resource?.jsonData?.metadata?.name ?? '';
const handleClick = () => {
// Navigate to benchmark page; user selects the SC in the benchmark form.
@@ -50,16 +50,14 @@ export function buildStorageClassColumns() {
label: 'Protocol',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'protocol');
return typeof p === 'string' ? formatProtocol(p) : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
return <span>{formatProtocol(protocol)}</span>;
@@ -69,16 +67,14 @@ export function buildStorageClassColumns() {
label: 'Pool',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'pool');
return typeof p === 'string' ? p : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const pool = getField(sc, 'parameters', 'pool') as string | undefined;
return <span>{pool ?? '—'}</span>;
@@ -88,16 +84,14 @@ export function buildStorageClassColumns() {
label: 'Server',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'server');
return typeof p === 'string' ? p : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const server = getField(sc, 'parameters', 'server') as string | undefined;
return <span>{server ?? '—'}</span>;
@@ -127,7 +121,9 @@ export function buildPVColumns() {
render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as string | undefined;
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as
| string
| undefined;
return <span>{formatProtocol(protocol)}</span>;
},
},
@@ -144,7 +140,9 @@ export function buildPVColumns() {
render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as string | undefined;
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as
| string
| undefined;
const pool = dataset?.split('/')[0];
return <span>{pool ?? '—'}</span>;
},
+16 -6
View File
@@ -18,7 +18,10 @@ import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
import TnsCsiSettings from './components/TnsCsiSettings';
import BenchmarkPage from './components/BenchmarkPage';
import DriverPodDetailSection from './components/DriverPodDetailSection';
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns';
import {
buildPVColumns,
buildStorageClassColumns,
} from './components/integrations/StorageClassColumns';
import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
import MetricsPage from './components/MetricsPage';
import OverviewPage from './components/OverviewPage';
@@ -192,11 +195,18 @@ registerDetailsViewSection(({ resource }) => {
// takes priority and falls back to the existing one (for mixed-driver tables).
function mergeColumns<T>(
existing: T[],
incoming: Array<{ label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode }>
incoming: Array<{
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
}>
): T[] {
type ObjCol = { label: string; getValue: (r: unknown) => unknown; render: (r: unknown) => React.ReactNode };
const isObjCol = (c: unknown): c is ObjCol =>
typeof c === 'object' && c !== null && 'label' in c;
type ObjCol = {
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
};
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
const result = [...existing];
const toAppend: typeof incoming = [];
for (const col of incoming) {
@@ -206,7 +216,7 @@ function mergeColumns<T>(
result[idx] = {
label: col.label,
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
render: (r: unknown) => col.getValue(r) !== null ? col.render(r) : prev.render(r),
render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
} as unknown as T;
} else {
toAppend.push(col);
+10 -7
View File
@@ -54,7 +54,9 @@ export const sampleCSIDriver: CSIDriver = {
},
};
export function makeSampleStorageClass(overrides?: Partial<TnsCsiStorageClass>): TnsCsiStorageClass {
export function makeSampleStorageClass(
overrides?: Partial<TnsCsiStorageClass>
): TnsCsiStorageClass {
return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io',
@@ -101,7 +103,9 @@ export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCs
export const samplePV = makeSamplePV();
export function makeSamplePVC(overrides?: Partial<TnsCsiPersistentVolumeClaim>): TnsCsiPersistentVolumeClaim {
export function makeSamplePVC(
overrides?: Partial<TnsCsiPersistentVolumeClaim>
): TnsCsiPersistentVolumeClaim {
return {
metadata: {
name: 'my-pvc',
@@ -173,7 +177,9 @@ export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeS
};
}
export function makeSampleSnapshotClass(overrides?: Partial<VolumeSnapshotClass>): VolumeSnapshotClass {
export function makeSampleSnapshotClass(
overrides?: Partial<VolumeSnapshotClass>
): VolumeSnapshotClass {
return {
metadata: {
name: 'tns-snap-class',
@@ -196,9 +202,7 @@ export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMet
{ labels: { protocol: 'iscsi' }, value: 5 },
],
volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 },
@@ -207,4 +211,3 @@ export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMet
...overrides,
};
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
"compilerOptions": {
"module": "esnext",
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]