Compare commits

...

64 Commits

Author SHA1 Message Date
Chris Farhood 11cbe6d7e0 docs: migrate Headlamp install namespace from kube-system to headlamp
Doc-only: redirect all references to Headlamp's own install
namespace from kube-system to headlamp, except:
- Driver namespace (CLAUDE.md) stays kube-system (upstream)
- CSI controller API paths (docs/architecture/overview.md) stay
  kube-system (upstream workload)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-06 14:09:48 +00:00
Chris Farhood 40949dd3b5 fix: drop bogus direct lodash devDependency that conflicted with override
The rebase added "lodash": "4.18.1" as a direct devDependency alongside
the >=4.18.0 override, which npm rejects with EOVERRIDE during the
headlamp-plugin build step. The plugin source does not import lodash;
the override alone is sufficient to patch the transitive CVE.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 02:57:31 +00:00
Chris Farhood f3401bbea3 Regenerate lockfile for lodash override
- Explicitly add lodash@4.18.1 to ensure override is respected
- Regenerated pnpm-lock.yaml with resolved lodash@4.18.1 (CVE fix)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 02:57:31 +00:00
privilegedescalation-engineer[bot] 0af2f24a27 fix: update vite to >=6.4.2 to patch arbitrary file read vulnerability (#28)
Vite versions >=6.0.0 <=6.4.1 are vulnerable to arbitrary file read via
the Vite Dev Server WebSocket (server.fs.deny bypass with queries).

CVE: GHSA-p9ff-h696-f583

Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:44:12 +00:00
privilegedescalation-engineer[bot] 409efe84d5 fix: pass pr_number to dual-approval-check workflow (#27)
Companion PR to privilegedescalation/.github#81

Co-authored-by: Hugh Hackman <hugh@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 03:33:22 +00:00
privilegedescalation-ceo[bot] a5032b23d1 Merge pull request #25 from privilegedescalation/fix/add-package-manager-field
fix: add packageManager field to package.json
2026-03-24 22:45:34 +00:00
privilegedescalation-engineer[bot] c241b8d9d5 release: v1.0.0 (#24)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-24 22:31:55 +00:00
Gandalf the Greybeard 7ae5efda73 fix: add packageManager field to package.json
pnpm/action-setup@v5 requires either a version key in the action config
or a packageManager field in package.json. Add the field to unblock the
release workflow.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 22:12:38 +00:00
privilegedescalation-ceo[bot] fd1d76c932 Merge pull request #19 from privilegedescalation/release/v1.0.0
release: tns-csi v1.0.0
2026-03-24 22:01:22 +00:00
Gandalf the Greybeard dc981feaa4 fix(ci): add missing eslint/prettier/typescript devDeps, fix tsconfig types
Add eslint@^8.57.0, @headlamp-k8s/eslint-config@^0.6.0, prettier@^2.8.8,
typescript@~5.6.2 as explicit devDependencies. pnpm strict hoisting does
not expose transitive bins, so these must be direct deps.

Remove vite/client and vite-plugin-svgr/client from tsconfig types; these
are transitive deps pnpm does not hoist and polaris plugin omits them.
2026-03-24 21:49:17 +00:00
Gandalf the Greybeard 77586a98eb release: prepare v1.0.0
- Bump version from 0.2.7 to 1.0.0 in package.json
- Add missing devDependencies: @mui/material, @types/react, @types/react-dom,
  notistack; upgrade vitest to ^3.2.4 (matching reference polaris plugin)
- Fix vitest.config.mts: add define block for process.env.NODE_ENV="test"
  to resolve act() errors in all 159 component tests
- Remove package-lock.json; adopt pnpm-lock.yaml as canonical lock file
- Update artifacthub-pkg.yml: version 1.0.0, new archive URL, TBD checksum,
  updated changes block describing this release
- Update CHANGELOG.md: add [1.0.0] - 2026-03-24 entry documenting test
  infrastructure fixes, dependency additions, post-0.2.7 CI/workflow changes;
  update version comparison links

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 21:29:47 +00:00
privilegedescalation-ceo[bot] bfe95475c6 Merge pull request #18 from privilegedescalation/feat/renovate-extend-org-config
feat: extend Renovate config from org-level preset
2026-03-24 18:46:11 +00:00
Hugh Hackman f69dfd6356 feat: extend Renovate config from org-level preset
Replaces the duplicated Renovate config with a simple extend from the
org-level preset (privilegedescalation/.github:renovate-config). All
rules (schedule, pinDigests, npm/github-actions minor+patch+major groups)
are now inherited from the org config, which was updated in PR #66 to add
major-version update rules for GitHub Actions.

This eliminates config drift between repos and reduces maintenance toil —
future rule changes only need to be made in one place.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 16:16:22 +00:00
privilegedescalation-ceo[bot] 3c5a837a9d Merge pull request #17 from privilegedescalation/chore/renovate-pin-digests
chore(renovate): add pinDigests for GitHub Actions SHA pinning
2026-03-22 11:10:58 +00:00
privilegedescalation-engineer[bot] f4e4e24b6c chore(renovate): add pinDigests to ensure SHA pinning for GitHub Actions
The org renovate-config.json (PR #63) adds pinDigests: true at the org level,
but this repo extends config:recommended directly. Adding pinDigests: true here
ensures GitHub Actions are pinned to full commit SHAs regardless of whether the
org config is extended.

Related: privilegedescalation/.github#63, PRI-757
2026-03-22 07:16:11 +00:00
privilegedescalation-ceo[bot] fef2c3c3e5 Merge pull request #16 from privilegedescalation/feat/dual-approval-status-check
ci: add dual-approval status check (CTO + QA)
2026-03-22 04:12:31 +00:00
privilegedescalation-engineer[bot] 423282ec6c ci: add dual-approval caller workflow
Calls the shared privilegedescalation/.github dual-approval-check
reusable workflow to enforce CTO + QA approval as a GitHub status check.

Once privilegedescalation/.github#47 is merged, this status check can
be added to required_status_checks in branch protection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-21 23:55:56 +00:00
privilegedescalation-paperclip[bot] 4ae7aa6a91 ci: pass GitHub App token secrets to release workflow (#15)
The shared release workflow now requires RELEASE_APP_ID and
RELEASE_APP_PRIVATE_KEY secrets for PR creation, since the org
blocks GITHUB_TOKEN from creating PRs.

Depends on privilegedescalation/.github#31

Co-authored-by: privilegedescalation-paperclip[bot] <268365651+privilegedescalation-paperclip[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:24:36 +00:00
privilegedescalation-paperclip[bot] c040181509 Merge pull request #14 from privilegedescalation/release/v0.2.7
release: v0.2.7
2026-03-19 21:50:39 +00:00
github-actions[bot] e0037f60d2 release: v0.2.7 2026-03-19 21:39:02 +00:00
privilegedescalation-paperclip[bot] ce5c0da56e fix: add pull-requests write permission to release workflow (#13)
The reusable release workflow declares pull-requests:write but the
caller didn't grant it, causing startup_failure on GitHub Actions.

Co-authored-by: Hugh Hackman [bot] <hugh-hackman[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:32:59 +00:00
null-pointer-nancy[bot] bc59cd7a23 Merge pull request #12 from privilegedescalation/fix/add-missing-devdependencies
fix: add missing devDependencies for CI
2026-03-18 23:43:52 +00:00
Gandalf the Greybeard aa9a0d38fe fix: add missing devDependencies for CI (vitest, testing-library, jsdom, react)
The package.json only listed @kinvolk/headlamp-plugin as a devDependency,
but CI runs tsc, eslint, prettier, and vitest which all require additional
packages. Add the same devDependencies used by the reference kube-vip plugin
and regenerate the lock file.

Also adds peerDependencies for react/react-dom to match the reference plugin
conventions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 23:26:41 +00:00
null-pointer-nancy[bot] 2c2ad720e5 Merge pull request #11 from privilegedescalation/fix/dep-security-overrides-tar-undici
fix: add npm overrides for tar and undici security advisories
2026-03-18 23:14:05 +00:00
Hugh Hackman dc6dee9d4d fix: regenerate package-lock.json for undici override
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 23:08:02 +00:00
Hugh Hackman 5e93973fa7 fix: add npm overrides for tar and undici security advisories
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 22:55:36 +00:00
null-pointer-nancy[bot] 93b5018f60 Merge pull request #9 from privilegedescalation/docs/remove-manual-install
docs: remove manual install sections from README
2026-03-17 12:19:34 +00:00
Gandalf the Greybeard 1b2a6046cd docs: remove manual install sections from README
ArtifactHub plugin installer is the only supported installation method.
Remove manual tarball, sidecar, and build-from-source install options
to align documentation with company policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 12:15:46 +00:00
null-pointer-nancy[bot] ba5c296a13 ci: retrigger after shared workflow fix (#8)
CI retrigger after shared workflow fix (.github PR#14)
2026-03-15 17:54:36 +00:00
Chris Farhood 2d92bce571 Merge pull request #7 from privilegedescalation/policy/artifacthub-only
policy: add ArtifactHub-only installation requirement
2026-03-15 12:43:06 -04:00
null-pointer-nancy[bot] 8fb4c18e8a policy: add ArtifactHub-only installation policy
Per CEO directive, ArtifactHub via the Headlamp plugin installer is the
only approved installation method. No exceptions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-15 16:36:43 +00:00
github-actions[bot] a20a2e29e6 release: v0.2.6 2026-03-09 03:06:22 +00:00
hugh-hackman[bot] dbbabef94a Merge PR #6
* ci: switch to org-level reusable workflows

* chore: retrigger CI after reusable workflows merged

* feat: add workflow_dispatch to CI workflow

---------

Co-authored-by: gandalf-the-greybeard[bot] <gandalf-the-greybeard[bot]@users.noreply.github.com>
Co-authored-by: hugh-hackman[bot] <266376744+hugh-hackman[bot]@users.noreply.github.com>
Co-authored-by: hugh-hackman[bot] <hugh-hackman[bot]@users.noreply.github.com>
2026-03-08 11:16:28 +00:00
gandalf-the-greybeard[bot] 91e1cbd618 Enhance Renovate configuration (#5)
- Target main branch explicitly
- Set weekly schedule (weekends)
- Limit concurrent PRs to 10
- Group minor/patch updates for npm and github-actions to reduce PR noise

Ref: PRI-16

Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:01:21 +00:00
Chris Farhood 47642375ba Merge pull request #4 from privilegedescalation/fix/format-check
fix: apply prettier formatting to pass CI
2026-03-07 11:57:25 -05:00
Gandalf the Greybeard 29f19e2346 fix: apply prettier formatting to pass CI format check
Three files had formatting inconsistencies causing the format:check
CI step to fail on main since 2026-03-04.

Fixes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:55:19 +00:00
Chris Farhood 441b5792f4 Merge pull request #2 from privilegedescalation/fix/repo-metadata
chore: add LICENSE and FUNDING.yml
2026-03-07 11:39:43 -05:00
Chris Farhood b9f8eec748 chore: add FUNDING.yml 2026-03-07 08:02:59 -05:00
Chris Farhood 6c0d8c3ee3 chore: add Apache-2.0 LICENSE file 2026-03-07 08:02:58 -05:00
DevContainer User d39a48a7d0 docs: add architecture decision records
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:49:59 +00:00
DevContainer User 076fa29995 Add artifacthub-headlamp agent skill
Adds Claude Code agent skill for ArtifactHub metadata and publishing,
sourced from headlamp-agent-skills repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:32:17 +00:00
DevContainer User c1c5e8a37d fix: resolve bugs in benchmark lifecycle, snapshot filtering, and dark mode
- Fix PVC bind loop leak on unmount via cancellation ref
- Fix DeleteOptions body structure for proper foreground propagation
- Filter snapshots to tns-csi driver only (was showing all drivers)
- Fix stale closures in Escape key handlers with useCallback
- Add loading state to cleanup delete button, remove window.confirm/alert
- Use CSS custom properties for protocol chart colors (dark mode support)
- Fix all 35 ESLint warnings (import sort, indent, boolean attrs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:47:33 +00:00
DevContainer User 6f35c6c81b Add headlamp-plugin-developer agent skill
Adds Claude Code agent skill for Headlamp plugin development,
sourced from headlamp-agent-skills repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:26:53 +00:00
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
github-actions[bot] ae8d93aaa7 chore: release v0.2.4 2026-02-26 17:37:55 +00:00
Chris Farhood 680289fba4 fix(release): use correct tarball name (tns-csi, not headlamp-tns-csi-plugin)
headlamp-plugin package names the tarball from package.json "name" field
which is "tns-csi", producing tns-csi-VERSION.tar.gz.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:39:06 +00:00
Chris Farhood a20c20a4ec chore: release v0.2.3
Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
2026-02-19 16:08:54 -05:00
76 changed files with 15767 additions and 19766 deletions
+241
View File
@@ -0,0 +1,241 @@
---
name: artifacthub-headlamp
description: Use when working with ArtifactHub metadata, releases, or publishing for Headlamp plugins. Covers artifacthub-repo.yml, artifacthub-pkg.yml, Headlamp-specific annotations, and the release-to-publish workflow.
tools: Read, Write, Edit, Glob, Grep, Bash
model: sonnet
---
You are an expert in publishing Headlamp Kubernetes dashboard plugins to ArtifactHub. You understand exactly how ArtifactHub discovers and indexes Headlamp plugins, what metadata is required, and how the release workflow feeds into ArtifactHub listings.
Before editing any metadata files, read the existing `artifacthub-repo.yml`, `artifacthub-pkg.yml`, and `package.json` to understand the current state.
---
## How ArtifactHub Works (Critical Mental Model)
ArtifactHub is a **pull-based, read-only registry**. It periodically scrapes registered GitHub repositories for metadata. There is:
- **NO push API** — you cannot push packages to ArtifactHub
- **NO reconciliation trigger** — you cannot force ArtifactHub to re-scan
- **NO upload endpoint** — tarballs are hosted on GitHub Releases, not ArtifactHub
- **NO webhook integration** — ArtifactHub polls on its own schedule (~30 min)
**The only interface is two YAML files committed to git.** ArtifactHub reads them, and that's it.
---
## Repository Registration
### artifacthub-repo.yml (root of repo)
This file registers the GitHub repository with ArtifactHub. Created once, rarely changed.
```yaml
# Artifact Hub repository metadata file
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
repositoryID: <uuid> # Assigned by ArtifactHub when you add the repo via the web UI
owners:
- name: <github-username-or-org>
email: <email>
```
**How to get the repositoryID:**
1. Log into artifacthub.io
2. Go to Control Panel → Repositories → Add
3. Select repository kind: "Headlamp plugins"
4. Provide the GitHub repo URL
5. ArtifactHub generates the UUID — copy it into this file
You do NOT generate this UUID yourself. It comes from ArtifactHub's web UI.
---
## Package Metadata
### artifacthub-pkg.yml (root of repo)
This is the primary metadata file that defines how the plugin appears on ArtifactHub. Updated with each release.
```yaml
version: "X.Y.Z" # MUST match package.json version
name: <package-name> # npm package name from package.json
displayName: <Human Readable Name> # Shown on ArtifactHub listing
createdAt: "YYYY-MM-DDTHH:MM:SSZ" # ISO 8601 — update each release
description: >-
Multi-line description of what the plugin does.
Be specific about features and requirements.
license: Apache-2.0
homeURL: https://github.com/<owner>/<repo>
appVersion: "X.Y.Z" # Version of upstream project (optional)
category: <category> # See categories below
keywords:
- headlamp
- kubernetes
- <plugin-specific>
maintainers:
- name: <name>
email: <email>
provider:
name: <name>
links:
- name: GitHub
url: https://github.com/<owner>/<repo>
- name: Issues
url: https://github.com/<owner>/<repo>/issues
changes: # Changelog for this version
- kind: added|changed|fixed|removed
description: "What changed"
annotations: # CRITICAL — Headlamp-specific
headlamp/plugin/archive-url: "https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz"
headlamp/plugin/archive-checksum: "sha256:<checksum>"
headlamp/plugin/version-compat: ">=X.Y.Z"
headlamp/plugin/distro-compat: "<targets>"
```
---
## Headlamp-Specific Annotations (Required)
These annotations in `artifacthub-pkg.yml` are what make ArtifactHub treat the package as a Headlamp plugin:
### headlamp/plugin/archive-url
**Required.** Direct download URL to the plugin tarball on GitHub Releases.
Format: `https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz`
- The tarball is built by `npx @kinvolk/headlamp-plugin build` and then `npx @kinvolk/headlamp-plugin package`
- The `<pkgname>` comes from `package.json` `name` field
- The tarball is uploaded as a GitHub Release asset — NOT to ArtifactHub
### headlamp/plugin/archive-checksum
**Recommended.** SHA256 checksum of the tarball.
Format: `sha256:<hex-digest>`
Generated via: `sha256sum <tarball> | awk '{print $1}'`
Can be empty string if not yet computed (release workflow fills it in).
### headlamp/plugin/version-compat
**Required.** Minimum Headlamp version the plugin works with.
Format: `>=X.Y.Z` (e.g., `>=0.20.0`, `>=0.26`)
### headlamp/plugin/distro-compat
**Required.** Comma-separated list of supported Headlamp deployment targets.
Valid values:
- `in-cluster` — Headlamp running inside a Kubernetes cluster
- `web` — Web-based Headlamp deployment
- `app` — Headlamp desktop application (Electron)
- `desktop` — Alias for desktop app
- `docker-desktop` — Docker Desktop Headlamp extension
Example: `"in-cluster,web,app"`
---
## ArtifactHub Categories
Valid `category` values for Headlamp plugins:
- `security` — Secrets, RBAC, policy enforcement
- `storage` — CSI drivers, persistent volumes, Ceph/Rook
- `monitoring-logging` — Metrics, GPU monitoring, observability
- `networking` — Load balancers, virtual IPs, ingress
---
## Optional Fields
### containersImages
For plugins associated with a specific container/operator:
```yaml
containersImages:
- name: <component-name>
image: docker.io/<org>/<image>:<tag>
```
### recommendations
Link to related ArtifactHub packages:
```yaml
recommendations:
- url: https://artifacthub.io/packages/helm/<repo>/<chart>
```
### install
Custom installation instructions (markdown):
```yaml
install: |
## Install via Headlamp Plugin Manager
...
```
### logoPath
Path to a logo image file in the repo (relative to root).
---
## The Release → ArtifactHub Pipeline
This is the actual flow. There is NO other way to publish:
```
1. Developer triggers release workflow (workflow_dispatch with version)
2. CI runs tests
3. Workflow updates:
- package.json (npm version)
- artifacthub-pkg.yml (version, archive-url, checksum, createdAt, changes)
4. Plugin is built: npx @kinvolk/headlamp-plugin build
5. Plugin is packaged: creates <pkgname>-<version>.tar.gz
6. SHA256 checksum is computed and written to artifacthub-pkg.yml
7. Changes committed to main
8. Git tag created: v<version>
9. GitHub Release created with tarball attached
10. ArtifactHub polls the repo (~30 min) and picks up the new metadata
11. Plugin appears/updates on artifacthub.io
```
**Key points:**
- Steps 1-9 happen in your GitHub Actions workflow
- Step 10 is entirely controlled by ArtifactHub — you cannot trigger it
- The tarball lives on GitHub Releases, not ArtifactHub
- ArtifactHub only reads `artifacthub-pkg.yml` to discover the download URL
---
## Common Mistakes to Avoid
1. **Trying to push/trigger ArtifactHub** — There is no API for this. Just commit metadata and wait.
2. **Version mismatch**`version` in `artifacthub-pkg.yml` MUST match `package.json`. The release workflow should update both.
3. **Wrong archive-url** — Must point to the actual GitHub Release asset URL. Verify the tarball filename matches what the build produces.
4. **Missing checksum** — While optional, missing checksums may cause warnings. The release workflow should compute and write it.
5. **Forgetting createdAt** — Must be updated each release. ArtifactHub uses this for sorting.
6. **Stale changes section** — The `changes` list should reflect the current version's changelog only, not cumulative history.
7. **Assuming ArtifactHub hosts anything** — It's an index/catalog. All artifacts are hosted elsewhere (GitHub Releases).
8. **Trying to generate repositoryID** — This UUID comes from ArtifactHub's web UI when you register the repo. Don't make one up.
---
## Tarball Structure
The plugin tarball built by `@kinvolk/headlamp-plugin` contains:
```
<pkgname>/
main.js # Bundled plugin code
package.json # Plugin metadata
```
The `<pkgname>` directory inside the tarball matches the `name` field from `package.json`.
---
## Validating Metadata
Before committing, check:
1. `version` matches across `package.json` and `artifacthub-pkg.yml`
2. `archive-url` version tag matches the `version` field
3. `name` in `artifacthub-pkg.yml` matches `package.json` `name`
4. `createdAt` is a valid ISO 8601 timestamp
5. All required annotations are present
6. `changes` entries use valid `kind` values: `added`, `changed`, `fixed`, `removed`
+320
View File
@@ -0,0 +1,320 @@
---
name: headlamp-plugin-developer
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
model: sonnet
---
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
---
## Plugin Registration Functions
All from `@kinvolk/headlamp-plugin/lib`:
```typescript
registerRoute({
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
sidebar?: string; // Sidebar entry name to highlight
component: () => JSX.Element; // Arrow function wrapper required
exact?: boolean;
name?: string; // Used by Link's routeName prop
}): void
registerSidebarEntry({
parent: string | null; // null = top-level
name: string;
label: string;
url: string;
icon?: string; // Iconify ID (e.g., 'mdi:lock')
}): void
registerDetailsViewSection(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
// Runs for ALL resource detail views — MUST check resource?.kind
registerDetailsViewHeaderAction(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
registerResourceTableColumnsProcessor(
(args: { id: string; columns: Column[] }) => Column[]
): void
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
registerPluginSettings(
pluginName: string,
component: React.ComponentType<{
data?: Record<string, string | number | boolean>;
onDataChange?: (data: Record<string, string | number | boolean>) => void;
}>,
showSaveButton?: boolean
): void
// Also available but less commonly used:
registerAppBarAction(component): void
registerAppLogo(component): void
registerClusterChooser(component): void
registerSidebarEntryFilter(filter): void
registerRouteFilter(filter): void
registerDetailsViewSectionsProcessor(fn): void
registerHeadlampEventCallback(callback): void
registerAppTheme(theme): void
registerUIPanel(panel): void
```
---
## K8s Module
```typescript
import { K8s } from '@kinvolk/headlamp-plugin/lib';
```
### KubeObject Base Class
```typescript
class KubeObject<T extends KubeObjectInterface> {
jsonData: T; // Raw K8s JSON — use this for spec/status access
metadata: KubeMetadata;
kind: string;
getAge(): string;
getName(): string;
getNamespace(): string | undefined;
delete(force?: boolean): Promise<void>;
patch(body: RecursivePartial<T>): Promise<void>;
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
static className: string;
}
```
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
### ResourceClasses
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
```typescript
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
```
---
## ApiProxy
```typescript
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
ApiProxy.request(
path: string,
options?: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: string; // JSON.stringify'd
isJSON?: boolean; // false for non-JSON (logs, metrics)
headers?: Record<string, string>;
}
): Promise<unknown>
// CRD endpoint factories
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
ApiProxy.apiFactory(group, version, resource): ApiClient
```
**Service proxy URL** (accessing in-cluster services):
```
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
```
---
## CommonComponents
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
`SectionBox` — container with title and optional `headerProps.actions`
`SectionHeader` — standalone header with title and actions array
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
`Link` — internal nav; `routeName` + `params` object
`Loader` — spinner with `title` prop
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
### SimpleTable (non-obvious props)
```typescript
<SimpleTable
data={items}
columns={[
{ label: 'Name', getter: (item) => item.metadata.name },
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
]}
emptyMessage="No items found."
/>
```
### NameValueTable (non-obvious props)
```typescript
<NameValueTable
rows={[
{ name: 'Key', value: 'display value' },
{ name: 'Hidden', value: 'x', hide: true },
]}
/>
```
### ConfigStore
```typescript
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
const store = new ConfigStore<MyConfig>('plugin-name');
store.get(): MyConfig;
store.update(partial: Partial<MyConfig>): void;
store.useConfig(): () => MyConfig;
```
### Pre-bundled (no package.json entry needed)
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
---
## CRD Class Pattern
```typescript
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
const { apiFactoryWithNamespace } = ApiProxy;
const { KubeObject } = K8s.cluster;
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
interface MyResourceInterface extends KubeObjectInterface {
spec: MySpec;
status?: MyStatus;
}
export class MyResource extends KubeObject<MyResourceInterface> {
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
static get className(): string { return 'MyResource'; }
get spec(): MySpec { return this.jsonData.spec; }
get status(): MyStatus | undefined { return this.jsonData.status; }
}
```
---
## Plugin Entry Point Pattern
```typescript
// 1. Sidebar (parent → children)
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
// 2. Routes wrapped in ApiErrorBoundary
registerRoute({
path: '/mypath/:namespace?/:name?',
sidebar: 'my-list',
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
exact: true, name: 'my-resource',
});
// 3. Detail injection wrapped in GenericErrorBoundary
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Secret') return null;
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
});
// 4. Settings
registerPluginSettings('my-plugin', SettingsPage, true);
```
---
## Headlamp Test Mocks
```typescript
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
SimpleTable: ({ data, columns }: any) => (
<table><tbody>{data.map((d: any, i: number) =>
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
)}</tbody></table>
),
NameValueTable: ({ rows }: any) => (
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
)}</dl>
),
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
Link: ({ children }: any) => <a>{children}</a>,
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
}));
```
---
## Theming & Dark Mode
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
### Required CSS variables for inline styles
```typescript
// Text
color: 'var(--mui-palette-text-primary)'
color: 'var(--mui-palette-text-secondary, #666)'
// Backgrounds
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
// Borders
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
// Interactive
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
color: 'var(--mui-palette-primary-contrastText, #fff)'
// Disabled states
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
// Links
color: 'var(--link-color, #1976d2)'
```
### Common mistakes to avoid
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
### Form inputs in custom components
```typescript
const inputStyle = {
border: '1px solid var(--mui-palette-divider, #ccc)',
borderRadius: '4px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
};
```
---
## Code Quality Rules
1. **Functional components only** — no class components (except ErrorBoundary)
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
3. **Headlamp CommonComponents + MUI**`@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
4. **Inline CSS only**`style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
5. **Accessibility**`aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
6. **Cancellation safety** — async effects must check a `cancelled` flag
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
9. Run `npm run tsc` and `npm test` after implementation changes
+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'],
};
+1
View File
@@ -0,0 +1 @@
github: [privilegedescalation]
+4 -28
View File
@@ -5,33 +5,9 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_call:
jobs:
lint-and-test:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Lint
run: npx eslint --ext .ts,.tsx src/
- name: Type-check
run: npx tsc --noEmit
- name: Run unit tests
run: npm test
ci:
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
+20
View File
@@ -0,0 +1,20 @@
name: Dual Approval (CTO + QA)
# Calls the shared dual-approval-check workflow.
# Passes when both privilegedescalation-cto and privilegedescalation-qa
# have approved the PR. Add "Dual Approval (CTO + QA)" to required_status_checks
# in branch protection to enforce this gate.
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [main]
types: [opened, reopened, synchronize]
jobs:
dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+12 -100
View File
@@ -4,108 +4,20 @@ 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
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
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)"
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}/headlamp-tns-csi-plugin-${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: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Package plugin
run: npx @kinvolk/headlamp-plugin package
- name: Validate tarball name
run: |
EXPECTED="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
ACTUAL=$(ls *.tar.gz)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
exit 1
fi
echo "✓ Tarball name validated: $ACTUAL"
- name: Compute checksum
id: compute_checksum
run: |
TARBALL="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: sha256:${CHECKSUM}"
- name: Update checksum in metadata
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 }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ inputs.version }}"
files: headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz
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"
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
version: ${{ inputs.version }}
upstream-repo: fenio/tns-csi
+7
View File
@@ -0,0 +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');
+61 -1
View File
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0] - 2026-03-24
### Added
- **Missing devDependencies** — added `@mui/material`, `@types/react`, `@types/react-dom`, and `notistack` to devDependencies so the full test suite resolves correctly; upgraded `vitest` to `^3.2.4`
### Changed
- **Test infrastructure fix** — added `define: { 'process.env.NODE_ENV': '"test"' }` to `vitest.config.mts` to prevent React production-build `act()` errors; all 159 component tests now pass
- **Version bump** — bumped package version from 0.2.7 to 1.0.0 (stable release)
- **Lock file** — removed `package-lock.json`; `pnpm-lock.yaml` is now the canonical lock file
- **Renovate config** — extended from org-level preset (PR #18)
- **GitHub Actions SHA pinning** — added `pinDigests` to Renovate config for SHA-pinned Actions (PR #17)
- **Dual-approval caller workflow** — added dual-approval status check workflow to CI (PR #16)
- **Release workflow** — GitHub App token secrets now passed to release workflow (PR #15)
## [0.2.6] - 2026-03-04
### Fixed
- **PVC bind loop leak** — benchmark page's PVC wait loop now checks a cancellation ref on unmount, preventing leaked async work after navigation
- **DeleteOptions body** — `deleteJob` now sends correct Kubernetes `DeleteOptions` with `apiVersion` and `kind` fields so foreground propagation is honored
- **Snapshot filtering** — `TnsCsiDataContext` now filters volume snapshots and snapshot classes to only tns-csi driver ones using `filterTnsCsiVolumeSnapshots` and `isTnsCsiVolumeSnapshotClass` (previously showed all drivers' snapshots)
- **Stale closures in Escape handlers** — `closeSc` / `closeVolume` in StorageClassesPage and VolumesPage wrapped in `useCallback` with proper deps, removing `eslint-disable` suppressions
- **Cleanup button double-click** — benchmark cleanup "Delete Job + PVC" button now has a `cleaningUp` loading state with disabled styling, replacing unsafe `window.confirm`/`window.alert` calls
- **Dark mode protocol colors** — replaced hardcoded hex colors in OverviewPage protocol chart with `var(--mui-palette-*)` tokens
- **Import style consistency** — unified `React.useMemo` to destructured `useMemo` in OverviewPage
- **Lint warnings** — fixed all 35 ESLint warnings (import sorting, indentation, boolean attributes, line length, chained call newlines)
## [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
- **Package name** — renamed from `headlamp-tns-csi-plugin` to `tns-csi` so the plugin displays correctly in Headlamp's Plugins list
## [0.2.2] - 2026-02-19
### Fixed
@@ -62,7 +118,11 @@ 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.2...HEAD
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.7...v1.0.0
[0.2.6]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.5...v0.2.6
[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
[0.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
+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
+24
View File
@@ -0,0 +1,24 @@
# Installation Policy
## Approved Installation Method
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
No other installation method is acceptable. This includes but is not limited to:
- Direct installation from GitHub release assets
- Manual npm pack / tarball extraction
- initContainer workarounds that bypass Artifact Hub
- Direct file copy or sidecar injection
## Enforcement
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
## Rationale
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
---
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-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.
+14 -35
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)
@@ -47,9 +47,14 @@ The tns-csi driver must be deployed in `kube-system` with the standard `app.kube
## Installing
### Option 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin). Install via the Headlamp UI:
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin). Configure Headlamp via Helm:
1. Go to **Settings → Plugins**
2. Click **Catalog** tab
3. Search for "TNS CSI" or "TrueNAS"
4. Click **Install**
Or configure Headlamp via Helm:
```yaml
config:
@@ -57,34 +62,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
```
Or install via the Headlamp UI:
1. Go to **Settings → Plugins**
2. Click **Catalog** tab
3. Search for "TNS CSI" or "TrueNAS"
4. Click **Install**
### Option 2: Manual Tarball Install
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/
```
### Option 3: Build from Source
```bash
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
cd headlamp-tns-csi-plugin
npm install
npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
- 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 / Security Setup
@@ -185,10 +164,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 +243,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 +265,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
+3 -3
View File
@@ -91,7 +91,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: kube-system # adjust to your Headlamp namespace
namespace: headlamp # adjust to your Headlamp namespace
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
@@ -143,7 +143,7 @@ The Kubernetes API server performs the pod proxy hop, so policies should permit
### Service Account (Default)
Headlamp runs with a dedicated service account (`headlamp` in `kube-system`). All users share the same RBAC permissions.
Headlamp runs with a dedicated service account (`headlamp` in `headlamp`). All users share the same RBAC permissions.
**Security Considerations:**
- All users have identical access to plugin functionality including Benchmark
@@ -223,7 +223,7 @@ All API requests are logged in Kubernetes API audit logs (if enabled). Pod proxy
"verb": "get",
"requestURI": "/api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics",
"user": {
"username": "system:serviceaccount:kube-system:headlamp"
"username": "system:serviceaccount:headlamp:headlamp"
}
}
```
+18 -8
View File
@@ -1,4 +1,4 @@
version: "0.2.2"
version: "1.0.0"
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.17.4"
keywords:
- headlamp
@@ -47,13 +47,23 @@ links:
url: https://github.com/longhorn/kbench
changes:
- kind: fixed
description: "Duplicate Protocol/Pool columns on mixed-driver clusters now merged into a single shared column"
- kind: fixed
description: "Plugin settings entry renamed from headlamp-tns-csi-plugin to tns-csi"
- kind: added
description: "Stable v1.0.0 release marking production readiness"
- kind: added
description: "Missing devDependencies for test infrastructure (@mui/material, @types/react, @types/react-dom, notistack, vitest upgraded to ^3.2.4)"
- kind: changed
description: "vitest.config.mts: added define block to set process.env.NODE_ENV=test, fixing act() errors in all component tests"
- kind: changed
description: "Renovate config extended from org-level preset"
- kind: changed
description: "GitHub Actions SHA pinning via pinDigests in Renovate"
- kind: changed
description: "Dual-approval caller workflow added to CI"
- kind: changed
description: "GitHub App token secrets passed to release workflow"
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.2/headlamp-tns-csi-plugin-0.2.2.tar.gz"
headlamp/plugin/archive-checksum: "sha256:fa6506d581ea5a609598912596995f3dd2b2501ee4f66aff5f6cd93beb525549"
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz"
headlamp/plugin/archive-checksum: sha256:e38846ceb17a79438f8aecc50f22920b0efa7456f3ebb3e628d89856af83ad01
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
@@ -0,0 +1,59 @@
# ADR 001: React Context for Shared CSI Driver State
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
The TNS CSI plugin needs to share data across multiple views: Overview, StorageClasses, Volumes, Snapshots, Metrics, and Benchmark pages, plus detail view sections for PVC, PV, and Pod. Data comes from three tracks:
1. **Headlamp `useList()` hooks** — StorageClass, PersistentVolume, PersistentVolumeClaim
2. **`ApiProxy.request()`** — CSIDriver resource, controller/node pods, VolumeSnapshotClasses, and VolumeSnapshots
3. **TrueNAS WebSocket API** — Pool capacity stats (optional, when API key is configured in settings)
The context exposes: `csiDriver`, `driverInstalled`, `storageClasses`, `persistentVolumes`, `persistentVolumeClaims`, `controllerPods`, `nodePods`, `volumeSnapshots`, `volumeSnapshotClasses`, `snapshotCrdAvailable`, `poolStats`, `poolStatsError`, `loading`, `error`, `refresh`.
---
## Decision
Use a single `TnsCsiDataProvider` React Context wrapping all routes. Three-track data fetching:
1. `useList()` for standard Kubernetes resources (StorageClass, PV, PVC)
2. `ApiProxy.request()` in `useEffect` for CSI-specific resources and snapshots
3. TrueNAS WebSocket client for pool capacity stats (only when API key is configured in settings)
---
## Consequences
- ✅ Single fetch point eliminates duplicate API calls
- ✅ All views share consistent data — no stale data across pages
- ✅ Three-track strategy handles different API requirements cleanly
- ✅ TrueNAS integration is opt-in — plugin works without it
- ⚠️ Large context with many fields increases cognitive overhead
- ⚠️ TrueNAS WebSocket adds complexity to the data layer
- ⚠️ All consumers re-render on any data change — mitigated by infrequent updates (polling interval)
---
## Alternatives Considered
1. **Separate contexts per data domain** — Rejected. Data is cross-referenced (PVCs filter by StorageClass provisioner), so splitting contexts would require cross-context coordination.
2. **Custom hooks without context** — Rejected. Would duplicate fetches across 6 pages, leading to redundant API calls and inconsistent data.
3. **Redux/Zustand** — Rejected. Not available in the Headlamp plugin environment.
---
## Changelog
| Date | Change |
|------|--------|
| 2026-03-05 | Initial decision |
@@ -0,0 +1,60 @@
# ADR 002: Read-Only Plugin with Benchmark Exception
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
The plugin is primarily a read-only observability tool for TNS CSI storage. However, it includes a Benchmark feature that runs kbench (FIO-based storage benchmarks) against storage classes. Running benchmarks requires creating temporary Kubernetes resources: a PVC for the test volume and a Job running the kbench container.
These resources are tagged with `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` for lifecycle tracking. The benchmark workflow includes:
1. `buildPvcManifest()` — Create PVC spec for test volume
2. `createPvc()` — Create the PVC in the cluster
3. `buildJobManifest()` — Create Job spec for kbench container
4. `createJob()` — Create the Job in the cluster
5. Poll for Job completion
6. `fetchKbenchLogs()` — Retrieve benchmark output from pod logs
7. `parseKbenchLog()` — Parse FIO results from kbench output
8. `deleteJob()` — Clean up the benchmark Job
9. `deletePvc()` — Clean up the test PVC
---
## Decision
The plugin is read-only for all storage observability features. The sole exception is the Benchmark feature, which creates and deletes temporary PVC + Job resources. All created resources are labeled for identification and cleaned up after benchmark completion. The benchmark is triggered explicitly by user action (button on StorageClass detail page via `registerDetailsViewHeaderAction`).
---
## Consequences
- ✅ Minimal RBAC requirements for normal operation (read-only)
- ✅ Benchmark is opt-in and requires explicit user action
- ✅ Resources are auto-cleaned after benchmark completion
-`managed-by` label enables easy identification of plugin-created resources
- ⚠️ Requires additional RBAC permissions (create/delete Jobs and PVCs) for benchmark feature
- ⚠️ Failed cleanup leaves orphaned resources — mitigated by `listKbenchJobs()` which finds orphaned resources by label for manual cleanup
---
## Alternatives Considered
1. **No benchmark feature (fully read-only)** — Rejected. Storage performance testing is a key use case for storage administrators evaluating CSI drivers.
2. **External benchmark tool with results import** — Rejected. Poor user experience requiring context-switching between tools.
3. **Benchmark as a separate plugin** — Rejected. Benchmark results are tied to storage class context and benefit from shared data in the plugin.
---
## Changelog
| Date | Change |
|------|--------|
| 2026-03-05 | Initial decision |
@@ -0,0 +1,55 @@
# ADR 003: Graceful Degradation for Optional CRDs
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
The plugin uses VolumeSnapshot and VolumeSnapshotClass CRDs from `snapshot.storage.k8s.io/v1`. These CRDs are part of the Kubernetes Volume Snapshot feature, which is optional — not all clusters have the snapshot controller installed.
The plugin should work on clusters without snapshot support, showing storage classes, volumes, metrics, and benchmarks without the snapshots page. The CRD fetch is wrapped in `try/catch`; if it fails, the `snapshotCrdAvailable` flag is set to `false`.
---
## Decision
Implement graceful degradation for optional CRDs. The snapshot API calls are wrapped in `try/catch` within the data context. When the snapshot CRDs are not installed:
- `snapshotCrdAvailable` is set to `false`
- Snapshot-related data arrays are empty
- The Snapshots page shows an informational message rather than an error
- All other plugin features remain fully functional
---
## Consequences
- ✅ Plugin works on clusters without snapshot CRDs installed
- ✅ No error state for missing optional features — clean informational messaging
- ✅ Clear user feedback about what features are available
- ✅ Core features (volumes, storage classes, metrics, benchmarks) always work
- ⚠️ Two code paths (with/without snapshots) to maintain and test
- ⚠️ Snapshot data might silently fail for reasons other than missing CRDs (e.g., RBAC issues)
---
## Alternatives Considered
1. **Require snapshot CRDs (hard dependency)** — Rejected. Too restrictive; many clusters do not have the snapshot controller installed.
2. **Feature detection via API discovery before fetching** — Considered, but `try/catch` on the actual fetch is simpler and catches all failure modes including RBAC restrictions.
3. **Disable snapshots page entirely when CRDs missing** — Rejected. Showing an informational message explaining how to enable snapshots is better UX than silently hiding the page.
---
## Changelog
| Date | Change |
|------|--------|
| 2026-03-05 | Initial decision |
@@ -0,0 +1,54 @@
# ADR 004: URL Hash-Based Detail Panel State
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
Several pages need to show detail panels for selected resources (e.g., clicking a PVC row shows PVC details). The detail panel state (which resource is selected) needs to be shareable via URL and survive page refresh. Options include:
- **React state** — Lost on refresh, not shareable
- **URL query parameters** — May cause full page reload, potential conflicts with Headlamp routing
- **URL hash fragments** — Client-side only, no reload, compatible with SPA routing
---
## Decision
Use URL hash fragments to encode detail panel state. When a user selects a resource, the hash is updated (e.g., `#pvc/namespace/name`). On page load, the hash is parsed to restore the selected resource. This enables deep-linking to specific resource details and browser back/forward navigation.
---
## Consequences
- ✅ Deep-linkable resource details — users can share URLs pointing to specific resources
- ✅ Survives page refresh without losing selected resource
- ✅ Browser back/forward navigation works naturally
- ✅ No server round-trip — hash changes are purely client-side
- ✅ Compatible with Headlamp's client-side routing
- ⚠️ Hash-based state is not a standard React pattern — requires team familiarity
- ⚠️ Requires manual hash parsing and updating logic
- ⚠️ Hash changes don't trigger React re-renders by default — requires `hashchange` event listener
---
## Alternatives Considered
1. **React state only** — Rejected. State is lost on refresh and cannot be shared via URL.
2. **URL query parameters** — Rejected. May conflict with Headlamp's routing and could trigger unintended navigation behavior.
3. **Separate detail routes** — Rejected. Too heavyweight for inline detail panels; would require full page transitions for what should be a panel toggle.
---
## Changelog
| Date | Change |
|------|--------|
| 2026-03-05 | Initial decision |
@@ -0,0 +1,53 @@
# ADR 005: Prometheus Metrics via Pod Proxy
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
The plugin displays CSI driver metrics (operation latencies, error rates, volume stats). The CSI driver pods expose a Prometheus metrics endpoint on port 8080 in the standard text exposition format. The plugin needs to fetch and parse these metrics. Options:
- **Query a Prometheus server** — Requires Prometheus to be installed in the cluster
- **Scrape the pod directly via Kubernetes pod proxy** — No additional dependencies
- **Use a metrics aggregation service** — Requires additional infrastructure
---
## Decision
Fetch metrics directly from the CSI driver pod's `/metrics` endpoint via Kubernetes pod proxy (`ApiProxy.request` to `/api/v1/namespaces/{ns}/pods/{pod}:8080/proxy/metrics`). Parse the Prometheus text exposition format in-browser using a custom parser in `metrics.ts`. No dependency on a Prometheus server installation.
---
## Consequences
- ✅ Works without Prometheus server installed — no additional infrastructure dependency
- ✅ Direct from source with no aggregation delay — metrics are always current
- ✅ Leverages existing Kubernetes API authentication and authorization
- ✅ No additional service dependencies to configure or maintain
- ⚠️ Custom Prometheus text format parser to maintain — mitigated by the parser being well-tested
- ⚠️ Only gets metrics from one pod at a time (no aggregation across replicas) — acceptable since CSI controller typically runs one replica
- ⚠️ No historical data (point-in-time only) — users needing historical trends should use a full Prometheus setup
---
## Alternatives Considered
1. **Query Prometheus server via service proxy** (like the intel-gpu plugin) — Rejected. Would require Prometheus to be installed, adding a hard infrastructure dependency.
2. **Use a metrics library (prom-client) for parsing** — Rejected. Adds a runtime dependency for a relatively simple parsing task.
3. **JSON metrics endpoint instead of Prometheus format** — Rejected. The CSI driver only exposes Prometheus text format; a JSON endpoint would require changes to the driver itself.
---
## Changelog
| Date | Change |
|------|--------|
| 2026-03-05 | Initial decision |
+44
View File
@@ -0,0 +1,44 @@
# Architecture Decision Records
## What is an ADR?
An Architecture Decision Record (ADR) captures an important architectural decision made along with its context and consequences. ADRs are a lightweight way to document the "why" behind technical choices, ensuring that future contributors understand the reasoning behind the current architecture.
## Format
This project uses the [Nygard-style ADR format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions):
- **Title**: Short noun phrase describing the decision
- **Status**: Proposed | Accepted | Deprecated | Superseded
- **Date**: When the decision was made
- **Context**: What is the issue that we're seeing that motivates this decision?
- **Decision**: What is the change that we're proposing and/or doing?
- **Consequences**: What becomes easier or more difficult to do because of this change?
- **Alternatives Considered**: What other options were evaluated?
## Index
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [001](001-react-context-state.md) | React Context for Shared CSI Driver State | Accepted | 2026-03-05 |
| [002](002-read-only-benchmark-exception.md) | Read-Only Plugin with Benchmark Exception | Accepted | 2026-03-05 |
| [003](003-optional-crd-degradation.md) | Graceful Degradation for Optional CRDs | Accepted | 2026-03-05 |
| [004](004-url-hash-detail-panels.md) | URL Hash-Based Detail Panel State | Accepted | 2026-03-05 |
| [005](005-prometheus-pod-proxy.md) | Prometheus Metrics via Pod Proxy | Accepted | 2026-03-05 |
## Creating New ADRs
1. Copy an existing ADR as a template
2. Assign the next sequential number (e.g., `006-your-title.md`)
3. Fill in all sections: Status, Date, Context, Decision, Consequences, Alternatives
4. Set the status to `Proposed` until reviewed
5. Update this README index table
6. Submit as part of a pull request for review
ADRs should not be deleted. If a decision is reversed, create a new ADR that supersedes the old one and update the old ADR's status to `Superseded by [ADR NNN](NNN-title.md)`.
## References
- [Michael Nygard - Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
- [ADR GitHub Organization](https://adr.github.io/)
- [Joel Parker Henderson - Architecture Decision Record](https://github.com/joelparkerhenderson/architecture-decision-record)
+1 -1
View File
@@ -28,7 +28,7 @@ The TNS-CSI plugin is a single-page React application bundled as a Headlamp plug
│ HTTPS
┌─────────────────────────────────────────────────────┐
│ Headlamp Pod (kube-system) │
│ Headlamp Pod (headlamp) │
│ │
│ Headlamp UI server + API proxy │
│ (forwards requests using service account token │
+11 -11
View File
@@ -9,11 +9,11 @@ helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
helm repo update
helm install headlamp headlamp/headlamp \
--namespace kube-system \
--namespace headlamp \
--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
@@ -44,7 +44,7 @@ Apply:
```bash
helm install headlamp headlamp/headlamp \
--namespace kube-system \
--namespace headlamp \
-f headlamp-values.yaml
```
@@ -64,7 +64,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: kube-system
namespace: headlamp
spec:
interval: 1h
chart:
@@ -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)
@@ -122,7 +122,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: kube-system
namespace: headlamp
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
@@ -136,7 +136,7 @@ To upgrade to a new plugin version, update the `url` in your values and apply:
```bash
helm upgrade headlamp headlamp/headlamp \
--namespace kube-system \
--namespace headlamp \
-f headlamp-values.yaml
```
+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.
+15 -15
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:**
@@ -32,7 +32,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: kube-system
namespace: headlamp
spec:
chart:
spec:
@@ -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**.
+5 -5
View File
@@ -27,14 +27,14 @@ 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:
```bash
helm upgrade headlamp headlamp/headlamp -f values.yaml -n kube-system
helm upgrade headlamp headlamp/headlamp -f values.yaml -n headlamp
```
## Step 2: Configure RBAC
@@ -70,7 +70,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: kube-system
namespace: headlamp
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
@@ -78,7 +78,7 @@ roleRef:
EOF
```
Adjust `name: headlamp` and `namespace: kube-system` to match your Headlamp service account.
Adjust `name: headlamp` and `namespace: headlamp` to match your Headlamp service account.
## Step 3: Verify
+2 -2
View File
@@ -77,7 +77,7 @@ If a page shows a loading spinner indefinitely:
1. **Check browser console** for errors (F12 → Console)
2. **Check network tab** for failed API requests (look for 403, 404, 500)
3. **Check Headlamp pod logs**: `kubectl logs -n kube-system -l app.kubernetes.io/name=headlamp`
3. **Check Headlamp pod logs**: `kubectl logs -n headlamp -l app.kubernetes.io/name=headlamp`
4. **Try refreshing** — the watch connection may have been interrupted
## Common API Errors
@@ -102,7 +102,7 @@ Look for errors related to `tns-csi`, `headlamp-plugin`, or Kubernetes API paths
**Headlamp pod logs:**
```bash
kubectl logs -n kube-system -l app.kubernetes.io/name=headlamp --tail=100
kubectl logs -n headlamp -l app.kubernetes.io/name=headlamp --tail=100
```
**tns-csi controller logs:**
+2 -2
View File
@@ -8,10 +8,10 @@ The Benchmark page requires permissions to create and delete Jobs and PVCs:
```bash
kubectl auth can-i create jobs -n <benchmark-namespace> \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
kubectl auth can-i create persistentvolumeclaims -n <benchmark-namespace> \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
```
Apply the additional permissions if missing — see [RBAC Issues](rbac.md) or [SECURITY.md](../../SECURITY.md).
+1 -1
View File
@@ -47,7 +47,7 @@ This requires `get` on `pods/proxy` in `kube-system`:
```bash
kubectl auth can-i get pods/proxy \
-n kube-system \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
```
### 5. Network Policies
+3 -3
View File
@@ -11,16 +11,16 @@ Use `kubectl auth can-i` to check specific permissions:
```bash
# Check if the Headlamp service account can list StorageClasses
kubectl auth can-i list storageclasses \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
# Check pod proxy access (for metrics)
kubectl auth can-i get pods/proxy \
-n kube-system \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
# Check snapshot access
kubectl auth can-i list volumesnapshots \
--as=system:serviceaccount:kube-system:headlamp
--as=system:serviceaccount:headlamp:headlamp
```
### Applying the Required RBAC
+2 -2
View File
@@ -47,7 +47,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp # adjust to your Headlamp service account name
namespace: kube-system # adjust to your Headlamp namespace
namespace: headlamp # adjust to your Headlamp namespace
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
@@ -99,7 +99,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: headlamp
namespace: kube-system
namespace: headlamp
roleRef:
kind: Role
name: headlamp-tns-csi-benchmark
Binary file not shown.
-18188
View File
File diff suppressed because it is too large Load Diff
+29 -3
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-tns-csi-plugin",
"version": "0.2.2",
"name": "tns-csi",
"version": "1.0.0",
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
"repository": {
"type": "git",
@@ -12,6 +12,7 @@
"homepage": "https://github.com/privilegedescalation/headlamp-tns-csi-plugin#readme",
"author": "privilegedescalation",
"license": "Apache-2.0",
"packageManager": "pnpm@10.32.1",
"scripts": {
"start": "headlamp-plugin start",
"build": "headlamp-plugin build",
@@ -24,7 +25,32 @@
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.13.0"
"@headlamp-k8s/eslint-config": "^0.6.0",
"@kinvolk/headlamp-plugin": "^0.13.0",
"@mui/material": "^5.15.14",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"eslint": "^8.57.0",
"jsdom": "^24.0.0",
"notistack": "^3.0.0",
"prettier": "^2.8.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"typescript": "~5.6.2",
"vitest": "^3.2.4"
},
"overrides": {
"tar": "^7.5.11",
"undici": "^7.24.3",
"vite": ">=6.4.2"
}
}
+11990
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>privilegedescalation/.github:renovate-config"]
}
+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 () => ({});
}
},
}));
+15 -6
View File
@@ -13,12 +13,14 @@ import {
filterTnsCsiPersistentVolumes,
filterTnsCsiPVCs,
filterTnsCsiStorageClasses,
filterTnsCsiVolumeSnapshots,
isKubeList,
isTnsCsiVolumeSnapshotClass,
TNS_CSI_PROVISIONER,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
TNS_CSI_PROVISIONER,
VolumeSnapshot,
VolumeSnapshotClass,
} from './k8s';
@@ -109,9 +111,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);
@@ -151,14 +153,19 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
);
if (!cancelled && isKubeList(vscList)) {
setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]);
const allSnapshotClasses = vscList.items as VolumeSnapshotClass[];
const tnsCsiSnapshotClasses = allSnapshotClasses.filter(isTnsCsiVolumeSnapshotClass);
setVolumeSnapshotClasses(tnsCsiSnapshotClasses);
setSnapshotCrdAvailable(true);
const tnsCsiClassNames = new Set(tnsCsiSnapshotClasses.map(c => c.metadata.name));
const vsList = await ApiProxy.request(
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
);
if (!cancelled && isKubeList(vsList)) {
setVolumeSnapshots(vsList.items as VolumeSnapshot[]);
const allSnapshots = vsList.items as VolumeSnapshot[];
setVolumeSnapshots(filterTnsCsiVolumeSnapshots(allSnapshots, tnsCsiClassNames));
}
}
} catch {
@@ -203,7 +210,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>;
+41 -32
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');
@@ -268,16 +269,19 @@ export async function fetchKbenchLogs(
export async function deleteJob(jobName: string, namespace: string): Promise<void> {
await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, {
method: 'DELETE',
body: JSON.stringify({ propagationPolicy: 'Foreground' }),
body: JSON.stringify({
apiVersion: 'v1',
kind: 'DeleteOptions',
propagationPolicy: 'Foreground',
}),
headers: { 'Content-Type': 'application/json' },
});
}
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 +370,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 +392,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`
)
);
}
};
-80
View File
@@ -1,80 +0,0 @@
/**
* AppBarDriverBadge — registerAppBarAction driver health badge.
*
* Displays "tns-csi: N/N" in the Headlamp top nav bar showing
* ready controller + node pod counts. Color-coded:
* green = all pods ready
* orange = some pods degraded
* red = no pods ready or driver missing
*
* Returns null if the driver is not installed (no CSIDriver object) --
* no clutter in clusters where tns-csi is absent.
*
* Wrapped in TnsCsiDataProvider at registration time (index.tsx).
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { isPodReady, TnsCsiPod } from '../api/k8s';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
function countReady(pods: TnsCsiPod[]): number {
return pods.filter(isPodReady).length;
}
function getBadgeColor(ready: number, total: number): string {
if (total === 0) return '#9e9e9e';
if (ready === total) return '#4caf50';
if (ready > 0) return '#ff9800';
return '#f44336';
}
export default function AppBarDriverBadge() {
const { driverInstalled, controllerPods, nodePods, loading } = useTnsCsiContext();
const history = useHistory();
if (loading || !driverInstalled) {
return null;
}
const controllerReady = countReady(controllerPods);
const controllerTotal = controllerPods.length;
const nodeReady = countReady(nodePods);
const nodeTotal = nodePods.length;
const totalReady = controllerReady + nodeReady;
const totalPods = controllerTotal + nodeTotal;
const color = getBadgeColor(totalReady, totalPods);
const handleClick = () => {
history.push('/tns-csi');
};
const labelText = `tns-csi: ${controllerReady}/${controllerTotal}c ${nodeReady}/${nodeTotal}n`;
const ariaLabel = `TNS-CSI driver: ${controllerReady} of ${controllerTotal} controller pods ready, ${nodeReady} of ${nodeTotal} node pods ready`;
return (
<button
onClick={handleClick}
style={{
cursor: 'pointer',
marginRight: '8px',
padding: '4px 12px',
borderRadius: '16px',
border: 'none',
backgroundColor: color,
color: 'white',
fontSize: '13px',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
aria-label={ariaLabel}
title={ariaLabel}
>
<span>tns-csi: {controllerReady}/{controllerTotal}c {nodeReady}/{nodeTotal}n</span>
</button>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({}),
},
ConfigStore: class {
get() {
return {};
}
set() {}
update() {}
useConfig() {
return () => ({});
}
},
}));
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async importOriginal => {
const actual = await importOriginal<typeof import('../api/kbench')>();
return {
...actual,
createPvc: vi.fn().mockResolvedValue(undefined),
createJob: vi.fn().mockResolvedValue(undefined),
deleteJob: vi.fn().mockResolvedValue(undefined),
deletePvc: vi.fn().mockResolvedValue(undefined),
getJobPhase: vi.fn().mockResolvedValue({ phase: 'Active', job: {} }),
fetchKbenchLogs: vi.fn().mockResolvedValue(''),
listKbenchJobs: vi.fn().mockResolvedValue([]),
generateJobName: vi.fn().mockReturnValue('kbench-abc123'),
generatePvcName: vi.fn().mockReturnValue('kbench-abc123-pvc'),
};
});
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { createJob, createPvc, listKbenchJobs } from '../api/kbench';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('BenchmarkPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listKbenchJobs).mockResolvedValue([]);
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<BenchmarkPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('renders benchmark guide section', () => {
mockContext();
render(<BenchmarkPage />);
expect(screen.getByText('Benchmark Guide')).toBeInTheDocument();
expect(screen.getByText(/Do not cancel mid-run/)).toBeInTheDocument();
});
it('renders Run New Benchmark form', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
expect(screen.getByText('Run New Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Select storage class for benchmark')).toBeInTheDocument();
});
it('populates SC dropdown with storage class names', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
mockContext({ storageClasses: [sc1, sc2] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options.length).toBe(2);
expect(select.options[0].value).toBe('sc-a');
expect(select.options[1].value).toBe('sc-b');
});
it('shows "No tns-csi storage classes found" when empty', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options[0].text).toContain('No tns-csi storage classes');
});
it('shows confirmation dialog when Run Benchmark is clicked', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByText(/~33Gi PVC/)).toBeInTheDocument();
});
it('cancels confirmation dialog', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
fireEvent.click(screen.getByLabelText('Cancel benchmark'));
expect(screen.queryByText('Confirm Benchmark')).not.toBeInTheDocument();
});
it('starts benchmark on confirmation and calls createPvc', async () => {
vi.useFakeTimers();
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
// PVC bind check
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
expect(vi.mocked(createPvc)).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('shows failed state when PVC creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockRejectedValueOnce(new Error('quota exceeded'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/quota exceeded/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('renders past benchmarks section', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('Past Benchmarks')).toBeInTheDocument();
});
expect(screen.getByText('No past benchmark jobs found.')).toBeInTheDocument();
});
it('renders past benchmark jobs in table', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([
{
jobName: 'kbench-old',
namespace: 'default',
storageClass: 'tns-nfs',
phase: 'Complete',
startedAt: '2025-01-01T00:00:00Z',
},
]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('kbench-old')).toBeInTheDocument();
});
expect(screen.getByText('Complete')).toBeInTheDocument();
});
it('disables Run Benchmark button when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const btn = screen.getByLabelText('Start kbench storage benchmark');
expect(btn).toBeDisabled();
});
it('shows confirmation dialog with selected SC and namespace', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
// Change namespace
const nsInput = screen.getByLabelText(
'Kubernetes namespace for benchmark job'
) as HTMLInputElement;
fireEvent.change(nsInput, { target: { value: 'bench-ns' } });
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
// Confirm dialog shows SC and namespace in <strong> tags
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm and start benchmark')).toBeInTheDocument();
// Namespace is shown in the dialog
const dialogText = screen.getByText(/bench-ns/);
expect(dialogText).toBeInTheDocument();
});
it('can change test size and mode', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
const sizeInput = screen.getByLabelText('FIO test size') as HTMLInputElement;
fireEvent.change(sizeInput, { target: { value: '10G' } });
expect(sizeInput.value).toBe('10G');
const modeSelect = screen.getByLabelText('Benchmark mode') as HTMLSelectElement;
fireEvent.change(modeSelect, { target: { value: 'quick' } });
expect(modeSelect.value).toBe('quick');
});
it('shows failed state when job creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockResolvedValueOnce(undefined);
// PVC binds immediately
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
vi.mocked(createJob).mockRejectedValueOnce(new Error('job already exists'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/job already exists/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
});
+386 -89
View File
@@ -15,7 +15,7 @@ import {
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { formatAge } from '../api/k8s';
import type { BenchmarkState, KbenchJobSummary, KbenchResult } from '../api/kbench';
import {
createJob,
@@ -32,7 +32,7 @@ import {
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { formatAge } from '../api/k8s';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
// ---------------------------------------------------------------------------
// Result display components
@@ -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,12 +175,17 @@ 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>
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter={true} />
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter={true} />
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter />
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter />
<ResultTable title="Latency" rows={latRows} higherIsBetter={false} />
</>
);
@@ -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>
@@ -412,6 +601,8 @@ export default function BenchmarkPage() {
const [currentResult, setCurrentResult] = useState<KbenchResult | null>(null);
const [lastNamespace, setLastNamespace] = useState('default');
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cancelledRef = useRef(false);
const [cleaningUp, setCleaningUp] = useState(false);
const scNames = storageClasses.map(sc => sc.metadata.name);
@@ -422,21 +613,39 @@ 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();
cancelledRef.current = false;
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;
}
@@ -444,15 +653,28 @@ export default function BenchmarkPage() {
setBenchState({ status: 'waiting-pvc', pvcName });
const pvcDeadline = Date.now() + MAX_PVC_WAIT_MS;
let pvcBound = false;
while (Date.now() < pvcDeadline) {
while (Date.now() < pvcDeadline && !cancelledRef.current) {
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 (cancelledRef.current) return;
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 +682,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,26 +714,55 @@ 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);
}
// Clean up polling on unmount
useEffect(() => () => stopPolling(), []);
// Clean up polling and cancel async loops on unmount
useEffect(
() => () => {
cancelledRef.current = true;
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 +774,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 +811,51 @@ 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' || cleaningUp) return;
setCleaningUp(true);
try {
await deleteJob(state.jobName, lastNamespace);
await deletePvc(state.pvcName, lastNamespace);
setBenchState({ status: 'idle' });
setCurrentResult(null);
} catch (err: unknown) {
setBenchState({
status: 'failed',
error: `Cleanup error: ${
err instanceof Error ? err.message : String(err)
}`,
jobName: state.jobName,
pvcName: state.pvcName,
});
} finally {
setCleaningUp(false);
}
}}
disabled={cleaningUp}
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: cleaningUp ? 'not-allowed' : 'pointer',
fontSize: '13px',
opacity: cleaningUp ? 0.6 : 1,
}}
>
{cleaningUp ? 'Deleting...' : 'Delete Job + PVC'}
</button>
),
},
]}
/>
</SectionBox>
</>
+21 -15
View File
@@ -6,12 +6,9 @@
* 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';
import { formatAge, getPodRestarts, isPodReady, TnsCsiPod } from '../api/k8s';
interface DriverPodDetailSectionProps {
resource: {
@@ -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)`,
};
});
+154
View File
@@ -0,0 +1,154 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
import { makeSampleMetrics, makeSamplePod, sampleCSIDriver } from '../test-helpers';
import DriverStatusCard from './DriverStatusCard';
describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => {
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('Not detected')).toBeInTheDocument();
});
it('shows "Degraded" when no pods are present', () => {
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('Degraded')).toBeInTheDocument();
});
it('shows "Metrics unavailable" when no metrics provided', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
/>
);
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
});
it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 1 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Healthy')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io installed')).toBeInTheDocument();
});
it('shows "Disconnected" when WS is disconnected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 0 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('shows "Unknown" when websocketConnected is null', () => {
const metrics = makeSampleMetrics({ websocketConnected: null });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('renders CSI capabilities section when driver is present', () => {
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('Persistent')).toBeInTheDocument();
});
it('does not render CSI capabilities when no driver', () => {
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
});
it('renders pod rows with image, restarts, and ready status', () => {
const pod = makeSamplePod({
metadata: { name: 'ctrl-pod-1', creationTimestamp: '2025-01-01T00:00:00Z' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{ name: 'tns-csi', ready: true, restartCount: 2, image: 'fenio/tns-csi:v0.6.0' },
],
},
});
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[pod]} nodePods={[]} />);
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // restarts
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows "No controller pod found" when controllerPods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
/>
);
expect(screen.getByText('No controller pod found')).toBeInTheDocument();
});
it('shows "No node pods found" when nodePods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[]}
/>
);
expect(screen.getByText('No node pods found')).toBeInTheDocument();
});
it('shows WS reconnects when available in metrics', () => {
const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
metrics={metrics}
/>
);
expect(screen.getByText('WS Reconnects')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('shows node pods count in section title', () => {
const node1 = makeSamplePod({ name: 'tns-csi-node-1' });
const node2 = makeSamplePod({ name: 'tns-csi-node-2' });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[node1, node2]}
/>
);
expect(screen.getByText('Node Pods (2)')).toBeInTheDocument();
});
});
+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>
)}
+162
View File
@@ -0,0 +1,162 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { fetchControllerMetrics } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleMetrics, makeSamplePod } from '../test-helpers';
import MetricsPage from './MetricsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('MetricsPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when context is loading', () => {
mockContext({ loading: true });
render(<MetricsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<MetricsPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/TNS-CSI driver not found/)).toBeInTheDocument();
});
it('shows "No controller pod found" when driver installed but no pods', () => {
mockContext({ driverInstalled: true, controllerPods: [] });
render(<MetricsPage />);
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
expect(screen.getByText(/No controller pod found/)).toBeInTheDocument();
});
it('shows metrics error when fetch fails', async () => {
const pod = makeSamplePod();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('connection refused'));
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('connection refused')).toBeInTheDocument();
});
});
it('renders three metric cards when fetch succeeds', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
expect(screen.getByText('Volume Operations')).toBeInTheDocument();
expect(screen.getByText('CSI Operations')).toBeInTheDocument();
});
it('displays correct WebSocket metric data', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
websocketConnected: 1,
websocketReconnectsTotal: 42,
websocketMessagesTotal: [{ labels: {}, value: 250 }],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
csiOperationsTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
expect(screen.getByText('42')).toBeInTheDocument(); // reconnects
expect(screen.getByText('250')).toBeInTheDocument(); // messages
});
it('displays CSI operations broken down by method', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 77 },
{ labels: { method: 'DeleteVolume' }, value: 13 },
],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
websocketMessagesTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('CreateVolume')).toBeInTheDocument();
});
expect(screen.getByText('77')).toBeInTheDocument();
expect(screen.getByText('DeleteVolume')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
});
it('refresh button triggers refetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValue(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
const initialCallCount = vi.mocked(fetchControllerMetrics).mock.calls.length;
fireEvent.click(screen.getByLabelText('Refresh metrics'));
await waitFor(() => {
expect(vi.mocked(fetchControllerMetrics).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('shows "Updated" timestamp after successful fetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
});
it('shows volume operations grouped by protocol', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 15 },
{ labels: { protocol: 'iscsi' }, value: 8 },
],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Operations (nfs)')).toBeInTheDocument();
});
expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument();
});
});
+35 -8
View File
@@ -11,9 +11,9 @@ import {
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiMetrics } from '../api/metrics';
import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
function formatAuditTime(iso: string): string {
const date = new Date(iso);
@@ -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>
+286
View File
@@ -0,0 +1,286 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async importOriginal => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { fetchControllerMetrics } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import {
defaultContext,
makeSampleMetrics,
makeSamplePod,
makeSamplePV,
makeSamplePVC,
makeSampleStorageClass,
sampleCSIDriver,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/CSIDriver tns.csi.io not found/)).toBeInTheDocument();
});
it('shows error section when error is present', () => {
mockContext({ error: 'cluster unavailable' });
render(<OverviewPage />);
expect(screen.getByText('cluster unavailable')).toBeInTheDocument();
});
it('always shows the development status notice', () => {
mockContext({ driverInstalled: true });
render(<OverviewPage />);
expect(screen.getByText(/active early development/)).toBeInTheDocument();
});
it('renders storage summary with SC/PV counts', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
const pvc = makeSamplePVC();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc],
persistentVolumes: [pv],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Storage Summary')).toBeInTheDocument();
expect(screen.getByText('Storage Classes')).toBeInTheDocument();
expect(screen.getByText('Persistent Volumes')).toBeInTheDocument();
});
it('renders capacity aggregation from PVs', () => {
const pv1 = makeSamplePV({
metadata: { name: 'pv-1' },
spec: { ...makeSamplePV().spec, capacity: { storage: '100Gi' } },
});
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, capacity: { storage: '50Gi' } },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv1, pv2],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
// 150 GiB total
expect(screen.getByText('150.0 GiB')).toBeInTheDocument();
});
it('renders protocol distribution bar', () => {
const sc1 = makeSampleStorageClass({ parameters: { protocol: 'nfs' } });
const sc2 = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc1, sc2],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Protocol Distribution')).toBeInTheDocument();
expect(screen.getByTestId('percentage-bar')).toBeInTheDocument();
});
it('renders pool capacity table when poolStats are present', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 }],
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('ONLINE')).toBeInTheDocument();
});
it('shows pool stats error hint', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStatsError: 'API key invalid',
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity Unavailable')).toBeInTheDocument();
expect(screen.getByText('API key invalid')).toBeInTheDocument();
expect(screen.getByText(/TrueNAS API key/)).toBeInTheDocument();
});
it('shows Prometheus fallback capacity by pool when no poolStats and metrics available', async () => {
const pod = makeSamplePod();
const pv = makeSamplePV();
const metrics = makeSampleMetrics({
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [],
poolStatsError: null,
});
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Provisioned Capacity by Pool')).toBeInTheDocument();
});
});
it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({
metadata: {
name: 'pending-pvc',
namespace: 'test',
creationTimestamp: '2025-01-01T00:00:00Z',
},
status: { phase: 'Pending' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pendingPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Attention: Non-Bound PVCs')).toBeInTheDocument();
expect(screen.getByText('pending-pvc')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('does not show non-bound PVCs section when all PVCs are bound', () => {
const pvc = makeSamplePVC({ status: { phase: 'Bound' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.queryByText('Attention: Non-Bound PVCs')).not.toBeInTheDocument();
});
it('refresh button calls context.refresh()', () => {
const refreshFn = vi.fn();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
refresh: refreshFn,
});
render(<OverviewPage />);
fireEvent.click(screen.getByLabelText('Refresh tns-csi data'));
expect(refreshFn).toHaveBeenCalledTimes(1);
});
it('shows metrics unavailable when fetchControllerMetrics fails', async () => {
const pod = makeSamplePod();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('timeout'));
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
});
expect(screen.getByText('timeout')).toBeInTheDocument();
});
it('shows PVC status breakdown with Pending and Lost counts', () => {
const boundPvc = makeSamplePVC({
metadata: { name: 'pvc-1', namespace: 'ns' },
status: { phase: 'Bound' },
});
const pendingPvc = makeSamplePVC({
metadata: { name: 'pvc-2', namespace: 'ns' },
status: { phase: 'Pending' },
});
const lostPvc = makeSamplePVC({
metadata: { name: 'pvc-3', namespace: 'ns' },
status: { phase: 'Lost' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [boundPvc, pendingPvc, lostPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument();
expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument();
});
});
+82 -61
View File
@@ -14,11 +14,11 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
import { fetchControllerMetrics } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
@@ -26,10 +26,10 @@ import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
const PROTOCOL_COLORS: Record<string, string> = {
NFS: '#1976d2',
'NVMe-oF': '#9c27b0',
iSCSI: '#f57c00',
Other: '#9e9e9e',
NFS: 'var(--mui-palette-primary-main, #1976d2)',
'NVMe-oF': 'var(--mui-palette-secondary-main, #9c27b0)',
iSCSI: 'var(--mui-palette-warning-main, #f57c00)',
Other: 'var(--mui-palette-action-disabled, #9e9e9e)',
};
function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) {
@@ -85,6 +85,24 @@ export default function OverviewPage() {
void fetchMetrics();
}, [fetchMetrics]);
const capacityByPool: Map<string, number> = useMemo(() => {
const map = new Map<string, number>();
if (!metrics) return map;
const handleToPool = new Map<string, string>();
for (const pv of persistentVolumes) {
const handle = pv.spec.csi?.volumeHandle;
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
if (handle && pool) handleToPool.set(handle, pool);
}
for (const sample of metrics.volumeCapacityBytes) {
const volumeId = sample.labels['volume_id'];
if (!volumeId) continue;
const pool = handleToPool.get(volumeId) ?? 'unknown';
map.set(pool, (map.get(pool) ?? 0) + sample.value);
}
return map;
}, [metrics, persistentVolumes]);
if (loading) {
return <Loader title="Loading TNS-CSI data..." />;
}
@@ -104,37 +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;
// Capacity by pool: join volumeCapacityBytes samples (volume_id, protocol)
// with PV volumeHandle → pool name from volumeAttributes.
const capacityByPool: Map<string, number> = React.useMemo(() => {
const map = new Map<string, number>();
if (!metrics) return map;
// Build lookup: volumeHandle → pool name
const handleToPool = new Map<string, string>();
for (const pv of persistentVolumes) {
const handle = pv.spec.csi?.volumeHandle;
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
if (handle && pool) handleToPool.set(handle, pool);
}
for (const sample of metrics.volumeCapacityBytes) {
const volumeId = sample.labels['volume_id'];
if (!volumeId) continue;
const pool = handleToPool.get(volumeId) ?? 'unknown';
map.set(pool, (map.get(pool) ?? 0) + sample.value);
}
return map;
}, [metrics, persistentVolumes]);
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
@@ -177,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',
},
]}
/>
@@ -226,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} />
@@ -242,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>,
},
]
: []),
]}
/>
@@ -262,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}
@@ -303,7 +318,8 @@ export default function OverviewPage() {
</SectionBox>
)}
{/* Provisioned capacity by pool (from Prometheus metrics — shown when TrueNAS API not configured) */}
{/* Provisioned capacity by pool (from Prometheus metrics —
shown when TrueNAS API not configured) */}
{poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && (
<SectionBox title="Provisioned Capacity by Pool">
<NameValueTable
@@ -322,17 +338,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}
/>
@@ -353,11 +369,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);
}
+91
View File
@@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV, makeSamplePVC } from '../test-helpers';
import PVCDetailSection from './PVCDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('PVCDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC is not in filtered list', () => {
mockContext({ persistentVolumeClaims: [] });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'other-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC has no bound PV', () => {
const pvc = makeSamplePVC({ metadata: { name: 'orphan-pvc', namespace: 'default' } });
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [], // no PVs to match
});
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'orphan-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('renders storage details when PVC and PV are found', () => {
const pvc = makeSamplePVC();
const pv = makeSamplePV();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('renders custom volume attributes (excluding protocol and server)', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
customAttr: 'customValue',
},
},
},
});
const pvc = makeSamplePVC();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument();
expect(screen.getByText('customValue')).toBeInTheDocument();
});
});
+4 -8
View File
@@ -5,13 +5,10 @@
* 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';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
interface PVCDetailSectionProps {
resource: {
@@ -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';
+105
View File
@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleSnapshot, makeSampleSnapshotClass } from '../test-helpers';
import SnapshotsPage from './SnapshotsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('SnapshotsPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<SnapshotsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...');
});
it('shows error state', () => {
mockContext({ error: 'something broke' });
render(<SnapshotsPage />);
expect(screen.getByText('something broke')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows notice when snapshot CRD is not available', () => {
mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect(screen.getByText(/VolumeSnapshot CRDs.*not found/)).toBeInTheDocument();
});
it('shows empty message when snapshots list is empty', () => {
mockContext({ snapshotCrdAvailable: true, volumeSnapshots: [] });
render(<SnapshotsPage />);
expect(screen.getByText('No tns-csi VolumeSnapshots found.')).toBeInTheDocument();
});
it('renders snapshot classes when available', () => {
const vsc = makeSampleSnapshotClass();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [vsc],
volumeSnapshots: [],
});
render(<SnapshotsPage />);
expect(screen.getByText('Snapshot Classes (1)')).toBeInTheDocument();
expect(screen.getByText('tns-snap-class')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
});
it('renders populated snapshots with readyToUse=true', () => {
const snap = makeSampleSnapshot();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('snap-001')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=false', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: false, restoreSize: '50Gi' },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('No')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=undefined as Unknown', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: undefined },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not render snapshot classes section when empty', () => {
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [],
volumeSnapshots: [makeSampleSnapshot()],
});
render(<SnapshotsPage />);
expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument();
});
});
+13 -7
View File
@@ -12,9 +12,9 @@ import {
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { VolumeSnapshot } from '../api/k8s';
import { formatAge } from '../api/k8s';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
export default function SnapshotsPage() {
const { volumeSnapshots, volumeSnapshotClasses, snapshotCrdAvailable, loading, error } =
@@ -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}
/>
+161
View File
@@ -0,0 +1,161 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/storage-classes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV, makeSampleStorageClass } from '../test-helpers';
import StorageClassesPage from './StorageClassesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('StorageClassesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<StorageClassesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<StorageClassesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows empty message when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<StorageClassesPage />);
expect(screen.getByText('No tns-csi StorageClasses found.')).toBeInTheDocument();
});
it('renders table with all columns populated', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
mockContext({
storageClasses: [sc],
persistentVolumes: [pv],
});
render(<StorageClassesPage />);
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument(); // expansion
expect(screen.getByText('1')).toBeInTheDocument(); // PV count
});
it('opens detail panel when clicking SC name', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByText('tns-nfs'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes#tns-nfs');
});
it('renders detail panel when hash is set', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('StorageClass Details')).toBeInTheDocument();
});
it('closes panel via close button', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel via backdrop click', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel on Escape key', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass({
parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
expect(screen.getByText(/nfs-common/)).toBeInTheDocument();
});
it('shows NVMe-oF protocol notes', () => {
mockHash = '#tns-nvmeof';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/nvme-cli/)).toBeInTheDocument();
});
it('shows iSCSI protocol notes', () => {
mockHash = '#tns-iscsi';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-iscsi' },
parameters: { protocol: 'iscsi', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/open-iscsi/)).toBeInTheDocument();
});
it('shows PV count for each storage class', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
const pv1 = makeSamplePV({ spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' } });
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' },
});
mockContext({
storageClasses: [sc1, sc2],
persistentVolumes: [pv1, pv2],
});
render(<StorageClassesPage />);
const cells = screen.getAllByRole('cell');
const pvCells = cells.filter(c => c.textContent === '2');
expect(pvCells.length).toBeGreaterThanOrEqual(1);
});
});
+83 -26
View File
@@ -13,11 +13,11 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import type { TnsCsiStorageClass } from '../api/k8s';
import { formatProtocol } from '../api/k8s';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
// ---------------------------------------------------------------------------
// Detail drawer
@@ -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);
@@ -164,10 +196,10 @@ export default function StorageClassesPage() {
history.push(`${location.pathname}#${name}`);
};
const closeSc = () => {
const closeSc = useCallback(() => {
setSelectedName(null);
history.push(location.pathname);
};
}, [history, location.pathname]);
useEffect(() => {
if (!selectedName) return;
@@ -176,8 +208,7 @@ export default function StorageClassesPage() {
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName]);
}, [selectedName, closeSc]);
if (loading) return <Loader title="Loading storage classes..." />;
@@ -186,7 +217,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 +232,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 +247,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 +294,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>
),
+145
View File
@@ -0,0 +1,145 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock(
'@kinvolk/headlamp-plugin/lib/CommonComponents',
async () => await import('./__mocks__/commonComponents')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/volumes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV } from '../test-helpers';
import VolumesPage from './VolumesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('VolumesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<VolumesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<VolumesPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows empty message when no PVs', () => {
mockContext({ persistentVolumes: [] });
render(<VolumesPage />);
expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument();
});
it('renders PV table with claim ref', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('pv-test-001')).toBeInTheDocument();
expect(screen.getByText('default/my-pvc')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
expect(screen.getByText('RWO')).toBeInTheDocument();
expect(screen.getByText('Bound')).toBeInTheDocument();
});
it('renders "—" for PV without claimRef', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
claimRef: undefined,
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const cells = screen.getAllByRole('cell');
const dashCells = cells.filter(c => c.textContent === '—');
expect(dashCells.length).toBeGreaterThanOrEqual(1);
});
it('opens detail panel when clicking PV name', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByText('pv-test-001'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes#pv-test-001');
});
it('renders detail panel with CSI attributes', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv], persistentVolumeClaims: [] });
render(<VolumesPage />);
expect(screen.getByText('Volume Details')).toBeInTheDocument();
expect(screen.getByText('CSI Attributes')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('shows Bound PVC section in detail panel when claimRef exists', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Bound PVC')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
});
it('shows Adoption section when annotation is present', () => {
mockHash = '#pv-adoptable';
const pv = makeSamplePV({
metadata: {
name: 'pv-adoptable',
annotations: { 'tns-csi.io/adoptable': 'true' },
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Adoption')).toBeInTheDocument();
expect(screen.getByText(/adopted cross-cluster/)).toBeInTheDocument();
});
it('closes panel on Escape key', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('closes panel via backdrop click', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('renders maximize/minimize button in panel', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const maxBtn = screen.getByLabelText('Maximize');
expect(maxBtn).toBeInTheDocument();
fireEvent.click(maxBtn);
expect(screen.getByLabelText('Minimize')).toBeInTheDocument();
});
});
+78 -25
View File
@@ -11,11 +11,11 @@ import {
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useEffect, useState } from 'react';
import React, { useCallback, 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';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
// ---------------------------------------------------------------------------
// 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);
@@ -144,10 +180,10 @@ export default function VolumesPage() {
history.push(`${location.pathname}#${name}`);
};
const closeVolume = () => {
const closeVolume = useCallback(() => {
setSelectedName(null);
history.push(location.pathname);
};
}, [history, location.pathname]);
useEffect(() => {
if (!selectedName) return;
@@ -156,8 +192,7 @@ export default function VolumesPage() {
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName]);
}, [selectedName, closeVolume]);
if (loading) return <Loader title="Loading volumes..." />;
@@ -166,7 +201,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 +224,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 +285,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} />
</>
@@ -0,0 +1,95 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => commonComponentsMock).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement(
'div',
{ 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement(
'table',
{ 'data-testid': 'simple-table' },
React.createElement(
'thead',
null,
React.createElement(
'tr',
null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement(
'tbody',
null,
data.map((item, i) =>
React.createElement(
'tr',
{ key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
React.createElement(
'table',
{ 'data-testid': 'name-value-table' },
React.createElement(
'tbody',
null,
rows.map(row =>
React.createElement(
'tr',
{ key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement(
'div',
{ 'data-testid': 'percentage-bar' },
data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
);
@@ -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>;
},
+17 -7
View File
@@ -15,17 +15,20 @@ import {
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
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 StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
import {
buildPVColumns,
buildStorageClassColumns,
} from './components/integrations/StorageClassColumns';
import MetricsPage from './components/MetricsPage';
import OverviewPage from './components/OverviewPage';
import PVCDetailSection from './components/PVCDetailSection';
import PVDetailSection from './components/PVDetailSection';
import SnapshotsPage from './components/SnapshotsPage';
import StorageClassesPage from './components/StorageClassesPage';
import TnsCsiSettings from './components/TnsCsiSettings';
import VolumesPage from './components/VolumesPage';
// ---------------------------------------------------------------------------
@@ -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);
+213
View File
@@ -0,0 +1,213 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type {
CSIDriver,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
VolumeSnapshot,
VolumeSnapshotClass,
} from './api/k8s';
import type { TnsCsiMetrics } from './api/metrics';
import type { TnsCsiContextValue } from './api/TnsCsiDataContext';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<TnsCsiContextValue>): TnsCsiContextValue {
return {
csiDriver: null,
driverInstalled: false,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
volumeSnapshots: [],
volumeSnapshotClasses: [],
snapshotCrdAvailable: false,
poolStats: [],
poolStatsError: null,
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export const sampleCSIDriver: CSIDriver = {
metadata: { name: 'tns.csi.io' },
spec: {
attachRequired: false,
podInfoOnMount: true,
volumeLifecycleModes: ['Persistent'],
},
};
export function makeSampleStorageClass(
overrides?: Partial<TnsCsiStorageClass>
): TnsCsiStorageClass {
return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io',
reclaimPolicy: 'Delete',
volumeBindingMode: 'Immediate',
allowVolumeExpansion: true,
parameters: {
protocol: 'nfs',
pool: 'tank',
server: '10.0.0.1',
},
...overrides,
};
}
export const sampleStorageClass = makeSampleStorageClass();
export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCsiPersistentVolume {
return {
metadata: {
name: 'pv-test-001',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
},
},
capacity: { storage: '100Gi' },
accessModes: ['ReadWriteOnce'],
persistentVolumeReclaimPolicy: 'Delete',
storageClassName: 'tns-nfs',
claimRef: { name: 'my-pvc', namespace: 'default' },
},
status: { phase: 'Bound' },
...overrides,
};
}
export const samplePV = makeSamplePV();
export function makeSamplePVC(
overrides?: Partial<TnsCsiPersistentVolumeClaim>
): TnsCsiPersistentVolumeClaim {
return {
metadata: {
name: 'my-pvc',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
storageClassName: 'tns-nfs',
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '100Gi' } },
volumeName: 'pv-test-001',
},
status: {
phase: 'Bound',
capacity: { storage: '100Gi' },
},
...overrides,
};
}
export const samplePVC = makeSamplePVC();
export function makeSamplePod(overrides?: Partial<TnsCsiPod> & { name?: string }): TnsCsiPod {
const name = overrides?.name ?? overrides?.metadata?.name ?? 'tns-csi-controller-abc';
return {
metadata: {
name,
creationTimestamp: '2025-01-01T00:00:00Z',
...overrides?.metadata,
},
spec: {
nodeName: 'node-1',
...overrides?.spec,
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'tns-csi',
ready: true,
restartCount: 0,
image: 'fenio/tns-csi:v0.5.0',
},
],
...overrides?.status,
},
};
}
export const samplePod = makeSamplePod();
export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeSnapshot {
return {
metadata: {
name: 'snap-001',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
source: { persistentVolumeClaimName: 'my-pvc' },
volumeSnapshotClassName: 'tns-snap-class',
},
status: {
readyToUse: true,
restoreSize: '100Gi',
},
...overrides,
};
}
export function makeSampleSnapshotClass(
overrides?: Partial<VolumeSnapshotClass>
): VolumeSnapshotClass {
return {
metadata: {
name: 'tns-snap-class',
creationTimestamp: '2025-01-01T00:00:00Z',
},
driver: 'tns.csi.io',
deletionPolicy: 'Delete',
...overrides,
};
}
export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMetrics {
return {
websocketConnected: 1,
websocketReconnectsTotal: 3,
websocketMessagesTotal: [{ labels: {}, value: 100 }],
websocketMessageDurationSeconds: [],
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 10 },
{ labels: { protocol: 'iscsi' }, value: 5 },
],
volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 },
],
csiOperationsDurationSeconds: [],
...overrides,
};
}
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
"compilerOptions": {
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
"module": "esnext",
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+3
View File
@@ -1,6 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
define: {
'process.env.NODE_ENV': '"test"',
},
test: {
globals: true,
environment: 'jsdom',