Compare commits

..

86 Commits

Author SHA1 Message Date
privilegedescalation-engineer[bot] be5d86bc8e release: v1.0.3 (#56)
CI / ci (push) Successful in 51s
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-14 00:36:19 +00:00
privilegedescalation-engineer[bot] e09e887390 chore(ci): add audit-ci allowlist for inherited @kinvolk/headlamp-plugin CVEs (PRI-855)
QA reviewed and approved. Adds audit-ci.jsonc with 3 CVE allowlist entries for dev-only dependencies.
2026-05-12 22:22:44 +00:00
privilegedescalation-ceo[bot] fc7198b045 Update CI and approval workflows for three-branch SDLC (#51)
CI triggers on dev/uat/main. Promotion gate replaces dual-approval.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 21:39:55 +00:00
privilegedescalation-engineer[bot] b0110e474c chore: remove E2E testing infrastructure (#50)
* docs: update install docs to headlamp namespace (PRI-434)

- Update Helm/plugin install URLs from v0.2.4 to v1.0.0
- README: add pods/proxy RBAC scope, clarify controller is in kube-system
- docs/getting-started/*: update all download URLs to v1.0.0
- docs/deployment/helm.md: update install URLs to v1.0.0
- docs/architecture/overview.md: Headlamp Pod label → headlamp namespace
- docs/README.md: fix ArtifactHub URL
- CHANGELOG.md: add [Unreleased] entry

Note: driver/API-path references to kube-system are preserved
as they describe where the tns-csi controller workload runs,
not where Headlamp is installed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* chore: remove E2E testing infrastructure

- Delete e2e/ directory (auth.setup.ts, tns-csi.spec.ts)
- Delete playwright.config.ts
- Delete scripts/deploy-e2e-headlamp.sh
- Delete scripts/teardown-e2e-headlamp.sh
- Delete .github/workflows/e2e.yaml
- Remove e2e script from package.json
- Remove @playwright/test dependency from package.json

Context: [PRI-1133](/PRI/issues/PRI-1133) — full E2E purge across org.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix: update pnpm-lock.yaml after E2E deletion

Remove @playwright/test dependencies after E2E infrastructure cleanup.
Resolves ERR_PNPM_OUTDATED_LOCKFILE on PR.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-11 20:11:07 +00:00
privilegedescalation-ceo[bot] 2e2713fd3f docs: replace hardcoded namespace with <your-namespace> placeholder
Users choose their own namespace for Headlamp. Replace all hardcoded
kube-system references that indicate Headlamp's install namespace with
<your-namespace> so users substitute their own value.

Upstream workload references left untouched:
- tns-csi controller pods in kube-system (upstream CSI driver)
- NetworkPolicy selectors targeting kube-system
- API server proxy paths to kube-system pods

Refs: PRI-434

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-10 21:34:56 +00:00
privilegedescalation-engineer[bot] be254b1eec feat(e2e): consolidate E2E infra + add waitForSidebar (PRI-698) (#42)
* feat(e2e): consolidate E2E test infrastructure + add waitForSidebar (PRI-698)

- Adds e2e/auth.setup.ts, e2e/tns-csi.spec.ts with waitForSidebar helper
- Adds playwright.config.ts, scripts/deploy-e2e-headlamp.sh, scripts/teardown-e2e-headlamp.sh
- Adds .github/workflows/e2e.yaml
- Plugin settings test waits for list before searching

* fix(e2e): add @playwright/test to devDependencies and e2e script (PRI-698)

@playwright/test was missing from devDependencies, causing the
'Install Playwright browsers' step to fail. Added it alongside
the e2e npm script so the reusable workflow can run playwright test.

* fix(e2e): reference @main workflow after .github merge

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-06 00:43:55 +00:00
privilegedescalation-engineer[bot] 90efdf5569 fix: override elliptic to patched version for GHSA-848j-6mx2-7j84
Merges pnpm.overrides entry for elliptic >=6.6.1 to address GHSA-848j-6mx2-7j84. Full pipeline cleared: CI + UAT (Patty) + QA (Regina) + CTO (Nancy). Admin override used due to dual-approval bot review limitation.
2026-05-05 14:29:20 +00:00
privilegedescalation-engineer[bot] 4ba90fa218 chore: replace Dependabot reference with Renovate (#31)
- SECURITY.md: update to mention Renovate (org-wide Mend Renovate)

Closes PRI-389. Parent PRI-387.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 21:19:18 +00:00
privilegedescalation-engineer[bot] a089a2cc2d fix: add markdownlint config to resolve CI failures (#30)
Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 20:02:47 +00:00
privilegedescalation-engineer[bot] bb283d8923 fix: override lodash >=4.18.0 to patch code injection vulnerability (#29)
* 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>

* 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>

---------

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 03:23:57 +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
Chris Farhood 3e757db799 chore: release v0.2.2
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 13:31:01 -05:00
Chris Farhood 81b0b35089 docs: add v0.2.1 changelog entry
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 07:07:41 -05:00
github-actions[bot] 76ab680d9a chore: release v0.2.1 2026-02-19 12:05:36 +00:00
Chris Farhood 9aeafc4344 fix: derive Pool from datasetName on tns-csi PV rows
tns-csi driver does not write pool directly into volumeAttributes; it
writes datasetName (e.g. "tank/pvc-abc123"). Extract the pool as the
first path segment so the Pool column populates correctly.

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 06:59:55 -05:00
Chris Farhood e082c60677 fix: repair brace mismatch in DataContext and replace PV Pool column with Dataset
- TnsCsiDataContext: pool stats fetch was outside the outer try block due
  to a brace mismatch introduced when adding TrueNAS API integration;
  this caused the entire fetchAsync function to throw a syntax-level
  error, breaking the OverviewPage
- StorageClassColumns (PV): replace non-populating Pool column with
  Dataset column (tns-csi driver writes datasetName, not pool, into
  PV volumeAttributes)

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 06:48:01 -05:00
Chris Farhood 96ea9e1207 feat: remove app bar driver health badge
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-18 19:28:00 -05:00
Chris Farhood a77aa3a1dc docs: update README download URLs to v0.2.0
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-18 16:58:55 -05:00
Chris Farhood 145101b1b5 docs: add v0.2.0 changelog entry and fix footer links
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-18 16:58:24 -05:00
github-actions[bot] 03d616a545 chore: release v0.2.0 2026-02-18 21:39:31 +00:00
Chris Farhood 7ef7b8b7b5 chore: add package-lock.json for CI reproducible installs
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-18 16:38:36 -05:00
Chris Farhood f1feb5c2f7 feat: native Headlamp integration, TrueNAS API, docs, and CI for v0.2.0
Native Headlamp integrations:
- registerResourceTableColumnsProcessor: add Protocol/Pool/Server columns to
  native StorageClass table and Protocol/Volume Handle to PV table
- registerDetailsViewSection: inject TNS-CSI section into PV detail pages
- registerDetailsViewSection: inject driver role/status into tns-csi Pod pages
- registerDetailsViewHeaderAction: Benchmark shortcut on StorageClass detail
- registerAppBarAction: driver health badge (N/Nc M/Mn, color-coded)
- Trim sidebar from 6 → 4 entries (Overview, Snapshots, Metrics, Benchmark)

TrueNAS API integration:
- src/api/truenas.ts: ConfigStore-backed settings, WebSocket JSON-RPC client
  for pool.query (auth.login_with_api_key + pool.query)
- src/components/TnsCsiSettings.tsx: API key + server override settings UI
  with connection test button
- TnsCsiDataContext: fetch real pool stats (size/allocated/free/status)
- OverviewPage: three-tier pool capacity display (real data → error → metrics
  fallback)

Documentation:
- README, CHANGELOG, CONTRIBUTING, SECURITY
- docs/: architecture, deployment (Helm), getting-started, user-guide,
  troubleshooting

CI:
- .github/workflows/ci.yaml: lint + type-check + test on PR/push
- .github/workflows/release.yaml: workflow_dispatch versioned release

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-18 16:37:56 -05:00
Chris Farhood f2f3c3a87e Rename sidebar top-level label from 'TNS CSI' to 'TrueNAS'
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-18 15:44:52 -05:00
Chris Farhood aeab42b6ec Fix: add required maintainer email to artifacthub-pkg.yml
Artifact Hub requires a non-empty email on all maintainer entries.

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-18 14:10:28 -05:00
Chris Farhood 44a7f654f0 Add Artifact Hub repositoryID for verified publisher
repositoryID: cae81660-2624-4e02-8ac2-a176cbe94402

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-18 14:04:30 -05:00
Chris Farhood 0d0bf0f609 Add Artifact Hub metadata for v0.1.0
artifacthub-pkg.yml: package metadata with headlamp plugin annotations
  - archive-url pointing to v0.1.0 GitHub release tarball
  - sha256 checksum: 14a3e8c13d0b894a41aa1cfccbcb1f6af09dcbb8fd95c7040a540987ea2096a7
  - version-compat: >=0.20.0
  - distro-compat: in-cluster, web, app

artifacthub-repo.yml: repository ownership (repositoryID to be filled
after registering on artifacthub.io)

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-18 14:00:28 -05:00
79 changed files with 19084 additions and 1202 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]
+13
View File
@@ -0,0 +1,13 @@
name: CI
on:
push:
branches: [main, dev, uat]
pull_request:
branches: [main, dev, uat]
workflow_dispatch:
workflow_call:
jobs:
ci:
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
+21
View File
@@ -0,0 +1,21 @@
name: Promotion Gate
# Calls the shared promotion gate workflow.
# dev PRs: no gate (engineer self-merges).
# uat PRs: QA approval required.
# main PRs: UAT approval required (uat→main promotions).
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [uat, main]
types: [opened, reopened, synchronize]
jobs:
promotion-gate:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+23
View File
@@ -0,0 +1,23 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g. 1.0.0)'
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
release:
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
+53
View File
@@ -0,0 +1,53 @@
{
"config": {
// Line length — not enforced for docs with code examples
"MD013": false,
// First line heading — files use YAML frontmatter, not headings
"MD041": false,
// Emphasis as heading — common pattern for Option 1/2/3 sections
"MD036": false,
// No duplicate heading — changelog files repeat section names intentionally
"MD024": false,
// Fenced code language — not always applicable for diagram blocks
"MD040": false,
// Table column style — table alignment is visual, not semantic
"MD060": false,
// Ordered list item prefix — number resets are intentional in documents
"MD029": false,
// No inline HTML — each elements are valid in valid Markdown
"MD033": false,
// List marker space — spacing after list markers varies by editor
"MD030": false,
// Blanks around headings — not always needed in compact docs
"MD022": false,
// Blanks around lists — not always needed in compact docs
"MD032": false,
// Blanks around fences — not always needed between adjacent blocks
"MD031": false,
// Multiple blanks — editor artifacts, not semantic
"MD012": false,
// Single title — files may have multiple H1 sections
"MD025": false,
// Trailing spaces — editor artifacts
"MD009": false,
// Bare URLs — URL shortening not always needed
"MD034": false,
// Single trailing newline — editor artifacts
"MD047": false,
// Trailing punctuation — heading punctuation is intentional
"MD026": false,
// Space in emphasis — double-asterisk bold spacing varies by renderer
"MD037": false,
// No hard tabs — some generated docs use tabs for indentation
"MD010": false,
// Code block style — generated docs may use inconsistent styles
"MD046": false,
// Comment style — generated docs have no comments
"MD048": false,
// Commands show output — shell examples intentionally show only commands
"MD014": false
},
"ignores": [
"docs/api-reference/generated/**"
]
}
+1
View File
@@ -0,0 +1 @@
docs/api-reference/generated/**
+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');
+133
View File
@@ -0,0 +1,133 @@
# Changelog
All notable changes to the Headlamp TNS-CSI Plugin will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- **docs: namespace references** — Updated all documentation, README, and ArtifactHub metadata to explicitly reference the `headlamp` namespace instead of generic "controller pod" language. RBAC examples now clearly scope `pods/proxy` access to `kube-system` where the tns-csi controller runs.
## [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
- **Duplicate columns** — Protocol and Pool columns on mixed-driver clusters (tns-csi + rook-ceph) are now merged into a single shared column rather than duplicated; whichever plugin loads first owns the column and the second merges into it
- **Plugin settings name** — settings entry now registers as `tns-csi` instead of `headlamp-tns-csi-plugin`
## [0.2.1] - 2026-02-19
### Fixed
- **OverviewPage crash** — brace mismatch in `TnsCsiDataContext` placed TrueNAS pool stats fetch outside the outer try block, breaking the entire context provider
- **PV Pool column** — tns-csi driver writes `datasetName` (e.g. `pool0/pvc-abc`), not `pool`, into `volumeAttributes`; Pool is now correctly derived from the first path segment
- **App bar badge removed** — removed the colored tns-csi status bubble from the top nav bar
## [0.2.0] - 2026-02-18
### Added
- **Native Headlamp integration** — Protocol/Pool/Server columns injected into the native StorageClass table; Protocol/Volume Handle columns into the native PV table
- **PV Detail Injection** — TNS-CSI section injected into Headlamp PV detail views with full CSI volume attributes
- **Pod Detail Injection** — Driver role/status section injected into tns-csi Pod detail pages (controller vs node role, ready status, restart count)
- **StorageClass Benchmark button** — "Benchmark" shortcut button added to tns-csi StorageClass detail page headers
- **App Bar Badge** — driver health badge in top nav bar showing `tns-csi: N/Nc M/Mn` (controller/node pod ready counts), color-coded green/orange/red
- **Sidebar trim** — reduced from 6 to 4 entries (Overview, Snapshots, Metrics, Benchmark); Storage Classes and Volumes accessible via direct URL
- **TrueNAS API integration** — WebSocket JSON-RPC client (`pool.query`) for real pool capacity (size/allocated/free/health status)
- **Plugin settings page** — API key and server address configuration with connection test button
- **Three-tier pool capacity display** — real TrueNAS API data → error hint → metrics-based provisioned-capacity fallback
- **CI workflow** — lint + type-check + test on every push and PR
- **Release workflow** — manual workflow_dispatch for versioned releases with automatic version bump, checksum, tag, and GitHub release creation
- **Documentation** — README, CHANGELOG, CONTRIBUTING, SECURITY, and full `docs/` suite (architecture, deployment, user guide, troubleshooting)
## [0.1.0] - 2026-02-18
### Added
- **Overview Dashboard** — driver health card, storage summary (StorageClass / PV / PVC counts), protocol distribution with PercentageBar, non-Bound PVC alert table, and live Prometheus metric snapshot
- **Storage Classes page** — table with Protocol, Pool, Server, Reclaim Policy, Expansion, and PV count columns; slide-in detail panel with protocol-specific prerequisite notes (NFS, NVMe-oF, iSCSI)
- **Volumes page** — PersistentVolume table with capacity, access modes, reclaim policy, phase status badge, and bound claim; slide-in detail panel with full CSI volume attributes
- **Snapshots page** — VolumeSnapshot table scoped to tns-csi VolumeSnapshotClasses; graceful degradation when snapshot CRD is not installed
- **Metrics page** — Prometheus WebSocket health indicator, per-volume I/O (read/write IOPS and bandwidth), CSI operation latency cards from controller pod port 8080
- **Benchmark page** — interactive kbench runner with StorageClass selection, capacity/access-mode configuration, Job+PVC lifecycle management, live FIO log streaming, and IOPS/bandwidth/latency result cards
- **PVC Detail Injection** — TNS-CSI section automatically injected into Headlamp's PVC detail views showing protocol, server, pool, volume handle, and link to the bound PV
- **TnsCsiDataContext** — shared React context provider for all plugin pages; extracts `jsonData` from Headlamp KubeObject instances so StorageClass `parameters` (protocol, pool, server) are accessible
- **Prometheus text format parser** — zero-dependency parser for the Prometheus exposition format used by the tns-csi controller
- **kbench FIO log parser** — parses `yasker/kbench` FIO output into structured IOPS, bandwidth (MB/s), and latency (µs) results
- **ArtifactHub publishing** — `artifacthub-pkg.yml` and `artifacthub-repo.yml` registered at Artifact Hub; plugin available in Headlamp catalog
### Infrastructure
- GitHub repository setup with initial CI and release workflows
- 67 unit tests with Vitest + @testing-library/react
- TypeScript strict mode with zero `any` types
- ESLint + Prettier code quality tooling
[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
[0.1.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/tag/v0.1.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
```
+430
View File
@@ -0,0 +1,430 @@
# Contributing to Headlamp TNS-CSI Plugin
Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to the project.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Workflow](#development-workflow)
- [Branching Strategy](#branching-strategy)
- [Commit Message Guidelines](#commit-message-guidelines)
- [Pull Request Process](#pull-request-process)
- [Code Style](#code-style)
- [Testing Requirements](#testing-requirements)
- [Documentation](#documentation)
- [Release Process](#release-process)
## Code of Conduct
This project follows a standard code of conduct:
- Be respectful and inclusive
- Welcome newcomers and help them get started
- Focus on constructive feedback
- Assume good intentions
## Getting Started
### Prerequisites
- Node.js 22 or later
- npm
- Access to a Kubernetes cluster with Headlamp and tns-csi installed (for end-to-end testing)
- Git
### Development Setup
1. **Fork and clone the repository:**
```bash
git clone https://github.com/YOUR_USERNAME/headlamp-tns-csi-plugin.git
cd headlamp-tns-csi-plugin
```
2. **Install dependencies:**
```bash
npm install
```
3. **Start development mode:**
```bash
npm start
# Plugin will be available at http://localhost:4466
```
4. **Run tests:**
```bash
npm test # 159 unit tests
npm run tsc # TypeScript type-check
```
5. **Build the plugin:**
```bash
npm run build
```
## Development Workflow
### Feature Development
1. Create a feature branch from `main`
2. Make your changes
3. Write/update tests
4. Update documentation
5. Run lint and tests locally
6. Submit a pull request
### Local Testing
**Option 1: Development Mode**
```bash
npm start
# Opens Headlamp at http://localhost:4466 with hot reload
```
**Option 2: Production Build**
```bash
npm run build
npm run package
# Installs the packaged tarball into a running Headlamp instance
```
### Connecting to a Real Cluster
The plugin requires a running tns-csi driver to display meaningful data. For development:
1. Configure `KUBECONFIG` to point at a cluster with tns-csi installed
2. Run `npm start` — Headlamp dev server will proxy API requests through your kubeconfig
3. The Benchmark page requires RBAC permissions for Jobs and PVCs in the target namespace
## Branching Strategy
### Main Branch
- **Purpose:** Stable, production-ready code
- **Protection:** Only merge via pull requests
- **Naming:** `main`
### Feature Branches
- **Purpose:** Development of new features or fixes
- **Naming Convention:**
- Features: `feat/description`
- Bug fixes: `fix/description`
- Documentation: `docs/description`
- Refactoring: `refactor/description`
- Chores: `chore/description`
**Examples:**
```bash
feat/add-volume-clone-support
fix/metrics-page-empty-on-restart
docs/update-rbac-guide
refactor/kbench-state-machine
chore/upgrade-dependencies
```
### Branching Rules
**✅ ALWAYS use feature branches for:**
- Code changes (new features, bug fixes, refactors)
- Test updates
- CI/CD workflow changes
- Dependency updates
**✅ MAY push directly to main for:**
- Documentation-only changes (README, CLAUDE.md, comments)
- Version bump commits (`package.json` + `artifacthub-pkg.yml`)
**❌ NEVER push directly to main for:**
- Any code changes to `src/`
- Test file changes
- Workflow changes
## Commit Message Guidelines
We follow [Conventional Commits](https://www.conventionalcommits.org/) format:
### Format
```
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
```
### Types
- **feat:** New feature
- **fix:** Bug fix
- **docs:** Documentation only
- **style:** Code style (formatting, no logic change)
- **refactor:** Code change that neither fixes a bug nor adds a feature
- **perf:** Performance improvement
- **test:** Adding or updating tests
- **chore:** Maintenance tasks (deps, build, CI)
- **ci:** CI/CD changes
### Scope (Optional)
- `api` — API-related changes
- `ui` — UI component changes
- `metrics` — Prometheus metrics changes
- `kbench` — Benchmark changes
- `tests` — Test-related changes
- `docs` — Documentation changes
### Examples
```bash
feat(ui): add PV clone button to Volumes detail panel
fix(api): extract jsonData from headlamp KubeObject instances for parameter access
docs: add RBAC examples for Benchmark page
chore: bump version to 0.2.0
test(kbench): add FIO log parser edge case tests
```
### Footer
Add `Co-Authored-By` for pair programming or AI assistance:
```
feat: add NVMe-oF protocol notes to StorageClass detail panel
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
```
## Pull Request Process
### Before Creating a PR
1. **Run all checks locally:**
```bash
npm run build # Verify build succeeds
npm run lint # Check for linting errors
npm run tsc # Type-check TypeScript
npm test # Run 159 unit tests
```
2. **Update documentation:**
- Update README.md if you added features or changed behavior
- Update CLAUDE.md if you changed architecture or constraints
- Add JSDoc comments for new exported APIs
3. **Write/update tests:**
- Add unit tests for new functions/components
- Ensure all 159 tests (plus yours) pass
### Creating a PR
1. **Push your branch:**
```bash
git push origin feat/your-feature
```
2. **Create PR on GitHub:**
- Use a descriptive title following commit conventions
- Link related issues with `Fixes #123` or `Closes #456`
3. **PR Title Format:**
```
feat: add VolumeSnapshot creation from Volumes page
fix: correct FIO log parser for multi-job output
docs: improve Benchmark RBAC setup guide
```
4. **PR Description Should Include:**
- Summary of changes
- Motivation and context
- Testing performed (which cluster/driver version)
- Screenshots for UI changes
- Breaking changes (if any)
### PR Review Process
1. **Automated Checks:**
- ✅ CI workflow (lint, type-check, build, test)
2. **Maintainer Review:**
- Code quality and style
- Test coverage
- Documentation completeness
- No new `any` types introduced
3. **Merging:**
- Use **merge commits** (not squash, not rebase)
- Delete feature branch after merge
## Code Style
### TypeScript
- **Strictness:** Full TypeScript strict mode — zero `any` types
- **Unknown at boundaries:** Use `unknown` + type guards at API boundaries (headlamp hooks, ApiProxy responses)
- **Interfaces over types:** Prefer `interface` for object shapes
- **No class components:** Functional components with hooks only
### React
- **Functional components only** — no class components
- **Props interfaces:** Always define props as named interfaces
- **Headlamp components:** Use only `@kinvolk/headlamp-plugin/lib/CommonComponents` — no direct MUI imports
- **Detail panels:** Follow the slide-in drawer pattern — URL hash state, Escape to close, backdrop overlay
### Headlamp KubeObject Access
Headlamp's `useList()` hooks return KubeObject class instances that store raw JSON under `.jsonData`. Always extract `jsonData` before passing objects to plain-object type helpers:
```typescript
// ✅ Correct — extract jsonData so .parameters, .spec, .status are accessible
const rawItems = items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
// ❌ Wrong — sc.parameters will be undefined on KubeObject instances
const scs = (allStorageClasses as unknown[]).filter(isTnsCsiStorageClass);
```
### Linting and Formatting
```bash
npm run lint # ESLint
npm run tsc # TypeScript check
```
### Naming Conventions
- **Components:** PascalCase (`OverviewPage`, `BenchmarkPage`)
- **Files:** Match component name (`OverviewPage.tsx`)
- **Hooks:** Prefix with `use` (`useTnsCsiContext`)
- **Utilities:** camelCase (`formatProtocol`, `parsePrometheusText`)
- **Constants:** UPPER_SNAKE_CASE (`TNS_CSI_PROVISIONER`)
### Import Organization
1. React imports
2. Third-party libraries
3. Headlamp plugin imports (`@kinvolk/headlamp-plugin/lib`)
4. Local imports (components, API, types)
## Testing Requirements
### Unit Tests (Required)
All 159 tests must pass before committing:
```bash
npm test # vitest run
npm run tsc # must exit 0
```
- All new functions must have unit tests
- Bug fixes should include regression tests
- Use descriptive test names
**Mock pattern for headlamp APIs:**
```typescript
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn().mockResolvedValue({ items: [] }) },
K8s: {
ResourceClasses: {
StorageClass: { useList: vi.fn(() => [[], null]) },
PersistentVolume: { useList: vi.fn(() => [[], null]) },
PersistentVolumeClaim: { useList: vi.fn(() => [[], null]) },
},
},
}));
```
### Test File Structure
```
src/api/k8s.test.ts -- Type guards, filter helpers, format utilities
src/api/metrics.test.ts -- Prometheus text parser
src/api/kbench.test.ts -- FIO log parser, manifest builders, format helpers
src/api/TnsCsiDataContext.test.tsx -- Context provider integration
```
## Documentation
### Documentation Updates Required
When making changes, update relevant documentation:
#### Code Changes
- **README.md** — User-facing features, installation, configuration, troubleshooting table
- **CLAUDE.md** — Architecture constraints, key constants, subagent guidance
- **CHANGELOG.md** — Add entry under `[Unreleased]`
- **JSDoc** — All exported functions and components
#### Architecture Changes
- **docs/architecture/overview.md** — If the data flow or component structure changes
- **CLAUDE.md** — Update architecture section
### JSDoc Style
Use JSDoc for all exported functions and types:
```typescript
/**
* Parses Prometheus text format exposition into a flat key→value map.
*
* Ignores comment lines and HELP/TYPE metadata. Returns only the last
* sample value for each unique metric+label combination.
*
* @param text - Raw Prometheus text format string
* @returns Map of metric name (with labels) to numeric value
*/
export function parsePrometheusText(text: string): Map<string, number> {
// ...
}
```
## Release Process
### Version Numbering
We follow [Semantic Versioning](https://semver.org/):
- **Major (1.0.0):** Breaking changes
- **Minor (0.1.0):** New features, backward compatible
- **Patch (0.0.1):** Bug fixes, backward compatible
### Creating a Release (Maintainers Only)
1. **Update CHANGELOG.md:**
- Move items from `[Unreleased]` to a new `[X.Y.Z] - YYYY-MM-DD` section
2. **Trigger the release workflow:**
- Go to **Actions → Release → Run workflow**
- Enter the version number (e.g., `0.2.0`)
3. **GitHub Actions automatically:**
- Updates `package.json` and `artifacthub-pkg.yml` version
- Builds plugin tarball
- Computes SHA256 checksum and updates metadata
- Commits, creates tag, and publishes GitHub release
4. **ArtifactHub syncs within 30 minutes**
### Pre-release Versions
For testing before stable release, use `-rc.N` suffix: `v0.2.0-rc.1`. Mark as "pre-release" on GitHub.
## Getting Help
- **Questions:** Open a [GitHub Discussion](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/discussions)
- **Bugs:** Open a [GitHub Issue](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues)
- **Architecture:** See [CLAUDE.md](CLAUDE.md) and [docs/architecture/overview.md](docs/architecture/overview.md)
## License
By contributing, you agree that your contributions will be licensed under the Apache-2.0 License.
+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.
+279
View File
@@ -0,0 +1,279 @@
# 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)
A [Headlamp](https://headlamp.dev/) plugin that surfaces [tns-csi](https://github.com/fenio/tns-csi) CSI driver visibility and kbench storage benchmarking directly in the Headlamp UI.
**[Documentation](#documentation) | [Installation](#installing) | [Security](#rbac--security-setup) | [Development](#development)**
## What It Does
Adds a **TrueNAS (tns-csi)** top-level sidebar section to Headlamp with full CSI driver observability and interactive storage benchmarking:
### Main Views
- **Overview Dashboard** — driver health card, storage summary (StorageClass / PV / PVC counts), protocol distribution, PercentageBar for Bound vs non-Bound PVCs, non-Bound PVC alert table, and live Prometheus metric snapshot
- **Storage Classes** — table of tns-csi StorageClasses with Protocol, Pool, Server, Reclaim Policy, Expansion, and PV count columns; click a row for a slide-in detail panel including protocol-specific prerequisite notes
- **Volumes** — table of tns-csi PersistentVolumes with capacity, access modes, reclaim policy, status badge, and bound claim; slide-in detail panel with full CSI volume attributes
- **Snapshots** — table of VolumeSnapshots scoped to tns-csi VolumeSnapshotClasses; shows ready status, size, source PVC, and class; graceful degradation when snapshot CRD is absent
- **Metrics** — Prometheus WebSocket health indicator, per-volume I/O (read/write IOPS and bandwidth from the controller pod), and CSI operation latency cards
- **Benchmark** — interactive kbench runner: select a tns-csi StorageClass, configure capacity and access mode, then run/stop a kbench Job+PVC lifecycle; live FIO log streaming with IOPS, bandwidth, and latency result cards
### Integrated Features
- **PVC Detail Injection** — TNS-CSI section automatically injected into Headlamp's PVC detail views showing protocol, server, pool, volume handle, and link to the bound PV
- **Dark Mode Support** — full theme adaptation using MUI CSS variables across all panels and drawers
- **Graceful Degradation** — Snapshot CRD absence is detected silently; missing Prometheus data shows placeholder cards rather than errors
- **kbench Lifecycle Management** — automatically creates and cleans up the benchmark Job and PVC; `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` label guards all managed resources
### Data & Refresh
StorageClasses, PersistentVolumes, and PVCs are fetched via Headlamp's `K8s.ResourceClasses` hooks (live watch). Driver pods, the CSIDriver object, VolumeSnapshots, and Prometheus metrics are fetched via `ApiProxy.request`. Metrics are polled from the tns-csi controller pod at port `8080` using the Prometheus text format parser.
The plugin is **read-only** except for the Benchmark page, which creates and deletes a Job and PVC in the namespace you select.
## Prerequisites
| Requirement | Minimum version |
| --------------------- | --------------- |
| Headlamp | v0.20+ |
| tns-csi driver | Any release |
| Kubernetes | v1.24+ |
| snapshot CRD (optional) | v1 |
The tns-csi driver must be deployed in `kube-system` with the standard `app.kubernetes.io/name=tns-csi-driver` labels. The controller pod must expose Prometheus metrics on port `8080`.
## Installing
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin). Install via the Headlamp UI:
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:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
## RBAC / Security Setup
The plugin reads from the Kubernetes API and the tns-csi controller pod's Prometheus endpoint (deployed in `kube-system`). The Benchmark page additionally creates and deletes Jobs and PVCs.
### Minimal read-only permissions
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: headlamp-tns-csi-reader
rules:
- apiGroups: [""]
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
verbs: ["get", "list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "csidrivers"]
verbs: ["get", "list", "watch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get"]
- apiGroups: [""]
resources: ["pods/proxy"]
verbs: ["get"]
resourceNames: ["pods"]
```
### Additional permissions for Benchmark page
```yaml
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["create", "delete"]
```
### Metrics access
The plugin fetches Prometheus metrics from the tns-csi controller pod via the Kubernetes pod proxy sub-resource in `kube-system`. Grant `get` on `pods/proxy` scoped to `kube-system`:
```yaml
- apiGroups: [""]
resources: ["pods/proxy"]
verbs: ["get"]
# Scope to kube-system where the tns-csi controller runs
```
Apply the role and bind it to your Headlamp service account with a ClusterRoleBinding.
## Documentation
**[Complete Documentation](docs/README.md)** — Documentation hub with all guides
### Quick Links
- **[Architecture](docs/architecture/overview.md)** — System architecture, data flow, component hierarchy
- **[Deployment](docs/deployment/helm.md)** — Production deployment with Helm, FluxCD
- **[Troubleshooting](docs/troubleshooting/README.md)** — Common issues and diagnosis
- **[Contributing](CONTRIBUTING.md)** — Development workflow, branching strategy, PR process
- **[Security](SECURITY.md)** — Security model, RBAC, vulnerability reporting
- **[Changelog](CHANGELOG.md)** — Complete release history
## Troubleshooting
**For comprehensive troubleshooting, see [docs/troubleshooting/README.md](docs/troubleshooting/README.md).**
Quick reference:
| Symptom | Likely Cause | Quick Fix |
| ------- | ------------ | --------- |
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
| **No StorageClasses listed** | Driver not installed or wrong provisioner | Verify `kubectl get sc` shows `tns.csi.io` provisioner |
| **Driver status "Not installed"** | CSIDriver object missing | Check `kubectl get csidriver tns.csi.io` |
| **Protocol/Pool/Server showing "—"** | StorageClass has no parameters | Inspect `kubectl get sc <name> -o yaml` |
| **Metrics page empty** | Controller pod unreachable or no metrics port | Check controller pod logs and port 8080 |
| **Snapshots tab empty** | Snapshot CRD not installed | Install `snapshot.storage.k8s.io` CRDs |
| **Benchmark fails to start** | Missing RBAC for Jobs/PVCs | Add batch/jobs create+delete permissions |
## Development
**For detailed development guide, see [CONTRIBUTING.md](CONTRIBUTING.md).**
### Quick Start
```bash
# Clone repository
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
cd headlamp-tns-csi-plugin
# Install dependencies
npm install
# Run with hot reload
npm start # Opens Headlamp at http://localhost:4466
# Build for production
npm run build # outputs dist/main.js
npm run package # creates tns-csi-<version>.tar.gz
# Run tests
npm test # 159 unit tests
npm run test:watch # watch mode
# Code quality
npm run lint # eslint
npm run tsc # type-check
```
## Project Structure
```
src/
index.tsx -- Entry point. Registers sidebar entries, routes,
detail section injection, and plugin settings.
api/
k8s.ts -- TypeScript types and filtering helpers for tns-csi
resources (StorageClass, PV, PVC, Pod, Snapshot).
metrics.ts -- Prometheus text format parser; fetchControllerMetrics
via ApiProxy from controller pod port 8080.
kbench.ts -- kbench Job+PVC manifest builders, FIO log parser,
BenchmarkState discriminated union, format helpers.
TnsCsiDataContext.tsx -- React context provider; shared data fetch across
all pages (StorageClasses, PVs, PVCs, pods, driver).
components/
OverviewPage.tsx -- Dashboard: driver health, storage summary,
protocol distribution, non-Bound PVC alerts.
StorageClassesPage.tsx -- StorageClass list + slide-in detail panel.
VolumesPage.tsx -- PV list + slide-in detail panel.
SnapshotsPage.tsx -- VolumeSnapshot list + slide-in detail panel.
MetricsPage.tsx -- Prometheus metrics display cards.
BenchmarkPage.tsx -- Interactive kbench runner (ONLY write operation).
DriverStatusCard.tsx -- Driver health/status card component.
PVCDetailSection.tsx -- TNS-CSI section injected into PVC detail views.
vitest.config.mts -- Vitest configuration (jsdom environment).
vitest.setup.ts -- localStorage shim for Node 22+.
```
## Key Technical Details
### Provisioner
All resources are filtered to provisioner `tns.csi.io`. StorageClasses with any other provisioner are invisible to the plugin.
### Driver Component Labels
| Component | Label Selector |
| --------- | -------------- |
| 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` |
### Metrics Endpoint
The plugin fetches Prometheus text format metrics from:
```
GET /api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics
```
Extracted metrics include `kubelet_volume_stats_*`, `csi_operations_seconds_*`, and any custom tns-csi metrics exposed on port `8080`.
### kbench Benchmarks
The Benchmark page creates resources labeled `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin`. It uses the `yasker/kbench:latest` image and runs a configurable FIO test. Results are parsed from the Job's pod log into IOPS, bandwidth (MB/s), and latency (µs) cards.
## Releasing
Releases are automated via **GitHub Actions**. To cut a release:
```bash
# 1. Update CHANGELOG.md with new version
# 2. Trigger the release workflow from GitHub Actions UI:
# Actions → Release → Run workflow → enter version X.Y.Z
```
This triggers the **GitHub Actions** release workflow (`.github/workflows/release.yaml`):
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`
5. Commit, tag, and create a GitHub release with the tarball attached
ArtifactHub syncs within 30 minutes. The new version will appear in the Headlamp plugin catalog automatically.
## Contributing
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Development workflow
- Branching strategy (feature branches required for code changes)
- Commit message conventions (Conventional Commits)
- PR process and review checklist
- Code style guidelines
- Testing requirements
## Links
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)** — Source code, issues, releases
- **[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
## License
[Apache-2.0 License](LICENSE) — see LICENSE file for details.
+243
View File
@@ -0,0 +1,243 @@
# Security Policy
## Overview
The Headlamp TNS-CSI Plugin is a visibility and benchmarking tool for the tns-csi Kubernetes CSI driver. Security considerations center on Kubernetes RBAC, network policies, and the limited write operations performed by the Benchmark page.
## Security Model
### Primarily Read-Only
The plugin is **read-only** for all pages except Benchmark:
- **No secrets access**: The plugin does not read or store Kubernetes Secrets
- **No CRD installation**: No custom resource definitions or cluster-level modifications
- **No PII**: CSI resource metadata (names, namespaces, parameters) does not contain personally identifiable information
- **No external egress**: All API calls go through the Kubernetes API server proxy; no external network calls
### The Benchmark Exception
The Benchmark page creates and deletes a Kubernetes Job and PVC to run storage benchmarks. These resources are:
- Labeled `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` for identification
- Created only in the namespace the user explicitly selects
- Automatically deleted when the benchmark completes or is stopped
- Using the `yasker/kbench:latest` image (a public, well-known benchmark tool)
Grant benchmark write permissions only to users who should be able to initiate storage tests.
### Data Flow
```
User Browser
↓ (HTTPS)
Headlamp Pod
↓ (in-cluster service account or user token)
Kubernetes API Server
↓ (list/watch StorageClasses, PVs, PVCs, pods, snapshots)
↓ (pod proxy: controller pod port 8080 → Prometheus metrics)
↓ (pod proxy: kbench pod → FIO log for benchmark results)
Plugin Frontend (React)
```
All communication uses Kubernetes authentication and authorization mechanisms. The plugin never stores credentials or bypasses RBAC.
## RBAC Requirements
### Minimal Read-Only Permissions
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: headlamp-tns-csi-reader
rules:
- apiGroups: [""]
resources: ["persistentvolumes", "pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "csidrivers"]
verbs: ["get", "list", "watch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log", "pods/proxy"]
verbs: ["get"]
# pods/proxy is used to fetch Prometheus metrics from the controller pod
```
### Additional Permissions for Benchmark Page
```yaml
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["create", "delete"]
```
### Binding Example
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-tns-csi
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
apiGroup: rbac.authorization.k8s.io
```
### ⚠️ Security Best Practices
1. **Principle of Least Privilege**: Grant benchmark write permissions (`jobs create/delete`, `persistentvolumeclaims create/delete`) only to users who need them
2. **Namespace Scoping for Benchmarks**: If possible, restrict benchmark Job/PVC permissions to a dedicated benchmark namespace using a namespaced Role rather than ClusterRole
3. **Pod Proxy Scoping**: Scope `pods/proxy` access to `kube-system` only, or to pods matching the tns-csi controller label
4. **Audit Logging**: Enable Kubernetes audit logging to track all API requests made through the plugin
5. **Image Pinning**: Consider pinning `yasker/kbench:latest` to a specific digest in your environment for supply chain security
## Network Security
### Network Policies
If your cluster uses NetworkPolicies, ensure the Kubernetes API server can proxy requests to the tns-csi controller pod on port `8080`:
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-api-server-to-tns-csi-controller
namespace: kube-system
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: tns-csi-driver
app.kubernetes.io/component: controller
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 8080
```
The Kubernetes API server performs the pod proxy hop, so policies should permit the API server (not Headlamp directly) to reach the controller pod.
### TLS/HTTPS
- **External Access**: Always access Headlamp over HTTPS
- **Internal Communication**: Headlamp to API server uses the service account token over the cluster's internal network
- **Pod Proxy**: API server → tns-csi controller happens over HTTP within the cluster (port 8080)
## Authentication Methods
### Service Account (Default)
Headlamp runs with a dedicated service account (`headlamp` in the namespace where Headlamp is installed). All users share the same RBAC permissions.
**Security Considerations:**
- All users have identical access to plugin functionality including Benchmark
- Suitable for trusted internal environments
- Simpler RBAC management
### OIDC Token Authentication
Headlamp can use per-user OIDC tokens. RBAC is enforced per-user, enabling fine-grained access control:
- Read-only users: bind only the reader ClusterRole
- Benchmark users: bind the additional write permissions
- Users without permissions see appropriate 403 errors
## Vulnerability Reporting
### Supported Versions
Security updates are applied to the latest release only.
| Version | Supported |
| ------- | ------------------ |
| latest | ✅ |
| < latest | ❌ |
### Reporting a Vulnerability
Report security vulnerabilities via:
1. **GitHub Security Advisories**: [Report a vulnerability](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/security/advisories/new)
2. **GitHub Issues**: Open an issue and mark it "security" if advisories are unavailable
**Please do not** open public GitHub issues for security vulnerabilities before a fix is available.
**Response Timeline:**
- **Acknowledgment**: Within 48 hours
- **Initial Assessment**: Within 1 week
- **Fix Timeline**: Critical: 12 weeks; High: 24 weeks; Medium/Low: next release cycle
## Dependency Security
The project uses:
- **npm audit**: Runs automatically during `npm install`
- **Renovate**: Automated dependency updates via Mend Renovate (org-wide configured)
Headlamp itself (`@kinvolk/headlamp-plugin`) is a peer dependency. Security updates to Headlamp should be applied by upgrading your Headlamp installation.
**Minimum supported Headlamp version**: v0.20.0
## Deployment Security Checklist
Before deploying to production:
- [ ] **RBAC configured**: ClusterRole and ClusterRoleBinding exist for Headlamp service account
- [ ] **Benchmark permissions scoped**: Write permissions granted only to appropriate users/groups
- [ ] **Network policies**: Allow API server → tns-csi controller traffic on port 8080
- [ ] **TLS enabled**: Headlamp accessible only via HTTPS
- [ ] **Audit logging enabled**: Kubernetes API audit logs capture requests
- [ ] **Plugin version**: Running latest release
- [ ] **Dependencies audited**: `npm audit` shows no critical vulnerabilities
## Compliance
### Data Residency
All data remains within your Kubernetes cluster. The plugin does not:
- Send data to external services
- Store data in browser localStorage (except any future settings)
- Use third-party analytics or tracking
### Audit Trail
All API requests are logged in Kubernetes API audit logs (if enabled). Pod proxy requests to the controller pod's metrics endpoint appear as:
```json
{
"verb": "get",
"requestURI": "/api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics",
"user": {
"username": "system:serviceaccount:<your-namespace>:headlamp"
}
}
```
### Privacy
The plugin processes only technical metadata (resource names, namespaces, CSI parameters, metrics values). No personal data is collected, stored, or transmitted.
## Contact
- **Security Issues**: [GitHub Security Advisories](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/security/advisories)
- **General Questions**: [GitHub Discussions](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/discussions)
- **Bug Reports**: [GitHub Issues](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues)
## License
This plugin is provided under the Apache-2.0 License. See [LICENSE](LICENSE) for details.
+69
View File
@@ -0,0 +1,69 @@
version: "1.0.3"
name: headlamp-tns-csi-plugin
displayName: TrueNAS CSI (tns-csi)
description: >-
Headlamp plugin for tns-csi CSI driver visibility and kbench storage
benchmarking. Surfaces StorageClasses with protocol/pool/server details,
PersistentVolumes, VolumeSnapshots, Prometheus metrics from the controller
pod, and an interactive kbench benchmark runner with FIO result cards.
Supports NFS, NVMe-oF, and iSCSI protocols. Read-only except for the
Benchmark page.
createdAt: "2026-02-18T00:00:00Z"
license: Apache-2.0
category: storage
homeURL: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
appVersion: "0.17.4"
keywords:
- headlamp
- kubernetes
- storage
- csi
- tns-csi
- truenas
- nfs
- nvmeof
- iscsi
- kbench
- benchmarking
- prometheus
maintainers:
- name: privilegedescalation
email: chris@farhood.org
provider:
name: privilegedescalation
links:
- name: GitHub
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
- name: Issues
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues
- name: tns-csi driver
url: https://github.com/fenio/tns-csi
- name: kbench
url: https://github.com/longhorn/kbench
changes:
- 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/v1.0.3/headlamp-tns-csi-plugin-1.0.3.tar.gz"
headlamp/plugin/archive-checksum: sha256:8a032919de65f9ed45a06f4110083cceb11b91625d97f7b49075adecf38e3adc
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
+6
View File
@@ -0,0 +1,6 @@
# Artifact Hub repository metadata
repositoryID: cae81660-2624-4e02-8ac2-a176cbe94402
owners:
- name: privilegedescalation
email: ""
+20
View File
@@ -0,0 +1,20 @@
{
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
// and do NOT ship in production plugin artifacts.
"allowlist": [
{
"id": "GHSA-hhpm-516h-p3p6",
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-36xf-7xpp-53w5",
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
},
{
"id": "GHSA-jf8v-p3pp-93qh",
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
}
]
}
+73
View File
@@ -0,0 +1,73 @@
# TNS-CSI Plugin Documentation
Welcome to the Headlamp TNS-CSI Plugin documentation.
## Quick Links
- **[Quick Start](getting-started/quick-start.md)** — Get up and running in 5 minutes
- **[Installation Guide](getting-started/installation.md)** — All installation methods
- **[Troubleshooting](troubleshooting/README.md)** — Common issues and fixes
## Documentation Index
### Getting Started
| Guide | Description |
| ----- | ----------- |
| [Quick Start](getting-started/quick-start.md) | Fastest path to a working installation |
| [Installation](getting-started/installation.md) | Plugin Manager, manual tarball, build from source |
| [Prerequisites](getting-started/prerequisites.md) | Headlamp version, tns-csi driver, RBAC |
### User Guide
| Guide | Description |
| ----- | ----------- |
| [Overview Dashboard](user-guide/overview.md) | Driver health, storage summary, protocol distribution |
| [Storage Classes](user-guide/storage-classes.md) | StorageClass list and detail panel |
| [Volumes](user-guide/volumes.md) | PersistentVolume list and detail panel |
| [Snapshots](user-guide/snapshots.md) | VolumeSnapshot list and CRD requirements |
| [Metrics](user-guide/metrics.md) | Prometheus metrics display |
| [Benchmark](user-guide/benchmark.md) | kbench interactive storage benchmarking |
| [PVC Detail Injection](user-guide/pvc-detail.md) | TNS-CSI section in PVC detail views |
| [RBAC Permissions](user-guide/rbac.md) | Required permissions per feature |
### Architecture
| Guide | Description |
| ----- | ----------- |
| [Overview](architecture/overview.md) | System architecture, data flow, component hierarchy |
| [Data Flow](architecture/data-flow.md) | How data moves from K8s API to the UI |
| [Design Decisions](architecture/design-decisions.md) | Key architectural choices and rationale |
### Deployment
| Guide | Description |
| ----- | ----------- |
| [Helm](deployment/helm.md) | Deploy with Helm (recommended) |
| [Production Checklist](deployment/production.md) | Security and reliability checklist |
### Troubleshooting
| Guide | Description |
| ----- | ----------- |
| [Common Issues](troubleshooting/README.md) | Quick diagnosis table |
| [RBAC Issues](troubleshooting/rbac.md) | 403 errors, missing permissions |
| [Driver Detection](troubleshooting/driver.md) | Driver not installed, wrong provisioner |
| [Metrics Issues](troubleshooting/metrics.md) | Empty metrics page, unreachable controller |
| [Benchmark Issues](troubleshooting/benchmark.md) | Benchmark fails to start or complete |
### Development
| Guide | Description |
| ----- | ----------- |
| [Development Setup](development/setup.md) | Clone, install, run dev server |
| [Testing](development/testing.md) | Unit tests, mocking headlamp APIs |
| [Release Process](development/release.md) | How releases are cut and published |
## External Links
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)**
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)**
- **[tns-csi Driver](https://github.com/fenio/tns-csi)**
- **[kbench](https://github.com/longhorn/kbench)**
- **[Headlamp](https://headlamp.dev/)**
@@ -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)
+140
View File
@@ -0,0 +1,140 @@
# Architecture Overview
## System Architecture
The TNS-CSI plugin is a single-page React application bundled as a Headlamp plugin. It runs entirely in the browser and communicates with Kubernetes exclusively through Headlamp's proxied API.
```
┌─────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ React Plugin Bundle │ │
│ │ │ │
│ │ index.tsx ── registerRoute/Sidebar/etc. │ │
│ │ │ │
│ │ TnsCsiDataProvider (React Context) │ │
│ │ ├── K8s.ResourceClasses hooks (live watch) │ │
│ │ └── ApiProxy.request (async fetch) │ │
│ │ │ │
│ │ Pages: │ │
│ │ OverviewPage StorageClassesPage │ │
│ │ VolumesPage SnapshotsPage │ │
│ │ MetricsPage BenchmarkPage │ │
│ │ │ │
│ │ PVCDetailSection (injected into PVC views) │ │
│ └──────────────────────────────────────────────┘ │
└───────────────────────┬─────────────────────────────┘
│ HTTPS
┌─────────────────────────────────────────────────────┐
│ Headlamp Pod (headlamp namespace) │
│ │
│ Headlamp UI server + API proxy │
│ (forwards requests using service account token │
│ or user-supplied OIDC token) │
└───────────────────────┬─────────────────────────────┘
│ in-cluster
┌─────────────────────────────────────────────────────┐
│ Kubernetes API Server │
│ │
│ ├── /apis/storage.k8s.io/v1/storageclasses │
│ ├── /api/v1/persistentvolumes │
│ ├── /api/v1/persistentvolumeclaims │
│ ├── /api/v1/namespaces/kube-system/pods │
│ ├── /apis/storage.k8s.io/v1/csidrivers │
│ ├── /apis/snapshot.storage.k8s.io/v1/... │
│ ├── /api/v1/namespaces/kube-system/pods/<pod>/proxy/metrics
│ └── (Benchmark) /apis/batch/v1/jobs │
└─────────────────────────────────────────────────────┘
```
## Component Hierarchy
```
index.tsx
└── TnsCsiDataProvider
├── OverviewPage
│ └── DriverStatusCard
├── StorageClassesPage
│ └── StorageClassDetailPanel (slide-in)
├── VolumesPage
│ └── VolumeDetailPanel (slide-in)
├── SnapshotsPage
│ └── SnapshotDetailPanel (slide-in)
├── MetricsPage
└── BenchmarkPage
registerDetailsViewSection
└── TnsCsiDataProvider
└── PVCDetailSection (injected)
```
## Data Sources
| Data | Source | Mechanism |
| ---- | ------ | --------- |
| StorageClasses | `storage.k8s.io/v1` | `K8s.ResourceClasses.StorageClass.useList()` — live watch |
| PersistentVolumes | `core/v1` | `K8s.ResourceClasses.PersistentVolume.useList()` — live watch |
| PersistentVolumeClaims | `core/v1` | `K8s.ResourceClasses.PersistentVolumeClaim.useList()` — live watch |
| CSIDriver | `storage.k8s.io/v1` | `ApiProxy.request` — one-shot fetch |
| Controller pods | `core/v1` | `ApiProxy.request` with label selector — one-shot fetch |
| Node pods | `core/v1` | `ApiProxy.request` with label selector — one-shot fetch |
| VolumeSnapshots | `snapshot.storage.k8s.io/v1` | `ApiProxy.request` — graceful degradation if CRD absent |
| Prometheus metrics | Controller pod port 8080 | `ApiProxy.request` pod proxy |
| kbench FIO logs | Benchmark Job pod | `ApiProxy.request` pod log |
## Key Design Decisions
### KubeObject jsonData Extraction
Headlamp's `useList()` hooks return KubeObject class instances, not plain JSON objects. The class only exposes getter-defined fields (`provisioner`, `reclaimPolicy`, `volumeBindingMode`, `allowVolumeExpansion` for StorageClass). All other fields — including `parameters`, `spec`, and `status` — must be accessed via `.jsonData`.
`TnsCsiDataContext.tsx` extracts `jsonData` from every item before passing to filter/type helpers:
```typescript
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
```
This is the single most important architectural invariant to preserve when working with headlamp hook data.
### Context Provider Pattern
`TnsCsiDataProvider` wraps every route component. This ensures:
- All data fetching happens once per page navigation (not once per component)
- All pages share the same filtered StorageClasses, PVs, PVCs, and pod lists
- The `refresh()` callback triggers a `refreshKey` increment which re-runs async fetches
### Read-Only Constraint
The only write operation in the entire plugin is `BenchmarkPage.tsx`, which creates and deletes a Kubernetes Job and PVC. All other pages are strictly read-only. This is intentional and should be preserved.
### Detail Panel Pattern
Slide-in detail panels use URL hash state (`location.hash`) so:
- Panel state survives browser refresh
- Back button closes the panel
- Deep-linking to a specific resource is possible
Pattern: `history.push(\`\${location.pathname}#\${name}\`)` to open, `history.push(location.pathname)` to close.
### Graceful Degradation
The snapshot CRD (`snapshot.storage.k8s.io/v1`) may not be installed. The context provider catches the 404/405 error and sets `snapshotCrdAvailable: false`. The Snapshots page shows an informational message instead of an error. Prometheus metrics similarly fall back to placeholder cards.
## Module Responsibilities
| File | Responsibility |
| ---- | -------------- |
| `src/index.tsx` | All registrations — sidebar entries, routes, detail section, plugin settings |
| `src/api/k8s.ts` | Type definitions, type guards, filter helpers, format utilities |
| `src/api/metrics.ts` | Prometheus text format parser, `fetchControllerMetrics` |
| `src/api/kbench.ts` | kbench manifest builders, FIO log parser, `BenchmarkState` discriminated union |
| `src/api/TnsCsiDataContext.tsx` | Shared data fetching and filtering; the `extractJsonData` pattern |
| `src/components/*.tsx` | Page and panel UI components |
+154
View File
@@ -0,0 +1,154 @@
# Deployment with Helm
## Basic Helm Installation
Add the Headlamp Helm repository and deploy with the plugin configured:
```bash
helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
helm repo update
helm install headlamp headlamp/headlamp \
--namespace <your-namespace> \
--create-namespace \
--set config.pluginsDir=/headlamp/plugins \
--set pluginsManager.sources[0].name=tns-csi \
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
## Complete values.yaml Example
```yaml
# headlamp-values.yaml
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
serviceAccount:
name: headlamp
# Optional: OIDC authentication
# oidcConfig:
# clientID: headlamp
# clientSecret: <your-secret>
# issuerURL: https://your-oidc-provider.example.com/
# scopes: "openid profile email groups"
```
Apply:
```bash
helm install headlamp headlamp/headlamp \
--namespace <your-namespace> \
-f headlamp-values.yaml
```
## FluxCD HelmRelease
```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: headlamp
namespace: <your-namespace>
spec:
interval: 12h
url: https://headlamp-k8s.github.io/headlamp/
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: <your-namespace>
spec:
interval: 1h
chart:
spec:
chart: headlamp
version: ">=0.26.0"
sourceRef:
kind: HelmRepository
name: headlamp
namespace: <your-namespace>
values:
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
## RBAC Manifest (Apply Separately)
After deploying Headlamp, apply the plugin's RBAC:
```bash
kubectl apply -f - <<'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: headlamp-tns-csi-reader
rules:
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "csidrivers"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log", "pods/proxy"]
verbs: ["get"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
# Uncomment for Benchmark page:
# - apiGroups: ["batch"]
# resources: ["jobs"]
# verbs: ["get", "list", "watch", "create", "delete"]
# - apiGroups: [""]
# resources: ["persistentvolumeclaims"]
# verbs: ["create", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-tns-csi
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
apiGroup: rbac.authorization.k8s.io
EOF
```
## Upgrading the Plugin
To upgrade to a new plugin version, update the `url` in your values and apply:
```bash
helm upgrade headlamp headlamp/headlamp \
--namespace <your-namespace> \
-f headlamp-values.yaml
```
Or update the FluxCD HelmRelease and let Flux reconcile.
## Production Checklist
- [ ] Headlamp v0.20+ deployed
- [ ] Plugin installed and sidebar entry visible
- [ ] RBAC ClusterRole and ClusterRoleBinding applied
- [ ] tns-csi driver installed in `kube-system` with standard labels
- [ ] Controller pod exposes port 8080 for Prometheus metrics
- [ ] Headlamp accessible via HTTPS
- [ ] (Optional) Snapshot CRD installed for Snapshots tab
- [ ] (Optional) Benchmark namespace created and write RBAC applied
+149
View File
@@ -0,0 +1,149 @@
# Testing Guide
## Test Suite Overview
The plugin has **159 unit tests** across 12 test files:
| File | Tests | Coverage |
| ---- | ----- | -------- |
| `src/api/k8s.test.ts` | Type guards, filter helpers, format utilities | k8s.ts |
| `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
```bash
# Run all tests once
npm test
# Watch mode (re-runs on file changes)
npm run test:watch
# TypeScript type-check (no emit)
npm run tsc
```
All tests must pass before committing. The CI workflow enforces this.
## Test Framework
- **Vitest** — test runner
- **@testing-library/react** — React component testing utilities
- **jsdom** — DOM environment (configured in `vitest.config.mts`)
## Mocking Headlamp APIs
Headlamp APIs must be mocked in tests. Use this pattern:
```typescript
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({ items: [] }),
},
K8s: {
ResourceClasses: {
StorageClass: {
useList: vi.fn(() => [[], null]),
},
PersistentVolume: {
useList: vi.fn(() => [[], null]),
},
PersistentVolumeClaim: {
useList: vi.fn(() => [[], null]),
},
},
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
SimpleTable: ({ data, emptyMessage }: { data: unknown[]; emptyMessage: string }) =>
data.length === 0 ? <p>{emptyMessage}</p> : <table />,
Loader: ({ title }: { title: string }) => <div>{title}</div>,
// add other CommonComponents as needed
}));
```
## Testing the Prometheus Parser
```typescript
import { parsePrometheusText, extractTnsCsiMetrics } from '../metrics';
it('parses gauge metrics correctly', () => {
const text = `
# HELP kubelet_volume_stats_capacity_bytes Capacity in bytes of the volume
# TYPE kubelet_volume_stats_capacity_bytes gauge
kubelet_volume_stats_capacity_bytes{namespace="default",persistentvolumeclaim="my-pvc"} 10737418240
`;
const metrics = parsePrometheusText(text);
expect(metrics.get('kubelet_volume_stats_capacity_bytes{namespace="default",persistentvolumeclaim="my-pvc"}')).toBe(10737418240);
});
```
## Testing the FIO Log Parser
```typescript
import { parseKbenchLog } from '../kbench';
it('parses kbench FIO output into result cards', () => {
const log = `
READ: bw=512MiB/s (537MB/s), 512MiB/s-512MiB/s (537MB/s-537MB/s), io=32.0GiB (34.4GB), run=63999-63999msec
iops : min=128000, max=135000, avg=131072.00, stdev=1024.00, samples=64
lat (usec) : min=10, max=500, avg=50.00, stdev=20.00
`;
const result = parseKbenchLog(log);
expect(result).not.toBeNull();
expect(result!.readBandwidthMBs).toBeGreaterThan(0);
});
```
## Testing Type Guards
```typescript
import { isTnsCsiStorageClass } from '../k8s';
it('identifies tns.csi.io provisioner', () => {
expect(isTnsCsiStorageClass({ provisioner: 'tns.csi.io', metadata: { name: 'test' } })).toBe(true);
expect(isTnsCsiStorageClass({ provisioner: 'other.csi.io', metadata: { name: 'test' } })).toBe(false);
expect(isTnsCsiStorageClass(null)).toBe(false);
expect(isTnsCsiStorageClass(undefined)).toBe(false);
});
```
## vitest.setup.ts
The setup file shims `localStorage` for Node 22+ (jsdom doesn't provide it in some versions):
```typescript
// vitest.setup.ts
if (typeof localStorage === 'undefined') {
const store: Record<string, string> = {};
Object.defineProperty(global, 'localStorage', {
value: {
getItem: (k: string) => store[k] ?? null,
setItem: (k: string, v: string) => { store[k] = v; },
removeItem: (k: string) => { delete store[k]; },
clear: () => { Object.keys(store).forEach(k => delete store[k]); },
},
});
}
```
## CI Test Enforcement
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.
All three checks must pass for the PR to merge.
+132
View File
@@ -0,0 +1,132 @@
# Installation Guide
## Installation Methods
### Method 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin).
**Via Headlamp UI:**
1. Navigate to **Settings → Plugins → Catalog**
2. Search for "TNS CSI" or "TrueNAS"
3. Click **Install**
4. Refresh the page
**Via Helm values:**
```yaml
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
**Via FluxCD HelmRelease:**
```yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: <your-namespace>
spec:
chart:
spec:
chart: headlamp
sourceRef:
kind: HelmRepository
name: headlamp
values:
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
### Method 2: Manual Tarball Install
Download and extract the plugin directly:
```bash
# Download the release tarball
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
# Extract into your Headlamp plugins directory
tar xzf tns-csi-1.0.0.tar.gz -C /headlamp/plugins/
```
The plugin directory should appear as `/headlamp/plugins/tns-csi/`.
Restart Headlamp (or the pod) after extracting.
### Method 3: Sidecar Container
For Headlamp deployments where you prefer managing plugins as container init sidecars:
```yaml
initContainers:
- name: install-tns-csi-plugin
image: alpine:3
command:
- sh
- -c
- |
wget -O /tmp/plugin.tar.gz \
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
tar xzf /tmp/plugin.tar.gz -C /headlamp/plugins/
volumeMounts:
- name: plugins
mountPath: /headlamp/plugins
```
### Method 4: Build from Source
For development or to test unreleased changes:
```bash
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
cd headlamp-tns-csi-plugin
npm install
npm run build
npm run package
# Produces tns-csi-<version>.tar.gz
# Extract to your Headlamp plugins directory
tar xzf tns-csi-<version>.tar.gz -C /headlamp/plugins/
```
Or use `headlamp-plugin extract` for automatic placement:
```bash
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
```
## Post-Installation
After installing the plugin:
1. **Configure RBAC** — see [RBAC Permissions](../user-guide/rbac.md)
2. **Verify the plugin loads** — refresh browser and look for "TrueNAS (tns-csi)" in the sidebar
3. **Check the Overview page** — driver health card should show tns-csi status
## Upgrading
To upgrade to a new version, repeat the installation method you used. The new tarball replaces the old plugin directory.
For Plugin Manager installs, the catalog will show available updates.
## Uninstalling
Remove the plugin directory from your Headlamp plugins directory:
```bash
rm -rf /headlamp/plugins/tns-csi/
```
Or via the Headlamp UI: **Settings → Plugins → tns-csi → Uninstall**.
+99
View File
@@ -0,0 +1,99 @@
# Quick Start
Get the TNS-CSI plugin running in Headlamp in about 5 minutes.
## Prerequisites
- Headlamp v0.20+ running in your cluster
- tns-csi driver installed in `kube-system`
- `kubectl` access to your cluster
## Step 1: Install the Plugin
### Via Headlamp UI (Easiest)
1. Open Headlamp and navigate to **Settings → Plugins → Catalog**
2. Search for **"TNS CSI"** or **"TrueNAS"**
3. Click **Install**
4. Refresh the browser
### Via Helm
Add the plugin source to your Headlamp Helm values:
```yaml
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: tns-csi
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
```
Then upgrade your Headlamp release:
```bash
helm upgrade headlamp headlamp/headlamp -f values.yaml -n <your-namespace>
```
## Step 2: Configure RBAC
The plugin needs read access to storage resources and the tns-csi controller pod's metrics endpoint.
Apply the minimal RBAC:
```bash
kubectl apply -f - <<'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: headlamp-tns-csi-reader
rules:
- apiGroups: [""]
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
verbs: ["get", "list", "watch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "csidrivers"]
verbs: ["get", "list", "watch"]
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log", "pods/proxy"]
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-tns-csi
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
apiGroup: rbac.authorization.k8s.io
EOF
```
Adjust `name: headlamp` and `namespace: <your-namespace>` to match your Headlamp service account.
## Step 3: Verify
1. Open Headlamp — you should see **TrueNAS (tns-csi)** in the left sidebar
2. Click **Overview** — you should see the driver health card and storage summary
3. Click **Storage Classes** — your tns-csi StorageClasses should appear with Protocol, Pool, and Server filled in
## Troubleshooting
| Problem | Fix |
| ------- | --- |
| No sidebar entry | Hard-refresh browser (Cmd+Shift+R) |
| Driver shows "Not installed" | Run `kubectl get csidriver tns.csi.io` |
| StorageClasses empty | Check `kubectl get sc` for `tns.csi.io` provisioner |
| Protocol/Pool/Server show "—" | Check `kubectl get sc <name> -o yaml` for `.parameters` |
| Metrics page empty | Verify controller pod exposes port 8080 |
For more detail see [Troubleshooting](../troubleshooting/README.md).
+112
View File
@@ -0,0 +1,112 @@
# Troubleshooting
## Quick Diagnosis
| Symptom | Likely Cause | Fix |
| ------- | ------------ | --- |
| **Plugin not in sidebar** | Not installed or browser cache | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
| **"TrueNAS (tns-csi)" missing from sidebar** | Plugin not loaded | Check Headlamp plugin manager or restart Headlamp pod |
| **No StorageClasses listed** | Wrong provisioner or driver not installed | See [Driver Detection](#driver-detection) |
| **Driver status "Not installed"** | CSIDriver object missing | `kubectl get csidriver tns.csi.io` |
| **Protocol/Pool/Server showing "—"** | StorageClass missing parameters | `kubectl get sc <name> -o yaml` to inspect |
| **403 on any page** | Missing RBAC | See [RBAC Issues](rbac.md) |
| **Metrics page empty** | Controller pod unreachable or no metrics | See [Metrics Issues](metrics.md) |
| **Snapshots tab: "CRD not available"** | Snapshot CRD not installed | Install `snapshot.storage.k8s.io` CRDs |
| **Snapshots tab empty (no message)** | No snapshots or wrong snapshot class | Check VolumeSnapshotClass driver field |
| **Benchmark fails immediately** | Missing RBAC for Jobs/PVCs | See [Benchmark Issues](benchmark.md) |
| **Benchmark stuck in "Running"** | kbench pod not starting | `kubectl get pods -n <ns> -l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` |
| **Page loads but data is stale** | Watch connection dropped | Click the Refresh button or reload the page |
## Driver Detection
The plugin detects the tns-csi driver by querying:
```
GET /apis/storage.k8s.io/v1/csidrivers/tns.csi.io
```
If this returns 404, the driver shows as "Not installed".
**Check:**
```bash
kubectl get csidriver tns.csi.io
```
If missing, verify the tns-csi driver is deployed. The driver registers its CSIDriver object on startup.
## StorageClass Parameters Showing "—"
StorageClass Protocol, Pool, and Server come from the StorageClass `parameters` field.
**Check:**
```bash
kubectl get sc -o yaml | grep -A5 "provisioner: tns.csi.io"
```
Expected output includes:
```yaml
parameters:
protocol: nfs
pool: tank/k8s
server: 192.168.1.1
```
If `parameters` is absent, the StorageClass was created without them — the CSI driver documentation specifies the required parameters for each protocol.
## Controller Pods Not Showing
The Overview page shows controller and node pod counts using label selectors:
- 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`
**Check:**
```bash
kubectl get pods -n kube-system -l app.kubernetes.io/name=tns-csi-driver
```
If pods exist but aren't showing, verify the `app.kubernetes.io/component` label is set correctly.
## Infinite Loading Spinner
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 <your-namespace> -l app.kubernetes.io/name=headlamp`
4. **Try refreshing** — the watch connection may have been interrupted
## Common API Errors
| HTTP Status | Meaning | Action |
| ----------- | ------- | ------ |
| `401 Unauthorized` | Token expired or invalid | Re-authenticate in Headlamp |
| `403 Forbidden` | Missing RBAC permission | See [RBAC Issues](rbac.md) |
| `404 Not Found` | Resource doesn't exist | Expected for optional resources (CSIDriver, snapshot CRD) |
| `503 Service Unavailable` | API server overloaded | Wait and retry |
## Getting More Information
**Browser console:**
```
F12 → Console tab
```
Look for errors related to `tns-csi`, `headlamp-plugin`, or Kubernetes API paths.
**Headlamp pod logs:**
```bash
kubectl logs -n <your-namespace> -l app.kubernetes.io/name=headlamp --tail=100
```
**tns-csi controller logs:**
```bash
kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller --tail=100
```
+93
View File
@@ -0,0 +1,93 @@
# Benchmark Issues
## Benchmark Fails to Start
### Check RBAC
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:<your-namespace>:headlamp
kubectl auth can-i create persistentvolumeclaims -n <benchmark-namespace> \
--as=system:serviceaccount:<your-namespace>:headlamp
```
Apply the additional permissions if missing — see [RBAC Issues](rbac.md) or [SECURITY.md](../../SECURITY.md).
### Check the Target Namespace Exists
The namespace you select in the Benchmark form must exist. Create it if needed:
```bash
kubectl create namespace <benchmark-namespace>
```
## Benchmark Stuck in "Running"
### Check the kbench Pod
```bash
kubectl get pods -n <benchmark-namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
```
Common states:
| Pod State | Cause | Action |
| --------- | ----- | ------ |
| `Pending` | PVC not provisioned or scheduler issue | Check PVC status and StorageClass |
| `Init:Error` | kbench image pull failure | Check image pull policy and network |
| `Running` | Benchmark in progress | Wait for completion |
| `Completed` | Finished — results should appear | Check FIO log section |
| `Error` / `OOMKilled` | kbench ran out of memory | Reduce test size or capacity |
### Check the PVC
```bash
kubectl get pvc -n <benchmark-namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
```
If the PVC is stuck in `Pending`, the StorageClass provisioner may not be able to create the volume:
```bash
kubectl describe pvc -n <benchmark-namespace> <pvc-name>
```
Look for events at the bottom of the describe output.
### View kbench Logs Directly
```bash
kubectl logs -n <benchmark-namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin \
--tail=100
```
## Leftover Resources After Failed Benchmark
If the benchmark was stopped or the plugin page was closed during a run, the Job and PVC may not have been cleaned up:
```bash
# List leftover resources
kubectl get jobs,pvc -n <benchmark-namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
# Clean up manually
kubectl delete jobs,pvc -n <benchmark-namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
```
The plugin adds the `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` label to all benchmark resources precisely to enable safe cleanup with this label selector.
## No Results Shown After Benchmark Completes
The plugin parses the FIO log output from the kbench pod. If results don't appear:
1. Check the pod completed successfully (status `Completed`, exit code 0)
2. View the raw log: `kubectl logs -n <ns> <kbench-pod>`
3. Look for the FIO result section — it should contain lines like `READ: bw=...` or `WRITE: bw=...`
If the kbench version produces output in a different format, the FIO log parser may not recognize it. Open a [GitHub Issue](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues) with a sample of the log output.
+68
View File
@@ -0,0 +1,68 @@
# Metrics Issues
## Metrics Page Shows No Data
### 1. Check the Controller Pod Is Running
```bash
kubectl get pods -n kube-system \
-l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller
```
The controller pod must be in `Running` state with all containers ready.
### 2. Verify Port 8080 Is Exposed
```bash
# Check the pod spec for port 8080
kubectl get pod -n kube-system <controller-pod-name> -o yaml | grep -A5 "ports:"
```
If port 8080 is not declared, the tns-csi driver version you're running may not expose Prometheus metrics. Check the driver documentation.
### 3. Test the Metrics Endpoint Directly
```bash
# Port-forward the controller pod
kubectl port-forward -n kube-system \
$(kubectl get pods -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller -o name | head -1) \
8080:8080
# In another terminal
curl http://localhost:8080/metrics | head -20
```
If this returns Prometheus text format output, the endpoint is working. If it returns 404 or connection refused, the controller isn't exposing metrics.
### 4. Check RBAC for Pod Proxy
The plugin accesses metrics via the Kubernetes pod proxy sub-resource:
```
GET /api/v1/namespaces/kube-system/pods/<pod>/proxy/metrics
```
This requires `get` on `pods/proxy` in `kube-system`:
```bash
kubectl auth can-i get pods/proxy \
-n kube-system \
--as=system:serviceaccount:<your-namespace>:headlamp
```
### 5. Network Policies
If `kube-system` has NetworkPolicies, ensure the Kubernetes API server can reach the controller pod on port 8080. The pod proxy hop is performed by the API server, not by Headlamp directly.
## Metrics Show Stale Values
The Metrics page fetches data on-demand when the page loads. Click **Refresh** to re-fetch the latest metrics from the controller pod.
## Some Metric Cards Show "—"
Not all tns-csi driver versions expose all metrics. The plugin shows placeholder "—" values for metrics that are absent from the Prometheus output. This is expected behavior.
The plugin specifically looks for:
- `kubelet_volume_stats_*` metrics (volume I/O)
- `csi_operations_seconds_*` metrics (CSI operation latency)
- Any tns-csi specific metrics on port 8080
+64
View File
@@ -0,0 +1,64 @@
# RBAC Issues
## 403 Forbidden Errors
A 403 error means the identity making the API request (Headlamp's service account or the logged-in user's token) lacks the required permission.
### Diagnosing Which Permission Is Missing
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:<your-namespace>:headlamp
# Check pod proxy access (for metrics)
kubectl auth can-i get pods/proxy \
-n kube-system \
--as=system:serviceaccount:<your-namespace>:headlamp
# Check snapshot access
kubectl auth can-i list volumesnapshots \
--as=system:serviceaccount:<your-namespace>:headlamp
```
### Applying the Required RBAC
See [RBAC Permissions](../user-guide/rbac.md) for the complete ClusterRole manifest.
Quick apply:
```bash
kubectl apply -f https://raw.githubusercontent.com/privilegedescalation/headlamp-tns-csi-plugin/main/docs/user-guide/rbac-manifest.yaml
```
Or manually apply the ClusterRole and ClusterRoleBinding from [SECURITY.md](../../SECURITY.md).
### OIDC Token Mode
If Headlamp is configured for OIDC authentication, each user's own token is used for API requests. The RBAC must be bound to the user's identity (email, group) rather than the service account:
```yaml
subjects:
- kind: Group
name: "engineering"
apiGroup: rbac.authorization.k8s.io
```
Users not in the group will see 403 errors in the plugin.
### Benchmark 403
The Benchmark page requires additional write permissions:
```yaml
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["create", "delete"]
```
If only the Benchmark page shows 403, add these rules to your ClusterRole (or a separate Role scoped to the benchmark namespace).
+79
View File
@@ -0,0 +1,79 @@
# Benchmark Page
The Benchmark page provides an interactive storage benchmark runner using [kbench](https://github.com/longhorn/kbench) (the Longhorn storage benchmark tool based on FIO).
## What It Does
1. You select a tns-csi StorageClass, a namespace, a PVC capacity, and an access mode
2. The plugin creates a PVC and a Kubernetes Job that runs `yasker/kbench:latest`
3. FIO log output streams in real-time from the kbench pod
4. When complete, results are parsed and displayed as IOPS, bandwidth (MB/s), and latency (µs) cards
## Prerequisites
- RBAC permissions for Jobs and PVCs — see [RBAC Permissions](rbac.md)
- The target namespace must exist
- The selected StorageClass must support the chosen access mode
## Running a Benchmark
1. Navigate to **TrueNAS (tns-csi) → Benchmark**
2. Select a StorageClass from the dropdown (only tns-csi classes are listed)
3. Enter the target namespace (defaults to `default`)
4. Set PVC capacity (e.g., `10Gi`)
5. Choose access mode (`ReadWriteOnce`, `ReadWriteMany`, etc.)
6. Click **Run Benchmark**
The benchmark progress shows:
- Benchmark state (Starting, Running, Parsing Results, Complete, Failed)
- Live FIO log output as it streams from the pod
- Result cards once FIO completes
## Result Cards
When the benchmark completes, the plugin displays:
| Card | Metric |
| ---- | ------ |
| Read IOPS | Random 4K read I/O operations per second |
| Write IOPS | Random 4K write I/O operations per second |
| Read Bandwidth | Sequential read throughput (MB/s) |
| Write Bandwidth | Sequential write throughput (MB/s) |
| Read Latency | Average read latency (µs) |
| Write Latency | Average write latency (µs) |
## Stopping a Benchmark
Click **Stop** to cancel the running benchmark. The plugin will delete the Job and PVC.
If the page is closed or navigated away from during a benchmark, the Job and PVC will remain in the cluster with the label:
```
app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
```
Clean them up manually:
```bash
kubectl delete jobs,pvc -n <namespace> \
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
```
## Resource Cleanup
The plugin automatically deletes the benchmark Job and PVC when:
- The benchmark completes successfully
- You click Stop
- The page component unmounts
## Protocol Notes
Different protocols have different performance characteristics:
| Protocol | Typical Use Case | Access Modes |
| -------- | ---------------- | ------------ |
| NFS | Shared storage, RWX workloads | RWO, RWX, RWOP |
| NVMe-oF | High-performance block storage | RWO, RWOP |
| iSCSI | Block storage | RWO, RWOP |
For NVMe-oF benchmarks, ensure nodes have the `nvme-tcp` kernel module loaded and the controller has a static IP.
+121
View File
@@ -0,0 +1,121 @@
# RBAC Permissions
## Overview
The plugin requires different permissions depending on which features you use. Start with the read-only set and add the benchmark write permissions only if needed.
## Read-Only Permissions (All Pages Except Benchmark)
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: headlamp-tns-csi-reader
rules:
# StorageClasses and CSIDriver
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses", "csidrivers"]
verbs: ["get", "list", "watch"]
# PersistentVolumes (cluster-scoped)
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch"]
# PersistentVolumeClaims (all namespaces)
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch"]
# tns-csi driver pods and their logs/proxy (for metrics)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log", "pods/proxy"]
verbs: ["get"]
# VolumeSnapshots (optional — gracefully degraded if absent)
- apiGroups: ["snapshot.storage.k8s.io"]
resources: ["volumesnapshots", "volumesnapshotclasses"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: headlamp-tns-csi
subjects:
- kind: ServiceAccount
name: headlamp # adjust to your Headlamp service account name
namespace: <your-namespace>
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
apiGroup: rbac.authorization.k8s.io
```
## Additional Permissions for Benchmark Page
The Benchmark page creates and deletes a Job and PVC. These rules can be added to the ClusterRole above, or bound as a separate namespaced Role scoped to a dedicated benchmark namespace.
```yaml
# Benchmark: create/delete kbench Job
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
# Benchmark: create/delete kbench PVC
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "delete"]
```
## Scoping Benchmark Permissions to a Namespace
For tighter security, restrict benchmark write permissions to a dedicated namespace using a Role + RoleBinding instead of ClusterRole:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: headlamp-tns-csi-benchmark
namespace: storage-benchmarks # dedicated benchmark namespace
rules:
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "delete"]
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: headlamp-tns-csi-benchmark
namespace: storage-benchmarks
subjects:
- kind: ServiceAccount
name: headlamp
namespace: <your-namespace>
roleRef:
kind: Role
name: headlamp-tns-csi-benchmark
apiGroup: rbac.authorization.k8s.io
```
With this configuration, benchmark jobs can only be created in the `storage-benchmarks` namespace.
## Permission Summary by Feature
| Feature | Permissions Required |
| ------- | -------------------- |
| Overview | `storageclasses list`, `persistentvolumes list`, `persistentvolumeclaims list`, `pods list` (kube-system), `csidrivers get` |
| Storage Classes | `storageclasses list` |
| Volumes | `persistentvolumes list` |
| Snapshots | `volumesnapshots list`, `volumesnapshotclasses list` |
| Metrics | `pods/proxy get` (kube-system controller pod) |
| Benchmark | `jobs create/delete`, `persistentvolumeclaims create/delete` |
| PVC Detail Injection | `persistentvolumeclaims get`, `persistentvolumes get` |
+30 -3
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-tns-csi-plugin",
"version": "0.1.0",
"name": "tns-csi",
"version": "1.0.3",
"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,33 @@
"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",
"elliptic": ">=6.6.1"
}
}
+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"]
}
+10
View File
@@ -20,6 +20,16 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
},
},
},
ConfigStore: class {
get() {
return {};
}
set() {}
update() {}
useConfig() {
return () => ({});
}
},
}));
import { TnsCsiDataProvider, useTnsCsiContext } from './TnsCsiDataContext';
+69 -9
View File
@@ -13,15 +13,18 @@ import {
filterTnsCsiPersistentVolumes,
filterTnsCsiPVCs,
filterTnsCsiStorageClasses,
filterTnsCsiVolumeSnapshots,
isKubeList,
isTnsCsiVolumeSnapshotClass,
TNS_CSI_PROVISIONER,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
TNS_CSI_PROVISIONER,
VolumeSnapshot,
VolumeSnapshotClass,
} from './k8s';
import { fetchTruenasPoolStats, getTnsCsiConfig, PoolStats } from './truenas';
// ---------------------------------------------------------------------------
// Context shape
@@ -46,6 +49,10 @@ export interface TnsCsiContextValue {
volumeSnapshotClasses: VolumeSnapshotClass[];
snapshotCrdAvailable: boolean;
// TrueNAS pool capacity (only populated when API key is configured)
poolStats: PoolStats[];
poolStatsError: string | null;
// Loading / error state
loading: boolean;
error: string | null;
@@ -88,6 +95,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
const [asyncLoading, setAsyncLoading] = useState(true);
const [asyncError, setAsyncError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [poolStats, setPoolStats] = useState<PoolStats[]>([]);
const [poolStatsError, setPoolStatsError] = useState<string | null>(null);
const refresh = useCallback(() => {
setRefreshKey(k => k + 1);
@@ -102,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);
@@ -144,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 {
@@ -161,6 +175,31 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
setVolumeSnapshots([]);
}
}
// TrueNAS pool stats (only when API key is configured)
const config = getTnsCsiConfig();
if (config.truenasApiKey.trim()) {
const server = config.truenasServerOverride.trim();
if (server) {
try {
const pools = await fetchTruenasPoolStats(server, config.truenasApiKey.trim());
if (!cancelled) {
setPoolStats(pools);
setPoolStatsError(null);
}
} catch (err: unknown) {
if (!cancelled) {
setPoolStats([]);
setPoolStatsError(err instanceof Error ? err.message : String(err));
}
}
}
} else {
if (!cancelled) {
setPoolStats([]);
setPoolStatsError(null);
}
}
} catch (err: unknown) {
if (!cancelled) {
setAsyncError(err instanceof Error ? err.message : String(err));
@@ -171,26 +210,43 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
}
void fetchAsync();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [refreshKey]);
// ---------------------------------------------------------------------------
// Derived / filtered values — memoized to avoid recomputation on every render
// ---------------------------------------------------------------------------
// Headlamp useList() returns KubeObject class instances that store raw Kubernetes
// JSON under `.jsonData`. Direct property access only works for fields that have
// explicit getter definitions in the class (e.g. provisioner, reclaimPolicy).
// Fields like `parameters`, `spec`, `status` must be read from `.jsonData`.
// We extract jsonData here so our plain-object type helpers work correctly.
const extractJsonData = (items: unknown[]): unknown[] =>
items.map(item =>
item && typeof item === 'object' && 'jsonData' in item
? (item as { jsonData: unknown }).jsonData
: item
);
const storageClasses = useMemo(() => {
if (!allStorageClasses) return [];
return filterTnsCsiStorageClasses(allStorageClasses as unknown[]);
return filterTnsCsiStorageClasses(extractJsonData(allStorageClasses as unknown[]));
}, [allStorageClasses]);
const persistentVolumes = useMemo(() => {
if (!allPvs) return [];
return filterTnsCsiPersistentVolumes(allPvs as unknown[]);
return filterTnsCsiPersistentVolumes(extractJsonData(allPvs as unknown[]));
}, [allPvs]);
const persistentVolumeClaims = useMemo(() => {
if (!allPvcs || persistentVolumes.length === 0) return [];
return filterTnsCsiPVCs(allPvcs as TnsCsiPersistentVolumeClaim[], persistentVolumes);
return filterTnsCsiPVCs(
extractJsonData(allPvcs as unknown[]) as TnsCsiPersistentVolumeClaim[],
persistentVolumes
);
}, [allPvcs, persistentVolumes]);
// ---------------------------------------------------------------------------
@@ -224,6 +280,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
poolStats,
poolStatsError,
loading,
error,
refresh,
@@ -239,6 +297,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
poolStats,
poolStatsError,
loading,
error,
refresh,
+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';
+181
View File
@@ -0,0 +1,181 @@
/**
* TrueNAS API client for the headlamp-tns-csi-plugin.
*
* Uses the TrueNAS WebSocket JSON-RPC 2.0 API to fetch pool-level capacity
* information (pool.query). Requires a TrueNAS API key configured by the user
* in plugin settings.
*
* The WebSocket connects directly from the browser to the TrueNAS server.
* The server address comes from the StorageClass parameters (already in context).
*
* All operations are read-only (pool.query only).
*/
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
// ---------------------------------------------------------------------------
// Config store — persists across sessions via Headlamp Redux store
// ---------------------------------------------------------------------------
export interface TnsCsiConfig {
truenasApiKey: string;
/** Override server address (defaults to StorageClass parameter 'server') */
truenasServerOverride: string;
}
const DEFAULT_CONFIG: TnsCsiConfig = {
truenasApiKey: '',
truenasServerOverride: '',
};
const configStore = new ConfigStore<TnsCsiConfig>('headlamp-tns-csi-plugin');
export function getTnsCsiConfig(): TnsCsiConfig {
return { ...DEFAULT_CONFIG, ...configStore.get() };
}
export function setTnsCsiConfig(partial: Partial<TnsCsiConfig>): void {
configStore.update(partial);
}
export function useTnsCsiConfig(): () => TnsCsiConfig {
return configStore.useConfig();
}
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PoolStats {
name: string;
/** Total pool capacity in bytes */
size: number;
/** Allocated (used) bytes */
allocated: number;
/** Free bytes */
free: number;
/** Pool health status string */
status: string;
}
// ---------------------------------------------------------------------------
// TrueNAS WebSocket JSON-RPC client
// ---------------------------------------------------------------------------
/**
* Opens a WebSocket to TrueNAS, authenticates with the API key, calls
* pool.query, collects results, and closes the connection.
*
* @param server - TrueNAS host/IP (no protocol prefix)
* @param apiKey - TrueNAS API key
* @returns Array of pool stats
*/
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`;
let ws: WebSocket;
const timeout = setTimeout(() => {
ws?.close();
reject(new Error('TrueNAS connection timed out (10s)'));
}, 10_000);
try {
ws = new WebSocket(url);
} catch (err) {
clearTimeout(timeout);
reject(new Error(`Failed to open WebSocket to ${server}: ${String(err)}`));
return;
}
let msgId = 1;
// State machine: connect → authenticate → query → done
type Phase = 'connecting' | 'authenticating' | 'querying' | 'done';
let phase: Phase = 'connecting';
ws.onopen = () => {
phase = 'authenticating';
ws.send(
JSON.stringify({
id: msgId++,
msg: 'method',
method: 'auth.login_with_api_key',
params: [apiKey],
})
);
};
ws.onmessage = (event: MessageEvent) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(event.data as string) as Record<string, unknown>;
} catch {
return;
}
if (phase === 'authenticating') {
const result = msg['result'];
if (result !== true) {
clearTimeout(timeout);
ws.close();
reject(new Error('TrueNAS authentication failed — check your API key'));
return;
}
phase = 'querying';
ws.send(
JSON.stringify({
id: msgId++,
msg: 'method',
method: 'pool.query',
params: [],
})
);
return;
}
if (phase === 'querying') {
const result = msg['result'];
if (!Array.isArray(result)) {
clearTimeout(timeout);
ws.close();
reject(new Error('pool.query returned unexpected result'));
return;
}
phase = 'done';
clearTimeout(timeout);
ws.close();
const pools: PoolStats[] = result.map((pool: unknown) => {
const p = pool as Record<string, unknown>;
return {
name: String(p['name'] ?? ''),
size: Number(p['size'] ?? 0),
allocated: Number(p['allocated'] ?? 0),
free: Number(p['free'] ?? 0),
status: String(p['status'] ?? 'UNKNOWN'),
};
});
resolve(pools);
}
};
ws.onerror = () => {
if (phase !== 'done') {
clearTimeout(timeout);
reject(
new Error(
`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`
)
);
}
};
ws.onclose = (event: CloseEvent) => {
if (phase !== 'done') {
clearTimeout(timeout);
reject(new Error(`WebSocket closed unexpectedly (code ${event.code}) while ${phase}`));
}
};
});
}
+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>
</>
+149
View File
@@ -0,0 +1,149 @@
/**
* DriverPodDetailSection — injected into Headlamp's Pod detail view.
*
* Shown only for tns-csi driver pods (identified by
* app.kubernetes.io/name=tns-csi-driver label). Returns null for all other pods.
* Uses registerDetailsViewSection in index.tsx.
*/
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatAge, getPodRestarts, isPodReady, TnsCsiPod } from '../api/k8s';
interface DriverPodDetailSectionProps {
resource: {
kind?: string;
metadata?: {
name?: string;
namespace?: string;
labels?: Record<string, string>;
creationTimestamp?: string;
};
spec?: { nodeName?: string };
status?: {
phase?: string;
conditions?: Array<{ type: string; status: string }>;
containerStatuses?: Array<{
name: string;
ready: boolean;
restartCount: number;
image?: string;
state?: {
running?: { startedAt?: string };
waiting?: { reason?: string };
terminated?: { exitCode?: number; reason?: string };
};
}>;
};
// KubeObject instance: raw JSON lives under jsonData;
// metadata here only exposes what the class getter provides (labels, creationTimestamp).
// The jsonData.metadata has the full shape.
jsonData?: {
metadata?: {
name?: string;
namespace?: string;
labels?: Record<string, string>;
creationTimestamp?: string;
};
spec?: { nodeName?: string };
status?: {
phase?: string;
conditions?: Array<{ type: string; status: string }>;
containerStatuses?: Array<{
name: string;
ready: boolean;
restartCount: number;
image?: string;
state?: {
running?: { startedAt?: string };
waiting?: { reason?: string };
terminated?: { exitCode?: number; reason?: string };
};
}>;
};
};
};
}
export default function DriverPodDetailSection({ resource }: DriverPodDetailSectionProps) {
// 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 spec = resource?.jsonData?.spec ?? resource?.spec;
const status = resource?.jsonData?.status ?? resource?.status;
const labels = meta?.labels ?? {};
// Guard: only tns-csi driver pods
if (labels['app.kubernetes.io/name'] !== 'tns-csi-driver') {
return null;
}
const component = labels['app.kubernetes.io/component'] ?? 'unknown';
const roleLabel =
component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
// Build a minimal pod shape that isPodReady / getPodRestarts can consume
const podShape: TnsCsiPod = {
metadata: {
name: meta?.name ?? '',
namespace: meta?.namespace,
creationTimestamp: meta?.creationTimestamp,
labels,
},
spec: { nodeName: spec?.nodeName },
status: status as TnsCsiPod['status'],
};
const ready = isPodReady(podShape);
const restarts = getPodRestarts(podShape);
const phase = status?.phase ?? '—';
const nodeName = spec?.nodeName ?? '—';
const age = formatAge(meta?.creationTimestamp);
// Container statuses
const containerStatuses = status?.containerStatuses ?? [];
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`;
} 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 ?? ''
}`;
}
return {
name: cs.name,
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'}${stateText}${
cs.restartCount
} restart(s)`,
};
});
return (
<SectionBox title="TNS-CSI Driver Info">
<NameValueTable
rows={[
{ name: 'Role', value: roleLabel },
{ name: 'Phase', value: phase },
{ name: 'Ready', value: ready ? 'Yes' : 'No' },
{ name: 'Restarts', value: String(restarts) },
{ name: 'Node', value: nodeName },
{ name: 'Age', value: age },
...containerRows,
]}
/>
</SectionBox>
);
}
+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();
});
});
+135 -31
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 } }>) {
@@ -58,6 +58,8 @@ export default function OverviewPage() {
persistentVolumeClaims,
controllerPods,
nodePods,
poolStats,
poolStatsError,
loading,
error,
refresh,
@@ -83,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..." />;
}
@@ -102,16 +122,21 @@ export default function OverviewPage() {
else pvcStatusCounts.Other++;
}
const nonBoundPvcs = persistentVolumeClaims.filter(
pvc => pvc.status?.phase !== 'Bound'
);
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
@@ -154,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',
},
]}
/>
@@ -203,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} />
@@ -219,37 +255,100 @@ 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>,
},
]
: []),
]}
/>
</SectionBox>
{/* Pool capacity — real data from TrueNAS API when configured */}
{poolStats.length > 0 && (
<SectionBox title="Pool Capacity">
<SimpleTable
columns={[
{ label: 'Pool', getter: p => p.name },
{
label: 'Status',
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: 'Used %',
getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
},
]}
data={poolStats}
/>
</SectionBox>
)}
{poolStatsError && (
<SectionBox title="Pool Capacity Unavailable">
<NameValueTable
rows={[
{
name: 'Error',
value: <StatusLabel status="warning">{poolStatsError}</StatusLabel>,
},
{
name: 'Note',
value: 'Check your TrueNAS API key and server address in plugin settings.',
},
]}
/>
</SectionBox>
)}
{/* 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
rows={[...capacityByPool.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([pool, bytes]) => ({
name: pool,
value: formatBytes(bytes),
}))}
/>
</SectionBox>
)}
{/* Non-bound PVCs warning */}
{nonBoundPvcs.length > 0 && (
<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}
/>
@@ -270,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,
+70
View File
@@ -0,0 +1,70 @@
/**
* PVDetailSection — injected into Headlamp's PersistentVolume detail view.
*
* Shown only when the PV uses tns.csi.io as the CSI driver.
* Uses registerDetailsViewSection in index.tsx.
*/
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React from 'react';
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
interface PVDetailSectionProps {
resource: {
kind?: string;
metadata?: { name?: string; namespace?: string };
spec?: {
csi?: {
driver?: string;
volumeHandle?: string;
volumeAttributes?: Record<string, string>;
};
storageClassName?: string;
capacity?: { storage?: string };
persistentVolumeReclaimPolicy?: string;
};
// KubeObject instance — raw JSON lives under jsonData
jsonData?: {
spec?: {
csi?: {
driver?: string;
volumeHandle?: string;
volumeAttributes?: Record<string, string>;
};
storageClassName?: string;
};
};
};
}
export default function PVDetailSection({ resource }: PVDetailSectionProps) {
// Extract from jsonData (KubeObject instance) or fall back to direct properties
const spec = resource?.jsonData?.spec ?? resource?.spec;
const csi = spec?.csi;
if (!csi || csi.driver !== TNS_CSI_PROVISIONER) {
return null;
}
const attrs = csi.volumeAttributes ?? {};
const protocol = formatProtocol(attrs['protocol']);
const otherAttrs = Object.entries(attrs).filter(
([k]) => !['protocol', 'server', 'pool'].includes(k)
);
return (
<SectionBox title="TNS-CSI Storage Details">
<NameValueTable
rows={[
{ name: 'Driver', value: TNS_CSI_PROVISIONER },
{ name: 'Protocol', value: protocol },
{ name: 'Server', value: attrs['server'] ?? '—' },
{ name: 'Pool', value: attrs['pool'] ?? '—' },
{ name: 'Volume Handle', value: csi.volumeHandle ?? '—' },
{ name: 'Storage Class', value: spec?.storageClassName ?? '—' },
...otherAttrs.map(([k, v]) => ({ name: k, value: v ?? '—' })),
]}
/>
</SectionBox>
);
}
+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}
+184
View File
@@ -0,0 +1,184 @@
/**
* TnsCsiSettings — plugin settings page.
*
* Lets users configure the TrueNAS API key and (optionally) a server address
* override. When configured, the plugin fetches real pool capacity data via
* the TrueNAS WebSocket JSON-RPC API (pool.query) and displays it on the
* Overview page.
*
* Settings are persisted via Headlamp's ConfigStore (Redux-backed).
*/
import {
NameValueTable,
SectionBox,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useState } from 'react';
import { fetchTruenasPoolStats, getTnsCsiConfig, setTnsCsiConfig } from '../api/truenas';
interface PluginSettingsProps {
data?: Record<string, string | number | boolean>;
onDataChange?: (data: Record<string, string | number | boolean>) => void;
}
const INPUT_STYLE: React.CSSProperties = {
width: '100%',
padding: '4px 8px',
border: '1px solid var(--mui-palette-divider, #e0e0e0)',
borderRadius: '4px',
fontSize: '14px',
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
color: 'var(--mui-palette-text-primary, #000)',
boxSizing: 'border-box',
};
const HINT_STYLE: React.CSSProperties = {
fontSize: '12px',
color: 'var(--mui-palette-text-secondary, #666)',
marginTop: '4px',
};
export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsProps) {
const saved = getTnsCsiConfig();
const [apiKey, setApiKey] = useState<string>(
(data?.truenasApiKey as string) ?? saved.truenasApiKey ?? ''
);
const [serverOverride, setServerOverride] = useState<string>(
(data?.truenasServerOverride as string) ?? saved.truenasServerOverride ?? ''
);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
function handleApiKeyChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setApiKey(val);
setTnsCsiConfig({ truenasApiKey: val });
onDataChange?.({ ...data, truenasApiKey: val });
}
function handleServerOverrideChange(e: React.ChangeEvent<HTMLInputElement>) {
const val = e.target.value;
setServerOverride(val);
setTnsCsiConfig({ truenasServerOverride: val });
onDataChange?.({ ...data, truenasServerOverride: val });
}
async function testConnection() {
setTesting(true);
setTestResult(null);
const server = serverOverride.trim() || '(from StorageClass)';
if (!serverOverride.trim()) {
setTesting(false);
setTestResult({
success: false,
message: 'Enter a Server Address to test the connection.',
});
return;
}
if (!apiKey.trim()) {
setTesting(false);
setTestResult({
success: false,
message: 'Enter an API key to test the connection.',
});
return;
}
try {
const pools = await fetchTruenasPoolStats(serverOverride.trim(), apiKey.trim());
const names = pools.map(p => p.name).join(', ');
setTestResult({
success: true,
message: `Connected to ${server}. Found ${pools.length} pool(s): ${names || '(none)'}`,
});
} catch (err: unknown) {
setTestResult({
success: false,
message: String(err instanceof Error ? err.message : err),
});
} finally {
setTesting(false);
}
}
return (
<SectionBox title="TrueNAS API (Optional)">
<NameValueTable
rows={[
{
name: 'API Key',
value: (
<div>
<input
type="password"
value={apiKey}
onChange={handleApiKeyChange}
placeholder="Paste your TrueNAS API key here"
style={INPUT_STYLE}
autoComplete="off"
/>
<div style={HINT_STYLE}>
Generate in TrueNAS UI Credentials API Keys. Required for real pool capacity
data on the Overview page.
</div>
</div>
),
},
{
name: 'Server Address',
value: (
<div>
<input
type="text"
value={serverOverride}
onChange={handleServerOverrideChange}
placeholder="e.g. 192.168.1.100 or truenas.local"
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.
</div>
</div>
),
},
{
name: 'Connection Test',
value: (
<div>
<button
onClick={() => void testConnection()}
disabled={testing}
style={{
padding: '6px 16px',
backgroundColor: testing
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
: 'var(--mui-palette-primary-main, #1976d2)',
color: testing
? 'var(--mui-palette-action-disabled, #9e9e9e)'
: 'var(--mui-palette-primary-contrastText, #fff)',
border: 'none',
borderRadius: '4px',
cursor: testing ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
{testing ? 'Testing…' : 'Test Connection'}
</button>
{testResult && (
<div style={{ marginTop: '8px' }}>
<StatusLabel status={testResult.success ? 'success' : 'error'}>
{testResult.message}
</StatusLabel>
</div>
)}
</div>
),
},
]}
/>
</SectionBox>
);
}
+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}`))
);
@@ -0,0 +1,70 @@
/**
* StorageClassBenchmarkButton — registerDetailsViewHeaderAction for StorageClass pages.
*
* Adds a "Benchmark" button to the detail page header of tns-csi StorageClasses.
* Navigates to /tns-csi/benchmark so the user can run a FIO benchmark
* against that storage class.
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { TNS_CSI_PROVISIONER } from '../../api/k8s';
interface StorageClassBenchmarkButtonProps {
resource: {
provisioner?: string;
metadata?: { name?: string };
// KubeObject instance — provisioner may be a direct getter or under jsonData
jsonData?: {
provisioner?: string;
metadata?: { name?: string };
};
};
}
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;
if (provisioner !== TNS_CSI_PROVISIONER) {
return null;
}
const scName = resource?.metadata?.name ?? resource?.jsonData?.metadata?.name ?? '';
const handleClick = () => {
// Navigate to benchmark page; user selects the SC in the benchmark form.
// Pass the SC name via hash so BenchmarkPage can pre-select it if desired.
history.push(`/tns-csi/benchmark#${scName}`);
};
return (
<button
onClick={handleClick}
style={{
cursor: 'pointer',
padding: '6px 16px',
borderRadius: '4px',
border: '1px solid currentColor',
backgroundColor: 'transparent',
color: 'inherit',
fontSize: '0.875rem',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
opacity: 0.85,
}}
aria-label={`Run benchmark on ${scName}`}
title={`Run FIO benchmark on storage class ${scName}`}
>
<span></span>
<span>Benchmark</span>
</button>
);
}
@@ -0,0 +1,151 @@
/**
* StorageClassColumns — registerResourceTableColumnsProcessor for StorageClass and PV tables.
*
* Adds Protocol/Pool/Server columns to the native /storage-classes table and
* Protocol/Pool columns to the native /persistent-volumes table.
* Pool on PVs is derived from the first segment of volumeAttributes.datasetName.
*
* Items in column processors are KubeObject class instances from Headlamp.
* Raw Kubernetes JSON fields (parameters, spec, status) must be accessed
* via .jsonData — only fields with explicit getters (provisioner, reclaimPolicy, etc.)
* are accessible as direct properties.
*/
import React from 'react';
import { formatProtocol, TNS_CSI_PROVISIONER } from '../../api/k8s';
// ---------------------------------------------------------------------------
// Helper: extract a field from either a KubeObject instance or a plain object
// ---------------------------------------------------------------------------
function getField(item: unknown, ...path: string[]): unknown {
if (!item || typeof item !== 'object') return undefined;
const obj = item as Record<string, unknown>;
// KubeObject instance — raw K8s JSON is under .jsonData
const raw: Record<string, unknown> =
'jsonData' in obj && obj['jsonData'] && typeof obj['jsonData'] === 'object'
? (obj['jsonData'] as Record<string, unknown>)
: obj;
let cur: unknown = raw;
for (const key of path) {
if (!cur || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
// ---------------------------------------------------------------------------
// StorageClass column definitions
// ---------------------------------------------------------------------------
/**
* Returns extra columns for the native StorageClass table.
* For non-tns-csi rows, cells show "—" (never undefined/null visible).
*/
export function buildStorageClassColumns() {
return [
{
label: 'Protocol',
getValue: (sc: unknown): string | null => {
const 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'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
return <span>{formatProtocol(protocol)}</span>;
},
},
{
label: 'Pool',
getValue: (sc: unknown): string | null => {
const 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'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const pool = getField(sc, 'parameters', 'pool') as string | undefined;
return <span>{pool ?? '—'}</span>;
},
},
{
label: 'Server',
getValue: (sc: unknown): string | null => {
const 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'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const server = getField(sc, 'parameters', 'server') as string | undefined;
return <span>{server ?? '—'}</span>;
},
},
];
}
// ---------------------------------------------------------------------------
// PersistentVolume column definitions
// ---------------------------------------------------------------------------
/**
* Returns extra columns for the native PersistentVolume table.
* For non-tns-csi PVs, cells show "—".
*/
export function buildPVColumns() {
return [
{
label: 'Protocol',
getValue: (pv: unknown): string | null => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return null;
const p = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol');
return typeof p === 'string' ? formatProtocol(p) : null;
},
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;
return <span>{formatProtocol(protocol)}</span>;
},
},
{
label: 'Pool',
getValue: (pv: unknown): string | null => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return null;
// tns-csi stores pool as the first segment of datasetName (e.g. "tank/pvc-abc")
const d = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName');
if (typeof d !== 'string') return null;
return d.split('/')[0] ?? null;
},
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 pool = dataset?.split('/')[0];
return <span>{pool ?? '—'}</span>;
},
},
];
}
+98 -36
View File
@@ -1,34 +1,45 @@
/**
* headlamp-tns-csi-plugin — entry point.
*
* Registers sidebar entries, routes, detail view section, and plugin settings
* for the tns-csi CSI driver Headlamp plugin.
* Registers sidebar entries, routes, detail view sections, table column
* processors, header actions, and app bar action for the tns-csi CSI driver.
*/
import {
registerDetailsViewHeaderAction,
registerDetailsViewSection,
registerPluginSettings,
registerResourceTableColumnsProcessor,
registerRoute,
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
import BenchmarkPage from './components/BenchmarkPage';
import DriverPodDetailSection from './components/DriverPodDetailSection';
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';
// ---------------------------------------------------------------------------
// Sidebar entries
// Sidebar entries (trimmed from 6 to 4 — Storage Classes and Volumes now
// surface via native Headlamp tables with injected columns/sections)
// ---------------------------------------------------------------------------
registerSidebarEntry({
parent: null,
name: 'tns-csi',
label: 'TNS CSI',
label: 'TrueNAS (tns-csi)',
url: '/tns-csi',
icon: 'mdi:database-cog',
});
@@ -41,22 +52,6 @@ registerSidebarEntry({
icon: 'mdi:view-dashboard',
});
registerSidebarEntry({
parent: 'tns-csi',
name: 'tns-csi-storage-classes',
label: 'Storage Classes',
url: '/tns-csi/storage-classes',
icon: 'mdi:database',
});
registerSidebarEntry({
parent: 'tns-csi',
name: 'tns-csi-volumes',
label: 'Volumes',
url: '/tns-csi/volumes',
icon: 'mdi:harddisk',
});
registerSidebarEntry({
parent: 'tns-csi',
name: 'tns-csi-snapshots',
@@ -82,7 +77,7 @@ registerSidebarEntry({
});
// ---------------------------------------------------------------------------
// Routes
// Routes (keep all routes so direct links still work)
// ---------------------------------------------------------------------------
registerRoute({
@@ -97,9 +92,11 @@ registerRoute({
),
});
// Routes for storage-classes and volumes are kept for direct URL access
// but are no longer in the sidebar — native Headlamp tables have tns-csi columns.
registerRoute({
path: '/tns-csi/storage-classes',
sidebar: 'tns-csi-storage-classes',
sidebar: 'tns-csi-overview',
name: 'tns-csi-storage-classes',
exact: true,
component: () => (
@@ -111,7 +108,7 @@ registerRoute({
registerRoute({
path: '/tns-csi/volumes',
sidebar: 'tns-csi-volumes',
sidebar: 'tns-csi-overview',
name: 'tns-csi-volumes',
exact: true,
component: () => (
@@ -158,7 +155,7 @@ registerRoute({
});
// ---------------------------------------------------------------------------
// PVC detail view injection
// Detail view section — PVC pages
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
@@ -171,19 +168,84 @@ registerDetailsViewSection(({ resource }) => {
);
});
// ---------------------------------------------------------------------------
// Detail view section — PV pages
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'PersistentVolume') return null;
return <PVDetailSection resource={resource} />;
});
// ---------------------------------------------------------------------------
// Detail view section — Pod pages (tns-csi driver pods only)
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Pod') return null;
return <DriverPodDetailSection resource={resource} />;
});
// ---------------------------------------------------------------------------
// Table column processors — native StorageClass and PV tables
// ---------------------------------------------------------------------------
// Merges incoming columns into existing ones by label.
// If a column with the same label already exists, the incoming getValue/render
// takes priority and falls back to the existing one (for mixed-driver tables).
function mergeColumns<T>(
existing: T[],
incoming: Array<{
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
}>
): T[] {
type ObjCol = {
label: string;
getValue: (r: unknown) => unknown;
render: (r: unknown) => React.ReactNode;
};
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
const result = [...existing];
const toAppend: typeof incoming = [];
for (const col of incoming) {
const idx = result.findIndex(c => isObjCol(c) && (c as ObjCol).label === col.label);
if (idx !== -1) {
const prev = result[idx] as ObjCol;
result[idx] = {
label: col.label,
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
} as unknown as T;
} else {
toAppend.push(col);
}
}
return [...result, ...(toAppend as unknown as T[])];
}
registerResourceTableColumnsProcessor(({ id, columns }) => {
if (id === 'headlamp-storageclasses') {
return mergeColumns(columns, buildStorageClassColumns());
}
if (id === 'headlamp-persistentvolumes') {
return mergeColumns(columns, buildPVColumns());
}
return columns;
});
// ---------------------------------------------------------------------------
// Header action — StorageClass detail page Benchmark shortcut
// ---------------------------------------------------------------------------
registerDetailsViewHeaderAction(({ resource }) => {
if (resource?.kind !== 'StorageClass') return null;
return <StorageClassBenchmarkButton resource={resource} />;
});
// ---------------------------------------------------------------------------
// Plugin settings
// ---------------------------------------------------------------------------
function TnsCsiSettings() {
return (
<div style={{ padding: '16px' }}>
<p style={{ color: 'var(--mui-palette-text-secondary)' }}>
TNS-CSI plugin settings. Configure defaults below.
</p>
{/* Future: default namespace, metrics refresh interval, auto-cleanup setting */}
</div>
);
}
registerPluginSettings('headlamp-tns-csi-plugin', TnsCsiSettings, true);
registerPluginSettings('tns-csi', TnsCsiSettings, true);
+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',