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