Compare commits

...

23 Commits

Author SHA1 Message Date
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
44 changed files with 23506 additions and 42 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"env": {
"browser": true,
"es2021": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": { "jsx": true },
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"settings": {
"react": { "version": "detect" }
},
"rules": {
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "warn"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx"],
"rules": {
"@typescript-eslint/no-require-imports": "off"
}
}
]
}
+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
typecheck:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run tsc
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
checks: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npx vitest run --reporter=default --reporter=junit --outputFile=test-results.xml
- uses: dorny/test-reporter@v1
if: always()
with:
name: Test Results
path: test-results.xml
reporter: java-junit
build:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: [lint, typecheck, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run build
+129
View File
@@ -0,0 +1,129 @@
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version to release (without v prefix, e.g., 0.2.0)'
required: true
type: string
concurrency:
group: release
cancel-in-progress: false
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run tsc
- run: npm test
release:
runs-on: ubuntu-latest
needs: [ci]
permissions:
contents: write
steps:
- name: Validate version format
run: |
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)"
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update package.json version
run: |
jq --arg version "${{ inputs.version }}" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
- name: Update artifacthub-pkg.yml version and URL
run: |
VERSION="${{ inputs.version }}"
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/tns-csi-${VERSION}.tar.gz"
sed -i "s|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Package plugin
run: npx @kinvolk/headlamp-plugin package
- name: Validate tarball
run: |
EXPECTED="tns-csi-${{ inputs.version }}.tar.gz"
if [ ! -f "$EXPECTED" ]; then
echo "::error::Expected tarball not found: $EXPECTED"
exit 1
fi
echo "Tarball validated: $EXPECTED"
- name: Compute checksum
id: compute_checksum
run: |
TARBALL="tns-csi-${{ inputs.version }}.tar.gz"
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
echo "Checksum: sha256:${CHECKSUM}"
- name: Update checksum in metadata
run: |
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}"
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml
- name: Commit version bump and metadata
run: |
git add package.json artifacthub-pkg.yml
git commit -m "chore: release v${{ inputs.version }}"
git push origin main
- name: Create and push tag
run: |
git tag "v${{ inputs.version }}"
git push origin "v${{ inputs.version }}"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ inputs.version }}"
files: tns-csi-${{ inputs.version }}.tar.gz
fail_on_unmatched_files: true
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
run: |
echo "Version bumped to ${{ inputs.version }}"
echo "Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
echo "Tag v${{ inputs.version }} created"
echo "GitHub release published with tarball"
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tar.gz
+76
View File
@@ -0,0 +1,76 @@
# 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]
## [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/v0.2.3...HEAD
[0.2.3]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/tag/v0.1.0
+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 20 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 # 67 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 Sonnet 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 67 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 67 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 67 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.
+296
View File
@@ -0,0 +1,296 @@
# Headlamp TNS-CSI Plugin
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/package/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)
[![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
### Option 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin). Configure Headlamp via Helm:
```yaml
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
```
Or install via the Headlamp UI:
1. Go to **Settings → Plugins**
2. Click **Catalog** tab
3. Search for "TNS CSI" or "TrueNAS"
4. Click **Install**
### Option 2: Manual Tarball Install
Download the `.tar.gz` from the [GitHub releases page](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases), then extract into Headlamp's plugin directory:
```bash
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
tar xzf headlamp-tns-csi-plugin-0.2.0.tar.gz -C /headlamp/plugins/
```
### Option 3: Build from Source
```bash
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
cd headlamp-tns-csi-plugin
npm install
npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
```
## RBAC / Security Setup
The plugin reads from the Kubernetes API and the tns-csi controller pod's Prometheus endpoint. 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"]
```
### 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. Grant `get` on `pods/proxy` in `kube-system`:
```yaml
- apiGroups: [""]
resources: ["pods/proxy"]
verbs: ["get"]
# Optionally scope to the controller pod namespace
```
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 headlamp-tns-csi-plugin-<version>.tar.gz
# Run tests
npm test # 67 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:20` 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/headlamp-tns-csi-plugin/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: kube-system # adjust to your Headlamp 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 `kube-system`). 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`
- **GitHub Dependabot**: Monitors dependencies and creates PRs for updates
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:kube-system: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.
+57
View File
@@ -0,0 +1,57 @@
version: "0.2.4"
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.1.0"
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: changed
description: "Package renamed to tns-csi so the plugin displays correctly in Headlamp's Plugins list"
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.4/tns-csi-0.2.4.tar.gz"
headlamp/plugin/archive-checksum: "sha256:a72b8094a70dd127aa9edb388411b3506bf5f6a7071c266c2aaa477c27badf60"
headlamp/plugin/version-compat: ">=0.20.0"
headlamp/plugin/distro-compat: "in-cluster,web,app"
+6
View File
@@ -0,0 +1,6 @@
# Artifact Hub repository metadata
repositoryID: cae81660-2624-4e02-8ac2-a176cbe94402
owners:
- name: privilegedescalation
email: ""
+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/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)**
- **[tns-csi Driver](https://github.com/fenio/tns-csi)**
- **[kbench](https://github.com/longhorn/kbench)**
- **[Headlamp](https://headlamp.dev/)**
+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 (kube-system) │
│ │
│ 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 kube-system \
--create-namespace \
--set config.pluginsDir=/headlamp/plugins \
--set pluginsManager.sources[0].name=headlamp-tns-csi-plugin \
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
```
## Complete values.yaml Example
```yaml
# headlamp-values.yaml
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
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 kube-system \
-f headlamp-values.yaml
```
## FluxCD HelmRelease
```yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: headlamp
namespace: kube-system
spec:
interval: 12h
url: https://headlamp-k8s.github.io/headlamp/
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: kube-system
spec:
interval: 1h
chart:
spec:
chart: headlamp
version: ">=0.26.0"
sourceRef:
kind: HelmRepository
name: headlamp
namespace: kube-system
values:
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
```
## 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: kube-system
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 kube-system \
-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 67 unit tests across 4 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 |
## 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 tests on every push and pull request:
```yaml
- name: Run unit tests
run: npm test
- name: Type-check
run: npx tsc --noEmit
```
Both must pass for the PR to merge.
+135
View File
@@ -0,0 +1,135 @@
# Installation Guide
## Installation Methods
### Method 1: Headlamp Plugin Manager (Recommended)
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin).
**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: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
```
**Via FluxCD HelmRelease:**
```yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: headlamp
namespace: kube-system
spec:
chart:
spec:
chart: headlamp
sourceRef:
kind: HelmRepository
name: headlamp
values:
config:
pluginsDir: /headlamp/plugins
pluginsManager:
sources:
- name: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
```
### 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/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
# Verify the checksum
echo "14a3e8c13d0b894a41aa1cfccbcb1f6af09dcbb8fd95c7040a540987ea2096a7 headlamp-tns-csi-plugin-0.1.0.tar.gz" | sha256sum --check
# Extract into your Headlamp plugins directory
tar xzf headlamp-tns-csi-plugin-0.1.0.tar.gz -C /headlamp/plugins/
```
The plugin directory should appear as `/headlamp/plugins/headlamp-tns-csi-plugin/`.
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/v0.1.0/headlamp-tns-csi-plugin-0.1.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 headlamp-tns-csi-plugin-0.1.0.tar.gz
# Extract to your Headlamp plugins directory
tar xzf headlamp-tns-csi-plugin-0.1.0.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/headlamp-tns-csi-plugin/
```
Or via the Headlamp UI: **Settings → Plugins → headlamp-tns-csi-plugin → 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: headlamp-tns-csi-plugin
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
```
Then upgrade your Headlamp release:
```bash
helm upgrade headlamp headlamp/headlamp -f values.yaml -n kube-system
```
## 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: kube-system
roleRef:
kind: ClusterRole
name: headlamp-tns-csi-reader
apiGroup: rbac.authorization.k8s.io
EOF
```
Adjust `name: headlamp` and `namespace: kube-system` 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 kube-system -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 kube-system -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:kube-system:headlamp
kubectl auth can-i create persistentvolumeclaims -n <benchmark-namespace> \
--as=system:serviceaccount:kube-system: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:kube-system: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:kube-system:headlamp
# Check pod proxy access (for metrics)
kubectl auth can-i get pods/proxy \
-n kube-system \
--as=system:serviceaccount:kube-system:headlamp
# Check snapshot access
kubectl auth can-i list volumesnapshots \
--as=system:serviceaccount:kube-system: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: kube-system # adjust to your Headlamp 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: kube-system
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` |
+18188
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "headlamp-tns-csi-plugin",
"version": "0.1.0",
"name": "tns-csi",
"version": "0.2.4",
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
"repository": {
"type": "git",
+6
View File
@@ -20,6 +20,12 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
},
},
},
ConfigStore: class {
get() { return {}; }
set() {}
update() {}
useConfig() { return () => ({}); }
},
}));
import { TnsCsiDataProvider, useTnsCsiContext } from './TnsCsiDataContext';
+54 -3
View File
@@ -22,6 +22,7 @@ import {
VolumeSnapshot,
VolumeSnapshotClass,
} from './k8s';
import { fetchTruenasPoolStats, getTnsCsiConfig, PoolStats } from './truenas';
// ---------------------------------------------------------------------------
// Context shape
@@ -46,6 +47,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 +93,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);
@@ -161,6 +168,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));
@@ -178,19 +210,34 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
// 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 +271,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
poolStats,
poolStatsError,
loading,
error,
refresh,
@@ -239,6 +288,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
volumeSnapshots,
volumeSnapshotClasses,
snapshotCrdAvailable,
poolStats,
poolStatsError,
loading,
error,
refresh,
+176
View File
@@ -0,0 +1,176 @@
/**
* 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}`));
}
};
});
}
+240
View File
@@ -0,0 +1,240 @@
import { fireEvent, render, screen, waitFor, act } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn().mockResolvedValue({}),
},
ConfigStore: class {
get() { return {}; }
set() {}
update() {}
useConfig() { return () => ({}); }
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/kbench', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/kbench')>();
return {
...actual,
createPvc: vi.fn().mockResolvedValue(undefined),
createJob: vi.fn().mockResolvedValue(undefined),
deleteJob: vi.fn().mockResolvedValue(undefined),
deletePvc: vi.fn().mockResolvedValue(undefined),
getJobPhase: vi.fn().mockResolvedValue({ phase: 'Active', job: {} }),
fetchKbenchLogs: vi.fn().mockResolvedValue(''),
listKbenchJobs: vi.fn().mockResolvedValue([]),
generateJobName: vi.fn().mockReturnValue('kbench-abc123'),
generatePvcName: vi.fn().mockReturnValue('kbench-abc123-pvc'),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import {
createPvc,
createJob,
deleteJob,
deletePvc,
getJobPhase,
fetchKbenchLogs,
listKbenchJobs,
parseKbenchLog,
} from '../api/kbench';
import { defaultContext, makeSampleStorageClass } from '../test-helpers';
import BenchmarkPage from './BenchmarkPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('BenchmarkPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listKbenchJobs).mockResolvedValue([]);
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<BenchmarkPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('renders benchmark guide section', () => {
mockContext();
render(<BenchmarkPage />);
expect(screen.getByText('Benchmark Guide')).toBeInTheDocument();
expect(screen.getByText(/Do not cancel mid-run/)).toBeInTheDocument();
});
it('renders Run New Benchmark form', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
expect(screen.getByText('Run New Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Select storage class for benchmark')).toBeInTheDocument();
});
it('populates SC dropdown with storage class names', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
mockContext({ storageClasses: [sc1, sc2] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options.length).toBe(2);
expect(select.options[0].value).toBe('sc-a');
expect(select.options[1].value).toBe('sc-b');
});
it('shows "No tns-csi storage classes found" when empty', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
expect(select.options[0].text).toContain('No tns-csi storage classes');
});
it('shows confirmation dialog when Run Benchmark is clicked', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByText(/~33Gi PVC/)).toBeInTheDocument();
});
it('cancels confirmation dialog', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
fireEvent.click(screen.getByLabelText('Cancel benchmark'));
expect(screen.queryByText('Confirm Benchmark')).not.toBeInTheDocument();
});
it('starts benchmark on confirmation and calls createPvc', async () => {
vi.useFakeTimers();
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
// PVC bind check
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
expect(vi.mocked(createPvc)).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it('shows failed state when PVC creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockRejectedValueOnce(new Error('quota exceeded'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/quota exceeded/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('renders past benchmarks section', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('Past Benchmarks')).toBeInTheDocument();
});
expect(screen.getByText('No past benchmark jobs found.')).toBeInTheDocument();
});
it('renders past benchmark jobs in table', async () => {
mockContext();
vi.mocked(listKbenchJobs).mockResolvedValueOnce([
{
jobName: 'kbench-old',
namespace: 'default',
storageClass: 'tns-nfs',
phase: 'Complete',
startedAt: '2025-01-01T00:00:00Z',
},
]);
render(<BenchmarkPage />);
await waitFor(() => {
expect(screen.getByText('kbench-old')).toBeInTheDocument();
});
expect(screen.getByText('Complete')).toBeInTheDocument();
});
it('disables Run Benchmark button when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<BenchmarkPage />);
const btn = screen.getByLabelText('Start kbench storage benchmark');
expect(btn).toBeDisabled();
});
it('shows confirmation dialog with selected SC and namespace', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
// Change namespace
const nsInput = screen.getByLabelText('Kubernetes namespace for benchmark job') as HTMLInputElement;
fireEvent.change(nsInput, { target: { value: 'bench-ns' } });
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
// Confirm dialog shows SC and namespace in <strong> tags
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
expect(screen.getByLabelText('Confirm and start benchmark')).toBeInTheDocument();
// Namespace is shown in the dialog
const dialogText = screen.getByText(/bench-ns/);
expect(dialogText).toBeInTheDocument();
});
it('can change test size and mode', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<BenchmarkPage />);
const sizeInput = screen.getByLabelText('FIO test size') as HTMLInputElement;
fireEvent.change(sizeInput, { target: { value: '10G' } });
expect(sizeInput.value).toBe('10G');
const modeSelect = screen.getByLabelText('Benchmark mode') as HTMLSelectElement;
fireEvent.change(modeSelect, { target: { value: 'quick' } });
expect(modeSelect.value).toBe('quick');
});
it('shows failed state when job creation fails', async () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
vi.mocked(createPvc).mockResolvedValueOnce(undefined);
// PVC binds immediately
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
vi.mocked(createJob).mockRejectedValueOnce(new Error('job already exists'));
render(<BenchmarkPage />);
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
await act(async () => {
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
});
await waitFor(() => {
expect(screen.getByText(/job already exists/)).toBeInTheDocument();
});
expect(screen.getByText('Failed')).toBeInTheDocument();
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* 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, isPodReady, getPodRestarts, 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>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
import DriverStatusCard from './DriverStatusCard';
import { makeSamplePod, sampleCSIDriver, makeSampleMetrics } from '../test-helpers';
describe('DriverStatusCard', () => {
it('shows "Not detected" when no CSI driver is present', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Not detected')).toBeInTheDocument();
});
it('shows "Degraded" when no pods are present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('Degraded')).toBeInTheDocument();
});
it('shows "Metrics unavailable" when no metrics provided', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
/>
);
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
});
it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 1 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Healthy')).toBeInTheDocument();
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io installed')).toBeInTheDocument();
});
it('shows "Disconnected" when WS is disconnected', () => {
const metrics = makeSampleMetrics({ websocketConnected: 0 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Disconnected')).toBeInTheDocument();
});
it('shows "Unknown" when websocketConnected is null', () => {
const metrics = makeSampleMetrics({ websocketConnected: null });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
metrics={metrics}
/>
);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('renders CSI capabilities section when driver is present', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
expect(screen.getByText('Persistent')).toBeInTheDocument();
});
it('does not render CSI capabilities when no driver', () => {
render(
<DriverStatusCard
csiDriver={null}
controllerPods={[]}
nodePods={[]}
/>
);
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
});
it('renders pod rows with image, restarts, and ready status', () => {
const pod = makeSamplePod({
metadata: { name: 'ctrl-pod-1', creationTimestamp: '2025-01-01T00:00:00Z' },
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{ name: 'tns-csi', ready: true, restartCount: 2, image: 'fenio/tns-csi:v0.6.0' },
],
},
});
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[pod]}
nodePods={[]}
/>
);
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument(); // restarts
expect(screen.getByText('Running')).toBeInTheDocument();
});
it('shows "No controller pod found" when controllerPods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
/>
);
expect(screen.getByText('No controller pod found')).toBeInTheDocument();
});
it('shows "No node pods found" when nodePods is empty', () => {
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[]}
/>
);
expect(screen.getByText('No node pods found')).toBeInTheDocument();
});
it('shows WS reconnects when available in metrics', () => {
const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[makeSamplePod({ name: 'node-1' })]}
metrics={metrics}
/>
);
expect(screen.getByText('WS Reconnects')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('shows node pods count in section title', () => {
const node1 = makeSamplePod({ name: 'tns-csi-node-1' });
const node2 = makeSamplePod({ name: 'tns-csi-node-2' });
render(
<DriverStatusCard
csiDriver={sampleCSIDriver}
controllerPods={[makeSamplePod()]}
nodePods={[node1, node2]}
/>
);
expect(screen.getByText('Node Pods (2)')).toBeInTheDocument();
});
});
+161
View File
@@ -0,0 +1,161 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import { defaultContext, makeSamplePod, makeSampleMetrics } from '../test-helpers';
import MetricsPage from './MetricsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('MetricsPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when context is loading', () => {
mockContext({ loading: true });
render(<MetricsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<MetricsPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/TNS-CSI driver not found/)).toBeInTheDocument();
});
it('shows "No controller pod found" when driver installed but no pods', () => {
mockContext({ driverInstalled: true, controllerPods: [] });
render(<MetricsPage />);
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
expect(screen.getByText(/No controller pod found/)).toBeInTheDocument();
});
it('shows metrics error when fetch fails', async () => {
const pod = makeSamplePod();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('connection refused'));
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('connection refused')).toBeInTheDocument();
});
});
it('renders three metric cards when fetch succeeds', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
expect(screen.getByText('Volume Operations')).toBeInTheDocument();
expect(screen.getByText('CSI Operations')).toBeInTheDocument();
});
it('displays correct WebSocket metric data', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
websocketConnected: 1,
websocketReconnectsTotal: 42,
websocketMessagesTotal: [{ labels: {}, value: 250 }],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
csiOperationsTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
expect(screen.getByText('42')).toBeInTheDocument(); // reconnects
expect(screen.getByText('250')).toBeInTheDocument(); // messages
});
it('displays CSI operations broken down by method', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 77 },
{ labels: { method: 'DeleteVolume' }, value: 13 },
],
// Zero out other metrics to avoid number collisions
volumeOperationsTotal: [],
volumeCapacityBytes: [],
websocketMessagesTotal: [],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('CreateVolume')).toBeInTheDocument();
});
expect(screen.getByText('77')).toBeInTheDocument();
expect(screen.getByText('DeleteVolume')).toBeInTheDocument();
expect(screen.getByText('13')).toBeInTheDocument();
});
it('refresh button triggers refetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValue(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
});
const initialCallCount = vi.mocked(fetchControllerMetrics).mock.calls.length;
fireEvent.click(screen.getByLabelText('Refresh metrics'));
await waitFor(() => {
expect(vi.mocked(fetchControllerMetrics).mock.calls.length).toBeGreaterThan(initialCallCount);
});
});
it('shows "Updated" timestamp after successful fetch', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics();
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
});
});
it('shows volume operations grouped by protocol', async () => {
const pod = makeSamplePod();
const metrics = makeSampleMetrics({
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 15 },
{ labels: { protocol: 'iscsi' }, value: 8 },
],
});
mockContext({ driverInstalled: true, controllerPods: [pod] });
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<MetricsPage />);
await waitFor(() => {
expect(screen.getByText('Operations (nfs)')).toBeInTheDocument();
});
expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument();
});
});
+276
View File
@@ -0,0 +1,276 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
vi.mock('../api/metrics', async (importOriginal) => {
const actual = await importOriginal<typeof import('../api/metrics')>();
return {
...actual,
fetchControllerMetrics: vi.fn(),
};
});
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { fetchControllerMetrics } from '../api/metrics';
import {
defaultContext,
makeSamplePod,
makeSamplePV,
makeSamplePVC,
makeSampleStorageClass,
makeSampleMetrics,
sampleCSIDriver,
} from '../test-helpers';
import OverviewPage from './OverviewPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('OverviewPage', () => {
beforeEach(() => {
vi.mocked(fetchControllerMetrics).mockReset();
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<OverviewPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...');
});
it('shows "Driver Not Detected" when driver not installed', () => {
mockContext({ driverInstalled: false });
render(<OverviewPage />);
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
expect(screen.getByText(/CSIDriver tns.csi.io not found/)).toBeInTheDocument();
});
it('shows error section when error is present', () => {
mockContext({ error: 'cluster unavailable' });
render(<OverviewPage />);
expect(screen.getByText('cluster unavailable')).toBeInTheDocument();
});
it('always shows the development status notice', () => {
mockContext({ driverInstalled: true });
render(<OverviewPage />);
expect(screen.getByText(/active early development/)).toBeInTheDocument();
});
it('renders storage summary with SC/PV counts', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
const pvc = makeSamplePVC();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc],
persistentVolumes: [pv],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Storage Summary')).toBeInTheDocument();
expect(screen.getByText('Storage Classes')).toBeInTheDocument();
expect(screen.getByText('Persistent Volumes')).toBeInTheDocument();
});
it('renders capacity aggregation from PVs', () => {
const pv1 = makeSamplePV({
metadata: { name: 'pv-1' },
spec: { ...makeSamplePV().spec, capacity: { storage: '100Gi' } },
});
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, capacity: { storage: '50Gi' } },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv1, pv2],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
// 150 GiB total
expect(screen.getByText('150.0 GiB')).toBeInTheDocument();
});
it('renders protocol distribution bar', () => {
const sc1 = makeSampleStorageClass({ parameters: { protocol: 'nfs' } });
const sc2 = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [sc1, sc2],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Protocol Distribution')).toBeInTheDocument();
expect(screen.getByTestId('percentage-bar')).toBeInTheDocument();
});
it('renders pool capacity table when poolStats are present', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [
{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 },
],
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('ONLINE')).toBeInTheDocument();
});
it('shows pool stats error hint', () => {
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStatsError: 'API key invalid',
});
render(<OverviewPage />);
expect(screen.getByText('Pool Capacity Unavailable')).toBeInTheDocument();
expect(screen.getByText('API key invalid')).toBeInTheDocument();
expect(screen.getByText(/TrueNAS API key/)).toBeInTheDocument();
});
it('shows Prometheus fallback capacity by pool when no poolStats and metrics available', async () => {
const pod = makeSamplePod();
const pv = makeSamplePV();
const metrics = makeSampleMetrics({
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [makeSampleStorageClass()],
persistentVolumes: [pv],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
poolStats: [],
poolStatsError: null,
});
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Provisioned Capacity by Pool')).toBeInTheDocument();
});
});
it('renders non-bound PVCs table', () => {
const pendingPvc = makeSamplePVC({
metadata: { name: 'pending-pvc', namespace: 'test', creationTimestamp: '2025-01-01T00:00:00Z' },
status: { phase: 'Pending' },
});
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pendingPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('Attention: Non-Bound PVCs')).toBeInTheDocument();
expect(screen.getByText('pending-pvc')).toBeInTheDocument();
expect(screen.getByText('Pending')).toBeInTheDocument();
});
it('does not show non-bound PVCs section when all PVCs are bound', () => {
const pvc = makeSamplePVC({ status: { phase: 'Bound' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [pvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.queryByText('Attention: Non-Bound PVCs')).not.toBeInTheDocument();
});
it('refresh button calls context.refresh()', () => {
const refreshFn = vi.fn();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
refresh: refreshFn,
});
render(<OverviewPage />);
fireEvent.click(screen.getByLabelText('Refresh tns-csi data'));
expect(refreshFn).toHaveBeenCalledTimes(1);
});
it('shows metrics unavailable when fetchControllerMetrics fails', async () => {
const pod = makeSamplePod();
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [pod],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('timeout'));
render(<OverviewPage />);
await waitFor(() => {
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
});
expect(screen.getByText('timeout')).toBeInTheDocument();
});
it('shows PVC status breakdown with Pending and Lost counts', () => {
const boundPvc = makeSamplePVC({ metadata: { name: 'pvc-1', namespace: 'ns' }, status: { phase: 'Bound' } });
const pendingPvc = makeSamplePVC({ metadata: { name: 'pvc-2', namespace: 'ns' }, status: { phase: 'Pending' } });
const lostPvc = makeSamplePVC({ metadata: { name: 'pvc-3', namespace: 'ns' }, status: { phase: 'Lost' } });
mockContext({
driverInstalled: true,
csiDriver: sampleCSIDriver,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [boundPvc, pendingPvc, lostPvc],
controllerPods: [makeSamplePod()],
nodePods: [makeSamplePod({ name: 'node-1' })],
});
render(<OverviewPage />);
expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument();
expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument();
});
});
+81 -1
View File
@@ -18,7 +18,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
import { fetchControllerMetrics } from '../api/metrics';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
@@ -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> = React.useMemo(() => {
const map = new Map<string, number>();
if (!metrics) return map;
const handleToPool = new Map<string, string>();
for (const pv of persistentVolumes) {
const handle = pv.spec.csi?.volumeHandle;
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
if (handle && pool) handleToPool.set(handle, pool);
}
for (const sample of metrics.volumeCapacityBytes) {
const volumeId = sample.labels['volume_id'];
if (!volumeId) continue;
const pool = handleToPool.get(volumeId) ?? 'unknown';
map.set(pool, (map.get(pool) ?? 0) + sample.value);
}
return map;
}, [metrics, persistentVolumes]);
if (loading) {
return <Loader title="Loading TNS-CSI data..." />;
}
@@ -234,6 +254,66 @@ export default function OverviewPage() {
/>
</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">
+94
View File
@@ -0,0 +1,94 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV, makeSamplePVC } from '../test-helpers';
import PVCDetailSection from './PVCDetailSection';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('PVCDetailSection', () => {
it('returns null when loading', () => {
mockContext({ loading: true });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC is not in filtered list', () => {
mockContext({ persistentVolumeClaims: [] });
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'other-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('returns null when PVC has no bound PV', () => {
const pvc = makeSamplePVC({ metadata: { name: 'orphan-pvc', namespace: 'default' } });
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [], // no PVs to match
});
const { container } = render(
<PVCDetailSection resource={{ metadata: { name: 'orphan-pvc', namespace: 'default' } }} />
);
expect(container.innerHTML).toBe('');
});
it('renders storage details when PVC and PV are found', () => {
const pvc = makeSamplePVC();
const pv = makeSamplePV();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('renders custom volume attributes (excluding protocol and server)', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
customAttr: 'customValue',
},
},
},
});
const pvc = makeSamplePVC();
mockContext({
persistentVolumeClaims: [pvc],
persistentVolumes: [pv],
});
render(
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
);
expect(screen.getByText('pool')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('customAttr')).toBeInTheDocument();
expect(screen.getByText('customValue')).toBeInTheDocument();
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* 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>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleSnapshot, makeSampleSnapshotClass } from '../test-helpers';
import SnapshotsPage from './SnapshotsPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('SnapshotsPage', () => {
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<SnapshotsPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...');
});
it('shows error state', () => {
mockContext({ error: 'something broke' });
render(<SnapshotsPage />);
expect(screen.getByText('something broke')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows notice when snapshot CRD is not available', () => {
mockContext({ snapshotCrdAvailable: false });
render(<SnapshotsPage />);
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
expect(
screen.getByText(/VolumeSnapshot CRDs.*not found/)
).toBeInTheDocument();
});
it('shows empty message when snapshots list is empty', () => {
mockContext({ snapshotCrdAvailable: true, volumeSnapshots: [] });
render(<SnapshotsPage />);
expect(screen.getByText('No tns-csi VolumeSnapshots found.')).toBeInTheDocument();
});
it('renders snapshot classes when available', () => {
const vsc = makeSampleSnapshotClass();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [vsc],
volumeSnapshots: [],
});
render(<SnapshotsPage />);
expect(screen.getByText('Snapshot Classes (1)')).toBeInTheDocument();
expect(screen.getByText('tns-snap-class')).toBeInTheDocument();
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
});
it('renders populated snapshots with readyToUse=true', () => {
const snap = makeSampleSnapshot();
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('snap-001')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=false', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: false, restoreSize: '50Gi' },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('No')).toBeInTheDocument();
});
it('renders snapshot with readyToUse=undefined as Unknown', () => {
const snap = makeSampleSnapshot({
status: { readyToUse: undefined },
});
mockContext({
snapshotCrdAvailable: true,
volumeSnapshots: [snap],
});
render(<SnapshotsPage />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not render snapshot classes section when empty', () => {
mockContext({
snapshotCrdAvailable: true,
volumeSnapshotClasses: [],
volumeSnapshots: [makeSampleSnapshot()],
});
render(<SnapshotsPage />);
expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument();
});
});
+158
View File
@@ -0,0 +1,158 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/storage-classes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSampleStorageClass, makeSamplePV } from '../test-helpers';
import StorageClassesPage from './StorageClassesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('StorageClassesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<StorageClassesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...');
});
it('shows error state', () => {
mockContext({ error: 'fetch failed' });
render(<StorageClassesPage />);
expect(screen.getByText('fetch failed')).toBeInTheDocument();
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('shows empty message when no storage classes', () => {
mockContext({ storageClasses: [] });
render(<StorageClassesPage />);
expect(screen.getByText('No tns-csi StorageClasses found.')).toBeInTheDocument();
});
it('renders table with all columns populated', () => {
const sc = makeSampleStorageClass();
const pv = makeSamplePV();
mockContext({
storageClasses: [sc],
persistentVolumes: [pv],
});
render(<StorageClassesPage />);
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('tank')).toBeInTheDocument();
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument();
expect(screen.getByText('Yes')).toBeInTheDocument(); // expansion
expect(screen.getByText('1')).toBeInTheDocument(); // PV count
});
it('opens detail panel when clicking SC name', () => {
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByText('tns-nfs'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes#tns-nfs');
});
it('renders detail panel when hash is set', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('StorageClass Details')).toBeInTheDocument();
});
it('closes panel via close button', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel via backdrop click', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('closes panel on Escape key', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass();
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
});
it('shows NFS protocol notes in detail panel', () => {
mockHash = '#tns-nfs';
const sc = makeSampleStorageClass({ parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' } });
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
expect(screen.getByText(/nfs-common/)).toBeInTheDocument();
});
it('shows NVMe-oF protocol notes', () => {
mockHash = '#tns-nvmeof';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-nvmeof' },
parameters: { protocol: 'nvmeof', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/nvme-cli/)).toBeInTheDocument();
});
it('shows iSCSI protocol notes', () => {
mockHash = '#tns-iscsi';
const sc = makeSampleStorageClass({
metadata: { name: 'tns-iscsi' },
parameters: { protocol: 'iscsi', pool: 'tank', server: '10.0.0.1' },
});
mockContext({ storageClasses: [sc], persistentVolumes: [] });
render(<StorageClassesPage />);
expect(screen.getByText(/open-iscsi/)).toBeInTheDocument();
});
it('shows PV count for each storage class', () => {
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
const pv1 = makeSamplePV({ spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' } });
const pv2 = makeSamplePV({
metadata: { name: 'pv-2' },
spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' },
});
mockContext({
storageClasses: [sc1, sc2],
persistentVolumes: [pv1, pv2],
});
render(<StorageClassesPage />);
const cells = screen.getAllByRole('cell');
const pvCells = cells.filter(c => c.textContent === '2');
expect(pvCells.length).toBeGreaterThanOrEqual(1);
});
});
+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>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () =>
require('./__mocks__/commonComponents.ts')
);
let mockHash = '';
const mockPush = vi.fn();
vi.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/tns-csi/volumes', hash: mockHash }),
useHistory: () => ({ push: mockPush }),
}));
vi.mock('../api/TnsCsiDataContext');
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import { defaultContext, makeSamplePV } from '../test-helpers';
import VolumesPage from './VolumesPage';
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
}
describe('VolumesPage', () => {
beforeEach(() => {
mockPush.mockClear();
mockHash = '';
});
it('shows loader when loading', () => {
mockContext({ loading: true });
render(<VolumesPage />);
expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...');
});
it('shows error state', () => {
mockContext({ error: 'api error' });
render(<VolumesPage />);
expect(screen.getByText('api error')).toBeInTheDocument();
});
it('shows empty message when no PVs', () => {
mockContext({ persistentVolumes: [] });
render(<VolumesPage />);
expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument();
});
it('renders PV table with claim ref', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('pv-test-001')).toBeInTheDocument();
expect(screen.getByText('default/my-pvc')).toBeInTheDocument();
expect(screen.getByText('NFS')).toBeInTheDocument();
expect(screen.getByText('100Gi')).toBeInTheDocument();
expect(screen.getByText('RWO')).toBeInTheDocument();
expect(screen.getByText('Bound')).toBeInTheDocument();
});
it('renders "—" for PV without claimRef', () => {
const pv = makeSamplePV({
spec: {
...makeSamplePV().spec,
claimRef: undefined,
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const cells = screen.getAllByRole('cell');
const dashCells = cells.filter(c => c.textContent === '—');
expect(dashCells.length).toBeGreaterThanOrEqual(1);
});
it('opens detail panel when clicking PV name', () => {
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByText('pv-test-001'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes#pv-test-001');
});
it('renders detail panel with CSI attributes', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv], persistentVolumeClaims: [] });
render(<VolumesPage />);
expect(screen.getByText('Volume Details')).toBeInTheDocument();
expect(screen.getByText('CSI Attributes')).toBeInTheDocument();
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
});
it('shows Bound PVC section in detail panel when claimRef exists', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Bound PVC')).toBeInTheDocument();
expect(screen.getByText('my-pvc')).toBeInTheDocument();
});
it('shows Adoption section when annotation is present', () => {
mockHash = '#pv-adoptable';
const pv = makeSamplePV({
metadata: {
name: 'pv-adoptable',
annotations: { 'tns-csi.io/adoptable': 'true' },
},
});
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
expect(screen.getByText('Adoption')).toBeInTheDocument();
expect(screen.getByText(/adopted cross-cluster/)).toBeInTheDocument();
});
it('closes panel on Escape key', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('closes panel via backdrop click', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
});
it('renders maximize/minimize button in panel', () => {
mockHash = '#pv-test-001';
const pv = makeSamplePV();
mockContext({ persistentVolumes: [pv] });
render(<VolumesPage />);
const maxBtn = screen.getByLabelText('Maximize');
expect(maxBtn).toBeInTheDocument();
fireEvent.click(maxBtn);
expect(screen.getByLabelText('Minimize')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
/**
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => commonComponentsMock).
*
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
*/
import React from 'react';
type RC = React.ReactNode;
export const Loader = ({ title }: { title?: string }) =>
React.createElement('div', { 'data-testid': 'loader' }, title);
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
React.createElement('div', { 'data-testid': 'section-box', 'data-title': title },
title ? React.createElement('h3', null, title) : null,
children
);
export const SectionHeader = ({ title }: { title: string }) =>
React.createElement('h1', { 'data-testid': 'section-header' }, title);
export const SimpleTable = ({
columns,
data,
emptyMessage,
}: {
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
data: unknown[];
emptyMessage?: string;
}) => {
if (data.length === 0 && emptyMessage) {
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
}
return React.createElement('table', { 'data-testid': 'simple-table' },
React.createElement('thead', null,
React.createElement('tr', null,
columns.map(col => React.createElement('th', { key: col.label }, col.label))
)
),
React.createElement('tbody', null,
data.map((item, i) =>
React.createElement('tr', { key: i },
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
)
)
)
);
};
export const NameValueTable = ({
rows,
}: {
rows: Array<{ name: string; value: RC }>;
}) =>
React.createElement('table', { 'data-testid': 'name-value-table' },
React.createElement('tbody', null,
rows.map(row =>
React.createElement('tr', { key: row.name },
React.createElement('td', null, row.name),
React.createElement('td', null, row.value)
)
)
)
);
export const StatusLabel = ({
status,
children,
}: {
status: string;
children?: RC;
}) =>
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
export const PercentageBar = ({
data,
}: {
data: Array<{ name: string; value: number }>;
total: number;
}) =>
React.createElement('div', { 'data-testid': 'percentage-bar' },
data.map(d =>
React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`)
)
);
@@ -0,0 +1,73 @@
/**
* 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,153 @@
/**
* 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>;
},
},
];
}
+88 -36
View File
@@ -1,34 +1,42 @@
/**
* 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 TnsCsiSettings from './components/TnsCsiSettings';
import BenchmarkPage from './components/BenchmarkPage';
import DriverPodDetailSection from './components/DriverPodDetailSection';
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns';
import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
import 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 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 +49,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 +74,7 @@ registerSidebarEntry({
});
// ---------------------------------------------------------------------------
// Routes
// Routes (keep all routes so direct links still work)
// ---------------------------------------------------------------------------
registerRoute({
@@ -97,9 +89,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 +105,7 @@ registerRoute({
registerRoute({
path: '/tns-csi/volumes',
sidebar: 'tns-csi-volumes',
sidebar: 'tns-csi-overview',
name: 'tns-csi-volumes',
exact: true,
component: () => (
@@ -158,7 +152,7 @@ registerRoute({
});
// ---------------------------------------------------------------------------
// PVC detail view injection
// Detail view section — PVC pages
// ---------------------------------------------------------------------------
registerDetailsViewSection(({ resource }) => {
@@ -171,19 +165,77 @@ 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);
+210
View File
@@ -0,0 +1,210 @@
/**
* Shared test helpers: mock factories, fixtures, and context setup
* for component tests.
*/
import { vi } from 'vitest';
import type { TnsCsiContextValue } from './api/TnsCsiDataContext';
import type {
CSIDriver,
TnsCsiPersistentVolume,
TnsCsiPersistentVolumeClaim,
TnsCsiPod,
TnsCsiStorageClass,
VolumeSnapshot,
VolumeSnapshotClass,
} from './api/k8s';
import type { TnsCsiMetrics } from './api/metrics';
// ---------------------------------------------------------------------------
// Default context value (everything empty / zeroed)
// ---------------------------------------------------------------------------
export function defaultContext(overrides?: Partial<TnsCsiContextValue>): TnsCsiContextValue {
return {
csiDriver: null,
driverInstalled: false,
storageClasses: [],
persistentVolumes: [],
persistentVolumeClaims: [],
controllerPods: [],
nodePods: [],
volumeSnapshots: [],
volumeSnapshotClasses: [],
snapshotCrdAvailable: false,
poolStats: [],
poolStatsError: null,
loading: false,
error: null,
refresh: vi.fn(),
...overrides,
};
}
// ---------------------------------------------------------------------------
// Sample fixtures
// ---------------------------------------------------------------------------
export const sampleCSIDriver: CSIDriver = {
metadata: { name: 'tns.csi.io' },
spec: {
attachRequired: false,
podInfoOnMount: true,
volumeLifecycleModes: ['Persistent'],
},
};
export function makeSampleStorageClass(overrides?: Partial<TnsCsiStorageClass>): TnsCsiStorageClass {
return {
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
provisioner: 'tns.csi.io',
reclaimPolicy: 'Delete',
volumeBindingMode: 'Immediate',
allowVolumeExpansion: true,
parameters: {
protocol: 'nfs',
pool: 'tank',
server: '10.0.0.1',
},
...overrides,
};
}
export const sampleStorageClass = makeSampleStorageClass();
export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCsiPersistentVolume {
return {
metadata: {
name: 'pv-test-001',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
csi: {
driver: 'tns.csi.io',
volumeHandle: 'tank/vol-001',
volumeAttributes: {
protocol: 'nfs',
server: '10.0.0.1',
pool: 'tank',
},
},
capacity: { storage: '100Gi' },
accessModes: ['ReadWriteOnce'],
persistentVolumeReclaimPolicy: 'Delete',
storageClassName: 'tns-nfs',
claimRef: { name: 'my-pvc', namespace: 'default' },
},
status: { phase: 'Bound' },
...overrides,
};
}
export const samplePV = makeSamplePV();
export function makeSamplePVC(overrides?: Partial<TnsCsiPersistentVolumeClaim>): TnsCsiPersistentVolumeClaim {
return {
metadata: {
name: 'my-pvc',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
storageClassName: 'tns-nfs',
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '100Gi' } },
volumeName: 'pv-test-001',
},
status: {
phase: 'Bound',
capacity: { storage: '100Gi' },
},
...overrides,
};
}
export const samplePVC = makeSamplePVC();
export function makeSamplePod(overrides?: Partial<TnsCsiPod> & { name?: string }): TnsCsiPod {
const name = overrides?.name ?? overrides?.metadata?.name ?? 'tns-csi-controller-abc';
return {
metadata: {
name,
creationTimestamp: '2025-01-01T00:00:00Z',
...overrides?.metadata,
},
spec: {
nodeName: 'node-1',
...overrides?.spec,
},
status: {
phase: 'Running',
conditions: [{ type: 'Ready', status: 'True' }],
containerStatuses: [
{
name: 'tns-csi',
ready: true,
restartCount: 0,
image: 'fenio/tns-csi:v0.5.0',
},
],
...overrides?.status,
},
};
}
export const samplePod = makeSamplePod();
export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeSnapshot {
return {
metadata: {
name: 'snap-001',
namespace: 'default',
creationTimestamp: '2025-01-01T00:00:00Z',
},
spec: {
source: { persistentVolumeClaimName: 'my-pvc' },
volumeSnapshotClassName: 'tns-snap-class',
},
status: {
readyToUse: true,
restoreSize: '100Gi',
},
...overrides,
};
}
export function makeSampleSnapshotClass(overrides?: Partial<VolumeSnapshotClass>): VolumeSnapshotClass {
return {
metadata: {
name: 'tns-snap-class',
creationTimestamp: '2025-01-01T00:00:00Z',
},
driver: 'tns.csi.io',
deletionPolicy: 'Delete',
...overrides,
};
}
export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMetrics {
return {
websocketConnected: 1,
websocketReconnectsTotal: 3,
websocketMessagesTotal: [{ labels: {}, value: 100 }],
websocketMessageDurationSeconds: [],
volumeOperationsTotal: [
{ labels: { protocol: 'nfs' }, value: 10 },
{ labels: { protocol: 'iscsi' }, value: 5 },
],
volumeOperationsDurationSeconds: [],
volumeCapacityBytes: [
{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 },
],
csiOperationsTotal: [
{ labels: { method: 'CreateVolume' }, value: 10 },
{ labels: { method: 'DeleteVolume' }, value: 2 },
],
csiOperationsDurationSeconds: [],
...overrides,
};
}