Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f743c4ecd0 | |||
| 629ab1050e | |||
| be5d86bc8e | |||
| 1daa5095ab | |||
| 9a944560f5 | |||
| 433e0d7870 | |||
| e09e887390 | |||
| fc7198b045 | |||
| b0110e474c | |||
| 2e2713fd3f | |||
| be254b1eec | |||
| 90efdf5569 | |||
| 4ba90fa218 | |||
| a089a2cc2d | |||
| bb283d8923 | |||
| 0af2f24a27 | |||
| 409efe84d5 | |||
| a5032b23d1 | |||
| c241b8d9d5 | |||
| 7ae5efda73 | |||
| fd1d76c932 | |||
| dc981feaa4 | |||
| 77586a98eb | |||
| bfe95475c6 | |||
| f69dfd6356 | |||
| 3c5a837a9d | |||
| f4e4e24b6c | |||
| fef2c3c3e5 | |||
| 423282ec6c | |||
| 4ae7aa6a91 | |||
| c040181509 | |||
| e0037f60d2 | |||
| ce5c0da56e | |||
| bc59cd7a23 | |||
| aa9a0d38fe | |||
| 2c2ad720e5 | |||
| dc6dee9d4d | |||
| 5e93973fa7 | |||
| 93b5018f60 | |||
| 1b2a6046cd | |||
| ba5c296a13 | |||
| 2d92bce571 | |||
| 8fb4c18e8a | |||
| a20a2e29e6 | |||
| dbbabef94a | |||
| 91e1cbd618 | |||
| 47642375ba | |||
| 29f19e2346 | |||
| 441b5792f4 | |||
| b9f8eec748 | |||
| 6c0d8c3ee3 | |||
| d39a48a7d0 | |||
| 076fa29995 | |||
| c1c5e8a37d | |||
| 6f35c6c81b | |||
| 6b0b9bc9ea | |||
| 50ed43f3a2 | |||
| e54c76e7cd | |||
| 1f6677e2f6 | |||
| 0882451c67 | |||
| 2988af9926 | |||
| 3cebde0673 | |||
| 71abc6792d | |||
| 5960cc521e | |||
| 06d18a3eb3 | |||
| e2512ec500 | |||
| e955cf80fb | |||
| 50c280d1df | |||
| ae8d93aaa7 | |||
| 680289fba4 | |||
| f7592d69db | |||
| fa401afecf | |||
| cdff1d1a07 | |||
| 54a33d70b0 | |||
| 7922630fc3 | |||
| a20c20a4ec | |||
| 3e757db799 | |||
| 81b0b35089 | |||
| 76ab680d9a | |||
| 9aeafc4344 | |||
| e082c60677 | |||
| 96ea9e1207 | |||
| a77aa3a1dc | |||
| 145101b1b5 | |||
| 03d616a545 | |||
| 7ef7b8b7b5 | |||
| f1feb5c2f7 | |||
| f2f3c3a87e | |||
| aeab42b6ec | |||
| 44a7f654f0 | |||
| 0d0bf0f609 |
@@ -0,0 +1,241 @@
|
||||
---
|
||||
name: artifacthub-headlamp
|
||||
description: Use when working with ArtifactHub metadata, releases, or publishing for Headlamp plugins. Covers artifacthub-repo.yml, artifacthub-pkg.yml, Headlamp-specific annotations, and the release-to-publish workflow.
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are an expert in publishing Headlamp Kubernetes dashboard plugins to ArtifactHub. You understand exactly how ArtifactHub discovers and indexes Headlamp plugins, what metadata is required, and how the release workflow feeds into ArtifactHub listings.
|
||||
|
||||
Before editing any metadata files, read the existing `artifacthub-repo.yml`, `artifacthub-pkg.yml`, and `package.json` to understand the current state.
|
||||
|
||||
---
|
||||
|
||||
## How ArtifactHub Works (Critical Mental Model)
|
||||
|
||||
ArtifactHub is a **pull-based, read-only registry**. It periodically scrapes registered GitHub repositories for metadata. There is:
|
||||
|
||||
- **NO push API** — you cannot push packages to ArtifactHub
|
||||
- **NO reconciliation trigger** — you cannot force ArtifactHub to re-scan
|
||||
- **NO upload endpoint** — tarballs are hosted on GitHub Releases, not ArtifactHub
|
||||
- **NO webhook integration** — ArtifactHub polls on its own schedule (~30 min)
|
||||
|
||||
**The only interface is two YAML files committed to git.** ArtifactHub reads them, and that's it.
|
||||
|
||||
---
|
||||
|
||||
## Repository Registration
|
||||
|
||||
### artifacthub-repo.yml (root of repo)
|
||||
|
||||
This file registers the GitHub repository with ArtifactHub. Created once, rarely changed.
|
||||
|
||||
```yaml
|
||||
# Artifact Hub repository metadata file
|
||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
|
||||
repositoryID: <uuid> # Assigned by ArtifactHub when you add the repo via the web UI
|
||||
owners:
|
||||
- name: <github-username-or-org>
|
||||
email: <email>
|
||||
```
|
||||
|
||||
**How to get the repositoryID:**
|
||||
1. Log into artifacthub.io
|
||||
2. Go to Control Panel → Repositories → Add
|
||||
3. Select repository kind: "Headlamp plugins"
|
||||
4. Provide the GitHub repo URL
|
||||
5. ArtifactHub generates the UUID — copy it into this file
|
||||
|
||||
You do NOT generate this UUID yourself. It comes from ArtifactHub's web UI.
|
||||
|
||||
---
|
||||
|
||||
## Package Metadata
|
||||
|
||||
### artifacthub-pkg.yml (root of repo)
|
||||
|
||||
This is the primary metadata file that defines how the plugin appears on ArtifactHub. Updated with each release.
|
||||
|
||||
```yaml
|
||||
version: "X.Y.Z" # MUST match package.json version
|
||||
name: <package-name> # npm package name from package.json
|
||||
displayName: <Human Readable Name> # Shown on ArtifactHub listing
|
||||
createdAt: "YYYY-MM-DDTHH:MM:SSZ" # ISO 8601 — update each release
|
||||
description: >-
|
||||
Multi-line description of what the plugin does.
|
||||
Be specific about features and requirements.
|
||||
license: Apache-2.0
|
||||
homeURL: https://github.com/<owner>/<repo>
|
||||
appVersion: "X.Y.Z" # Version of upstream project (optional)
|
||||
category: <category> # See categories below
|
||||
keywords:
|
||||
- headlamp
|
||||
- kubernetes
|
||||
- <plugin-specific>
|
||||
maintainers:
|
||||
- name: <name>
|
||||
email: <email>
|
||||
provider:
|
||||
name: <name>
|
||||
links:
|
||||
- name: GitHub
|
||||
url: https://github.com/<owner>/<repo>
|
||||
- name: Issues
|
||||
url: https://github.com/<owner>/<repo>/issues
|
||||
changes: # Changelog for this version
|
||||
- kind: added|changed|fixed|removed
|
||||
description: "What changed"
|
||||
annotations: # CRITICAL — Headlamp-specific
|
||||
headlamp/plugin/archive-url: "https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz"
|
||||
headlamp/plugin/archive-checksum: "sha256:<checksum>"
|
||||
headlamp/plugin/version-compat: ">=X.Y.Z"
|
||||
headlamp/plugin/distro-compat: "<targets>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Headlamp-Specific Annotations (Required)
|
||||
|
||||
These annotations in `artifacthub-pkg.yml` are what make ArtifactHub treat the package as a Headlamp plugin:
|
||||
|
||||
### headlamp/plugin/archive-url
|
||||
**Required.** Direct download URL to the plugin tarball on GitHub Releases.
|
||||
|
||||
Format: `https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz`
|
||||
|
||||
- The tarball is built by `npx @kinvolk/headlamp-plugin build` and then `npx @kinvolk/headlamp-plugin package`
|
||||
- The `<pkgname>` comes from `package.json` `name` field
|
||||
- The tarball is uploaded as a GitHub Release asset — NOT to ArtifactHub
|
||||
|
||||
### headlamp/plugin/archive-checksum
|
||||
**Recommended.** SHA256 checksum of the tarball.
|
||||
|
||||
Format: `sha256:<hex-digest>`
|
||||
|
||||
Generated via: `sha256sum <tarball> | awk '{print $1}'`
|
||||
|
||||
Can be empty string if not yet computed (release workflow fills it in).
|
||||
|
||||
### headlamp/plugin/version-compat
|
||||
**Required.** Minimum Headlamp version the plugin works with.
|
||||
|
||||
Format: `>=X.Y.Z` (e.g., `>=0.20.0`, `>=0.26`)
|
||||
|
||||
### headlamp/plugin/distro-compat
|
||||
**Required.** Comma-separated list of supported Headlamp deployment targets.
|
||||
|
||||
Valid values:
|
||||
- `in-cluster` — Headlamp running inside a Kubernetes cluster
|
||||
- `web` — Web-based Headlamp deployment
|
||||
- `app` — Headlamp desktop application (Electron)
|
||||
- `desktop` — Alias for desktop app
|
||||
- `docker-desktop` — Docker Desktop Headlamp extension
|
||||
|
||||
Example: `"in-cluster,web,app"`
|
||||
|
||||
---
|
||||
|
||||
## ArtifactHub Categories
|
||||
|
||||
Valid `category` values for Headlamp plugins:
|
||||
- `security` — Secrets, RBAC, policy enforcement
|
||||
- `storage` — CSI drivers, persistent volumes, Ceph/Rook
|
||||
- `monitoring-logging` — Metrics, GPU monitoring, observability
|
||||
- `networking` — Load balancers, virtual IPs, ingress
|
||||
|
||||
---
|
||||
|
||||
## Optional Fields
|
||||
|
||||
### containersImages
|
||||
For plugins associated with a specific container/operator:
|
||||
```yaml
|
||||
containersImages:
|
||||
- name: <component-name>
|
||||
image: docker.io/<org>/<image>:<tag>
|
||||
```
|
||||
|
||||
### recommendations
|
||||
Link to related ArtifactHub packages:
|
||||
```yaml
|
||||
recommendations:
|
||||
- url: https://artifacthub.io/packages/helm/<repo>/<chart>
|
||||
```
|
||||
|
||||
### install
|
||||
Custom installation instructions (markdown):
|
||||
```yaml
|
||||
install: |
|
||||
## Install via Headlamp Plugin Manager
|
||||
...
|
||||
```
|
||||
|
||||
### logoPath
|
||||
Path to a logo image file in the repo (relative to root).
|
||||
|
||||
---
|
||||
|
||||
## The Release → ArtifactHub Pipeline
|
||||
|
||||
This is the actual flow. There is NO other way to publish:
|
||||
|
||||
```
|
||||
1. Developer triggers release workflow (workflow_dispatch with version)
|
||||
2. CI runs tests
|
||||
3. Workflow updates:
|
||||
- package.json (npm version)
|
||||
- artifacthub-pkg.yml (version, archive-url, checksum, createdAt, changes)
|
||||
4. Plugin is built: npx @kinvolk/headlamp-plugin build
|
||||
5. Plugin is packaged: creates <pkgname>-<version>.tar.gz
|
||||
6. SHA256 checksum is computed and written to artifacthub-pkg.yml
|
||||
7. Changes committed to main
|
||||
8. Git tag created: v<version>
|
||||
9. GitHub Release created with tarball attached
|
||||
10. ArtifactHub polls the repo (~30 min) and picks up the new metadata
|
||||
11. Plugin appears/updates on artifacthub.io
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Steps 1-9 happen in your GitHub Actions workflow
|
||||
- Step 10 is entirely controlled by ArtifactHub — you cannot trigger it
|
||||
- The tarball lives on GitHub Releases, not ArtifactHub
|
||||
- ArtifactHub only reads `artifacthub-pkg.yml` to discover the download URL
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
1. **Trying to push/trigger ArtifactHub** — There is no API for this. Just commit metadata and wait.
|
||||
2. **Version mismatch** — `version` in `artifacthub-pkg.yml` MUST match `package.json`. The release workflow should update both.
|
||||
3. **Wrong archive-url** — Must point to the actual GitHub Release asset URL. Verify the tarball filename matches what the build produces.
|
||||
4. **Missing checksum** — While optional, missing checksums may cause warnings. The release workflow should compute and write it.
|
||||
5. **Forgetting createdAt** — Must be updated each release. ArtifactHub uses this for sorting.
|
||||
6. **Stale changes section** — The `changes` list should reflect the current version's changelog only, not cumulative history.
|
||||
7. **Assuming ArtifactHub hosts anything** — It's an index/catalog. All artifacts are hosted elsewhere (GitHub Releases).
|
||||
8. **Trying to generate repositoryID** — This UUID comes from ArtifactHub's web UI when you register the repo. Don't make one up.
|
||||
|
||||
---
|
||||
|
||||
## Tarball Structure
|
||||
|
||||
The plugin tarball built by `@kinvolk/headlamp-plugin` contains:
|
||||
|
||||
```
|
||||
<pkgname>/
|
||||
main.js # Bundled plugin code
|
||||
package.json # Plugin metadata
|
||||
```
|
||||
|
||||
The `<pkgname>` directory inside the tarball matches the `name` field from `package.json`.
|
||||
|
||||
---
|
||||
|
||||
## Validating Metadata
|
||||
|
||||
Before committing, check:
|
||||
1. `version` matches across `package.json` and `artifacthub-pkg.yml`
|
||||
2. `archive-url` version tag matches the `version` field
|
||||
3. `name` in `artifacthub-pkg.yml` matches `package.json` `name`
|
||||
4. `createdAt` is a valid ISO 8601 timestamp
|
||||
5. All required annotations are present
|
||||
6. `changes` entries use valid `kind` values: `added`, `changed`, `fixed`, `removed`
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
name: headlamp-plugin-developer
|
||||
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
|
||||
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
|
||||
|
||||
---
|
||||
|
||||
## Plugin Registration Functions
|
||||
|
||||
All from `@kinvolk/headlamp-plugin/lib`:
|
||||
|
||||
```typescript
|
||||
registerRoute({
|
||||
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
|
||||
sidebar?: string; // Sidebar entry name to highlight
|
||||
component: () => JSX.Element; // Arrow function wrapper required
|
||||
exact?: boolean;
|
||||
name?: string; // Used by Link's routeName prop
|
||||
}): void
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: string | null; // null = top-level
|
||||
name: string;
|
||||
label: string;
|
||||
url: string;
|
||||
icon?: string; // Iconify ID (e.g., 'mdi:lock')
|
||||
}): void
|
||||
|
||||
registerDetailsViewSection(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
// Runs for ALL resource detail views — MUST check resource?.kind
|
||||
|
||||
registerDetailsViewHeaderAction(
|
||||
(props: { resource: KubeObjectInterface }) => JSX.Element | null
|
||||
): void
|
||||
|
||||
registerResourceTableColumnsProcessor(
|
||||
(args: { id: string; columns: Column[] }) => Column[]
|
||||
): void
|
||||
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
|
||||
|
||||
registerPluginSettings(
|
||||
pluginName: string,
|
||||
component: React.ComponentType<{
|
||||
data?: Record<string, string | number | boolean>;
|
||||
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||
}>,
|
||||
showSaveButton?: boolean
|
||||
): void
|
||||
|
||||
// Also available but less commonly used:
|
||||
registerAppBarAction(component): void
|
||||
registerAppLogo(component): void
|
||||
registerClusterChooser(component): void
|
||||
registerSidebarEntryFilter(filter): void
|
||||
registerRouteFilter(filter): void
|
||||
registerDetailsViewSectionsProcessor(fn): void
|
||||
registerHeadlampEventCallback(callback): void
|
||||
registerAppTheme(theme): void
|
||||
registerUIPanel(panel): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## K8s Module
|
||||
|
||||
```typescript
|
||||
import { K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
```
|
||||
|
||||
### KubeObject Base Class
|
||||
|
||||
```typescript
|
||||
class KubeObject<T extends KubeObjectInterface> {
|
||||
jsonData: T; // Raw K8s JSON — use this for spec/status access
|
||||
metadata: KubeMetadata;
|
||||
kind: string;
|
||||
|
||||
getAge(): string;
|
||||
getName(): string;
|
||||
getNamespace(): string | undefined;
|
||||
delete(force?: boolean): Promise<void>;
|
||||
patch(body: RecursivePartial<T>): Promise<void>;
|
||||
|
||||
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
|
||||
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
|
||||
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
|
||||
static className: string;
|
||||
}
|
||||
```
|
||||
|
||||
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
|
||||
|
||||
### ResourceClasses
|
||||
|
||||
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
|
||||
```typescript
|
||||
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
|
||||
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ApiProxy
|
||||
|
||||
```typescript
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
ApiProxy.request(
|
||||
path: string,
|
||||
options?: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: string; // JSON.stringify'd
|
||||
isJSON?: boolean; // false for non-JSON (logs, metrics)
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Promise<unknown>
|
||||
|
||||
// CRD endpoint factories
|
||||
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
|
||||
ApiProxy.apiFactory(group, version, resource): ApiClient
|
||||
```
|
||||
|
||||
**Service proxy URL** (accessing in-cluster services):
|
||||
```
|
||||
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CommonComponents
|
||||
|
||||
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
|
||||
|
||||
`SectionBox` — container with title and optional `headerProps.actions`
|
||||
`SectionHeader` — standalone header with title and actions array
|
||||
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
|
||||
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
|
||||
`Link` — internal nav; `routeName` + `params` object
|
||||
`Loader` — spinner with `title` prop
|
||||
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
|
||||
|
||||
### SimpleTable (non-obvious props)
|
||||
```typescript
|
||||
<SimpleTable
|
||||
data={items}
|
||||
columns={[
|
||||
{ label: 'Name', getter: (item) => item.metadata.name },
|
||||
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
|
||||
]}
|
||||
emptyMessage="No items found."
|
||||
/>
|
||||
```
|
||||
|
||||
### NameValueTable (non-obvious props)
|
||||
```typescript
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Key', value: 'display value' },
|
||||
{ name: 'Hidden', value: 'x', hide: true },
|
||||
]}
|
||||
/>
|
||||
```
|
||||
|
||||
### ConfigStore
|
||||
```typescript
|
||||
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
|
||||
const store = new ConfigStore<MyConfig>('plugin-name');
|
||||
store.get(): MyConfig;
|
||||
store.update(partial: Partial<MyConfig>): void;
|
||||
store.useConfig(): () => MyConfig;
|
||||
```
|
||||
|
||||
### Pre-bundled (no package.json entry needed)
|
||||
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
|
||||
|
||||
---
|
||||
|
||||
## CRD Class Pattern
|
||||
|
||||
```typescript
|
||||
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
|
||||
const { apiFactoryWithNamespace } = ApiProxy;
|
||||
const { KubeObject } = K8s.cluster;
|
||||
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
|
||||
|
||||
interface MyResourceInterface extends KubeObjectInterface {
|
||||
spec: MySpec;
|
||||
status?: MyStatus;
|
||||
}
|
||||
|
||||
export class MyResource extends KubeObject<MyResourceInterface> {
|
||||
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
|
||||
static get className(): string { return 'MyResource'; }
|
||||
get spec(): MySpec { return this.jsonData.spec; }
|
||||
get status(): MyStatus | undefined { return this.jsonData.status; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin Entry Point Pattern
|
||||
|
||||
```typescript
|
||||
// 1. Sidebar (parent → children)
|
||||
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
|
||||
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
|
||||
|
||||
// 2. Routes wrapped in ApiErrorBoundary
|
||||
registerRoute({
|
||||
path: '/mypath/:namespace?/:name?',
|
||||
sidebar: 'my-list',
|
||||
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
|
||||
exact: true, name: 'my-resource',
|
||||
});
|
||||
|
||||
// 3. Detail injection wrapped in GenericErrorBoundary
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'Secret') return null;
|
||||
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
|
||||
});
|
||||
|
||||
// 4. Settings
|
||||
registerPluginSettings('my-plugin', SettingsPage, true);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Headlamp Test Mocks
|
||||
|
||||
```typescript
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
|
||||
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
|
||||
SimpleTable: ({ data, columns }: any) => (
|
||||
<table><tbody>{data.map((d: any, i: number) =>
|
||||
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
|
||||
)}</tbody></table>
|
||||
),
|
||||
NameValueTable: ({ rows }: any) => (
|
||||
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
|
||||
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
|
||||
)}</dl>
|
||||
),
|
||||
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
|
||||
Link: ({ children }: any) => <a>{children}</a>,
|
||||
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Theming & Dark Mode
|
||||
|
||||
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
|
||||
|
||||
### Required CSS variables for inline styles
|
||||
```typescript
|
||||
// Text
|
||||
color: 'var(--mui-palette-text-primary)'
|
||||
color: 'var(--mui-palette-text-secondary, #666)'
|
||||
|
||||
// Backgrounds
|
||||
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
|
||||
|
||||
// Borders
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
|
||||
|
||||
// Interactive
|
||||
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
|
||||
color: 'var(--mui-palette-primary-contrastText, #fff)'
|
||||
|
||||
// Disabled states
|
||||
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
|
||||
// Links
|
||||
color: 'var(--link-color, #1976d2)'
|
||||
```
|
||||
|
||||
### Common mistakes to avoid
|
||||
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
|
||||
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
|
||||
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
|
||||
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
|
||||
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
|
||||
|
||||
### Form inputs in custom components
|
||||
```typescript
|
||||
const inputStyle = {
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Rules
|
||||
|
||||
1. **Functional components only** — no class components (except ErrorBoundary)
|
||||
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
|
||||
3. **Headlamp CommonComponents + MUI** — `@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
|
||||
4. **Inline CSS only** — `style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
|
||||
5. **Accessibility** — `aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
|
||||
6. **Cancellation safety** — async effects must check a `cancelled` flag
|
||||
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
|
||||
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
|
||||
9. Run `npm run tsc` and `npm test` after implementation changes
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"github",
|
||||
"kubernetes",
|
||||
"flux",
|
||||
"playwright"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['@headlamp-k8s/eslint-config'],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
github: [privilegedescalation]
|
||||
@@ -0,0 +1,13 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Promotion Gate
|
||||
|
||||
# Calls the shared promotion gate workflow.
|
||||
# dev PRs: no gate (engineer self-merges).
|
||||
# uat PRs: QA approval required.
|
||||
# main PRs: UAT approval required (uat→main promotions).
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
branches: [uat, main]
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
promotion-gate:
|
||||
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
|
||||
secrets:
|
||||
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
upstream-repo: fenio/tns-csi
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.headlamp-plugin/
|
||||
*.tar.gz
|
||||
.env
|
||||
.env.local
|
||||
.eslintcache
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"config": {
|
||||
// Line length — not enforced for docs with code examples
|
||||
"MD013": false,
|
||||
// First line heading — files use YAML frontmatter, not headings
|
||||
"MD041": false,
|
||||
// Emphasis as heading — common pattern for Option 1/2/3 sections
|
||||
"MD036": false,
|
||||
// No duplicate heading — changelog files repeat section names intentionally
|
||||
"MD024": false,
|
||||
// Fenced code language — not always applicable for diagram blocks
|
||||
"MD040": false,
|
||||
// Table column style — table alignment is visual, not semantic
|
||||
"MD060": false,
|
||||
// Ordered list item prefix — number resets are intentional in documents
|
||||
"MD029": false,
|
||||
// No inline HTML — each elements are valid in valid Markdown
|
||||
"MD033": false,
|
||||
// List marker space — spacing after list markers varies by editor
|
||||
"MD030": false,
|
||||
// Blanks around headings — not always needed in compact docs
|
||||
"MD022": false,
|
||||
// Blanks around lists — not always needed in compact docs
|
||||
"MD032": false,
|
||||
// Blanks around fences — not always needed between adjacent blocks
|
||||
"MD031": false,
|
||||
// Multiple blanks — editor artifacts, not semantic
|
||||
"MD012": false,
|
||||
// Single title — files may have multiple H1 sections
|
||||
"MD025": false,
|
||||
// Trailing spaces — editor artifacts
|
||||
"MD009": false,
|
||||
// Bare URLs — URL shortening not always needed
|
||||
"MD034": false,
|
||||
// Single trailing newline — editor artifacts
|
||||
"MD047": false,
|
||||
// Trailing punctuation — heading punctuation is intentional
|
||||
"MD026": false,
|
||||
// Space in emphasis — double-asterisk bold spacing varies by renderer
|
||||
"MD037": false,
|
||||
// No hard tabs — some generated docs use tabs for indentation
|
||||
"MD010": false,
|
||||
// Code block style — generated docs may use inconsistent styles
|
||||
"MD046": false,
|
||||
// Comment style — generated docs have no comments
|
||||
"MD048": false,
|
||||
// Commands show output — shell examples intentionally show only commands
|
||||
"MD014": false
|
||||
},
|
||||
"ignores": [
|
||||
"docs/api-reference/generated/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
docs/api-reference/generated/**
|
||||
@@ -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');
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Headlamp TNS-CSI Plugin will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **docs: namespace references** — Updated all documentation, README, and ArtifactHub metadata to explicitly reference the `headlamp` namespace instead of generic "controller pod" language. RBAC examples now clearly scope `pods/proxy` access to `kube-system` where the tns-csi controller runs.
|
||||
|
||||
## [1.0.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Missing devDependencies** — added `@mui/material`, `@types/react`, `@types/react-dom`, and `notistack` to devDependencies so the full test suite resolves correctly; upgraded `vitest` to `^3.2.4`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Test infrastructure fix** — added `define: { 'process.env.NODE_ENV': '"test"' }` to `vitest.config.mts` to prevent React production-build `act()` errors; all 159 component tests now pass
|
||||
- **Version bump** — bumped package version from 0.2.7 to 1.0.0 (stable release)
|
||||
- **Lock file** — removed `package-lock.json`; `pnpm-lock.yaml` is now the canonical lock file
|
||||
- **Renovate config** — extended from org-level preset (PR #18)
|
||||
- **GitHub Actions SHA pinning** — added `pinDigests` to Renovate config for SHA-pinned Actions (PR #17)
|
||||
- **Dual-approval caller workflow** — added dual-approval status check workflow to CI (PR #16)
|
||||
- **Release workflow** — GitHub App token secrets now passed to release workflow (PR #15)
|
||||
|
||||
## [0.2.6] - 2026-03-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PVC bind loop leak** — benchmark page's PVC wait loop now checks a cancellation ref on unmount, preventing leaked async work after navigation
|
||||
- **DeleteOptions body** — `deleteJob` now sends correct Kubernetes `DeleteOptions` with `apiVersion` and `kind` fields so foreground propagation is honored
|
||||
- **Snapshot filtering** — `TnsCsiDataContext` now filters volume snapshots and snapshot classes to only tns-csi driver ones using `filterTnsCsiVolumeSnapshots` and `isTnsCsiVolumeSnapshotClass` (previously showed all drivers' snapshots)
|
||||
- **Stale closures in Escape handlers** — `closeSc` / `closeVolume` in StorageClassesPage and VolumesPage wrapped in `useCallback` with proper deps, removing `eslint-disable` suppressions
|
||||
- **Cleanup button double-click** — benchmark cleanup "Delete Job + PVC" button now has a `cleaningUp` loading state with disabled styling, replacing unsafe `window.confirm`/`window.alert` calls
|
||||
- **Dark mode protocol colors** — replaced hardcoded hex colors in OverviewPage protocol chart with `var(--mui-palette-*)` tokens
|
||||
- **Import style consistency** — unified `React.useMemo` to destructured `useMemo` in OverviewPage
|
||||
- **Lint warnings** — fixed all 35 ESLint warnings (import sorting, indentation, boolean attributes, line length, chained call newlines)
|
||||
|
||||
## [0.2.4] - 2026-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- **Component tests** — 159 unit tests across 12 test files (up from 67 across 4)
|
||||
- **ESLint configuration** — added `.eslintrc.json` so `npm run lint` works
|
||||
- **JUnit test reporter** — CI posts test summary directly on PRs via `dorny/test-reporter`
|
||||
|
||||
### Changed
|
||||
|
||||
- **CI workflow** — split into 4 parallel jobs (lint, typecheck, test, build) with build gating on the other three
|
||||
- **Release workflow** — CI gate (`needs: [ci]`) prevents releases when checks fail; concurrency control prevents racing releases
|
||||
- **Node.js** — upgraded from 20 to 22 (current LTS) in all workflows
|
||||
- **CI scripts** — replaced inline `npx` commands with `npm run` scripts
|
||||
- **appVersion tracking** — `artifacthub-pkg.yml` now tracks the latest upstream tns-csi release (0.12.0); release workflow auto-fetches it
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Conditional hook** — moved `React.useMemo` above early return in OverviewPage to fix `react-hooks/rules-of-hooks` violation
|
||||
- **Tarball name** — release workflow now uses correct name `tns-csi-VERSION.tar.gz` (matching package.json `name` field)
|
||||
|
||||
## [0.2.3] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
|
||||
- **Package name** — renamed from `headlamp-tns-csi-plugin` to `tns-csi` so the plugin displays correctly in Headlamp's Plugins list
|
||||
|
||||
## [0.2.2] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Duplicate columns** — Protocol and Pool columns on mixed-driver clusters (tns-csi + rook-ceph) are now merged into a single shared column rather than duplicated; whichever plugin loads first owns the column and the second merges into it
|
||||
- **Plugin settings name** — settings entry now registers as `tns-csi` instead of `headlamp-tns-csi-plugin`
|
||||
|
||||
## [0.2.1] - 2026-02-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OverviewPage crash** — brace mismatch in `TnsCsiDataContext` placed TrueNAS pool stats fetch outside the outer try block, breaking the entire context provider
|
||||
- **PV Pool column** — tns-csi driver writes `datasetName` (e.g. `pool0/pvc-abc`), not `pool`, into `volumeAttributes`; Pool is now correctly derived from the first path segment
|
||||
- **App bar badge removed** — removed the colored tns-csi status bubble from the top nav bar
|
||||
|
||||
## [0.2.0] - 2026-02-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Native Headlamp integration** — Protocol/Pool/Server columns injected into the native StorageClass table; Protocol/Volume Handle columns into the native PV table
|
||||
- **PV Detail Injection** — TNS-CSI section injected into Headlamp PV detail views with full CSI volume attributes
|
||||
- **Pod Detail Injection** — Driver role/status section injected into tns-csi Pod detail pages (controller vs node role, ready status, restart count)
|
||||
- **StorageClass Benchmark button** — "Benchmark" shortcut button added to tns-csi StorageClass detail page headers
|
||||
- **App Bar Badge** — driver health badge in top nav bar showing `tns-csi: N/Nc M/Mn` (controller/node pod ready counts), color-coded green/orange/red
|
||||
- **Sidebar trim** — reduced from 6 to 4 entries (Overview, Snapshots, Metrics, Benchmark); Storage Classes and Volumes accessible via direct URL
|
||||
- **TrueNAS API integration** — WebSocket JSON-RPC client (`pool.query`) for real pool capacity (size/allocated/free/health status)
|
||||
- **Plugin settings page** — API key and server address configuration with connection test button
|
||||
- **Three-tier pool capacity display** — real TrueNAS API data → error hint → metrics-based provisioned-capacity fallback
|
||||
- **CI workflow** — lint + type-check + test on every push and PR
|
||||
- **Release workflow** — manual workflow_dispatch for versioned releases with automatic version bump, checksum, tag, and GitHub release creation
|
||||
- **Documentation** — README, CHANGELOG, CONTRIBUTING, SECURITY, and full `docs/` suite (architecture, deployment, user guide, troubleshooting)
|
||||
|
||||
## [0.1.0] - 2026-02-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Overview Dashboard** — driver health card, storage summary (StorageClass / PV / PVC counts), protocol distribution with PercentageBar, non-Bound PVC alert table, and live Prometheus metric snapshot
|
||||
- **Storage Classes page** — table with Protocol, Pool, Server, Reclaim Policy, Expansion, and PV count columns; slide-in detail panel with protocol-specific prerequisite notes (NFS, NVMe-oF, iSCSI)
|
||||
- **Volumes page** — PersistentVolume table with capacity, access modes, reclaim policy, phase status badge, and bound claim; slide-in detail panel with full CSI volume attributes
|
||||
- **Snapshots page** — VolumeSnapshot table scoped to tns-csi VolumeSnapshotClasses; graceful degradation when snapshot CRD is not installed
|
||||
- **Metrics page** — Prometheus WebSocket health indicator, per-volume I/O (read/write IOPS and bandwidth), CSI operation latency cards from controller pod port 8080
|
||||
- **Benchmark page** — interactive kbench runner with StorageClass selection, capacity/access-mode configuration, Job+PVC lifecycle management, live FIO log streaming, and IOPS/bandwidth/latency result cards
|
||||
- **PVC Detail Injection** — TNS-CSI section automatically injected into Headlamp's PVC detail views showing protocol, server, pool, volume handle, and link to the bound PV
|
||||
- **TnsCsiDataContext** — shared React context provider for all plugin pages; extracts `jsonData` from Headlamp KubeObject instances so StorageClass `parameters` (protocol, pool, server) are accessible
|
||||
- **Prometheus text format parser** — zero-dependency parser for the Prometheus exposition format used by the tns-csi controller
|
||||
- **kbench FIO log parser** — parses `yasker/kbench` FIO output into structured IOPS, bandwidth (MB/s), and latency (µs) results
|
||||
- **ArtifactHub publishing** — `artifacthub-pkg.yml` and `artifacthub-repo.yml` registered at Artifact Hub; plugin available in Headlamp catalog
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- GitHub repository setup with initial CI and release workflows
|
||||
- 67 unit tests with Vitest + @testing-library/react
|
||||
- TypeScript strict mode with zero `any` types
|
||||
- ESLint + Prettier code quality tooling
|
||||
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.7...v1.0.0
|
||||
[0.2.6]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.5...v0.2.6
|
||||
[0.2.4]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
|
||||
[0.1.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/tag/v0.1.0
|
||||
@@ -63,36 +63,12 @@ src/
|
||||
- Context provider (`TnsCsiDataProvider`) wraps each route component in `index.tsx`
|
||||
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
|
||||
|
||||
## Subagent guidance
|
||||
|
||||
When launching subagents for tasks in this repo:
|
||||
|
||||
- **Research tasks** (reading files, searching code, exploring GitHub): use `subagent_type: Explore`
|
||||
with tools: Read, Glob, Grep, Bash, WebFetch, GitHub MCP
|
||||
- **Implementation tasks** (writing/editing files): use `subagent_type: general-purpose`
|
||||
- **Debugging**: use `subagent_type: debugger`
|
||||
- **Avoid** launching background agents for open-ended research — do research in the main session
|
||||
using Glob, Grep, Read, and GitHub MCP directly, then delegate scoped write tasks to agents
|
||||
- The main session has broader tool approvals than subagent sandboxes; use it for exploration
|
||||
|
||||
### Local agents (`.claude/agents/`)
|
||||
|
||||
Three meta-orchestration agents are installed for this project:
|
||||
|
||||
| Agent | Model | Use when |
|
||||
|---|---|---|
|
||||
| `agent-organizer` | sonnet | Decomposing a large task into subtasks and selecting the right agent for each |
|
||||
| `multi-agent-coordinator` | opus | Running multiple concurrent agents that need to share state and synchronize |
|
||||
| `agent-installer` | haiku | Browsing or installing additional agents from awesome-claude-code-subagents |
|
||||
|
||||
Use `agent-organizer` first when a task is large enough to require multiple agents. It will plan the team composition and hand off to `multi-agent-coordinator` for execution.
|
||||
|
||||
## Testing
|
||||
|
||||
All tests must pass before committing:
|
||||
|
||||
```bash
|
||||
npm test # 67 tests across 4 test files
|
||||
npm test
|
||||
npm run tsc # must exit 0
|
||||
```
|
||||
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
# Contributing to Headlamp TNS-CSI Plugin
|
||||
|
||||
Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Branching Strategy](#branching-strategy)
|
||||
- [Commit Message Guidelines](#commit-message-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Code Style](#code-style)
|
||||
- [Testing Requirements](#testing-requirements)
|
||||
- [Documentation](#documentation)
|
||||
- [Release Process](#release-process)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project follows a standard code of conduct:
|
||||
- Be respectful and inclusive
|
||||
- Welcome newcomers and help them get started
|
||||
- Focus on constructive feedback
|
||||
- Assume good intentions
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22 or later
|
||||
- npm
|
||||
- Access to a Kubernetes cluster with Headlamp and tns-csi installed (for end-to-end testing)
|
||||
- Git
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Fork and clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/headlamp-tns-csi-plugin.git
|
||||
cd headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start development mode:**
|
||||
```bash
|
||||
npm start
|
||||
# Plugin will be available at http://localhost:4466
|
||||
```
|
||||
|
||||
4. **Run tests:**
|
||||
```bash
|
||||
npm test # 159 unit tests
|
||||
npm run tsc # TypeScript type-check
|
||||
```
|
||||
|
||||
5. **Build the plugin:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Feature Development
|
||||
|
||||
1. Create a feature branch from `main`
|
||||
2. Make your changes
|
||||
3. Write/update tests
|
||||
4. Update documentation
|
||||
5. Run lint and tests locally
|
||||
6. Submit a pull request
|
||||
|
||||
### Local Testing
|
||||
|
||||
**Option 1: Development Mode**
|
||||
```bash
|
||||
npm start
|
||||
# Opens Headlamp at http://localhost:4466 with hot reload
|
||||
```
|
||||
|
||||
**Option 2: Production Build**
|
||||
```bash
|
||||
npm run build
|
||||
npm run package
|
||||
# Installs the packaged tarball into a running Headlamp instance
|
||||
```
|
||||
|
||||
### Connecting to a Real Cluster
|
||||
|
||||
The plugin requires a running tns-csi driver to display meaningful data. For development:
|
||||
|
||||
1. Configure `KUBECONFIG` to point at a cluster with tns-csi installed
|
||||
2. Run `npm start` — Headlamp dev server will proxy API requests through your kubeconfig
|
||||
3. The Benchmark page requires RBAC permissions for Jobs and PVCs in the target namespace
|
||||
|
||||
## Branching Strategy
|
||||
|
||||
### Main Branch
|
||||
|
||||
- **Purpose:** Stable, production-ready code
|
||||
- **Protection:** Only merge via pull requests
|
||||
- **Naming:** `main`
|
||||
|
||||
### Feature Branches
|
||||
|
||||
- **Purpose:** Development of new features or fixes
|
||||
- **Naming Convention:**
|
||||
- Features: `feat/description`
|
||||
- Bug fixes: `fix/description`
|
||||
- Documentation: `docs/description`
|
||||
- Refactoring: `refactor/description`
|
||||
- Chores: `chore/description`
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
feat/add-volume-clone-support
|
||||
fix/metrics-page-empty-on-restart
|
||||
docs/update-rbac-guide
|
||||
refactor/kbench-state-machine
|
||||
chore/upgrade-dependencies
|
||||
```
|
||||
|
||||
### Branching Rules
|
||||
|
||||
**✅ ALWAYS use feature branches for:**
|
||||
- Code changes (new features, bug fixes, refactors)
|
||||
- Test updates
|
||||
- CI/CD workflow changes
|
||||
- Dependency updates
|
||||
|
||||
**✅ MAY push directly to main for:**
|
||||
- Documentation-only changes (README, CLAUDE.md, comments)
|
||||
- Version bump commits (`package.json` + `artifacthub-pkg.yml`)
|
||||
|
||||
**❌ NEVER push directly to main for:**
|
||||
- Any code changes to `src/`
|
||||
- Test file changes
|
||||
- Workflow changes
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/) format:
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer(s)]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- **feat:** New feature
|
||||
- **fix:** Bug fix
|
||||
- **docs:** Documentation only
|
||||
- **style:** Code style (formatting, no logic change)
|
||||
- **refactor:** Code change that neither fixes a bug nor adds a feature
|
||||
- **perf:** Performance improvement
|
||||
- **test:** Adding or updating tests
|
||||
- **chore:** Maintenance tasks (deps, build, CI)
|
||||
- **ci:** CI/CD changes
|
||||
|
||||
### Scope (Optional)
|
||||
|
||||
- `api` — API-related changes
|
||||
- `ui` — UI component changes
|
||||
- `metrics` — Prometheus metrics changes
|
||||
- `kbench` — Benchmark changes
|
||||
- `tests` — Test-related changes
|
||||
- `docs` — Documentation changes
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
feat(ui): add PV clone button to Volumes detail panel
|
||||
|
||||
fix(api): extract jsonData from headlamp KubeObject instances for parameter access
|
||||
|
||||
docs: add RBAC examples for Benchmark page
|
||||
|
||||
chore: bump version to 0.2.0
|
||||
|
||||
test(kbench): add FIO log parser edge case tests
|
||||
```
|
||||
|
||||
### Footer
|
||||
|
||||
Add `Co-Authored-By` for pair programming or AI assistance:
|
||||
|
||||
```
|
||||
feat: add NVMe-oF protocol notes to StorageClass detail panel
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
Co-Authored-By: Happy <yesreply@happy.engineering>
|
||||
```
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Creating a PR
|
||||
|
||||
1. **Run all checks locally:**
|
||||
```bash
|
||||
npm run build # Verify build succeeds
|
||||
npm run lint # Check for linting errors
|
||||
npm run tsc # Type-check TypeScript
|
||||
npm test # Run 159 unit tests
|
||||
```
|
||||
|
||||
2. **Update documentation:**
|
||||
- Update README.md if you added features or changed behavior
|
||||
- Update CLAUDE.md if you changed architecture or constraints
|
||||
- Add JSDoc comments for new exported APIs
|
||||
|
||||
3. **Write/update tests:**
|
||||
- Add unit tests for new functions/components
|
||||
- Ensure all 159 tests (plus yours) pass
|
||||
|
||||
### Creating a PR
|
||||
|
||||
1. **Push your branch:**
|
||||
```bash
|
||||
git push origin feat/your-feature
|
||||
```
|
||||
|
||||
2. **Create PR on GitHub:**
|
||||
- Use a descriptive title following commit conventions
|
||||
- Link related issues with `Fixes #123` or `Closes #456`
|
||||
|
||||
3. **PR Title Format:**
|
||||
```
|
||||
feat: add VolumeSnapshot creation from Volumes page
|
||||
fix: correct FIO log parser for multi-job output
|
||||
docs: improve Benchmark RBAC setup guide
|
||||
```
|
||||
|
||||
4. **PR Description Should Include:**
|
||||
- Summary of changes
|
||||
- Motivation and context
|
||||
- Testing performed (which cluster/driver version)
|
||||
- Screenshots for UI changes
|
||||
- Breaking changes (if any)
|
||||
|
||||
### PR Review Process
|
||||
|
||||
1. **Automated Checks:**
|
||||
- ✅ CI workflow (lint, type-check, build, test)
|
||||
|
||||
2. **Maintainer Review:**
|
||||
- Code quality and style
|
||||
- Test coverage
|
||||
- Documentation completeness
|
||||
- No new `any` types introduced
|
||||
|
||||
3. **Merging:**
|
||||
- Use **merge commits** (not squash, not rebase)
|
||||
- Delete feature branch after merge
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strictness:** Full TypeScript strict mode — zero `any` types
|
||||
- **Unknown at boundaries:** Use `unknown` + type guards at API boundaries (headlamp hooks, ApiProxy responses)
|
||||
- **Interfaces over types:** Prefer `interface` for object shapes
|
||||
- **No class components:** Functional components with hooks only
|
||||
|
||||
### React
|
||||
|
||||
- **Functional components only** — no class components
|
||||
- **Props interfaces:** Always define props as named interfaces
|
||||
- **Headlamp components:** Use only `@kinvolk/headlamp-plugin/lib/CommonComponents` — no direct MUI imports
|
||||
- **Detail panels:** Follow the slide-in drawer pattern — URL hash state, Escape to close, backdrop overlay
|
||||
|
||||
### Headlamp KubeObject Access
|
||||
|
||||
Headlamp's `useList()` hooks return KubeObject class instances that store raw JSON under `.jsonData`. Always extract `jsonData` before passing objects to plain-object type helpers:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct — extract jsonData so .parameters, .spec, .status are accessible
|
||||
const rawItems = items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
|
||||
// ❌ Wrong — sc.parameters will be undefined on KubeObject instances
|
||||
const scs = (allStorageClasses as unknown[]).filter(isTnsCsiStorageClass);
|
||||
```
|
||||
|
||||
### Linting and Formatting
|
||||
|
||||
```bash
|
||||
npm run lint # ESLint
|
||||
npm run tsc # TypeScript check
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components:** PascalCase (`OverviewPage`, `BenchmarkPage`)
|
||||
- **Files:** Match component name (`OverviewPage.tsx`)
|
||||
- **Hooks:** Prefix with `use` (`useTnsCsiContext`)
|
||||
- **Utilities:** camelCase (`formatProtocol`, `parsePrometheusText`)
|
||||
- **Constants:** UPPER_SNAKE_CASE (`TNS_CSI_PROVISIONER`)
|
||||
|
||||
### Import Organization
|
||||
|
||||
1. React imports
|
||||
2. Third-party libraries
|
||||
3. Headlamp plugin imports (`@kinvolk/headlamp-plugin/lib`)
|
||||
4. Local imports (components, API, types)
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
### Unit Tests (Required)
|
||||
|
||||
All 159 tests must pass before committing:
|
||||
|
||||
```bash
|
||||
npm test # vitest run
|
||||
npm run tsc # must exit 0
|
||||
```
|
||||
|
||||
- All new functions must have unit tests
|
||||
- Bug fixes should include regression tests
|
||||
- Use descriptive test names
|
||||
|
||||
**Mock pattern for headlamp APIs:**
|
||||
|
||||
```typescript
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: { request: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
StorageClass: { useList: vi.fn(() => [[], null]) },
|
||||
PersistentVolume: { useList: vi.fn(() => [[], null]) },
|
||||
PersistentVolumeClaim: { useList: vi.fn(() => [[], null]) },
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Test File Structure
|
||||
|
||||
```
|
||||
src/api/k8s.test.ts -- Type guards, filter helpers, format utilities
|
||||
src/api/metrics.test.ts -- Prometheus text parser
|
||||
src/api/kbench.test.ts -- FIO log parser, manifest builders, format helpers
|
||||
src/api/TnsCsiDataContext.test.tsx -- Context provider integration
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### Documentation Updates Required
|
||||
|
||||
When making changes, update relevant documentation:
|
||||
|
||||
#### Code Changes
|
||||
- **README.md** — User-facing features, installation, configuration, troubleshooting table
|
||||
- **CLAUDE.md** — Architecture constraints, key constants, subagent guidance
|
||||
- **CHANGELOG.md** — Add entry under `[Unreleased]`
|
||||
- **JSDoc** — All exported functions and components
|
||||
|
||||
#### Architecture Changes
|
||||
- **docs/architecture/overview.md** — If the data flow or component structure changes
|
||||
- **CLAUDE.md** — Update architecture section
|
||||
|
||||
### JSDoc Style
|
||||
|
||||
Use JSDoc for all exported functions and types:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Parses Prometheus text format exposition into a flat key→value map.
|
||||
*
|
||||
* Ignores comment lines and HELP/TYPE metadata. Returns only the last
|
||||
* sample value for each unique metric+label combination.
|
||||
*
|
||||
* @param text - Raw Prometheus text format string
|
||||
* @returns Map of metric name (with labels) to numeric value
|
||||
*/
|
||||
export function parsePrometheusText(text: string): Map<string, number> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Release Process
|
||||
|
||||
### Version Numbering
|
||||
|
||||
We follow [Semantic Versioning](https://semver.org/):
|
||||
- **Major (1.0.0):** Breaking changes
|
||||
- **Minor (0.1.0):** New features, backward compatible
|
||||
- **Patch (0.0.1):** Bug fixes, backward compatible
|
||||
|
||||
### Creating a Release (Maintainers Only)
|
||||
|
||||
1. **Update CHANGELOG.md:**
|
||||
- Move items from `[Unreleased]` to a new `[X.Y.Z] - YYYY-MM-DD` section
|
||||
|
||||
2. **Trigger the release workflow:**
|
||||
- Go to **Actions → Release → Run workflow**
|
||||
- Enter the version number (e.g., `0.2.0`)
|
||||
|
||||
3. **GitHub Actions automatically:**
|
||||
- Updates `package.json` and `artifacthub-pkg.yml` version
|
||||
- Builds plugin tarball
|
||||
- Computes SHA256 checksum and updates metadata
|
||||
- Commits, creates tag, and publishes GitHub release
|
||||
|
||||
4. **ArtifactHub syncs within 30 minutes**
|
||||
|
||||
### Pre-release Versions
|
||||
|
||||
For testing before stable release, use `-rc.N` suffix: `v0.2.0-rc.1`. Mark as "pre-release" on GitHub.
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Questions:** Open a [GitHub Discussion](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/discussions)
|
||||
- **Bugs:** Open a [GitHub Issue](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues)
|
||||
- **Architecture:** See [CLAUDE.md](CLAUDE.md) and [docs/architecture/overview.md](docs/architecture/overview.md)
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the Apache-2.0 License.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Installation Policy
|
||||
|
||||
## Approved Installation Method
|
||||
|
||||
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
|
||||
|
||||
No other installation method is acceptable. This includes but is not limited to:
|
||||
|
||||
- Direct installation from GitHub release assets
|
||||
- Manual npm pack / tarball extraction
|
||||
- initContainer workarounds that bypass Artifact Hub
|
||||
- Direct file copy or sidecar injection
|
||||
|
||||
## Enforcement
|
||||
|
||||
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
|
||||
|
||||
## Rationale
|
||||
|
||||
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
|
||||
|
||||
---
|
||||
|
||||
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
|
||||
@@ -0,0 +1,73 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,279 @@
|
||||
# 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)
|
||||
|
||||
A [Headlamp](https://headlamp.dev/) plugin that surfaces [tns-csi](https://github.com/fenio/tns-csi) CSI driver visibility and kbench storage benchmarking directly in the Headlamp UI.
|
||||
|
||||
**[Documentation](#documentation) | [Installation](#installing) | [Security](#rbac--security-setup) | [Development](#development)**
|
||||
|
||||
## What It Does
|
||||
|
||||
Adds a **TrueNAS (tns-csi)** top-level sidebar section to Headlamp with full CSI driver observability and interactive storage benchmarking:
|
||||
|
||||
### Main Views
|
||||
|
||||
- **Overview Dashboard** — driver health card, storage summary (StorageClass / PV / PVC counts), protocol distribution, PercentageBar for Bound vs non-Bound PVCs, non-Bound PVC alert table, and live Prometheus metric snapshot
|
||||
- **Storage Classes** — table of tns-csi StorageClasses with Protocol, Pool, Server, Reclaim Policy, Expansion, and PV count columns; click a row for a slide-in detail panel including protocol-specific prerequisite notes
|
||||
- **Volumes** — table of tns-csi PersistentVolumes with capacity, access modes, reclaim policy, status badge, and bound claim; slide-in detail panel with full CSI volume attributes
|
||||
- **Snapshots** — table of VolumeSnapshots scoped to tns-csi VolumeSnapshotClasses; shows ready status, size, source PVC, and class; graceful degradation when snapshot CRD is absent
|
||||
- **Metrics** — Prometheus WebSocket health indicator, per-volume I/O (read/write IOPS and bandwidth from the controller pod), and CSI operation latency cards
|
||||
- **Benchmark** — interactive kbench runner: select a tns-csi StorageClass, configure capacity and access mode, then run/stop a kbench Job+PVC lifecycle; live FIO log streaming with IOPS, bandwidth, and latency result cards
|
||||
|
||||
### Integrated Features
|
||||
|
||||
- **PVC Detail Injection** — TNS-CSI section automatically injected into Headlamp's PVC detail views showing protocol, server, pool, volume handle, and link to the bound PV
|
||||
- **Dark Mode Support** — full theme adaptation using MUI CSS variables across all panels and drawers
|
||||
- **Graceful Degradation** — Snapshot CRD absence is detected silently; missing Prometheus data shows placeholder cards rather than errors
|
||||
- **kbench Lifecycle Management** — automatically creates and cleans up the benchmark Job and PVC; `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` label guards all managed resources
|
||||
|
||||
### Data & Refresh
|
||||
|
||||
StorageClasses, PersistentVolumes, and PVCs are fetched via Headlamp's `K8s.ResourceClasses` hooks (live watch). Driver pods, the CSIDriver object, VolumeSnapshots, and Prometheus metrics are fetched via `ApiProxy.request`. Metrics are polled from the tns-csi controller pod at port `8080` using the Prometheus text format parser.
|
||||
|
||||
The plugin is **read-only** except for the Benchmark page, which creates and deletes a Job and PVC in the namespace you select.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Minimum version |
|
||||
| --------------------- | --------------- |
|
||||
| Headlamp | v0.20+ |
|
||||
| tns-csi driver | Any release |
|
||||
| Kubernetes | v1.24+ |
|
||||
| snapshot CRD (optional) | v1 |
|
||||
|
||||
The tns-csi driver must be deployed in `kube-system` with the standard `app.kubernetes.io/name=tns-csi-driver` labels. The controller pod must expose Prometheus metrics on port `8080`.
|
||||
|
||||
## Installing
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin). Install via the Headlamp UI:
|
||||
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Click **Catalog** tab
|
||||
3. Search for "TNS CSI" or "TrueNAS"
|
||||
4. Click **Install**
|
||||
|
||||
Or configure Headlamp via Helm:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
## RBAC / Security Setup
|
||||
|
||||
The plugin reads from the Kubernetes API and the tns-csi controller pod's Prometheus endpoint (deployed in `kube-system`). The Benchmark page additionally creates and deletes Jobs and PVCs.
|
||||
|
||||
### Minimal read-only permissions
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-tns-csi-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses", "csidrivers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/proxy"]
|
||||
verbs: ["get"]
|
||||
resourceNames: ["pods"]
|
||||
```
|
||||
|
||||
### Additional permissions for Benchmark page
|
||||
|
||||
```yaml
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["create", "delete"]
|
||||
```
|
||||
|
||||
### Metrics access
|
||||
|
||||
The plugin fetches Prometheus metrics from the tns-csi controller pod via the Kubernetes pod proxy sub-resource in `kube-system`. Grant `get` on `pods/proxy` scoped to `kube-system`:
|
||||
|
||||
```yaml
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/proxy"]
|
||||
verbs: ["get"]
|
||||
# Scope to kube-system where the tns-csi controller runs
|
||||
```
|
||||
|
||||
Apply the role and bind it to your Headlamp service account with a ClusterRoleBinding.
|
||||
|
||||
## Documentation
|
||||
|
||||
**[Complete Documentation](docs/README.md)** — Documentation hub with all guides
|
||||
|
||||
### Quick Links
|
||||
|
||||
- **[Architecture](docs/architecture/overview.md)** — System architecture, data flow, component hierarchy
|
||||
- **[Deployment](docs/deployment/helm.md)** — Production deployment with Helm, FluxCD
|
||||
- **[Troubleshooting](docs/troubleshooting/README.md)** — Common issues and diagnosis
|
||||
- **[Contributing](CONTRIBUTING.md)** — Development workflow, branching strategy, PR process
|
||||
- **[Security](SECURITY.md)** — Security model, RBAC, vulnerability reporting
|
||||
- **[Changelog](CHANGELOG.md)** — Complete release history
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**For comprehensive troubleshooting, see [docs/troubleshooting/README.md](docs/troubleshooting/README.md).**
|
||||
|
||||
Quick reference:
|
||||
|
||||
| Symptom | Likely Cause | Quick Fix |
|
||||
| ------- | ------------ | --------- |
|
||||
| **Plugin not in sidebar** | Plugin not installed or needs browser refresh | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
|
||||
| **No StorageClasses listed** | Driver not installed or wrong provisioner | Verify `kubectl get sc` shows `tns.csi.io` provisioner |
|
||||
| **Driver status "Not installed"** | CSIDriver object missing | Check `kubectl get csidriver tns.csi.io` |
|
||||
| **Protocol/Pool/Server showing "—"** | StorageClass has no parameters | Inspect `kubectl get sc <name> -o yaml` |
|
||||
| **Metrics page empty** | Controller pod unreachable or no metrics port | Check controller pod logs and port 8080 |
|
||||
| **Snapshots tab empty** | Snapshot CRD not installed | Install `snapshot.storage.k8s.io` CRDs |
|
||||
| **Benchmark fails to start** | Missing RBAC for Jobs/PVCs | Add batch/jobs create+delete permissions |
|
||||
|
||||
## Development
|
||||
|
||||
**For detailed development guide, see [CONTRIBUTING.md](CONTRIBUTING.md).**
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
|
||||
cd headlamp-tns-csi-plugin
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run with hot reload
|
||||
npm start # Opens Headlamp at http://localhost:4466
|
||||
|
||||
# Build for production
|
||||
npm run build # outputs dist/main.js
|
||||
npm run package # creates tns-csi-<version>.tar.gz
|
||||
|
||||
# Run tests
|
||||
npm test # 159 unit tests
|
||||
npm run test:watch # watch mode
|
||||
|
||||
# Code quality
|
||||
npm run lint # eslint
|
||||
npm run tsc # type-check
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
index.tsx -- Entry point. Registers sidebar entries, routes,
|
||||
detail section injection, and plugin settings.
|
||||
api/
|
||||
k8s.ts -- TypeScript types and filtering helpers for tns-csi
|
||||
resources (StorageClass, PV, PVC, Pod, Snapshot).
|
||||
metrics.ts -- Prometheus text format parser; fetchControllerMetrics
|
||||
via ApiProxy from controller pod port 8080.
|
||||
kbench.ts -- kbench Job+PVC manifest builders, FIO log parser,
|
||||
BenchmarkState discriminated union, format helpers.
|
||||
TnsCsiDataContext.tsx -- React context provider; shared data fetch across
|
||||
all pages (StorageClasses, PVs, PVCs, pods, driver).
|
||||
components/
|
||||
OverviewPage.tsx -- Dashboard: driver health, storage summary,
|
||||
protocol distribution, non-Bound PVC alerts.
|
||||
StorageClassesPage.tsx -- StorageClass list + slide-in detail panel.
|
||||
VolumesPage.tsx -- PV list + slide-in detail panel.
|
||||
SnapshotsPage.tsx -- VolumeSnapshot list + slide-in detail panel.
|
||||
MetricsPage.tsx -- Prometheus metrics display cards.
|
||||
BenchmarkPage.tsx -- Interactive kbench runner (ONLY write operation).
|
||||
DriverStatusCard.tsx -- Driver health/status card component.
|
||||
PVCDetailSection.tsx -- TNS-CSI section injected into PVC detail views.
|
||||
vitest.config.mts -- Vitest configuration (jsdom environment).
|
||||
vitest.setup.ts -- localStorage shim for Node 22+.
|
||||
```
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Provisioner
|
||||
|
||||
All resources are filtered to provisioner `tns.csi.io`. StorageClasses with any other provisioner are invisible to the plugin.
|
||||
|
||||
### Driver Component Labels
|
||||
|
||||
| Component | Label Selector |
|
||||
| --------- | -------------- |
|
||||
| Controller | `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller` |
|
||||
| Node | `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node` |
|
||||
|
||||
### Metrics Endpoint
|
||||
|
||||
The plugin fetches Prometheus text format metrics from:
|
||||
|
||||
```
|
||||
GET /api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics
|
||||
```
|
||||
|
||||
Extracted metrics include `kubelet_volume_stats_*`, `csi_operations_seconds_*`, and any custom tns-csi metrics exposed on port `8080`.
|
||||
|
||||
### kbench Benchmarks
|
||||
|
||||
The Benchmark page creates resources labeled `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin`. It uses the `yasker/kbench:latest` image and runs a configurable FIO test. Results are parsed from the Job's pod log into IOPS, bandwidth (MB/s), and latency (µs) cards.
|
||||
|
||||
## Releasing
|
||||
|
||||
Releases are automated via **GitHub Actions**. To cut a release:
|
||||
|
||||
```bash
|
||||
# 1. Update CHANGELOG.md with new version
|
||||
# 2. Trigger the release workflow from GitHub Actions UI:
|
||||
# Actions → Release → Run workflow → enter version X.Y.Z
|
||||
```
|
||||
|
||||
This triggers the **GitHub Actions** release workflow (`.github/workflows/release.yaml`):
|
||||
|
||||
1. Build the plugin in a `node:22` container
|
||||
2. Update `package.json` and `artifacthub-pkg.yml` with the new version
|
||||
3. Package a `.tar.gz` tarball
|
||||
4. Compute SHA256 checksum and update `artifacthub-pkg.yml`
|
||||
5. Commit, tag, and create a GitHub release with the tarball attached
|
||||
|
||||
ArtifactHub syncs within 30 minutes. The new version will appear in the Headlamp plugin catalog automatically.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
|
||||
- Development workflow
|
||||
- Branching strategy (feature branches required for code changes)
|
||||
- Commit message conventions (Conventional Commits)
|
||||
- PR process and review checklist
|
||||
- Code style guidelines
|
||||
- Testing requirements
|
||||
|
||||
## Links
|
||||
|
||||
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)** — Source code, issues, releases
|
||||
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)** — Plugin catalog listing
|
||||
- **[Headlamp](https://headlamp.dev/)** — Kubernetes web UI
|
||||
- **[tns-csi driver](https://github.com/fenio/tns-csi)** — TrueNAS CSI driver
|
||||
- **[kbench](https://github.com/longhorn/kbench)** — Storage benchmark tool
|
||||
|
||||
## License
|
||||
|
||||
[Apache-2.0 License](LICENSE) — see LICENSE file for details.
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# Security Policy
|
||||
|
||||
## Overview
|
||||
|
||||
The Headlamp TNS-CSI Plugin is a visibility and benchmarking tool for the tns-csi Kubernetes CSI driver. Security considerations center on Kubernetes RBAC, network policies, and the limited write operations performed by the Benchmark page.
|
||||
|
||||
## Security Model
|
||||
|
||||
### Primarily Read-Only
|
||||
|
||||
The plugin is **read-only** for all pages except Benchmark:
|
||||
|
||||
- **No secrets access**: The plugin does not read or store Kubernetes Secrets
|
||||
- **No CRD installation**: No custom resource definitions or cluster-level modifications
|
||||
- **No PII**: CSI resource metadata (names, namespaces, parameters) does not contain personally identifiable information
|
||||
- **No external egress**: All API calls go through the Kubernetes API server proxy; no external network calls
|
||||
|
||||
### The Benchmark Exception
|
||||
|
||||
The Benchmark page creates and deletes a Kubernetes Job and PVC to run storage benchmarks. These resources are:
|
||||
|
||||
- Labeled `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` for identification
|
||||
- Created only in the namespace the user explicitly selects
|
||||
- Automatically deleted when the benchmark completes or is stopped
|
||||
- Using the `yasker/kbench:latest` image (a public, well-known benchmark tool)
|
||||
|
||||
Grant benchmark write permissions only to users who should be able to initiate storage tests.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Browser
|
||||
↓ (HTTPS)
|
||||
Headlamp Pod
|
||||
↓ (in-cluster service account or user token)
|
||||
Kubernetes API Server
|
||||
↓ (list/watch StorageClasses, PVs, PVCs, pods, snapshots)
|
||||
↓ (pod proxy: controller pod port 8080 → Prometheus metrics)
|
||||
↓ (pod proxy: kbench pod → FIO log for benchmark results)
|
||||
Plugin Frontend (React)
|
||||
```
|
||||
|
||||
All communication uses Kubernetes authentication and authorization mechanisms. The plugin never stores credentials or bypasses RBAC.
|
||||
|
||||
## RBAC Requirements
|
||||
|
||||
### Minimal Read-Only Permissions
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-tns-csi-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes", "pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses", "csidrivers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log", "pods/proxy"]
|
||||
verbs: ["get"]
|
||||
# pods/proxy is used to fetch Prometheus metrics from the controller pod
|
||||
```
|
||||
|
||||
### Additional Permissions for Benchmark Page
|
||||
|
||||
```yaml
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["create", "delete"]
|
||||
```
|
||||
|
||||
### Binding Example
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: headlamp-tns-csi
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
### ⚠️ Security Best Practices
|
||||
|
||||
1. **Principle of Least Privilege**: Grant benchmark write permissions (`jobs create/delete`, `persistentvolumeclaims create/delete`) only to users who need them
|
||||
2. **Namespace Scoping for Benchmarks**: If possible, restrict benchmark Job/PVC permissions to a dedicated benchmark namespace using a namespaced Role rather than ClusterRole
|
||||
3. **Pod Proxy Scoping**: Scope `pods/proxy` access to `kube-system` only, or to pods matching the tns-csi controller label
|
||||
4. **Audit Logging**: Enable Kubernetes audit logging to track all API requests made through the plugin
|
||||
5. **Image Pinning**: Consider pinning `yasker/kbench:latest` to a specific digest in your environment for supply chain security
|
||||
|
||||
## Network Security
|
||||
|
||||
### Network Policies
|
||||
|
||||
If your cluster uses NetworkPolicies, ensure the Kubernetes API server can proxy requests to the tns-csi controller pod on port `8080`:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: allow-api-server-to-tns-csi-controller
|
||||
namespace: kube-system
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: tns-csi-driver
|
||||
app.kubernetes.io/component: controller
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
```
|
||||
|
||||
The Kubernetes API server performs the pod proxy hop, so policies should permit the API server (not Headlamp directly) to reach the controller pod.
|
||||
|
||||
### TLS/HTTPS
|
||||
|
||||
- **External Access**: Always access Headlamp over HTTPS
|
||||
- **Internal Communication**: Headlamp to API server uses the service account token over the cluster's internal network
|
||||
- **Pod Proxy**: API server → tns-csi controller happens over HTTP within the cluster (port 8080)
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
### Service Account (Default)
|
||||
|
||||
Headlamp runs with a dedicated service account (`headlamp` in the namespace where Headlamp is installed). All users share the same RBAC permissions.
|
||||
|
||||
**Security Considerations:**
|
||||
- All users have identical access to plugin functionality including Benchmark
|
||||
- Suitable for trusted internal environments
|
||||
- Simpler RBAC management
|
||||
|
||||
### OIDC Token Authentication
|
||||
|
||||
Headlamp can use per-user OIDC tokens. RBAC is enforced per-user, enabling fine-grained access control:
|
||||
|
||||
- Read-only users: bind only the reader ClusterRole
|
||||
- Benchmark users: bind the additional write permissions
|
||||
- Users without permissions see appropriate 403 errors
|
||||
|
||||
## Vulnerability Reporting
|
||||
|
||||
### Supported Versions
|
||||
|
||||
Security updates are applied to the latest release only.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | ✅ |
|
||||
| < latest | ❌ |
|
||||
|
||||
### Reporting a Vulnerability
|
||||
|
||||
Report security vulnerabilities via:
|
||||
|
||||
1. **GitHub Security Advisories**: [Report a vulnerability](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/security/advisories/new)
|
||||
2. **GitHub Issues**: Open an issue and mark it "security" if advisories are unavailable
|
||||
|
||||
**Please do not** open public GitHub issues for security vulnerabilities before a fix is available.
|
||||
|
||||
**Response Timeline:**
|
||||
- **Acknowledgment**: Within 48 hours
|
||||
- **Initial Assessment**: Within 1 week
|
||||
- **Fix Timeline**: Critical: 1–2 weeks; High: 2–4 weeks; Medium/Low: next release cycle
|
||||
|
||||
## Dependency Security
|
||||
|
||||
The project uses:
|
||||
- **npm audit**: Runs automatically during `npm install`
|
||||
- **Renovate**: Automated dependency updates via Mend Renovate (org-wide configured)
|
||||
|
||||
Headlamp itself (`@kinvolk/headlamp-plugin`) is a peer dependency. Security updates to Headlamp should be applied by upgrading your Headlamp installation.
|
||||
|
||||
**Minimum supported Headlamp version**: v0.20.0
|
||||
|
||||
## Deployment Security Checklist
|
||||
|
||||
Before deploying to production:
|
||||
|
||||
- [ ] **RBAC configured**: ClusterRole and ClusterRoleBinding exist for Headlamp service account
|
||||
- [ ] **Benchmark permissions scoped**: Write permissions granted only to appropriate users/groups
|
||||
- [ ] **Network policies**: Allow API server → tns-csi controller traffic on port 8080
|
||||
- [ ] **TLS enabled**: Headlamp accessible only via HTTPS
|
||||
- [ ] **Audit logging enabled**: Kubernetes API audit logs capture requests
|
||||
- [ ] **Plugin version**: Running latest release
|
||||
- [ ] **Dependencies audited**: `npm audit` shows no critical vulnerabilities
|
||||
|
||||
## Compliance
|
||||
|
||||
### Data Residency
|
||||
|
||||
All data remains within your Kubernetes cluster. The plugin does not:
|
||||
- Send data to external services
|
||||
- Store data in browser localStorage (except any future settings)
|
||||
- Use third-party analytics or tracking
|
||||
|
||||
### Audit Trail
|
||||
|
||||
All API requests are logged in Kubernetes API audit logs (if enabled). Pod proxy requests to the controller pod's metrics endpoint appear as:
|
||||
|
||||
```json
|
||||
{
|
||||
"verb": "get",
|
||||
"requestURI": "/api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics",
|
||||
"user": {
|
||||
"username": "system:serviceaccount:<your-namespace>:headlamp"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Privacy
|
||||
|
||||
The plugin processes only technical metadata (resource names, namespaces, CSI parameters, metrics values). No personal data is collected, stored, or transmitted.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Security Issues**: [GitHub Security Advisories](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/security/advisories)
|
||||
- **General Questions**: [GitHub Discussions](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/discussions)
|
||||
- **Bug Reports**: [GitHub Issues](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues)
|
||||
|
||||
## License
|
||||
|
||||
This plugin is provided under the Apache-2.0 License. See [LICENSE](LICENSE) for details.
|
||||
@@ -0,0 +1,69 @@
|
||||
version: "1.0.3"
|
||||
name: headlamp-tns-csi-plugin
|
||||
displayName: TrueNAS CSI (tns-csi)
|
||||
description: >-
|
||||
Headlamp plugin for tns-csi CSI driver visibility and kbench storage
|
||||
benchmarking. Surfaces StorageClasses with protocol/pool/server details,
|
||||
PersistentVolumes, VolumeSnapshots, Prometheus metrics from the controller
|
||||
pod, and an interactive kbench benchmark runner with FIO result cards.
|
||||
Supports NFS, NVMe-oF, and iSCSI protocols. Read-only except for the
|
||||
Benchmark page.
|
||||
createdAt: "2026-02-18T00:00:00Z"
|
||||
license: Apache-2.0
|
||||
category: storage
|
||||
|
||||
homeURL: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
|
||||
appVersion: "0.17.4"
|
||||
|
||||
keywords:
|
||||
- headlamp
|
||||
- kubernetes
|
||||
- storage
|
||||
- csi
|
||||
- tns-csi
|
||||
- truenas
|
||||
- nfs
|
||||
- nvmeof
|
||||
- iscsi
|
||||
- kbench
|
||||
- benchmarking
|
||||
- prometheus
|
||||
|
||||
maintainers:
|
||||
- name: privilegedescalation
|
||||
email: chris@farhood.org
|
||||
|
||||
provider:
|
||||
name: privilegedescalation
|
||||
|
||||
links:
|
||||
- name: GitHub
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
|
||||
- name: Issues
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues
|
||||
- name: tns-csi driver
|
||||
url: https://github.com/fenio/tns-csi
|
||||
- name: kbench
|
||||
url: https://github.com/longhorn/kbench
|
||||
|
||||
changes:
|
||||
- kind: added
|
||||
description: "Stable v1.0.0 release marking production readiness"
|
||||
- kind: added
|
||||
description: "Missing devDependencies for test infrastructure (@mui/material, @types/react, @types/react-dom, notistack, vitest upgraded to ^3.2.4)"
|
||||
- kind: changed
|
||||
description: "vitest.config.mts: added define block to set process.env.NODE_ENV=test, fixing act() errors in all component tests"
|
||||
- kind: changed
|
||||
description: "Renovate config extended from org-level preset"
|
||||
- kind: changed
|
||||
description: "GitHub Actions SHA pinning via pinDigests in Renovate"
|
||||
- kind: changed
|
||||
description: "Dual-approval caller workflow added to CI"
|
||||
- kind: changed
|
||||
description: "GitHub App token secrets passed to release workflow"
|
||||
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.3/headlamp-tns-csi-plugin-1.0.3.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:8a032919de65f9ed45a06f4110083cceb11b91625d97f7b49075adecf38e3adc
|
||||
headlamp/plugin/version-compat: ">=0.20.0"
|
||||
headlamp/plugin/distro-compat: "in-cluster,web,app"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Artifact Hub repository metadata
|
||||
repositoryID: cae81660-2624-4e02-8ac2-a176cbe94402
|
||||
|
||||
owners:
|
||||
- name: privilegedescalation
|
||||
email: ""
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Allowlist for inherited dev-dependency CVEs from @kinvolk/headlamp-plugin
|
||||
// CTO decision (PRI-854): these high-severity vulns are dev/build-time only,
|
||||
// trace to @kinvolk/headlamp-plugin transitive deps (Picomatch, Vite, lodash),
|
||||
// and do NOT ship in production plugin artifacts.
|
||||
"allowlist": [
|
||||
{
|
||||
"id": "GHSA-hhpm-516h-p3p6",
|
||||
"reason": "Picomatch ReDoS: devDependency only, does not ship in production plugin bundle"
|
||||
},
|
||||
{
|
||||
"id": "GHSA-36xf-7xpp-53w5",
|
||||
"reason": "Vite arbitrary file read: devDependency only, does not ship in production plugin bundle"
|
||||
},
|
||||
{
|
||||
"id": "GHSA-jf8v-p3pp-93qh",
|
||||
"reason": "lodash code injection via _.template: devDependency only, does not ship in production plugin bundle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# TNS-CSI Plugin Documentation
|
||||
|
||||
Welcome to the Headlamp TNS-CSI Plugin documentation.
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **[Quick Start](getting-started/quick-start.md)** — Get up and running in 5 minutes
|
||||
- **[Installation Guide](getting-started/installation.md)** — All installation methods
|
||||
- **[Troubleshooting](troubleshooting/README.md)** — Common issues and fixes
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Getting Started
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Quick Start](getting-started/quick-start.md) | Fastest path to a working installation |
|
||||
| [Installation](getting-started/installation.md) | Plugin Manager, manual tarball, build from source |
|
||||
| [Prerequisites](getting-started/prerequisites.md) | Headlamp version, tns-csi driver, RBAC |
|
||||
|
||||
### User Guide
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Overview Dashboard](user-guide/overview.md) | Driver health, storage summary, protocol distribution |
|
||||
| [Storage Classes](user-guide/storage-classes.md) | StorageClass list and detail panel |
|
||||
| [Volumes](user-guide/volumes.md) | PersistentVolume list and detail panel |
|
||||
| [Snapshots](user-guide/snapshots.md) | VolumeSnapshot list and CRD requirements |
|
||||
| [Metrics](user-guide/metrics.md) | Prometheus metrics display |
|
||||
| [Benchmark](user-guide/benchmark.md) | kbench interactive storage benchmarking |
|
||||
| [PVC Detail Injection](user-guide/pvc-detail.md) | TNS-CSI section in PVC detail views |
|
||||
| [RBAC Permissions](user-guide/rbac.md) | Required permissions per feature |
|
||||
|
||||
### Architecture
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Overview](architecture/overview.md) | System architecture, data flow, component hierarchy |
|
||||
| [Data Flow](architecture/data-flow.md) | How data moves from K8s API to the UI |
|
||||
| [Design Decisions](architecture/design-decisions.md) | Key architectural choices and rationale |
|
||||
|
||||
### Deployment
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Helm](deployment/helm.md) | Deploy with Helm (recommended) |
|
||||
| [Production Checklist](deployment/production.md) | Security and reliability checklist |
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Common Issues](troubleshooting/README.md) | Quick diagnosis table |
|
||||
| [RBAC Issues](troubleshooting/rbac.md) | 403 errors, missing permissions |
|
||||
| [Driver Detection](troubleshooting/driver.md) | Driver not installed, wrong provisioner |
|
||||
| [Metrics Issues](troubleshooting/metrics.md) | Empty metrics page, unreachable controller |
|
||||
| [Benchmark Issues](troubleshooting/benchmark.md) | Benchmark fails to start or complete |
|
||||
|
||||
### Development
|
||||
|
||||
| Guide | Description |
|
||||
| ----- | ----------- |
|
||||
| [Development Setup](development/setup.md) | Clone, install, run dev server |
|
||||
| [Testing](development/testing.md) | Unit tests, mocking headlamp APIs |
|
||||
| [Release Process](development/release.md) | How releases are cut and published |
|
||||
|
||||
## External Links
|
||||
|
||||
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)**
|
||||
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)**
|
||||
- **[tns-csi Driver](https://github.com/fenio/tns-csi)**
|
||||
- **[kbench](https://github.com/longhorn/kbench)**
|
||||
- **[Headlamp](https://headlamp.dev/)**
|
||||
@@ -0,0 +1,59 @@
|
||||
# ADR 001: React Context for Shared CSI Driver State
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The TNS CSI plugin needs to share data across multiple views: Overview, StorageClasses, Volumes, Snapshots, Metrics, and Benchmark pages, plus detail view sections for PVC, PV, and Pod. Data comes from three tracks:
|
||||
|
||||
1. **Headlamp `useList()` hooks** — StorageClass, PersistentVolume, PersistentVolumeClaim
|
||||
2. **`ApiProxy.request()`** — CSIDriver resource, controller/node pods, VolumeSnapshotClasses, and VolumeSnapshots
|
||||
3. **TrueNAS WebSocket API** — Pool capacity stats (optional, when API key is configured in settings)
|
||||
|
||||
The context exposes: `csiDriver`, `driverInstalled`, `storageClasses`, `persistentVolumes`, `persistentVolumeClaims`, `controllerPods`, `nodePods`, `volumeSnapshots`, `volumeSnapshotClasses`, `snapshotCrdAvailable`, `poolStats`, `poolStatsError`, `loading`, `error`, `refresh`.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use a single `TnsCsiDataProvider` React Context wrapping all routes. Three-track data fetching:
|
||||
|
||||
1. `useList()` for standard Kubernetes resources (StorageClass, PV, PVC)
|
||||
2. `ApiProxy.request()` in `useEffect` for CSI-specific resources and snapshots
|
||||
3. TrueNAS WebSocket client for pool capacity stats (only when API key is configured in settings)
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- ✅ Single fetch point eliminates duplicate API calls
|
||||
- ✅ All views share consistent data — no stale data across pages
|
||||
- ✅ Three-track strategy handles different API requirements cleanly
|
||||
- ✅ TrueNAS integration is opt-in — plugin works without it
|
||||
- ⚠️ Large context with many fields increases cognitive overhead
|
||||
- ⚠️ TrueNAS WebSocket adds complexity to the data layer
|
||||
- ⚠️ All consumers re-render on any data change — mitigated by infrequent updates (polling interval)
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Separate contexts per data domain** — Rejected. Data is cross-referenced (PVCs filter by StorageClass provisioner), so splitting contexts would require cross-context coordination.
|
||||
|
||||
2. **Custom hooks without context** — Rejected. Would duplicate fetches across 6 pages, leading to redundant API calls and inconsistent data.
|
||||
|
||||
3. **Redux/Zustand** — Rejected. Not available in the Headlamp plugin environment.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-03-05 | Initial decision |
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR 002: Read-Only Plugin with Benchmark Exception
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The plugin is primarily a read-only observability tool for TNS CSI storage. However, it includes a Benchmark feature that runs kbench (FIO-based storage benchmarks) against storage classes. Running benchmarks requires creating temporary Kubernetes resources: a PVC for the test volume and a Job running the kbench container.
|
||||
|
||||
These resources are tagged with `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` for lifecycle tracking. The benchmark workflow includes:
|
||||
|
||||
1. `buildPvcManifest()` — Create PVC spec for test volume
|
||||
2. `createPvc()` — Create the PVC in the cluster
|
||||
3. `buildJobManifest()` — Create Job spec for kbench container
|
||||
4. `createJob()` — Create the Job in the cluster
|
||||
5. Poll for Job completion
|
||||
6. `fetchKbenchLogs()` — Retrieve benchmark output from pod logs
|
||||
7. `parseKbenchLog()` — Parse FIO results from kbench output
|
||||
8. `deleteJob()` — Clean up the benchmark Job
|
||||
9. `deletePvc()` — Clean up the test PVC
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
The plugin is read-only for all storage observability features. The sole exception is the Benchmark feature, which creates and deletes temporary PVC + Job resources. All created resources are labeled for identification and cleaned up after benchmark completion. The benchmark is triggered explicitly by user action (button on StorageClass detail page via `registerDetailsViewHeaderAction`).
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- ✅ Minimal RBAC requirements for normal operation (read-only)
|
||||
- ✅ Benchmark is opt-in and requires explicit user action
|
||||
- ✅ Resources are auto-cleaned after benchmark completion
|
||||
- ✅ `managed-by` label enables easy identification of plugin-created resources
|
||||
- ⚠️ Requires additional RBAC permissions (create/delete Jobs and PVCs) for benchmark feature
|
||||
- ⚠️ Failed cleanup leaves orphaned resources — mitigated by `listKbenchJobs()` which finds orphaned resources by label for manual cleanup
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **No benchmark feature (fully read-only)** — Rejected. Storage performance testing is a key use case for storage administrators evaluating CSI drivers.
|
||||
|
||||
2. **External benchmark tool with results import** — Rejected. Poor user experience requiring context-switching between tools.
|
||||
|
||||
3. **Benchmark as a separate plugin** — Rejected. Benchmark results are tied to storage class context and benefit from shared data in the plugin.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-03-05 | Initial decision |
|
||||
@@ -0,0 +1,55 @@
|
||||
# ADR 003: Graceful Degradation for Optional CRDs
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The plugin uses VolumeSnapshot and VolumeSnapshotClass CRDs from `snapshot.storage.k8s.io/v1`. These CRDs are part of the Kubernetes Volume Snapshot feature, which is optional — not all clusters have the snapshot controller installed.
|
||||
|
||||
The plugin should work on clusters without snapshot support, showing storage classes, volumes, metrics, and benchmarks without the snapshots page. The CRD fetch is wrapped in `try/catch`; if it fails, the `snapshotCrdAvailable` flag is set to `false`.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Implement graceful degradation for optional CRDs. The snapshot API calls are wrapped in `try/catch` within the data context. When the snapshot CRDs are not installed:
|
||||
|
||||
- `snapshotCrdAvailable` is set to `false`
|
||||
- Snapshot-related data arrays are empty
|
||||
- The Snapshots page shows an informational message rather than an error
|
||||
- All other plugin features remain fully functional
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- ✅ Plugin works on clusters without snapshot CRDs installed
|
||||
- ✅ No error state for missing optional features — clean informational messaging
|
||||
- ✅ Clear user feedback about what features are available
|
||||
- ✅ Core features (volumes, storage classes, metrics, benchmarks) always work
|
||||
- ⚠️ Two code paths (with/without snapshots) to maintain and test
|
||||
- ⚠️ Snapshot data might silently fail for reasons other than missing CRDs (e.g., RBAC issues)
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Require snapshot CRDs (hard dependency)** — Rejected. Too restrictive; many clusters do not have the snapshot controller installed.
|
||||
|
||||
2. **Feature detection via API discovery before fetching** — Considered, but `try/catch` on the actual fetch is simpler and catches all failure modes including RBAC restrictions.
|
||||
|
||||
3. **Disable snapshots page entirely when CRDs missing** — Rejected. Showing an informational message explaining how to enable snapshots is better UX than silently hiding the page.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-03-05 | Initial decision |
|
||||
@@ -0,0 +1,54 @@
|
||||
# ADR 004: URL Hash-Based Detail Panel State
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Several pages need to show detail panels for selected resources (e.g., clicking a PVC row shows PVC details). The detail panel state (which resource is selected) needs to be shareable via URL and survive page refresh. Options include:
|
||||
|
||||
- **React state** — Lost on refresh, not shareable
|
||||
- **URL query parameters** — May cause full page reload, potential conflicts with Headlamp routing
|
||||
- **URL hash fragments** — Client-side only, no reload, compatible with SPA routing
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use URL hash fragments to encode detail panel state. When a user selects a resource, the hash is updated (e.g., `#pvc/namespace/name`). On page load, the hash is parsed to restore the selected resource. This enables deep-linking to specific resource details and browser back/forward navigation.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- ✅ Deep-linkable resource details — users can share URLs pointing to specific resources
|
||||
- ✅ Survives page refresh without losing selected resource
|
||||
- ✅ Browser back/forward navigation works naturally
|
||||
- ✅ No server round-trip — hash changes are purely client-side
|
||||
- ✅ Compatible with Headlamp's client-side routing
|
||||
- ⚠️ Hash-based state is not a standard React pattern — requires team familiarity
|
||||
- ⚠️ Requires manual hash parsing and updating logic
|
||||
- ⚠️ Hash changes don't trigger React re-renders by default — requires `hashchange` event listener
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **React state only** — Rejected. State is lost on refresh and cannot be shared via URL.
|
||||
|
||||
2. **URL query parameters** — Rejected. May conflict with Headlamp's routing and could trigger unintended navigation behavior.
|
||||
|
||||
3. **Separate detail routes** — Rejected. Too heavyweight for inline detail panels; would require full page transitions for what should be a panel toggle.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-03-05 | Initial decision |
|
||||
@@ -0,0 +1,53 @@
|
||||
# ADR 005: Prometheus Metrics via Pod Proxy
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The plugin displays CSI driver metrics (operation latencies, error rates, volume stats). The CSI driver pods expose a Prometheus metrics endpoint on port 8080 in the standard text exposition format. The plugin needs to fetch and parse these metrics. Options:
|
||||
|
||||
- **Query a Prometheus server** — Requires Prometheus to be installed in the cluster
|
||||
- **Scrape the pod directly via Kubernetes pod proxy** — No additional dependencies
|
||||
- **Use a metrics aggregation service** — Requires additional infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Fetch metrics directly from the CSI driver pod's `/metrics` endpoint via Kubernetes pod proxy (`ApiProxy.request` to `/api/v1/namespaces/{ns}/pods/{pod}:8080/proxy/metrics`). Parse the Prometheus text exposition format in-browser using a custom parser in `metrics.ts`. No dependency on a Prometheus server installation.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
- ✅ Works without Prometheus server installed — no additional infrastructure dependency
|
||||
- ✅ Direct from source with no aggregation delay — metrics are always current
|
||||
- ✅ Leverages existing Kubernetes API authentication and authorization
|
||||
- ✅ No additional service dependencies to configure or maintain
|
||||
- ⚠️ Custom Prometheus text format parser to maintain — mitigated by the parser being well-tested
|
||||
- ⚠️ Only gets metrics from one pod at a time (no aggregation across replicas) — acceptable since CSI controller typically runs one replica
|
||||
- ⚠️ No historical data (point-in-time only) — users needing historical trends should use a full Prometheus setup
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Query Prometheus server via service proxy** (like the intel-gpu plugin) — Rejected. Would require Prometheus to be installed, adding a hard infrastructure dependency.
|
||||
|
||||
2. **Use a metrics library (prom-client) for parsing** — Rejected. Adds a runtime dependency for a relatively simple parsing task.
|
||||
|
||||
3. **JSON metrics endpoint instead of Prometheus format** — Rejected. The CSI driver only exposes Prometheus text format; a JSON endpoint would require changes to the driver itself.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Date | Change |
|
||||
|------|--------|
|
||||
| 2026-03-05 | Initial decision |
|
||||
@@ -0,0 +1,44 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
## What is an ADR?
|
||||
|
||||
An Architecture Decision Record (ADR) captures an important architectural decision made along with its context and consequences. ADRs are a lightweight way to document the "why" behind technical choices, ensuring that future contributors understand the reasoning behind the current architecture.
|
||||
|
||||
## Format
|
||||
|
||||
This project uses the [Nygard-style ADR format](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions):
|
||||
|
||||
- **Title**: Short noun phrase describing the decision
|
||||
- **Status**: Proposed | Accepted | Deprecated | Superseded
|
||||
- **Date**: When the decision was made
|
||||
- **Context**: What is the issue that we're seeing that motivates this decision?
|
||||
- **Decision**: What is the change that we're proposing and/or doing?
|
||||
- **Consequences**: What becomes easier or more difficult to do because of this change?
|
||||
- **Alternatives Considered**: What other options were evaluated?
|
||||
|
||||
## Index
|
||||
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [001](001-react-context-state.md) | React Context for Shared CSI Driver State | Accepted | 2026-03-05 |
|
||||
| [002](002-read-only-benchmark-exception.md) | Read-Only Plugin with Benchmark Exception | Accepted | 2026-03-05 |
|
||||
| [003](003-optional-crd-degradation.md) | Graceful Degradation for Optional CRDs | Accepted | 2026-03-05 |
|
||||
| [004](004-url-hash-detail-panels.md) | URL Hash-Based Detail Panel State | Accepted | 2026-03-05 |
|
||||
| [005](005-prometheus-pod-proxy.md) | Prometheus Metrics via Pod Proxy | Accepted | 2026-03-05 |
|
||||
|
||||
## Creating New ADRs
|
||||
|
||||
1. Copy an existing ADR as a template
|
||||
2. Assign the next sequential number (e.g., `006-your-title.md`)
|
||||
3. Fill in all sections: Status, Date, Context, Decision, Consequences, Alternatives
|
||||
4. Set the status to `Proposed` until reviewed
|
||||
5. Update this README index table
|
||||
6. Submit as part of a pull request for review
|
||||
|
||||
ADRs should not be deleted. If a decision is reversed, create a new ADR that supersedes the old one and update the old ADR's status to `Superseded by [ADR NNN](NNN-title.md)`.
|
||||
|
||||
## References
|
||||
|
||||
- [Michael Nygard - Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions)
|
||||
- [ADR GitHub Organization](https://adr.github.io/)
|
||||
- [Joel Parker Henderson - Architecture Decision Record](https://github.com/joelparkerhenderson/architecture-decision-record)
|
||||
@@ -0,0 +1,140 @@
|
||||
# Architecture Overview
|
||||
|
||||
## System Architecture
|
||||
|
||||
The TNS-CSI plugin is a single-page React application bundled as a Headlamp plugin. It runs entirely in the browser and communicates with Kubernetes exclusively through Headlamp's proxied API.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Browser │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ React Plugin Bundle │ │
|
||||
│ │ │ │
|
||||
│ │ index.tsx ── registerRoute/Sidebar/etc. │ │
|
||||
│ │ │ │
|
||||
│ │ TnsCsiDataProvider (React Context) │ │
|
||||
│ │ ├── K8s.ResourceClasses hooks (live watch) │ │
|
||||
│ │ └── ApiProxy.request (async fetch) │ │
|
||||
│ │ │ │
|
||||
│ │ Pages: │ │
|
||||
│ │ OverviewPage StorageClassesPage │ │
|
||||
│ │ VolumesPage SnapshotsPage │ │
|
||||
│ │ MetricsPage BenchmarkPage │ │
|
||||
│ │ │ │
|
||||
│ │ PVCDetailSection (injected into PVC views) │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└───────────────────────┬─────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Headlamp Pod (headlamp namespace) │
|
||||
│ │
|
||||
│ Headlamp UI server + API proxy │
|
||||
│ (forwards requests using service account token │
|
||||
│ or user-supplied OIDC token) │
|
||||
└───────────────────────┬─────────────────────────────┘
|
||||
│ in-cluster
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Kubernetes API Server │
|
||||
│ │
|
||||
│ ├── /apis/storage.k8s.io/v1/storageclasses │
|
||||
│ ├── /api/v1/persistentvolumes │
|
||||
│ ├── /api/v1/persistentvolumeclaims │
|
||||
│ ├── /api/v1/namespaces/kube-system/pods │
|
||||
│ ├── /apis/storage.k8s.io/v1/csidrivers │
|
||||
│ ├── /apis/snapshot.storage.k8s.io/v1/... │
|
||||
│ ├── /api/v1/namespaces/kube-system/pods/<pod>/proxy/metrics
|
||||
│ └── (Benchmark) /apis/batch/v1/jobs │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
index.tsx
|
||||
└── TnsCsiDataProvider
|
||||
├── OverviewPage
|
||||
│ └── DriverStatusCard
|
||||
├── StorageClassesPage
|
||||
│ └── StorageClassDetailPanel (slide-in)
|
||||
├── VolumesPage
|
||||
│ └── VolumeDetailPanel (slide-in)
|
||||
├── SnapshotsPage
|
||||
│ └── SnapshotDetailPanel (slide-in)
|
||||
├── MetricsPage
|
||||
└── BenchmarkPage
|
||||
|
||||
registerDetailsViewSection
|
||||
└── TnsCsiDataProvider
|
||||
└── PVCDetailSection (injected)
|
||||
```
|
||||
|
||||
## Data Sources
|
||||
|
||||
| Data | Source | Mechanism |
|
||||
| ---- | ------ | --------- |
|
||||
| StorageClasses | `storage.k8s.io/v1` | `K8s.ResourceClasses.StorageClass.useList()` — live watch |
|
||||
| PersistentVolumes | `core/v1` | `K8s.ResourceClasses.PersistentVolume.useList()` — live watch |
|
||||
| PersistentVolumeClaims | `core/v1` | `K8s.ResourceClasses.PersistentVolumeClaim.useList()` — live watch |
|
||||
| CSIDriver | `storage.k8s.io/v1` | `ApiProxy.request` — one-shot fetch |
|
||||
| Controller pods | `core/v1` | `ApiProxy.request` with label selector — one-shot fetch |
|
||||
| Node pods | `core/v1` | `ApiProxy.request` with label selector — one-shot fetch |
|
||||
| VolumeSnapshots | `snapshot.storage.k8s.io/v1` | `ApiProxy.request` — graceful degradation if CRD absent |
|
||||
| Prometheus metrics | Controller pod port 8080 | `ApiProxy.request` pod proxy |
|
||||
| kbench FIO logs | Benchmark Job pod | `ApiProxy.request` pod log |
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### KubeObject jsonData Extraction
|
||||
|
||||
Headlamp's `useList()` hooks return KubeObject class instances, not plain JSON objects. The class only exposes getter-defined fields (`provisioner`, `reclaimPolicy`, `volumeBindingMode`, `allowVolumeExpansion` for StorageClass). All other fields — including `parameters`, `spec`, and `status` — must be accessed via `.jsonData`.
|
||||
|
||||
`TnsCsiDataContext.tsx` extracts `jsonData` from every item before passing to filter/type helpers:
|
||||
|
||||
```typescript
|
||||
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||
items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
```
|
||||
|
||||
This is the single most important architectural invariant to preserve when working with headlamp hook data.
|
||||
|
||||
### Context Provider Pattern
|
||||
|
||||
`TnsCsiDataProvider` wraps every route component. This ensures:
|
||||
- All data fetching happens once per page navigation (not once per component)
|
||||
- All pages share the same filtered StorageClasses, PVs, PVCs, and pod lists
|
||||
- The `refresh()` callback triggers a `refreshKey` increment which re-runs async fetches
|
||||
|
||||
### Read-Only Constraint
|
||||
|
||||
The only write operation in the entire plugin is `BenchmarkPage.tsx`, which creates and deletes a Kubernetes Job and PVC. All other pages are strictly read-only. This is intentional and should be preserved.
|
||||
|
||||
### Detail Panel Pattern
|
||||
|
||||
Slide-in detail panels use URL hash state (`location.hash`) so:
|
||||
- Panel state survives browser refresh
|
||||
- Back button closes the panel
|
||||
- Deep-linking to a specific resource is possible
|
||||
|
||||
Pattern: `history.push(\`\${location.pathname}#\${name}\`)` to open, `history.push(location.pathname)` to close.
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
The snapshot CRD (`snapshot.storage.k8s.io/v1`) may not be installed. The context provider catches the 404/405 error and sets `snapshotCrdAvailable: false`. The Snapshots page shows an informational message instead of an error. Prometheus metrics similarly fall back to placeholder cards.
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
| File | Responsibility |
|
||||
| ---- | -------------- |
|
||||
| `src/index.tsx` | All registrations — sidebar entries, routes, detail section, plugin settings |
|
||||
| `src/api/k8s.ts` | Type definitions, type guards, filter helpers, format utilities |
|
||||
| `src/api/metrics.ts` | Prometheus text format parser, `fetchControllerMetrics` |
|
||||
| `src/api/kbench.ts` | kbench manifest builders, FIO log parser, `BenchmarkState` discriminated union |
|
||||
| `src/api/TnsCsiDataContext.tsx` | Shared data fetching and filtering; the `extractJsonData` pattern |
|
||||
| `src/components/*.tsx` | Page and panel UI components |
|
||||
@@ -0,0 +1,154 @@
|
||||
# Deployment with Helm
|
||||
|
||||
## Basic Helm Installation
|
||||
|
||||
Add the Headlamp Helm repository and deploy with the plugin configured:
|
||||
|
||||
```bash
|
||||
helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
|
||||
helm repo update
|
||||
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace <your-namespace> \
|
||||
--create-namespace \
|
||||
--set config.pluginsDir=/headlamp/plugins \
|
||||
--set pluginsManager.sources[0].name=tns-csi \
|
||||
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
## Complete values.yaml Example
|
||||
|
||||
```yaml
|
||||
# headlamp-values.yaml
|
||||
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
|
||||
serviceAccount:
|
||||
name: headlamp
|
||||
|
||||
# Optional: OIDC authentication
|
||||
# oidcConfig:
|
||||
# clientID: headlamp
|
||||
# clientSecret: <your-secret>
|
||||
# issuerURL: https://your-oidc-provider.example.com/
|
||||
# scopes: "openid profile email groups"
|
||||
```
|
||||
|
||||
Apply:
|
||||
|
||||
```bash
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace <your-namespace> \
|
||||
-f headlamp-values.yaml
|
||||
```
|
||||
|
||||
## FluxCD HelmRelease
|
||||
|
||||
```yaml
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: HelmRepository
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
interval: 12h
|
||||
url: https://headlamp-k8s.github.io/headlamp/
|
||||
---
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
interval: 1h
|
||||
chart:
|
||||
spec:
|
||||
chart: headlamp
|
||||
version: ">=0.26.0"
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
values:
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
## RBAC Manifest (Apply Separately)
|
||||
|
||||
After deploying Headlamp, apply the plugin's RBAC:
|
||||
|
||||
```bash
|
||||
kubectl apply -f - <<'EOF'
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-tns-csi-reader
|
||||
rules:
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses", "csidrivers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log", "pods/proxy"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
# Uncomment for Benchmark page:
|
||||
# - apiGroups: ["batch"]
|
||||
# resources: ["jobs"]
|
||||
# verbs: ["get", "list", "watch", "create", "delete"]
|
||||
# - apiGroups: [""]
|
||||
# resources: ["persistentvolumeclaims"]
|
||||
# verbs: ["create", "delete"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: headlamp-tns-csi
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
EOF
|
||||
```
|
||||
|
||||
## Upgrading the Plugin
|
||||
|
||||
To upgrade to a new plugin version, update the `url` in your values and apply:
|
||||
|
||||
```bash
|
||||
helm upgrade headlamp headlamp/headlamp \
|
||||
--namespace <your-namespace> \
|
||||
-f headlamp-values.yaml
|
||||
```
|
||||
|
||||
Or update the FluxCD HelmRelease and let Flux reconcile.
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [ ] Headlamp v0.20+ deployed
|
||||
- [ ] Plugin installed and sidebar entry visible
|
||||
- [ ] RBAC ClusterRole and ClusterRoleBinding applied
|
||||
- [ ] tns-csi driver installed in `kube-system` with standard labels
|
||||
- [ ] Controller pod exposes port 8080 for Prometheus metrics
|
||||
- [ ] Headlamp accessible via HTTPS
|
||||
- [ ] (Optional) Snapshot CRD installed for Snapshots tab
|
||||
- [ ] (Optional) Benchmark namespace created and write RBAC applied
|
||||
@@ -0,0 +1,149 @@
|
||||
# Testing Guide
|
||||
|
||||
## Test Suite Overview
|
||||
|
||||
The plugin has **159 unit tests** across 12 test files:
|
||||
|
||||
| File | Tests | Coverage |
|
||||
| ---- | ----- | -------- |
|
||||
| `src/api/k8s.test.ts` | Type guards, filter helpers, format utilities | k8s.ts |
|
||||
| `src/api/metrics.test.ts` | Prometheus text format parser | metrics.ts |
|
||||
| `src/api/kbench.test.ts` | FIO log parser, manifest builders, format helpers | kbench.ts |
|
||||
| `src/api/TnsCsiDataContext.test.tsx` | Context provider integration | TnsCsiDataContext.tsx |
|
||||
| `src/components/OverviewPage.test.tsx` | Overview dashboard rendering | OverviewPage.tsx |
|
||||
| `src/components/StorageClassesPage.test.tsx` | StorageClass list and detail panel | StorageClassesPage.tsx |
|
||||
| `src/components/VolumesPage.test.tsx` | PV list and detail panel | VolumesPage.tsx |
|
||||
| `src/components/SnapshotsPage.test.tsx` | VolumeSnapshot list | SnapshotsPage.tsx |
|
||||
| `src/components/MetricsPage.test.tsx` | Prometheus metrics display | MetricsPage.tsx |
|
||||
| `src/components/BenchmarkPage.test.tsx` | kbench runner UI | BenchmarkPage.tsx |
|
||||
| `src/components/DriverStatusCard.test.tsx` | Driver health card | DriverStatusCard.tsx |
|
||||
| `src/components/PVCDetailSection.test.tsx` | PVC detail injection | PVCDetailSection.tsx |
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests once
|
||||
npm test
|
||||
|
||||
# Watch mode (re-runs on file changes)
|
||||
npm run test:watch
|
||||
|
||||
# TypeScript type-check (no emit)
|
||||
npm run tsc
|
||||
```
|
||||
|
||||
All tests must pass before committing. The CI workflow enforces this.
|
||||
|
||||
## Test Framework
|
||||
|
||||
- **Vitest** — test runner
|
||||
- **@testing-library/react** — React component testing utilities
|
||||
- **jsdom** — DOM environment (configured in `vitest.config.mts`)
|
||||
|
||||
## Mocking Headlamp APIs
|
||||
|
||||
Headlamp APIs must be mocked in tests. Use this pattern:
|
||||
|
||||
```typescript
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: {
|
||||
request: vi.fn().mockResolvedValue({ items: [] }),
|
||||
},
|
||||
K8s: {
|
||||
ResourceClasses: {
|
||||
StorageClass: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolume: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
PersistentVolumeClaim: {
|
||||
useList: vi.fn(() => [[], null]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
SectionBox: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SectionHeader: ({ title }: { title: string }) => <h1>{title}</h1>,
|
||||
SimpleTable: ({ data, emptyMessage }: { data: unknown[]; emptyMessage: string }) =>
|
||||
data.length === 0 ? <p>{emptyMessage}</p> : <table />,
|
||||
Loader: ({ title }: { title: string }) => <div>{title}</div>,
|
||||
// add other CommonComponents as needed
|
||||
}));
|
||||
```
|
||||
|
||||
## Testing the Prometheus Parser
|
||||
|
||||
```typescript
|
||||
import { parsePrometheusText, extractTnsCsiMetrics } from '../metrics';
|
||||
|
||||
it('parses gauge metrics correctly', () => {
|
||||
const text = `
|
||||
# HELP kubelet_volume_stats_capacity_bytes Capacity in bytes of the volume
|
||||
# TYPE kubelet_volume_stats_capacity_bytes gauge
|
||||
kubelet_volume_stats_capacity_bytes{namespace="default",persistentvolumeclaim="my-pvc"} 10737418240
|
||||
`;
|
||||
const metrics = parsePrometheusText(text);
|
||||
expect(metrics.get('kubelet_volume_stats_capacity_bytes{namespace="default",persistentvolumeclaim="my-pvc"}')).toBe(10737418240);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing the FIO Log Parser
|
||||
|
||||
```typescript
|
||||
import { parseKbenchLog } from '../kbench';
|
||||
|
||||
it('parses kbench FIO output into result cards', () => {
|
||||
const log = `
|
||||
READ: bw=512MiB/s (537MB/s), 512MiB/s-512MiB/s (537MB/s-537MB/s), io=32.0GiB (34.4GB), run=63999-63999msec
|
||||
iops : min=128000, max=135000, avg=131072.00, stdev=1024.00, samples=64
|
||||
lat (usec) : min=10, max=500, avg=50.00, stdev=20.00
|
||||
`;
|
||||
const result = parseKbenchLog(log);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.readBandwidthMBs).toBeGreaterThan(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Type Guards
|
||||
|
||||
```typescript
|
||||
import { isTnsCsiStorageClass } from '../k8s';
|
||||
|
||||
it('identifies tns.csi.io provisioner', () => {
|
||||
expect(isTnsCsiStorageClass({ provisioner: 'tns.csi.io', metadata: { name: 'test' } })).toBe(true);
|
||||
expect(isTnsCsiStorageClass({ provisioner: 'other.csi.io', metadata: { name: 'test' } })).toBe(false);
|
||||
expect(isTnsCsiStorageClass(null)).toBe(false);
|
||||
expect(isTnsCsiStorageClass(undefined)).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
## vitest.setup.ts
|
||||
|
||||
The setup file shims `localStorage` for Node 22+ (jsdom doesn't provide it in some versions):
|
||||
|
||||
```typescript
|
||||
// vitest.setup.ts
|
||||
if (typeof localStorage === 'undefined') {
|
||||
const store: Record<string, string> = {};
|
||||
Object.defineProperty(global, 'localStorage', {
|
||||
value: {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v; },
|
||||
removeItem: (k: string) => { delete store[k]; },
|
||||
clear: () => { Object.keys(store).forEach(k => delete store[k]); },
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## CI Test Enforcement
|
||||
|
||||
The GitHub Actions CI workflow runs lint, typecheck, and test as three parallel jobs on every push and PR. A fourth `build` job gates on all three passing. The test job uses a JUnit reporter that posts test summaries directly on PRs.
|
||||
|
||||
All three checks must pass for the PR to merge.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Installation Guide
|
||||
|
||||
## Installation Methods
|
||||
|
||||
### Method 1: Headlamp Plugin Manager (Recommended)
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin).
|
||||
|
||||
**Via Headlamp UI:**
|
||||
|
||||
1. Navigate to **Settings → Plugins → Catalog**
|
||||
2. Search for "TNS CSI" or "TrueNAS"
|
||||
3. Click **Install**
|
||||
4. Refresh the page
|
||||
|
||||
**Via Helm values:**
|
||||
|
||||
```yaml
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
**Via FluxCD HelmRelease:**
|
||||
|
||||
```yaml
|
||||
apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
chart:
|
||||
spec:
|
||||
chart: headlamp
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: headlamp
|
||||
values:
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
### Method 2: Manual Tarball Install
|
||||
|
||||
Download and extract the plugin directly:
|
||||
|
||||
```bash
|
||||
# Download the release tarball
|
||||
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
|
||||
# Extract into your Headlamp plugins directory
|
||||
tar xzf tns-csi-1.0.0.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
The plugin directory should appear as `/headlamp/plugins/tns-csi/`.
|
||||
|
||||
Restart Headlamp (or the pod) after extracting.
|
||||
|
||||
### Method 3: Sidecar Container
|
||||
|
||||
For Headlamp deployments where you prefer managing plugins as container init sidecars:
|
||||
|
||||
```yaml
|
||||
initContainers:
|
||||
- name: install-tns-csi-plugin
|
||||
image: alpine:3
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
wget -O /tmp/plugin.tar.gz \
|
||||
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
tar xzf /tmp/plugin.tar.gz -C /headlamp/plugins/
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
mountPath: /headlamp/plugins
|
||||
```
|
||||
|
||||
### Method 4: Build from Source
|
||||
|
||||
For development or to test unreleased changes:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
|
||||
cd headlamp-tns-csi-plugin
|
||||
npm install
|
||||
npm run build
|
||||
npm run package
|
||||
# Produces tns-csi-<version>.tar.gz
|
||||
|
||||
# Extract to your Headlamp plugins directory
|
||||
tar xzf tns-csi-<version>.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
Or use `headlamp-plugin extract` for automatic placement:
|
||||
|
||||
```bash
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
## Post-Installation
|
||||
|
||||
After installing the plugin:
|
||||
|
||||
1. **Configure RBAC** — see [RBAC Permissions](../user-guide/rbac.md)
|
||||
2. **Verify the plugin loads** — refresh browser and look for "TrueNAS (tns-csi)" in the sidebar
|
||||
3. **Check the Overview page** — driver health card should show tns-csi status
|
||||
|
||||
## Upgrading
|
||||
|
||||
To upgrade to a new version, repeat the installation method you used. The new tarball replaces the old plugin directory.
|
||||
|
||||
For Plugin Manager installs, the catalog will show available updates.
|
||||
|
||||
## Uninstalling
|
||||
|
||||
Remove the plugin directory from your Headlamp plugins directory:
|
||||
|
||||
```bash
|
||||
rm -rf /headlamp/plugins/tns-csi/
|
||||
```
|
||||
|
||||
Or via the Headlamp UI: **Settings → Plugins → tns-csi → Uninstall**.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Quick Start
|
||||
|
||||
Get the TNS-CSI plugin running in Headlamp in about 5 minutes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Headlamp v0.20+ running in your cluster
|
||||
- tns-csi driver installed in `kube-system`
|
||||
- `kubectl` access to your cluster
|
||||
|
||||
## Step 1: Install the Plugin
|
||||
|
||||
### Via Headlamp UI (Easiest)
|
||||
|
||||
1. Open Headlamp and navigate to **Settings → Plugins → Catalog**
|
||||
2. Search for **"TNS CSI"** or **"TrueNAS"**
|
||||
3. Click **Install**
|
||||
4. Refresh the browser
|
||||
|
||||
### Via Helm
|
||||
|
||||
Add the plugin source to your Headlamp Helm values:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
Then upgrade your Headlamp release:
|
||||
|
||||
```bash
|
||||
helm upgrade headlamp headlamp/headlamp -f values.yaml -n <your-namespace>
|
||||
```
|
||||
|
||||
## Step 2: Configure RBAC
|
||||
|
||||
The plugin needs read access to storage resources and the tns-csi controller pod's metrics endpoint.
|
||||
|
||||
Apply the minimal RBAC:
|
||||
|
||||
```bash
|
||||
kubectl apply -f - <<'EOF'
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-tns-csi-reader
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes", "persistentvolumeclaims", "pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses", "csidrivers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log", "pods/proxy"]
|
||||
verbs: ["get"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: headlamp-tns-csi
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
EOF
|
||||
```
|
||||
|
||||
Adjust `name: headlamp` and `namespace: <your-namespace>` to match your Headlamp service account.
|
||||
|
||||
## Step 3: Verify
|
||||
|
||||
1. Open Headlamp — you should see **TrueNAS (tns-csi)** in the left sidebar
|
||||
2. Click **Overview** — you should see the driver health card and storage summary
|
||||
3. Click **Storage Classes** — your tns-csi StorageClasses should appear with Protocol, Pool, and Server filled in
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
| ------- | --- |
|
||||
| No sidebar entry | Hard-refresh browser (Cmd+Shift+R) |
|
||||
| Driver shows "Not installed" | Run `kubectl get csidriver tns.csi.io` |
|
||||
| StorageClasses empty | Check `kubectl get sc` for `tns.csi.io` provisioner |
|
||||
| Protocol/Pool/Server show "—" | Check `kubectl get sc <name> -o yaml` for `.parameters` |
|
||||
| Metrics page empty | Verify controller pod exposes port 8080 |
|
||||
|
||||
For more detail see [Troubleshooting](../troubleshooting/README.md).
|
||||
@@ -0,0 +1,112 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Quick Diagnosis
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
| ------- | ------------ | --- |
|
||||
| **Plugin not in sidebar** | Not installed or browser cache | Hard refresh (Cmd+Shift+R / Ctrl+Shift+F5) |
|
||||
| **"TrueNAS (tns-csi)" missing from sidebar** | Plugin not loaded | Check Headlamp plugin manager or restart Headlamp pod |
|
||||
| **No StorageClasses listed** | Wrong provisioner or driver not installed | See [Driver Detection](#driver-detection) |
|
||||
| **Driver status "Not installed"** | CSIDriver object missing | `kubectl get csidriver tns.csi.io` |
|
||||
| **Protocol/Pool/Server showing "—"** | StorageClass missing parameters | `kubectl get sc <name> -o yaml` to inspect |
|
||||
| **403 on any page** | Missing RBAC | See [RBAC Issues](rbac.md) |
|
||||
| **Metrics page empty** | Controller pod unreachable or no metrics | See [Metrics Issues](metrics.md) |
|
||||
| **Snapshots tab: "CRD not available"** | Snapshot CRD not installed | Install `snapshot.storage.k8s.io` CRDs |
|
||||
| **Snapshots tab empty (no message)** | No snapshots or wrong snapshot class | Check VolumeSnapshotClass driver field |
|
||||
| **Benchmark fails immediately** | Missing RBAC for Jobs/PVCs | See [Benchmark Issues](benchmark.md) |
|
||||
| **Benchmark stuck in "Running"** | kbench pod not starting | `kubectl get pods -n <ns> -l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` |
|
||||
| **Page loads but data is stale** | Watch connection dropped | Click the Refresh button or reload the page |
|
||||
|
||||
## Driver Detection
|
||||
|
||||
The plugin detects the tns-csi driver by querying:
|
||||
|
||||
```
|
||||
GET /apis/storage.k8s.io/v1/csidrivers/tns.csi.io
|
||||
```
|
||||
|
||||
If this returns 404, the driver shows as "Not installed".
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
kubectl get csidriver tns.csi.io
|
||||
```
|
||||
|
||||
If missing, verify the tns-csi driver is deployed. The driver registers its CSIDriver object on startup.
|
||||
|
||||
## StorageClass Parameters Showing "—"
|
||||
|
||||
StorageClass Protocol, Pool, and Server come from the StorageClass `parameters` field.
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
kubectl get sc -o yaml | grep -A5 "provisioner: tns.csi.io"
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
|
||||
```yaml
|
||||
parameters:
|
||||
protocol: nfs
|
||||
pool: tank/k8s
|
||||
server: 192.168.1.1
|
||||
```
|
||||
|
||||
If `parameters` is absent, the StorageClass was created without them — the CSI driver documentation specifies the required parameters for each protocol.
|
||||
|
||||
## Controller Pods Not Showing
|
||||
|
||||
The Overview page shows controller and node pod counts using label selectors:
|
||||
|
||||
- Controller: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller`
|
||||
- Node: `app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=node`
|
||||
|
||||
**Check:**
|
||||
|
||||
```bash
|
||||
kubectl get pods -n kube-system -l app.kubernetes.io/name=tns-csi-driver
|
||||
```
|
||||
|
||||
If pods exist but aren't showing, verify the `app.kubernetes.io/component` label is set correctly.
|
||||
|
||||
## Infinite Loading Spinner
|
||||
|
||||
If a page shows a loading spinner indefinitely:
|
||||
|
||||
1. **Check browser console** for errors (F12 → Console)
|
||||
2. **Check network tab** for failed API requests (look for 403, 404, 500)
|
||||
3. **Check Headlamp pod logs**: `kubectl logs -n <your-namespace> -l app.kubernetes.io/name=headlamp`
|
||||
4. **Try refreshing** — the watch connection may have been interrupted
|
||||
|
||||
## Common API Errors
|
||||
|
||||
| HTTP Status | Meaning | Action |
|
||||
| ----------- | ------- | ------ |
|
||||
| `401 Unauthorized` | Token expired or invalid | Re-authenticate in Headlamp |
|
||||
| `403 Forbidden` | Missing RBAC permission | See [RBAC Issues](rbac.md) |
|
||||
| `404 Not Found` | Resource doesn't exist | Expected for optional resources (CSIDriver, snapshot CRD) |
|
||||
| `503 Service Unavailable` | API server overloaded | Wait and retry |
|
||||
|
||||
## Getting More Information
|
||||
|
||||
**Browser console:**
|
||||
|
||||
```
|
||||
F12 → Console tab
|
||||
```
|
||||
|
||||
Look for errors related to `tns-csi`, `headlamp-plugin`, or Kubernetes API paths.
|
||||
|
||||
**Headlamp pod logs:**
|
||||
|
||||
```bash
|
||||
kubectl logs -n <your-namespace> -l app.kubernetes.io/name=headlamp --tail=100
|
||||
```
|
||||
|
||||
**tns-csi controller logs:**
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller --tail=100
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Benchmark Issues
|
||||
|
||||
## Benchmark Fails to Start
|
||||
|
||||
### Check RBAC
|
||||
|
||||
The Benchmark page requires permissions to create and delete Jobs and PVCs:
|
||||
|
||||
```bash
|
||||
kubectl auth can-i create jobs -n <benchmark-namespace> \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
kubectl auth can-i create persistentvolumeclaims -n <benchmark-namespace> \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
Apply the additional permissions if missing — see [RBAC Issues](rbac.md) or [SECURITY.md](../../SECURITY.md).
|
||||
|
||||
### Check the Target Namespace Exists
|
||||
|
||||
The namespace you select in the Benchmark form must exist. Create it if needed:
|
||||
|
||||
```bash
|
||||
kubectl create namespace <benchmark-namespace>
|
||||
```
|
||||
|
||||
## Benchmark Stuck in "Running"
|
||||
|
||||
### Check the kbench Pod
|
||||
|
||||
```bash
|
||||
kubectl get pods -n <benchmark-namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
Common states:
|
||||
|
||||
| Pod State | Cause | Action |
|
||||
| --------- | ----- | ------ |
|
||||
| `Pending` | PVC not provisioned or scheduler issue | Check PVC status and StorageClass |
|
||||
| `Init:Error` | kbench image pull failure | Check image pull policy and network |
|
||||
| `Running` | Benchmark in progress | Wait for completion |
|
||||
| `Completed` | Finished — results should appear | Check FIO log section |
|
||||
| `Error` / `OOMKilled` | kbench ran out of memory | Reduce test size or capacity |
|
||||
|
||||
### Check the PVC
|
||||
|
||||
```bash
|
||||
kubectl get pvc -n <benchmark-namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
If the PVC is stuck in `Pending`, the StorageClass provisioner may not be able to create the volume:
|
||||
|
||||
```bash
|
||||
kubectl describe pvc -n <benchmark-namespace> <pvc-name>
|
||||
```
|
||||
|
||||
Look for events at the bottom of the describe output.
|
||||
|
||||
### View kbench Logs Directly
|
||||
|
||||
```bash
|
||||
kubectl logs -n <benchmark-namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin \
|
||||
--tail=100
|
||||
```
|
||||
|
||||
## Leftover Resources After Failed Benchmark
|
||||
|
||||
If the benchmark was stopped or the plugin page was closed during a run, the Job and PVC may not have been cleaned up:
|
||||
|
||||
```bash
|
||||
# List leftover resources
|
||||
kubectl get jobs,pvc -n <benchmark-namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
|
||||
# Clean up manually
|
||||
kubectl delete jobs,pvc -n <benchmark-namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
The plugin adds the `app.kubernetes.io/managed-by=headlamp-tns-csi-plugin` label to all benchmark resources precisely to enable safe cleanup with this label selector.
|
||||
|
||||
## No Results Shown After Benchmark Completes
|
||||
|
||||
The plugin parses the FIO log output from the kbench pod. If results don't appear:
|
||||
|
||||
1. Check the pod completed successfully (status `Completed`, exit code 0)
|
||||
2. View the raw log: `kubectl logs -n <ns> <kbench-pod>`
|
||||
3. Look for the FIO result section — it should contain lines like `READ: bw=...` or `WRITE: bw=...`
|
||||
|
||||
If the kbench version produces output in a different format, the FIO log parser may not recognize it. Open a [GitHub Issue](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/issues) with a sample of the log output.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Metrics Issues
|
||||
|
||||
## Metrics Page Shows No Data
|
||||
|
||||
### 1. Check the Controller Pod Is Running
|
||||
|
||||
```bash
|
||||
kubectl get pods -n kube-system \
|
||||
-l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller
|
||||
```
|
||||
|
||||
The controller pod must be in `Running` state with all containers ready.
|
||||
|
||||
### 2. Verify Port 8080 Is Exposed
|
||||
|
||||
```bash
|
||||
# Check the pod spec for port 8080
|
||||
kubectl get pod -n kube-system <controller-pod-name> -o yaml | grep -A5 "ports:"
|
||||
```
|
||||
|
||||
If port 8080 is not declared, the tns-csi driver version you're running may not expose Prometheus metrics. Check the driver documentation.
|
||||
|
||||
### 3. Test the Metrics Endpoint Directly
|
||||
|
||||
```bash
|
||||
# Port-forward the controller pod
|
||||
kubectl port-forward -n kube-system \
|
||||
$(kubectl get pods -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller -o name | head -1) \
|
||||
8080:8080
|
||||
|
||||
# In another terminal
|
||||
curl http://localhost:8080/metrics | head -20
|
||||
```
|
||||
|
||||
If this returns Prometheus text format output, the endpoint is working. If it returns 404 or connection refused, the controller isn't exposing metrics.
|
||||
|
||||
### 4. Check RBAC for Pod Proxy
|
||||
|
||||
The plugin accesses metrics via the Kubernetes pod proxy sub-resource:
|
||||
|
||||
```
|
||||
GET /api/v1/namespaces/kube-system/pods/<pod>/proxy/metrics
|
||||
```
|
||||
|
||||
This requires `get` on `pods/proxy` in `kube-system`:
|
||||
|
||||
```bash
|
||||
kubectl auth can-i get pods/proxy \
|
||||
-n kube-system \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
### 5. Network Policies
|
||||
|
||||
If `kube-system` has NetworkPolicies, ensure the Kubernetes API server can reach the controller pod on port 8080. The pod proxy hop is performed by the API server, not by Headlamp directly.
|
||||
|
||||
## Metrics Show Stale Values
|
||||
|
||||
The Metrics page fetches data on-demand when the page loads. Click **Refresh** to re-fetch the latest metrics from the controller pod.
|
||||
|
||||
## Some Metric Cards Show "—"
|
||||
|
||||
Not all tns-csi driver versions expose all metrics. The plugin shows placeholder "—" values for metrics that are absent from the Prometheus output. This is expected behavior.
|
||||
|
||||
The plugin specifically looks for:
|
||||
- `kubelet_volume_stats_*` metrics (volume I/O)
|
||||
- `csi_operations_seconds_*` metrics (CSI operation latency)
|
||||
- Any tns-csi specific metrics on port 8080
|
||||
@@ -0,0 +1,64 @@
|
||||
# RBAC Issues
|
||||
|
||||
## 403 Forbidden Errors
|
||||
|
||||
A 403 error means the identity making the API request (Headlamp's service account or the logged-in user's token) lacks the required permission.
|
||||
|
||||
### Diagnosing Which Permission Is Missing
|
||||
|
||||
Use `kubectl auth can-i` to check specific permissions:
|
||||
|
||||
```bash
|
||||
# Check if the Headlamp service account can list StorageClasses
|
||||
kubectl auth can-i list storageclasses \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
# Check pod proxy access (for metrics)
|
||||
kubectl auth can-i get pods/proxy \
|
||||
-n kube-system \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
# Check snapshot access
|
||||
kubectl auth can-i list volumesnapshots \
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
### Applying the Required RBAC
|
||||
|
||||
See [RBAC Permissions](../user-guide/rbac.md) for the complete ClusterRole manifest.
|
||||
|
||||
Quick apply:
|
||||
|
||||
```bash
|
||||
kubectl apply -f https://raw.githubusercontent.com/privilegedescalation/headlamp-tns-csi-plugin/main/docs/user-guide/rbac-manifest.yaml
|
||||
```
|
||||
|
||||
Or manually apply the ClusterRole and ClusterRoleBinding from [SECURITY.md](../../SECURITY.md).
|
||||
|
||||
### OIDC Token Mode
|
||||
|
||||
If Headlamp is configured for OIDC authentication, each user's own token is used for API requests. The RBAC must be bound to the user's identity (email, group) rather than the service account:
|
||||
|
||||
```yaml
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: "engineering"
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
Users not in the group will see 403 errors in the plugin.
|
||||
|
||||
### Benchmark 403
|
||||
|
||||
The Benchmark page requires additional write permissions:
|
||||
|
||||
```yaml
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["create", "delete"]
|
||||
```
|
||||
|
||||
If only the Benchmark page shows 403, add these rules to your ClusterRole (or a separate Role scoped to the benchmark namespace).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Benchmark Page
|
||||
|
||||
The Benchmark page provides an interactive storage benchmark runner using [kbench](https://github.com/longhorn/kbench) (the Longhorn storage benchmark tool based on FIO).
|
||||
|
||||
## What It Does
|
||||
|
||||
1. You select a tns-csi StorageClass, a namespace, a PVC capacity, and an access mode
|
||||
2. The plugin creates a PVC and a Kubernetes Job that runs `yasker/kbench:latest`
|
||||
3. FIO log output streams in real-time from the kbench pod
|
||||
4. When complete, results are parsed and displayed as IOPS, bandwidth (MB/s), and latency (µs) cards
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- RBAC permissions for Jobs and PVCs — see [RBAC Permissions](rbac.md)
|
||||
- The target namespace must exist
|
||||
- The selected StorageClass must support the chosen access mode
|
||||
|
||||
## Running a Benchmark
|
||||
|
||||
1. Navigate to **TrueNAS (tns-csi) → Benchmark**
|
||||
2. Select a StorageClass from the dropdown (only tns-csi classes are listed)
|
||||
3. Enter the target namespace (defaults to `default`)
|
||||
4. Set PVC capacity (e.g., `10Gi`)
|
||||
5. Choose access mode (`ReadWriteOnce`, `ReadWriteMany`, etc.)
|
||||
6. Click **Run Benchmark**
|
||||
|
||||
The benchmark progress shows:
|
||||
- Benchmark state (Starting, Running, Parsing Results, Complete, Failed)
|
||||
- Live FIO log output as it streams from the pod
|
||||
- Result cards once FIO completes
|
||||
|
||||
## Result Cards
|
||||
|
||||
When the benchmark completes, the plugin displays:
|
||||
|
||||
| Card | Metric |
|
||||
| ---- | ------ |
|
||||
| Read IOPS | Random 4K read I/O operations per second |
|
||||
| Write IOPS | Random 4K write I/O operations per second |
|
||||
| Read Bandwidth | Sequential read throughput (MB/s) |
|
||||
| Write Bandwidth | Sequential write throughput (MB/s) |
|
||||
| Read Latency | Average read latency (µs) |
|
||||
| Write Latency | Average write latency (µs) |
|
||||
|
||||
## Stopping a Benchmark
|
||||
|
||||
Click **Stop** to cancel the running benchmark. The plugin will delete the Job and PVC.
|
||||
|
||||
If the page is closed or navigated away from during a benchmark, the Job and PVC will remain in the cluster with the label:
|
||||
|
||||
```
|
||||
app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
Clean them up manually:
|
||||
|
||||
```bash
|
||||
kubectl delete jobs,pvc -n <namespace> \
|
||||
-l app.kubernetes.io/managed-by=headlamp-tns-csi-plugin
|
||||
```
|
||||
|
||||
## Resource Cleanup
|
||||
|
||||
The plugin automatically deletes the benchmark Job and PVC when:
|
||||
- The benchmark completes successfully
|
||||
- You click Stop
|
||||
- The page component unmounts
|
||||
|
||||
## Protocol Notes
|
||||
|
||||
Different protocols have different performance characteristics:
|
||||
|
||||
| Protocol | Typical Use Case | Access Modes |
|
||||
| -------- | ---------------- | ------------ |
|
||||
| NFS | Shared storage, RWX workloads | RWO, RWX, RWOP |
|
||||
| NVMe-oF | High-performance block storage | RWO, RWOP |
|
||||
| iSCSI | Block storage | RWO, RWOP |
|
||||
|
||||
For NVMe-oF benchmarks, ensure nodes have the `nvme-tcp` kernel module loaded and the controller has a static IP.
|
||||
@@ -0,0 +1,121 @@
|
||||
# RBAC Permissions
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin requires different permissions depending on which features you use. Start with the read-only set and add the benchmark write permissions only if needed.
|
||||
|
||||
## Read-Only Permissions (All Pages Except Benchmark)
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRole
|
||||
metadata:
|
||||
name: headlamp-tns-csi-reader
|
||||
rules:
|
||||
# StorageClasses and CSIDriver
|
||||
- apiGroups: ["storage.k8s.io"]
|
||||
resources: ["storageclasses", "csidrivers"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
# PersistentVolumes (cluster-scoped)
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumes"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
# PersistentVolumeClaims (all namespaces)
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
|
||||
# tns-csi driver pods and their logs/proxy (for metrics)
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log", "pods/proxy"]
|
||||
verbs: ["get"]
|
||||
|
||||
# VolumeSnapshots (optional — gracefully degraded if absent)
|
||||
- apiGroups: ["snapshot.storage.k8s.io"]
|
||||
resources: ["volumesnapshots", "volumesnapshotclasses"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: ClusterRoleBinding
|
||||
metadata:
|
||||
name: headlamp-tns-csi
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # adjust to your Headlamp service account name
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
## Additional Permissions for Benchmark Page
|
||||
|
||||
The Benchmark page creates and deletes a Job and PVC. These rules can be added to the ClusterRole above, or bound as a separate namespaced Role scoped to a dedicated benchmark namespace.
|
||||
|
||||
```yaml
|
||||
# Benchmark: create/delete kbench Job
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
|
||||
# Benchmark: create/delete kbench PVC
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
```
|
||||
|
||||
## Scoping Benchmark Permissions to a Namespace
|
||||
|
||||
For tighter security, restrict benchmark write permissions to a dedicated namespace using a Role + RoleBinding instead of ClusterRole:
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: headlamp-tns-csi-benchmark
|
||||
namespace: storage-benchmarks # dedicated benchmark namespace
|
||||
rules:
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["persistentvolumeclaims"]
|
||||
verbs: ["get", "list", "watch", "create", "delete"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods", "pods/log"]
|
||||
verbs: ["get", "list", "watch"]
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: headlamp-tns-csi-benchmark
|
||||
namespace: storage-benchmarks
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: headlamp-tns-csi-benchmark
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
```
|
||||
|
||||
With this configuration, benchmark jobs can only be created in the `storage-benchmarks` namespace.
|
||||
|
||||
## Permission Summary by Feature
|
||||
|
||||
| Feature | Permissions Required |
|
||||
| ------- | -------------------- |
|
||||
| Overview | `storageclasses list`, `persistentvolumes list`, `persistentvolumeclaims list`, `pods list` (kube-system), `csidrivers get` |
|
||||
| Storage Classes | `storageclasses list` |
|
||||
| Volumes | `persistentvolumes list` |
|
||||
| Snapshots | `volumesnapshots list`, `volumesnapshotclasses list` |
|
||||
| Metrics | `pods/proxy get` (kube-system controller pod) |
|
||||
| Benchmark | `jobs create/delete`, `persistentvolumeclaims create/delete` |
|
||||
| PVC Detail Injection | `persistentvolumeclaims get`, `persistentvolumes get` |
|
||||
+30
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-tns-csi-plugin",
|
||||
"version": "0.1.0",
|
||||
"name": "tns-csi",
|
||||
"version": "1.0.3",
|
||||
"description": "Headlamp plugin for TNS-CSI driver visibility and benchmarking",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -12,6 +12,7 @@
|
||||
"homepage": "https://github.com/privilegedescalation/headlamp-tns-csi-plugin#readme",
|
||||
"author": "privilegedescalation",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
"build": "headlamp-plugin build",
|
||||
@@ -24,7 +25,33 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0"
|
||||
"@headlamp-k8s/eslint-config": "^0.6.0",
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"notistack": "^3.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"tar": "^7.5.11",
|
||||
"undici": "^7.24.3",
|
||||
"vite": ">=6.4.2",
|
||||
"elliptic": ">=6.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+11990
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["github>privilegedescalation/.github:renovate-config"]
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
ConfigStore: class {
|
||||
get() {
|
||||
return {};
|
||||
}
|
||||
set() {}
|
||||
update() {}
|
||||
useConfig() {
|
||||
return () => ({});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { TnsCsiDataProvider, useTnsCsiContext } from './TnsCsiDataContext';
|
||||
|
||||
@@ -13,15 +13,18 @@ import {
|
||||
filterTnsCsiPersistentVolumes,
|
||||
filterTnsCsiPVCs,
|
||||
filterTnsCsiStorageClasses,
|
||||
filterTnsCsiVolumeSnapshots,
|
||||
isKubeList,
|
||||
isTnsCsiVolumeSnapshotClass,
|
||||
TNS_CSI_PROVISIONER,
|
||||
TnsCsiPersistentVolume,
|
||||
TnsCsiPersistentVolumeClaim,
|
||||
TnsCsiPod,
|
||||
TnsCsiStorageClass,
|
||||
TNS_CSI_PROVISIONER,
|
||||
VolumeSnapshot,
|
||||
VolumeSnapshotClass,
|
||||
} from './k8s';
|
||||
import { fetchTruenasPoolStats, getTnsCsiConfig, PoolStats } from './truenas';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context shape
|
||||
@@ -46,6 +49,10 @@ export interface TnsCsiContextValue {
|
||||
volumeSnapshotClasses: VolumeSnapshotClass[];
|
||||
snapshotCrdAvailable: boolean;
|
||||
|
||||
// TrueNAS pool capacity (only populated when API key is configured)
|
||||
poolStats: PoolStats[];
|
||||
poolStatsError: string | null;
|
||||
|
||||
// Loading / error state
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -88,6 +95,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
const [asyncLoading, setAsyncLoading] = useState(true);
|
||||
const [asyncError, setAsyncError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [poolStats, setPoolStats] = useState<PoolStats[]>([]);
|
||||
const [poolStatsError, setPoolStatsError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
@@ -102,9 +111,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
try {
|
||||
// CSIDriver
|
||||
try {
|
||||
const driver = await ApiProxy.request(
|
||||
const driver = (await ApiProxy.request(
|
||||
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
|
||||
) as CSIDriver;
|
||||
)) as CSIDriver;
|
||||
if (!cancelled) setCsiDriver(driver);
|
||||
} catch {
|
||||
if (!cancelled) setCsiDriver(null);
|
||||
@@ -144,14 +153,19 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
|
||||
);
|
||||
if (!cancelled && isKubeList(vscList)) {
|
||||
setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]);
|
||||
const allSnapshotClasses = vscList.items as VolumeSnapshotClass[];
|
||||
const tnsCsiSnapshotClasses = allSnapshotClasses.filter(isTnsCsiVolumeSnapshotClass);
|
||||
setVolumeSnapshotClasses(tnsCsiSnapshotClasses);
|
||||
setSnapshotCrdAvailable(true);
|
||||
|
||||
const tnsCsiClassNames = new Set(tnsCsiSnapshotClasses.map(c => c.metadata.name));
|
||||
|
||||
const vsList = await ApiProxy.request(
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
|
||||
);
|
||||
if (!cancelled && isKubeList(vsList)) {
|
||||
setVolumeSnapshots(vsList.items as VolumeSnapshot[]);
|
||||
const allSnapshots = vsList.items as VolumeSnapshot[];
|
||||
setVolumeSnapshots(filterTnsCsiVolumeSnapshots(allSnapshots, tnsCsiClassNames));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -161,6 +175,31 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
setVolumeSnapshots([]);
|
||||
}
|
||||
}
|
||||
|
||||
// TrueNAS pool stats (only when API key is configured)
|
||||
const config = getTnsCsiConfig();
|
||||
if (config.truenasApiKey.trim()) {
|
||||
const server = config.truenasServerOverride.trim();
|
||||
if (server) {
|
||||
try {
|
||||
const pools = await fetchTruenasPoolStats(server, config.truenasApiKey.trim());
|
||||
if (!cancelled) {
|
||||
setPoolStats(pools);
|
||||
setPoolStatsError(null);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setPoolStats([]);
|
||||
setPoolStatsError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) {
|
||||
setPoolStats([]);
|
||||
setPoolStatsError(null);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setAsyncError(err instanceof Error ? err.message : String(err));
|
||||
@@ -171,26 +210,43 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
void fetchAsync();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived / filtered values — memoized to avoid recomputation on every render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Headlamp useList() returns KubeObject class instances that store raw Kubernetes
|
||||
// JSON under `.jsonData`. Direct property access only works for fields that have
|
||||
// explicit getter definitions in the class (e.g. provisioner, reclaimPolicy).
|
||||
// Fields like `parameters`, `spec`, `status` must be read from `.jsonData`.
|
||||
// We extract jsonData here so our plain-object type helpers work correctly.
|
||||
const extractJsonData = (items: unknown[]): unknown[] =>
|
||||
items.map(item =>
|
||||
item && typeof item === 'object' && 'jsonData' in item
|
||||
? (item as { jsonData: unknown }).jsonData
|
||||
: item
|
||||
);
|
||||
|
||||
const storageClasses = useMemo(() => {
|
||||
if (!allStorageClasses) return [];
|
||||
return filterTnsCsiStorageClasses(allStorageClasses as unknown[]);
|
||||
return filterTnsCsiStorageClasses(extractJsonData(allStorageClasses as unknown[]));
|
||||
}, [allStorageClasses]);
|
||||
|
||||
const persistentVolumes = useMemo(() => {
|
||||
if (!allPvs) return [];
|
||||
return filterTnsCsiPersistentVolumes(allPvs as unknown[]);
|
||||
return filterTnsCsiPersistentVolumes(extractJsonData(allPvs as unknown[]));
|
||||
}, [allPvs]);
|
||||
|
||||
const persistentVolumeClaims = useMemo(() => {
|
||||
if (!allPvcs || persistentVolumes.length === 0) return [];
|
||||
return filterTnsCsiPVCs(allPvcs as TnsCsiPersistentVolumeClaim[], persistentVolumes);
|
||||
return filterTnsCsiPVCs(
|
||||
extractJsonData(allPvcs as unknown[]) as TnsCsiPersistentVolumeClaim[],
|
||||
persistentVolumes
|
||||
);
|
||||
}, [allPvcs, persistentVolumes]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -224,6 +280,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
volumeSnapshots,
|
||||
volumeSnapshotClasses,
|
||||
snapshotCrdAvailable,
|
||||
poolStats,
|
||||
poolStatsError,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
@@ -239,6 +297,8 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
volumeSnapshots,
|
||||
volumeSnapshotClasses,
|
||||
snapshotCrdAvailable,
|
||||
poolStats,
|
||||
poolStatsError,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
|
||||
+6
-5
@@ -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>;
|
||||
|
||||
+41
-32
@@ -22,7 +22,7 @@ export interface KbenchMetricGroup {
|
||||
export interface KbenchResult {
|
||||
iops: KbenchMetricGroup;
|
||||
bandwidth: KbenchMetricGroup; // KiB/s
|
||||
latency: KbenchMetricGroup; // nanoseconds
|
||||
latency: KbenchMetricGroup; // nanoseconds
|
||||
metadata: KbenchResultMetadata;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,14 @@ export interface KbenchResultMetadata {
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export type BenchmarkStatus = 'idle' | 'creating-pvc' | 'waiting-pvc' | 'running' | 'parsing' | 'complete' | 'failed';
|
||||
export type BenchmarkStatus =
|
||||
| 'idle'
|
||||
| 'creating-pvc'
|
||||
| 'waiting-pvc'
|
||||
| 'running'
|
||||
| 'parsing'
|
||||
| 'complete'
|
||||
| 'failed';
|
||||
|
||||
export type BenchmarkState =
|
||||
| { status: 'idle' }
|
||||
@@ -90,8 +97,8 @@ export interface KbenchJobOptions {
|
||||
pvcName: string;
|
||||
namespace: string;
|
||||
storageClass: string;
|
||||
size?: string; // default "30G"
|
||||
mode?: string; // default "full"
|
||||
size?: string; // default "30G"
|
||||
mode?: string; // default "full"
|
||||
}
|
||||
|
||||
export function buildPvcManifest(opts: KbenchJobOptions): object {
|
||||
@@ -155,9 +162,7 @@ export function buildJobManifest(opts: KbenchJobOptions): object {
|
||||
{ name: 'SIZE', value: opts.size ?? '30G' },
|
||||
{ name: 'CPU_IDLE_PROF', value: 'disabled' },
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'vol', mountPath: '/volume/' },
|
||||
],
|
||||
volumeMounts: [{ name: 'vol', mountPath: '/volume/' }],
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
@@ -212,9 +217,9 @@ export async function getJobPhase(
|
||||
jobName: string,
|
||||
namespace: string
|
||||
): Promise<{ phase: JobPhase; job: K8sJob }> {
|
||||
const job = await ApiProxy.request(
|
||||
const job = (await ApiProxy.request(
|
||||
`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`
|
||||
) as K8sJob;
|
||||
)) as K8sJob;
|
||||
|
||||
const status = job.status;
|
||||
let phase: JobPhase = 'Unknown';
|
||||
@@ -225,13 +230,10 @@ export async function getJobPhase(
|
||||
return { phase, job };
|
||||
}
|
||||
|
||||
export async function getPvcPhase(
|
||||
pvcName: string,
|
||||
namespace: string
|
||||
): Promise<string> {
|
||||
const pvc = await ApiProxy.request(
|
||||
export async function getPvcPhase(pvcName: string, namespace: string): Promise<string> {
|
||||
const pvc = (await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`
|
||||
) as { status?: { phase?: string } };
|
||||
)) as { status?: { phase?: string } };
|
||||
return pvc.status?.phase ?? 'Unknown';
|
||||
}
|
||||
|
||||
@@ -239,24 +241,23 @@ export async function getPvcPhase(
|
||||
* Fetches the logs from the kbench pod (via the Job's pod selector).
|
||||
* Uses the pod label selector to find the pod.
|
||||
*/
|
||||
export async function fetchKbenchLogs(
|
||||
jobName: string,
|
||||
namespace: string
|
||||
): Promise<string> {
|
||||
export async function fetchKbenchLogs(jobName: string, namespace: string): Promise<string> {
|
||||
// Find pod with label kbench=fio and job-name=<jobName>
|
||||
const podList = await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(`job-name=${jobName}`)}`
|
||||
) as { items?: Array<{ metadata?: { name?: string } }> };
|
||||
const podList = (await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/pods?labelSelector=${encodeURIComponent(
|
||||
`job-name=${jobName}`
|
||||
)}`
|
||||
)) as { items?: Array<{ metadata?: { name?: string } }> };
|
||||
|
||||
const podName = podList.items?.[0]?.metadata?.name;
|
||||
if (!podName) {
|
||||
throw new Error(`No pod found for kbench job "${jobName}"`);
|
||||
}
|
||||
|
||||
const logs = await ApiProxy.request(
|
||||
const logs = (await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/pods/${podName}/log?container=kbench`,
|
||||
{ isJSON: false }
|
||||
) as unknown;
|
||||
)) as unknown;
|
||||
|
||||
if (typeof logs !== 'string') {
|
||||
throw new Error('Pod logs were not returned as text');
|
||||
@@ -268,16 +269,19 @@ export async function fetchKbenchLogs(
|
||||
export async function deleteJob(jobName: string, namespace: string): Promise<void> {
|
||||
await ApiProxy.request(`/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ propagationPolicy: 'Foreground' }),
|
||||
body: JSON.stringify({
|
||||
apiVersion: 'v1',
|
||||
kind: 'DeleteOptions',
|
||||
propagationPolicy: 'Foreground',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deletePvc(pvcName: string, namespace: string): Promise<void> {
|
||||
await ApiProxy.request(
|
||||
`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
await ApiProxy.request(`/api/v1/namespaces/${namespace}/persistentvolumeclaims/${pvcName}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -366,7 +370,7 @@ export function parseKbenchLog(logText: string): KbenchResult | null {
|
||||
bandwidth,
|
||||
latency,
|
||||
metadata: {
|
||||
storageClass: '', // filled in by the caller
|
||||
storageClass: '', // filled in by the caller
|
||||
size: '30G',
|
||||
startedAt: '',
|
||||
completedAt: new Date().toISOString(),
|
||||
@@ -388,9 +392,14 @@ export async function listKbenchJobs(namespace: string = ''): Promise<KbenchJobS
|
||||
? `/apis/batch/v1/namespaces/${namespace}/jobs?labelSelector=${selector}`
|
||||
: `/apis/batch/v1/jobs?labelSelector=${selector}`;
|
||||
|
||||
const list = await ApiProxy.request(path) as {
|
||||
const list = (await ApiProxy.request(path)) as {
|
||||
items?: Array<{
|
||||
metadata?: { name?: string; namespace?: string; annotations?: Record<string, string>; creationTimestamp?: string };
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
annotations?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
status?: K8sJobStatus;
|
||||
}>;
|
||||
};
|
||||
|
||||
+1
-4
@@ -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';
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* TrueNAS API client for the headlamp-tns-csi-plugin.
|
||||
*
|
||||
* Uses the TrueNAS WebSocket JSON-RPC 2.0 API to fetch pool-level capacity
|
||||
* information (pool.query). Requires a TrueNAS API key configured by the user
|
||||
* in plugin settings.
|
||||
*
|
||||
* The WebSocket connects directly from the browser to the TrueNAS server.
|
||||
* The server address comes from the StorageClass parameters (already in context).
|
||||
*
|
||||
* All operations are read-only (pool.query only).
|
||||
*/
|
||||
|
||||
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config store — persists across sessions via Headlamp Redux store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TnsCsiConfig {
|
||||
truenasApiKey: string;
|
||||
/** Override server address (defaults to StorageClass parameter 'server') */
|
||||
truenasServerOverride: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: TnsCsiConfig = {
|
||||
truenasApiKey: '',
|
||||
truenasServerOverride: '',
|
||||
};
|
||||
|
||||
const configStore = new ConfigStore<TnsCsiConfig>('headlamp-tns-csi-plugin');
|
||||
|
||||
export function getTnsCsiConfig(): TnsCsiConfig {
|
||||
return { ...DEFAULT_CONFIG, ...configStore.get() };
|
||||
}
|
||||
|
||||
export function setTnsCsiConfig(partial: Partial<TnsCsiConfig>): void {
|
||||
configStore.update(partial);
|
||||
}
|
||||
|
||||
export function useTnsCsiConfig(): () => TnsCsiConfig {
|
||||
return configStore.useConfig();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PoolStats {
|
||||
name: string;
|
||||
/** Total pool capacity in bytes */
|
||||
size: number;
|
||||
/** Allocated (used) bytes */
|
||||
allocated: number;
|
||||
/** Free bytes */
|
||||
free: number;
|
||||
/** Pool health status string */
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TrueNAS WebSocket JSON-RPC client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens a WebSocket to TrueNAS, authenticates with the API key, calls
|
||||
* pool.query, collects results, and closes the connection.
|
||||
*
|
||||
* @param server - TrueNAS host/IP (no protocol prefix)
|
||||
* @param apiKey - TrueNAS API key
|
||||
* @returns Array of pool stats
|
||||
*/
|
||||
export function fetchTruenasPoolStats(server: string, apiKey: string): Promise<PoolStats[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// TrueNAS WebSocket endpoint — supports both SCALE and CORE
|
||||
const url = `wss://${server}/api/current`;
|
||||
let ws: WebSocket;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws?.close();
|
||||
reject(new Error('TrueNAS connection timed out (10s)'));
|
||||
}, 10_000);
|
||||
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Failed to open WebSocket to ${server}: ${String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let msgId = 1;
|
||||
// State machine: connect → authenticate → query → done
|
||||
type Phase = 'connecting' | 'authenticating' | 'querying' | 'done';
|
||||
let phase: Phase = 'connecting';
|
||||
|
||||
ws.onopen = () => {
|
||||
phase = 'authenticating';
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'auth.login_with_api_key',
|
||||
params: [apiKey],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
let msg: Record<string, unknown>;
|
||||
try {
|
||||
msg = JSON.parse(event.data as string) as Record<string, unknown>;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'authenticating') {
|
||||
const result = msg['result'];
|
||||
if (result !== true) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(new Error('TrueNAS authentication failed — check your API key'));
|
||||
return;
|
||||
}
|
||||
phase = 'querying';
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'pool.query',
|
||||
params: [],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === 'querying') {
|
||||
const result = msg['result'];
|
||||
if (!Array.isArray(result)) {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
reject(new Error('pool.query returned unexpected result'));
|
||||
return;
|
||||
}
|
||||
phase = 'done';
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
|
||||
const pools: PoolStats[] = result.map((pool: unknown) => {
|
||||
const p = pool as Record<string, unknown>;
|
||||
return {
|
||||
name: String(p['name'] ?? ''),
|
||||
size: Number(p['size'] ?? 0),
|
||||
allocated: Number(p['allocated'] ?? 0),
|
||||
free: Number(p['free'] ?? 0),
|
||||
status: String(p['status'] ?? 'UNKNOWN'),
|
||||
};
|
||||
});
|
||||
resolve(pools);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
if (phase !== 'done') {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
if (phase !== 'done') {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket closed unexpectedly (code ${event.code}) while ${phase}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
ApiProxy: {
|
||||
request: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
ConfigStore: class {
|
||||
get() {
|
||||
return {};
|
||||
}
|
||||
set() {}
|
||||
update() {}
|
||||
useConfig() {
|
||||
return () => ({});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
vi.mock('../api/kbench', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../api/kbench')>();
|
||||
return {
|
||||
...actual,
|
||||
createPvc: vi.fn().mockResolvedValue(undefined),
|
||||
createJob: vi.fn().mockResolvedValue(undefined),
|
||||
deleteJob: vi.fn().mockResolvedValue(undefined),
|
||||
deletePvc: vi.fn().mockResolvedValue(undefined),
|
||||
getJobPhase: vi.fn().mockResolvedValue({ phase: 'Active', job: {} }),
|
||||
fetchKbenchLogs: vi.fn().mockResolvedValue(''),
|
||||
listKbenchJobs: vi.fn().mockResolvedValue([]),
|
||||
generateJobName: vi.fn().mockReturnValue('kbench-abc123'),
|
||||
generatePvcName: vi.fn().mockReturnValue('kbench-abc123-pvc'),
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
|
||||
import { createJob, createPvc, listKbenchJobs } from '../api/kbench';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSampleStorageClass } from '../test-helpers';
|
||||
import BenchmarkPage from './BenchmarkPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('BenchmarkPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listKbenchJobs).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('shows loader when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<BenchmarkPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
|
||||
});
|
||||
|
||||
it('renders benchmark guide section', () => {
|
||||
mockContext();
|
||||
render(<BenchmarkPage />);
|
||||
expect(screen.getByText('Benchmark Guide')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Do not cancel mid-run/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Run New Benchmark form', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<BenchmarkPage />);
|
||||
expect(screen.getByText('Run New Benchmark')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Select storage class for benchmark')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates SC dropdown with storage class names', () => {
|
||||
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
|
||||
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
|
||||
mockContext({ storageClasses: [sc1, sc2] });
|
||||
render(<BenchmarkPage />);
|
||||
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
|
||||
expect(select.options.length).toBe(2);
|
||||
expect(select.options[0].value).toBe('sc-a');
|
||||
expect(select.options[1].value).toBe('sc-b');
|
||||
});
|
||||
|
||||
it('shows "No tns-csi storage classes found" when empty', () => {
|
||||
mockContext({ storageClasses: [] });
|
||||
render(<BenchmarkPage />);
|
||||
const select = screen.getByLabelText('Select storage class for benchmark') as HTMLSelectElement;
|
||||
expect(select.options[0].text).toContain('No tns-csi storage classes');
|
||||
});
|
||||
|
||||
it('shows confirmation dialog when Run Benchmark is clicked', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<BenchmarkPage />);
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
|
||||
expect(screen.getByText(/~33Gi PVC/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels confirmation dialog', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<BenchmarkPage />);
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
fireEvent.click(screen.getByLabelText('Cancel benchmark'));
|
||||
expect(screen.queryByText('Confirm Benchmark')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts benchmark on confirmation and calls createPvc', async () => {
|
||||
vi.useFakeTimers();
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
|
||||
// PVC bind check
|
||||
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
|
||||
|
||||
render(<BenchmarkPage />);
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
|
||||
});
|
||||
|
||||
expect(vi.mocked(createPvc)).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows failed state when PVC creation fails', async () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
vi.mocked(createPvc).mockRejectedValueOnce(new Error('quota exceeded'));
|
||||
|
||||
render(<BenchmarkPage />);
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/quota exceeded/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders past benchmarks section', async () => {
|
||||
mockContext();
|
||||
vi.mocked(listKbenchJobs).mockResolvedValueOnce([]);
|
||||
render(<BenchmarkPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Past Benchmarks')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('No past benchmark jobs found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders past benchmark jobs in table', async () => {
|
||||
mockContext();
|
||||
vi.mocked(listKbenchJobs).mockResolvedValueOnce([
|
||||
{
|
||||
jobName: 'kbench-old',
|
||||
namespace: 'default',
|
||||
storageClass: 'tns-nfs',
|
||||
phase: 'Complete',
|
||||
startedAt: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
render(<BenchmarkPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('kbench-old')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Complete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Run Benchmark button when no storage classes', () => {
|
||||
mockContext({ storageClasses: [] });
|
||||
render(<BenchmarkPage />);
|
||||
const btn = screen.getByLabelText('Start kbench storage benchmark');
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows confirmation dialog with selected SC and namespace', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<BenchmarkPage />);
|
||||
|
||||
// Change namespace
|
||||
const nsInput = screen.getByLabelText(
|
||||
'Kubernetes namespace for benchmark job'
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(nsInput, { target: { value: 'bench-ns' } });
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
// Confirm dialog shows SC and namespace in <strong> tags
|
||||
expect(screen.getByText('Confirm Benchmark')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Confirm and start benchmark')).toBeInTheDocument();
|
||||
// Namespace is shown in the dialog
|
||||
const dialogText = screen.getByText(/bench-ns/);
|
||||
expect(dialogText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can change test size and mode', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<BenchmarkPage />);
|
||||
|
||||
const sizeInput = screen.getByLabelText('FIO test size') as HTMLInputElement;
|
||||
fireEvent.change(sizeInput, { target: { value: '10G' } });
|
||||
expect(sizeInput.value).toBe('10G');
|
||||
|
||||
const modeSelect = screen.getByLabelText('Benchmark mode') as HTMLSelectElement;
|
||||
fireEvent.change(modeSelect, { target: { value: 'quick' } });
|
||||
expect(modeSelect.value).toBe('quick');
|
||||
});
|
||||
|
||||
it('shows failed state when job creation fails', async () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
vi.mocked(createPvc).mockResolvedValueOnce(undefined);
|
||||
// PVC binds immediately
|
||||
vi.mocked(ApiProxy.request).mockResolvedValue({ status: { phase: 'Bound' } });
|
||||
vi.mocked(createJob).mockRejectedValueOnce(new Error('job already exists'));
|
||||
|
||||
render(<BenchmarkPage />);
|
||||
fireEvent.click(screen.getByLabelText('Start kbench storage benchmark'));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText('Confirm and start benchmark'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/job already exists/)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { formatAge } from '../api/k8s';
|
||||
import type { BenchmarkState, KbenchJobSummary, KbenchResult } from '../api/kbench';
|
||||
import {
|
||||
createJob,
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
listKbenchJobs,
|
||||
parseKbenchLog,
|
||||
} from '../api/kbench';
|
||||
import { formatAge } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result display components
|
||||
@@ -46,7 +46,15 @@ interface MetricRowData {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: MetricRowData[]; higherIsBetter: boolean }) {
|
||||
function ResultTable({
|
||||
title,
|
||||
rows,
|
||||
higherIsBetter,
|
||||
}: {
|
||||
title: string;
|
||||
rows: MetricRowData[];
|
||||
higherIsBetter: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SectionBox title={title}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
||||
@@ -55,14 +63,24 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
|
||||
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 600 }}>Metric</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Read</th>
|
||||
<th style={{ textAlign: 'right', padding: '8px 4px', fontWeight: 600 }}>Write</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 4px', fontWeight: 400, color: 'var(--mui-palette-text-secondary)' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 4px',
|
||||
fontWeight: 400,
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{higherIsBetter ? '↑ higher is better' : '↓ lower is better'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.label} style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}>
|
||||
<tr
|
||||
key={row.label}
|
||||
style={{ borderBottom: '1px solid var(--mui-palette-divider, #f0f0f0)' }}
|
||||
>
|
||||
<td style={{ padding: '8px 4px' }}>{row.label}</td>
|
||||
<td style={{ padding: '8px 4px', textAlign: 'right', fontFamily: 'monospace' }}>
|
||||
{row.formatter(row.read)}
|
||||
@@ -83,21 +101,69 @@ function ResultTable({ title, rows, higherIsBetter }: { title: string; rows: Met
|
||||
|
||||
function KbenchResultDisplay({ result }: { result: KbenchResult }) {
|
||||
const iopsRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.iops.randomRead, write: result.iops.randomWrite, formatter: formatIops },
|
||||
{ label: 'Sequential', read: result.iops.sequentialRead, write: result.iops.sequentialWrite, formatter: formatIops },
|
||||
{ label: 'CPU Idleness', read: result.iops.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '' },
|
||||
{
|
||||
label: 'Random',
|
||||
read: result.iops.randomRead,
|
||||
write: result.iops.randomWrite,
|
||||
formatter: formatIops,
|
||||
},
|
||||
{
|
||||
label: 'Sequential',
|
||||
read: result.iops.sequentialRead,
|
||||
write: result.iops.sequentialWrite,
|
||||
formatter: formatIops,
|
||||
},
|
||||
{
|
||||
label: 'CPU Idleness',
|
||||
read: result.iops.cpuIdleness,
|
||||
write: null,
|
||||
formatter: v => `${v}%`,
|
||||
note: result.iops.cpuIdleness < 40 ? '⚠ Low — may indicate CPU-bound results' : '',
|
||||
},
|
||||
];
|
||||
|
||||
const bwRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.bandwidth.randomRead, write: result.bandwidth.randomWrite, formatter: formatBandwidth },
|
||||
{ label: 'Sequential', read: result.bandwidth.sequentialRead, write: result.bandwidth.sequentialWrite, formatter: formatBandwidth },
|
||||
{ label: 'CPU Idleness', read: result.bandwidth.cpuIdleness, write: null, formatter: v => `${v}%` },
|
||||
{
|
||||
label: 'Random',
|
||||
read: result.bandwidth.randomRead,
|
||||
write: result.bandwidth.randomWrite,
|
||||
formatter: formatBandwidth,
|
||||
},
|
||||
{
|
||||
label: 'Sequential',
|
||||
read: result.bandwidth.sequentialRead,
|
||||
write: result.bandwidth.sequentialWrite,
|
||||
formatter: formatBandwidth,
|
||||
},
|
||||
{
|
||||
label: 'CPU Idleness',
|
||||
read: result.bandwidth.cpuIdleness,
|
||||
write: null,
|
||||
formatter: v => `${v}%`,
|
||||
},
|
||||
];
|
||||
|
||||
const latRows: MetricRowData[] = [
|
||||
{ label: 'Random', read: result.latency.randomRead, write: result.latency.randomWrite, formatter: formatLatency },
|
||||
{ label: 'Sequential', read: result.latency.sequentialRead, write: result.latency.sequentialWrite, formatter: formatLatency },
|
||||
{ label: 'CPU Idleness', read: result.latency.cpuIdleness, write: null, formatter: v => `${v}%`, note: result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '' },
|
||||
{
|
||||
label: 'Random',
|
||||
read: result.latency.randomRead,
|
||||
write: result.latency.randomWrite,
|
||||
formatter: formatLatency,
|
||||
},
|
||||
{
|
||||
label: 'Sequential',
|
||||
read: result.latency.sequentialRead,
|
||||
write: result.latency.sequentialWrite,
|
||||
formatter: formatLatency,
|
||||
},
|
||||
{
|
||||
label: 'CPU Idleness',
|
||||
read: result.latency.cpuIdleness,
|
||||
write: null,
|
||||
formatter: v => `${v}%`,
|
||||
note:
|
||||
result.latency.cpuIdleness < 40 ? '⚠ CPU-starved — latency results may be unreliable' : '',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -109,12 +175,17 @@ function KbenchResultDisplay({ result }: { result: KbenchResult }) {
|
||||
{ name: 'Test Size', value: result.metadata.size },
|
||||
{ name: 'Job', value: result.metadata.jobName || '—' },
|
||||
{ name: 'Namespace', value: result.metadata.namespace || '—' },
|
||||
{ name: 'Completed', value: result.metadata.completedAt ? new Date(result.metadata.completedAt).toLocaleString() : '—' },
|
||||
{
|
||||
name: 'Completed',
|
||||
value: result.metadata.completedAt
|
||||
? new Date(result.metadata.completedAt).toLocaleString()
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter={true} />
|
||||
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter={true} />
|
||||
<ResultTable title="IOPS (Read/Write)" rows={iopsRows} higherIsBetter />
|
||||
<ResultTable title="Bandwidth" rows={bwRows} higherIsBetter />
|
||||
<ResultTable title="Latency" rows={latRows} higherIsBetter={false} />
|
||||
</>
|
||||
);
|
||||
@@ -154,32 +225,66 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
|
||||
|
||||
return (
|
||||
<SectionBox title="Run New Benchmark">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: '12px 16px', alignItems: 'center', maxWidth: '600px' }}>
|
||||
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>Storage Class *</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '200px 1fr',
|
||||
gap: '12px 16px',
|
||||
alignItems: 'center',
|
||||
maxWidth: '600px',
|
||||
}}
|
||||
>
|
||||
<label htmlFor="kbench-sc" style={{ fontWeight: 500 }}>
|
||||
Storage Class *
|
||||
</label>
|
||||
<select
|
||||
id="kbench-sc"
|
||||
value={storageClass}
|
||||
onChange={e => setStorageClass(e.target.value)}
|
||||
disabled={disabled || storageClasses.length === 0}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
aria-label="Select storage class for benchmark"
|
||||
>
|
||||
{storageClasses.length === 0 && <option value="">No tns-csi storage classes found</option>}
|
||||
{storageClasses.map(sc => <option key={sc} value={sc}>{sc}</option>)}
|
||||
{storageClasses.length === 0 && (
|
||||
<option value="">No tns-csi storage classes found</option>
|
||||
)}
|
||||
{storageClasses.map(sc => (
|
||||
<option key={sc} value={sc}>
|
||||
{sc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>Namespace</label>
|
||||
<label htmlFor="kbench-ns" style={{ fontWeight: 500 }}>
|
||||
Namespace
|
||||
</label>
|
||||
<input
|
||||
id="kbench-ns"
|
||||
type="text"
|
||||
value={namespace}
|
||||
onChange={e => setNamespace(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
aria-label="Kubernetes namespace for benchmark job"
|
||||
/>
|
||||
|
||||
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>Test Size</label>
|
||||
<label htmlFor="kbench-size" style={{ fontWeight: 500 }}>
|
||||
Test Size
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
id="kbench-size"
|
||||
@@ -187,21 +292,44 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
|
||||
value={size}
|
||||
onChange={e => setSize(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', width: '120px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
fontSize: '14px',
|
||||
width: '120px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
aria-label="FIO test size"
|
||||
/>
|
||||
<span style={{ marginLeft: '8px', fontSize: '12px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
PVC will be ~10% larger (33Gi for 30G)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>Mode</label>
|
||||
<label htmlFor="kbench-mode" style={{ fontWeight: 500 }}>
|
||||
Mode
|
||||
</label>
|
||||
<select
|
||||
id="kbench-mode"
|
||||
value={mode}
|
||||
onChange={e => setMode(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ padding: '6px 8px', borderRadius: '4px', border: '1px solid var(--mui-palette-divider, #ccc)', fontSize: '14px', backgroundColor: 'var(--mui-palette-background-paper)', color: 'var(--mui-palette-text-primary)' }}
|
||||
style={{
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--mui-palette-divider, #ccc)',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
aria-label="Benchmark mode"
|
||||
>
|
||||
<option value="full">Full (~6 minutes)</option>
|
||||
@@ -216,7 +344,9 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
|
||||
aria-label="Start kbench storage benchmark"
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
backgroundColor: disabled ? 'var(--mui-palette-action-disabled, #ccc)' : 'var(--mui-palette-primary-main, #1976d2)',
|
||||
backgroundColor: disabled
|
||||
? 'var(--mui-palette-action-disabled, #ccc)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
@@ -231,35 +361,82 @@ function RunForm({ storageClasses, onRun, disabled }: RunFormProps) {
|
||||
|
||||
{showConfirm && (
|
||||
<div
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2000, backgroundColor: 'rgba(0,0,0,0.5)' }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="kbench-confirm-title"
|
||||
>
|
||||
<div style={{ backgroundColor: 'var(--mui-palette-background-paper, #fff)', borderRadius: '8px', padding: '24px', maxWidth: '480px', boxShadow: '0 4px 24px rgba(0,0,0,0.2)', color: 'var(--mui-palette-text-primary)' }}>
|
||||
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>Confirm Benchmark</h3>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
maxWidth: '480px',
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.2)',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
<h3 id="kbench-confirm-title" style={{ margin: '0 0 16px' }}>
|
||||
Confirm Benchmark
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
|
||||
This will create a <strong>~33Gi PVC</strong> and run an FIO benchmark (
|
||||
<strong>~6 minutes</strong>).
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px', fontSize: '14px' }}>
|
||||
Storage class: <strong>{storageClass}</strong> · Namespace: <strong>{namespace}</strong>
|
||||
Storage class: <strong>{storageClass}</strong> · Namespace:{' '}
|
||||
<strong>{namespace}</strong>
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
The Job and PVC will remain until manually deleted. You will be prompted to clean up after completion.
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 16px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
The Job and PVC will remain until manually deleted. You will be prompted to clean up
|
||||
after completion.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
aria-label="Cancel benchmark"
|
||||
style={{ padding: '8px 16px', border: '1px solid var(--mui-palette-divider)', borderRadius: '4px', background: 'transparent', cursor: 'pointer', fontSize: '14px', color: 'var(--mui-palette-text-primary)' }}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
border: '1px solid var(--mui-palette-divider)',
|
||||
borderRadius: '4px',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: 'var(--mui-palette-text-primary)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm and start benchmark"
|
||||
style={{ padding: '8px 16px', backgroundColor: 'var(--mui-palette-primary-main, #1976d2)', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontSize: '14px', fontWeight: 500 }}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Start Benchmark
|
||||
</button>
|
||||
@@ -305,9 +482,7 @@ function BenchmarkProgress({ state }: { state: BenchmarkState }) {
|
||||
{
|
||||
name: 'Status',
|
||||
value: (
|
||||
<StatusLabel status={statusColor[state.status]}>
|
||||
{labels[state.status]}
|
||||
</StatusLabel>
|
||||
<StatusLabel status={statusColor[state.status]}>{labels[state.status]}</StatusLabel>
|
||||
),
|
||||
},
|
||||
...('jobName' in state && state.jobName ? [{ name: 'Job', value: state.jobName }] : []),
|
||||
@@ -344,7 +519,9 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
|
||||
}
|
||||
}, [namespace]);
|
||||
|
||||
useEffect(() => { void loadJobs(); }, [loadJobs]);
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
async function handleDelete(job: KbenchJobSummary) {
|
||||
if (!window.confirm(`Delete job "${job.jobName}" and its PVC "${job.jobName}-pvc"?`)) return;
|
||||
@@ -372,7 +549,11 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (j: KbenchJobSummary) => (
|
||||
<StatusLabel status={j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'}>
|
||||
<StatusLabel
|
||||
status={
|
||||
j.phase === 'Complete' ? 'success' : j.phase === 'Failed' ? 'error' : 'warning'
|
||||
}
|
||||
>
|
||||
{j.phase}
|
||||
</StatusLabel>
|
||||
),
|
||||
@@ -385,7 +566,15 @@ function PastBenchmarks({ namespace }: PastBenchmarksProps) {
|
||||
onClick={() => void handleDelete(j)}
|
||||
disabled={deleting === j.jobName}
|
||||
aria-label={`Delete benchmark job ${j.jobName}`}
|
||||
style={{ padding: '4px 10px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '12px' }}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
border: '1px solid var(--mui-palette-error-main, #d32f2f)',
|
||||
color: 'var(--mui-palette-error-main, #d32f2f)',
|
||||
background: 'transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{deleting === j.jobName ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
@@ -412,6 +601,8 @@ export default function BenchmarkPage() {
|
||||
const [currentResult, setCurrentResult] = useState<KbenchResult | null>(null);
|
||||
const [lastNamespace, setLastNamespace] = useState('default');
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const [cleaningUp, setCleaningUp] = useState(false);
|
||||
|
||||
const scNames = storageClasses.map(sc => sc.metadata.name);
|
||||
|
||||
@@ -422,21 +613,39 @@ export default function BenchmarkPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runBenchmark(opts: { storageClass: string; namespace: string; size: string; mode: string }) {
|
||||
async function runBenchmark(opts: {
|
||||
storageClass: string;
|
||||
namespace: string;
|
||||
size: string;
|
||||
mode: string;
|
||||
}) {
|
||||
stopPolling();
|
||||
cancelledRef.current = false;
|
||||
setCurrentResult(null);
|
||||
setLastNamespace(opts.namespace);
|
||||
|
||||
const jobName = generateJobName();
|
||||
const pvcName = generatePvcName(jobName);
|
||||
const jobOpts = { jobName, pvcName, namespace: opts.namespace, storageClass: opts.storageClass, size: opts.size, mode: opts.mode };
|
||||
const jobOpts = {
|
||||
jobName,
|
||||
pvcName,
|
||||
namespace: opts.namespace,
|
||||
storageClass: opts.storageClass,
|
||||
size: opts.size,
|
||||
mode: opts.mode,
|
||||
};
|
||||
|
||||
// Step 1: Create PVC
|
||||
setBenchState({ status: 'creating-pvc' });
|
||||
try {
|
||||
await createPvc(jobOpts);
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: `Failed to create PVC: ${err instanceof Error ? err.message : String(err)}`,
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -444,15 +653,28 @@ export default function BenchmarkPage() {
|
||||
setBenchState({ status: 'waiting-pvc', pvcName });
|
||||
const pvcDeadline = Date.now() + MAX_PVC_WAIT_MS;
|
||||
let pvcBound = false;
|
||||
while (Date.now() < pvcDeadline) {
|
||||
while (Date.now() < pvcDeadline && !cancelledRef.current) {
|
||||
try {
|
||||
const pvc = await ApiProxy.request(`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`) as { status?: { phase?: string } };
|
||||
if (pvc.status?.phase === 'Bound') { pvcBound = true; break; }
|
||||
} catch { /* retry */ }
|
||||
const pvc = (await ApiProxy.request(
|
||||
`/api/v1/namespaces/${opts.namespace}/persistentvolumeclaims/${pvcName}`
|
||||
)) as { status?: { phase?: string } };
|
||||
if (pvc.status?.phase === 'Bound') {
|
||||
pvcBound = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
/* retry */
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
}
|
||||
if (cancelledRef.current) return;
|
||||
if (!pvcBound) {
|
||||
setBenchState({ status: 'failed', error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.', jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: 'PVC did not bind within 2 minutes. Check StorageClass and provisioner.',
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -460,7 +682,12 @@ export default function BenchmarkPage() {
|
||||
try {
|
||||
await createJob(jobOpts);
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: `Failed to create Job: ${err instanceof Error ? err.message : String(err)}`,
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -487,26 +714,55 @@ export default function BenchmarkPage() {
|
||||
setCurrentResult(result);
|
||||
setBenchState({ status: 'complete', result, jobName, pvcName });
|
||||
} else {
|
||||
setBenchState({ status: 'failed', error: 'Could not parse FIO output from pod logs.', jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: 'Could not parse FIO output from pod logs.',
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setBenchState({ status: 'failed', error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: `Log retrieval failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
}
|
||||
} else if (phase === 'Failed') {
|
||||
stopPolling();
|
||||
setBenchState({ status: 'failed', error: 'kbench Job failed. Check pod logs for details.', jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: 'kbench Job failed. Check pod logs for details.',
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
stopPolling();
|
||||
setBenchState({ status: 'failed', error: `Polling error: ${err instanceof Error ? err.message : String(err)}`, jobName, pvcName });
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: `Polling error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
jobName,
|
||||
pvcName,
|
||||
});
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Clean up polling on unmount
|
||||
useEffect(() => () => stopPolling(), []);
|
||||
// Clean up polling and cancel async loops on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelledRef.current = true;
|
||||
stopPolling();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const isRunning = benchState.status !== 'idle' && benchState.status !== 'complete' && benchState.status !== 'failed';
|
||||
const isRunning =
|
||||
benchState.status !== 'idle' &&
|
||||
benchState.status !== 'complete' &&
|
||||
benchState.status !== 'failed';
|
||||
|
||||
if (loading) return <Loader title="Loading tns-csi data..." />;
|
||||
|
||||
@@ -518,15 +774,35 @@ export default function BenchmarkPage() {
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Duration', value: 'Full benchmark takes ~6 minutes. Do not cancel mid-run.' },
|
||||
{ name: 'Test Size', value: 'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).' },
|
||||
{ name: 'Cache Warning', value: 'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.' },
|
||||
{ name: 'CPU Idleness', value: 'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.' },
|
||||
{ name: 'Interpretation', value: 'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.' },
|
||||
{
|
||||
name: 'Test Size',
|
||||
value:
|
||||
'SIZE must be at least 10% smaller than PVC capacity (default: 30G in 33Gi PVC).',
|
||||
},
|
||||
{
|
||||
name: 'Cache Warning',
|
||||
value:
|
||||
'For accurate results, SIZE should be at least 25× the read/write bandwidth to bypass cache.',
|
||||
},
|
||||
{
|
||||
name: 'CPU Idleness',
|
||||
value:
|
||||
'Latency benchmark CPU Idleness should be ≥40%. Lower values indicate CPU-starved results.',
|
||||
},
|
||||
{
|
||||
name: 'Interpretation',
|
||||
value:
|
||||
'Lower read latency than local storage is a red flag (likely caching). Better write than local is nearly impossible for distributed storage.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
<RunForm storageClasses={scNames} onRun={opts => void runBenchmark(opts)} disabled={isRunning} />
|
||||
<RunForm
|
||||
storageClasses={scNames}
|
||||
onRun={opts => void runBenchmark(opts)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<BenchmarkProgress state={benchState} />
|
||||
|
||||
@@ -535,30 +811,51 @@ export default function BenchmarkPage() {
|
||||
<KbenchResultDisplay result={currentResult} />
|
||||
<SectionBox title="Cleanup">
|
||||
<NameValueTable
|
||||
rows={[{
|
||||
name: 'Resources',
|
||||
value: (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const state = benchState;
|
||||
if (state.status !== 'complete') return;
|
||||
if (!window.confirm(`Delete job "${state.jobName}" and PVC "${state.pvcName}"?`)) return;
|
||||
try {
|
||||
await deleteJob(state.jobName, lastNamespace);
|
||||
await deletePvc(state.pvcName, lastNamespace);
|
||||
setBenchState({ status: 'idle' });
|
||||
setCurrentResult(null);
|
||||
} catch (err: unknown) {
|
||||
alert(`Cleanup error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}}
|
||||
aria-label="Delete benchmark job and PVC"
|
||||
style={{ padding: '6px 14px', border: '1px solid var(--mui-palette-error-main, #d32f2f)', color: 'var(--mui-palette-error-main, #d32f2f)', background: 'transparent', borderRadius: '4px', cursor: 'pointer', fontSize: '13px' }}
|
||||
>
|
||||
Delete Job + PVC
|
||||
</button>
|
||||
),
|
||||
}]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Resources',
|
||||
value: (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const state = benchState;
|
||||
if (state.status !== 'complete' || cleaningUp) return;
|
||||
setCleaningUp(true);
|
||||
try {
|
||||
await deleteJob(state.jobName, lastNamespace);
|
||||
await deletePvc(state.pvcName, lastNamespace);
|
||||
setBenchState({ status: 'idle' });
|
||||
setCurrentResult(null);
|
||||
} catch (err: unknown) {
|
||||
setBenchState({
|
||||
status: 'failed',
|
||||
error: `Cleanup error: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
jobName: state.jobName,
|
||||
pvcName: state.pvcName,
|
||||
});
|
||||
} finally {
|
||||
setCleaningUp(false);
|
||||
}
|
||||
}}
|
||||
disabled={cleaningUp}
|
||||
aria-label="Delete benchmark job and PVC"
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
border: '1px solid var(--mui-palette-error-main, #d32f2f)',
|
||||
color: 'var(--mui-palette-error-main, #d32f2f)',
|
||||
background: 'transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: cleaningUp ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
opacity: cleaningUp ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{cleaningUp ? 'Deleting...' : 'Delete Job + PVC'}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* DriverPodDetailSection — injected into Headlamp's Pod detail view.
|
||||
*
|
||||
* Shown only for tns-csi driver pods (identified by
|
||||
* app.kubernetes.io/name=tns-csi-driver label). Returns null for all other pods.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatAge, getPodRestarts, isPodReady, TnsCsiPod } from '../api/k8s';
|
||||
|
||||
interface DriverPodDetailSectionProps {
|
||||
resource: {
|
||||
kind?: string;
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec?: { nodeName?: string };
|
||||
status?: {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: Array<{
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
// KubeObject instance: raw JSON lives under jsonData;
|
||||
// metadata here only exposes what the class getter provides (labels, creationTimestamp).
|
||||
// The jsonData.metadata has the full shape.
|
||||
jsonData?: {
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec?: { nodeName?: string };
|
||||
status?: {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: Array<{
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function DriverPodDetailSection({ resource }: DriverPodDetailSectionProps) {
|
||||
// Extract from jsonData (KubeObject instance) or fall back to direct props.
|
||||
// jsonData.metadata has the full shape including name/namespace; resource.metadata
|
||||
// only exposes fields that the Headlamp class getter provides (labels, creationTimestamp).
|
||||
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as
|
||||
| {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
}
|
||||
| undefined;
|
||||
const spec = resource?.jsonData?.spec ?? resource?.spec;
|
||||
const status = resource?.jsonData?.status ?? resource?.status;
|
||||
const labels = meta?.labels ?? {};
|
||||
|
||||
// Guard: only tns-csi driver pods
|
||||
if (labels['app.kubernetes.io/name'] !== 'tns-csi-driver') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const component = labels['app.kubernetes.io/component'] ?? 'unknown';
|
||||
const roleLabel =
|
||||
component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
|
||||
|
||||
// Build a minimal pod shape that isPodReady / getPodRestarts can consume
|
||||
const podShape: TnsCsiPod = {
|
||||
metadata: {
|
||||
name: meta?.name ?? '',
|
||||
namespace: meta?.namespace,
|
||||
creationTimestamp: meta?.creationTimestamp,
|
||||
labels,
|
||||
},
|
||||
spec: { nodeName: spec?.nodeName },
|
||||
status: status as TnsCsiPod['status'],
|
||||
};
|
||||
|
||||
const ready = isPodReady(podShape);
|
||||
const restarts = getPodRestarts(podShape);
|
||||
const phase = status?.phase ?? '—';
|
||||
const nodeName = spec?.nodeName ?? '—';
|
||||
const age = formatAge(meta?.creationTimestamp);
|
||||
|
||||
// Container statuses
|
||||
const containerStatuses = status?.containerStatuses ?? [];
|
||||
const containerRows = containerStatuses.map(cs => {
|
||||
let stateText = 'Unknown';
|
||||
if (cs.state?.running) {
|
||||
stateText = `Running since ${
|
||||
cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'
|
||||
} ago`;
|
||||
} else if (cs.state?.waiting) {
|
||||
stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`;
|
||||
} else if (cs.state?.terminated) {
|
||||
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${
|
||||
cs.state.terminated.reason ?? ''
|
||||
}`;
|
||||
}
|
||||
return {
|
||||
name: cs.name,
|
||||
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'} — ${stateText} — ${
|
||||
cs.restartCount
|
||||
} restart(s)`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionBox title="TNS-CSI Driver Info">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Role', value: roleLabel },
|
||||
{ name: 'Phase', value: phase },
|
||||
{ name: 'Ready', value: ready ? 'Yes' : 'No' },
|
||||
{ name: 'Restarts', value: String(restarts) },
|
||||
{ name: 'Node', value: nodeName },
|
||||
{ name: 'Age', value: age },
|
||||
...containerRows,
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
import { makeSampleMetrics, makeSamplePod, sampleCSIDriver } from '../test-helpers';
|
||||
import DriverStatusCard from './DriverStatusCard';
|
||||
|
||||
describe('DriverStatusCard', () => {
|
||||
it('shows "Not detected" when no CSI driver is present', () => {
|
||||
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
|
||||
expect(screen.getByText('Not detected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Degraded" when no pods are present', () => {
|
||||
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
|
||||
expect(screen.getByText('Degraded')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Metrics unavailable" when no metrics provided', () => {
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Metrics unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Healthy" and "Connected" when all pods ready and WS connected', () => {
|
||||
const metrics = makeSampleMetrics({ websocketConnected: 1 });
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
|
||||
metrics={metrics}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Healthy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('tns.csi.io installed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Disconnected" when WS is disconnected', () => {
|
||||
const metrics = makeSampleMetrics({ websocketConnected: 0 });
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
|
||||
metrics={metrics}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Unknown" when websocketConnected is null', () => {
|
||||
const metrics = makeSampleMetrics({ websocketConnected: null });
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[makeSamplePod({ name: 'tns-csi-node-abc' })]}
|
||||
metrics={metrics}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CSI capabilities section when driver is present', () => {
|
||||
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[]} nodePods={[]} />);
|
||||
expect(screen.getByText('CSI Driver Capabilities')).toBeInTheDocument();
|
||||
expect(screen.getByText('false')).toBeInTheDocument(); // attachRequired
|
||||
expect(screen.getByText('true')).toBeInTheDocument(); // podInfoOnMount
|
||||
expect(screen.getByText('Persistent')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render CSI capabilities when no driver', () => {
|
||||
render(<DriverStatusCard csiDriver={null} controllerPods={[]} nodePods={[]} />);
|
||||
expect(screen.queryByText('CSI Driver Capabilities')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pod rows with image, restarts, and ready status', () => {
|
||||
const pod = makeSamplePod({
|
||||
metadata: { name: 'ctrl-pod-1', creationTimestamp: '2025-01-01T00:00:00Z' },
|
||||
status: {
|
||||
phase: 'Running',
|
||||
conditions: [{ type: 'Ready', status: 'True' }],
|
||||
containerStatuses: [
|
||||
{ name: 'tns-csi', ready: true, restartCount: 2, image: 'fenio/tns-csi:v0.6.0' },
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<DriverStatusCard csiDriver={sampleCSIDriver} controllerPods={[pod]} nodePods={[]} />);
|
||||
expect(screen.getByText('ctrl-pod-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('fenio/tns-csi:v0.6.0')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument(); // restarts
|
||||
expect(screen.getByText('Running')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No controller pod found" when controllerPods is empty', () => {
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[]}
|
||||
nodePods={[makeSamplePod({ name: 'node-1' })]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No controller pod found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No node pods found" when nodePods is empty', () => {
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('No node pods found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows WS reconnects when available in metrics', () => {
|
||||
const metrics = makeSampleMetrics({ websocketReconnectsTotal: 7 });
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[makeSamplePod({ name: 'node-1' })]}
|
||||
metrics={metrics}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('WS Reconnects')).toBeInTheDocument();
|
||||
expect(screen.getByText('7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows node pods count in section title', () => {
|
||||
const node1 = makeSamplePod({ name: 'tns-csi-node-1' });
|
||||
const node2 = makeSamplePod({ name: 'tns-csi-node-2' });
|
||||
render(
|
||||
<DriverStatusCard
|
||||
csiDriver={sampleCSIDriver}
|
||||
controllerPods={[makeSamplePod()]}
|
||||
nodePods={[node1, node2]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Node Pods (2)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
vi.mock('../api/metrics', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../api/metrics')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchControllerMetrics: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { fetchControllerMetrics } from '../api/metrics';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSampleMetrics, makeSamplePod } from '../test-helpers';
|
||||
import MetricsPage from './MetricsPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('MetricsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchControllerMetrics).mockReset();
|
||||
});
|
||||
|
||||
it('shows loader when context is loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<MetricsPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading tns-csi data...');
|
||||
});
|
||||
|
||||
it('shows "Driver Not Detected" when driver not installed', () => {
|
||||
mockContext({ driverInstalled: false });
|
||||
render(<MetricsPage />);
|
||||
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/TNS-CSI driver not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No controller pod found" when driver installed but no pods', () => {
|
||||
mockContext({ driverInstalled: true, controllerPods: [] });
|
||||
render(<MetricsPage />);
|
||||
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No controller pod found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows metrics error when fetch fails', async () => {
|
||||
const pod = makeSamplePod();
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('connection refused'));
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders three metric cards when fetch succeeds', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics();
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Volume Operations')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSI Operations')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays correct WebSocket metric data', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics({
|
||||
websocketConnected: 1,
|
||||
websocketReconnectsTotal: 42,
|
||||
websocketMessagesTotal: [{ labels: {}, value: 250 }],
|
||||
// Zero out other metrics to avoid number collisions
|
||||
volumeOperationsTotal: [],
|
||||
volumeCapacityBytes: [],
|
||||
csiOperationsTotal: [],
|
||||
});
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Connected')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('42')).toBeInTheDocument(); // reconnects
|
||||
expect(screen.getByText('250')).toBeInTheDocument(); // messages
|
||||
});
|
||||
|
||||
it('displays CSI operations broken down by method', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics({
|
||||
csiOperationsTotal: [
|
||||
{ labels: { method: 'CreateVolume' }, value: 77 },
|
||||
{ labels: { method: 'DeleteVolume' }, value: 13 },
|
||||
],
|
||||
// Zero out other metrics to avoid number collisions
|
||||
volumeOperationsTotal: [],
|
||||
volumeCapacityBytes: [],
|
||||
websocketMessagesTotal: [],
|
||||
});
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('CreateVolume')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('77')).toBeInTheDocument();
|
||||
expect(screen.getByText('DeleteVolume')).toBeInTheDocument();
|
||||
expect(screen.getByText('13')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refresh button triggers refetch', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics();
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValue(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('WebSocket Health')).toBeInTheDocument();
|
||||
});
|
||||
const initialCallCount = vi.mocked(fetchControllerMetrics).mock.calls.length;
|
||||
fireEvent.click(screen.getByLabelText('Refresh metrics'));
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(fetchControllerMetrics).mock.calls.length).toBeGreaterThan(initialCallCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows "Updated" timestamp after successful fetch', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics();
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Updated:/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows volume operations grouped by protocol', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const metrics = makeSampleMetrics({
|
||||
volumeOperationsTotal: [
|
||||
{ labels: { protocol: 'nfs' }, value: 15 },
|
||||
{ labels: { protocol: 'iscsi' }, value: 8 },
|
||||
],
|
||||
});
|
||||
mockContext({ driverInstalled: true, controllerPods: [pod] });
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<MetricsPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Operations (nfs)')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Operations (iscsi)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { TnsCsiMetrics } from '../api/metrics';
|
||||
import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
function formatAuditTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -40,7 +40,9 @@ function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
|
||||
{
|
||||
name: 'Connection Status',
|
||||
value: (
|
||||
<StatusLabel status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}>
|
||||
<StatusLabel
|
||||
status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}
|
||||
>
|
||||
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
@@ -137,7 +139,14 @@ export default function MetricsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title="TNS-CSI — Metrics" />
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
{lastUpdated && (
|
||||
@@ -169,7 +178,14 @@ export default function MetricsPage() {
|
||||
{!driverInstalled && (
|
||||
<SectionBox title="Driver Not Detected">
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel> }]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: (
|
||||
<StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
@@ -178,11 +194,18 @@ export default function MetricsPage() {
|
||||
<SectionBox title="Metrics Unavailable">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Status', value: <StatusLabel status="warning">No controller pod found</StatusLabel> },
|
||||
{ name: 'Note', value: 'Ensure controller pod is running with metrics enabled on port 8080.' },
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="warning">No controller pod found</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Note',
|
||||
value: 'Ensure controller pod is running with metrics enabled on port 8080.',
|
||||
},
|
||||
{
|
||||
name: 'Troubleshooting',
|
||||
value: 'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
|
||||
value:
|
||||
'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -194,7 +217,11 @@ export default function MetricsPage() {
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
|
||||
{ name: 'Note', value: 'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.' },
|
||||
{
|
||||
name: 'Note',
|
||||
value:
|
||||
'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
vi.mock('../api/metrics', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../api/metrics')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchControllerMetrics: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { fetchControllerMetrics } from '../api/metrics';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import {
|
||||
defaultContext,
|
||||
makeSampleMetrics,
|
||||
makeSamplePod,
|
||||
makeSamplePV,
|
||||
makeSamplePVC,
|
||||
makeSampleStorageClass,
|
||||
sampleCSIDriver,
|
||||
} from '../test-helpers';
|
||||
import OverviewPage from './OverviewPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('OverviewPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchControllerMetrics).mockReset();
|
||||
});
|
||||
|
||||
it('shows loader when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading TNS-CSI data...');
|
||||
});
|
||||
|
||||
it('shows "Driver Not Detected" when driver not installed', () => {
|
||||
mockContext({ driverInstalled: false });
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Driver Not Detected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/CSIDriver tns.csi.io not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error section when error is present', () => {
|
||||
mockContext({ error: 'cluster unavailable' });
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('cluster unavailable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('always shows the development status notice', () => {
|
||||
mockContext({ driverInstalled: true });
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText(/active early development/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders storage summary with SC/PV counts', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
const pv = makeSamplePV();
|
||||
const pvc = makeSamplePVC();
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [sc],
|
||||
persistentVolumes: [pv],
|
||||
persistentVolumeClaims: [pvc],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Storage Summary')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage Classes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Persistent Volumes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders capacity aggregation from PVs', () => {
|
||||
const pv1 = makeSamplePV({
|
||||
metadata: { name: 'pv-1' },
|
||||
spec: { ...makeSamplePV().spec, capacity: { storage: '100Gi' } },
|
||||
});
|
||||
const pv2 = makeSamplePV({
|
||||
metadata: { name: 'pv-2' },
|
||||
spec: { ...makeSamplePV().spec, capacity: { storage: '50Gi' } },
|
||||
});
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [makeSampleStorageClass()],
|
||||
persistentVolumes: [pv1, pv2],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
// 150 GiB total
|
||||
expect(screen.getByText('150.0 GiB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders protocol distribution bar', () => {
|
||||
const sc1 = makeSampleStorageClass({ parameters: { protocol: 'nfs' } });
|
||||
const sc2 = makeSampleStorageClass({
|
||||
metadata: { name: 'tns-nvmeof' },
|
||||
parameters: { protocol: 'nvmeof' },
|
||||
});
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [sc1, sc2],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Protocol Distribution')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('percentage-bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders pool capacity table when poolStats are present', () => {
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
poolStats: [{ name: 'tank', status: 'ONLINE', size: 1e12, allocated: 5e11, free: 5e11 }],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Pool Capacity')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank')).toBeInTheDocument();
|
||||
expect(screen.getByText('ONLINE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows pool stats error hint', () => {
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
poolStatsError: 'API key invalid',
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Pool Capacity Unavailable')).toBeInTheDocument();
|
||||
expect(screen.getByText('API key invalid')).toBeInTheDocument();
|
||||
expect(screen.getByText(/TrueNAS API key/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Prometheus fallback capacity by pool when no poolStats and metrics available', async () => {
|
||||
const pod = makeSamplePod();
|
||||
const pv = makeSamplePV();
|
||||
const metrics = makeSampleMetrics({
|
||||
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
|
||||
});
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [makeSampleStorageClass()],
|
||||
persistentVolumes: [pv],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [pod],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
poolStats: [],
|
||||
poolStatsError: null,
|
||||
});
|
||||
vi.mocked(fetchControllerMetrics).mockResolvedValueOnce(metrics);
|
||||
render(<OverviewPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Provisioned Capacity by Pool')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders non-bound PVCs table', () => {
|
||||
const pendingPvc = makeSamplePVC({
|
||||
metadata: {
|
||||
name: 'pending-pvc',
|
||||
namespace: 'test',
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
status: { phase: 'Pending' },
|
||||
});
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [pendingPvc],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('Attention: Non-Bound PVCs')).toBeInTheDocument();
|
||||
expect(screen.getByText('pending-pvc')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pending')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show non-bound PVCs section when all PVCs are bound', () => {
|
||||
const pvc = makeSamplePVC({ status: { phase: 'Bound' } });
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [pvc],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.queryByText('Attention: Non-Bound PVCs')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refresh button calls context.refresh()', () => {
|
||||
const refreshFn = vi.fn();
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [],
|
||||
nodePods: [],
|
||||
refresh: refreshFn,
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
fireEvent.click(screen.getByLabelText('Refresh tns-csi data'));
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows metrics unavailable when fetchControllerMetrics fails', async () => {
|
||||
const pod = makeSamplePod();
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [pod],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
vi.mocked(fetchControllerMetrics).mockRejectedValueOnce(new Error('timeout'));
|
||||
render(<OverviewPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Metrics Unavailable')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('timeout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PVC status breakdown with Pending and Lost counts', () => {
|
||||
const boundPvc = makeSamplePVC({
|
||||
metadata: { name: 'pvc-1', namespace: 'ns' },
|
||||
status: { phase: 'Bound' },
|
||||
});
|
||||
const pendingPvc = makeSamplePVC({
|
||||
metadata: { name: 'pvc-2', namespace: 'ns' },
|
||||
status: { phase: 'Pending' },
|
||||
});
|
||||
const lostPvc = makeSamplePVC({
|
||||
metadata: { name: 'pvc-3', namespace: 'ns' },
|
||||
status: { phase: 'Lost' },
|
||||
});
|
||||
mockContext({
|
||||
driverInstalled: true,
|
||||
csiDriver: sampleCSIDriver,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [boundPvc, pendingPvc, lostPvc],
|
||||
controllerPods: [makeSamplePod()],
|
||||
nodePods: [makeSamplePod({ name: 'node-1' })],
|
||||
});
|
||||
render(<OverviewPage />);
|
||||
expect(screen.getByText('PVCs (Pending)')).toBeInTheDocument();
|
||||
expect(screen.getByText('PVCs (Lost)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+135
-31
@@ -14,11 +14,11 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||
import type { TnsCsiMetrics } from '../api/metrics';
|
||||
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
|
||||
import { fetchControllerMetrics } from '../api/metrics';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import DriverStatusCard from './DriverStatusCard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,10 +26,10 @@ import DriverStatusCard from './DriverStatusCard';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
NFS: '#1976d2',
|
||||
'NVMe-oF': '#9c27b0',
|
||||
iSCSI: '#f57c00',
|
||||
Other: '#9e9e9e',
|
||||
NFS: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
'NVMe-oF': 'var(--mui-palette-secondary-main, #9c27b0)',
|
||||
iSCSI: 'var(--mui-palette-warning-main, #f57c00)',
|
||||
Other: 'var(--mui-palette-action-disabled, #9e9e9e)',
|
||||
};
|
||||
|
||||
function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) {
|
||||
@@ -58,6 +58,8 @@ export default function OverviewPage() {
|
||||
persistentVolumeClaims,
|
||||
controllerPods,
|
||||
nodePods,
|
||||
poolStats,
|
||||
poolStatsError,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
@@ -83,6 +85,24 @@ export default function OverviewPage() {
|
||||
void fetchMetrics();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const capacityByPool: Map<string, number> = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!metrics) return map;
|
||||
const handleToPool = new Map<string, string>();
|
||||
for (const pv of persistentVolumes) {
|
||||
const handle = pv.spec.csi?.volumeHandle;
|
||||
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
|
||||
if (handle && pool) handleToPool.set(handle, pool);
|
||||
}
|
||||
for (const sample of metrics.volumeCapacityBytes) {
|
||||
const volumeId = sample.labels['volume_id'];
|
||||
if (!volumeId) continue;
|
||||
const pool = handleToPool.get(volumeId) ?? 'unknown';
|
||||
map.set(pool, (map.get(pool) ?? 0) + sample.value);
|
||||
}
|
||||
return map;
|
||||
}, [metrics, persistentVolumes]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading TNS-CSI data..." />;
|
||||
}
|
||||
@@ -102,16 +122,21 @@ export default function OverviewPage() {
|
||||
else pvcStatusCounts.Other++;
|
||||
}
|
||||
|
||||
const nonBoundPvcs = persistentVolumeClaims.filter(
|
||||
pvc => pvc.status?.phase !== 'Bound'
|
||||
);
|
||||
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
|
||||
|
||||
const chartData = protocolChartData(storageClasses);
|
||||
const totalScs = storageClasses.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title="TNS-CSI — Overview" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
@@ -154,11 +179,16 @@ export default function OverviewPage() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>,
|
||||
value: (
|
||||
<StatusLabel status="error">
|
||||
CSIDriver tns.csi.io not found on this cluster
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Install',
|
||||
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
|
||||
value:
|
||||
'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -203,7 +233,13 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Storage Summary">
|
||||
{totalScs > 0 && chartData.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Protocol Distribution
|
||||
</div>
|
||||
<PercentageBar data={chartData} total={totalScs} />
|
||||
@@ -219,37 +255,100 @@ export default function OverviewPage() {
|
||||
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
|
||||
},
|
||||
...(pvcStatusCounts.Pending > 0
|
||||
? [{
|
||||
name: 'PVCs (Pending)',
|
||||
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
|
||||
}]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Pending)',
|
||||
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pvcStatusCounts.Lost > 0
|
||||
? [{
|
||||
name: 'PVCs (Lost)',
|
||||
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
|
||||
}]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Lost)',
|
||||
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{/* Pool capacity — real data from TrueNAS API when configured */}
|
||||
{poolStats.length > 0 && (
|
||||
<SectionBox title="Pool Capacity">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Pool', getter: p => p.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: p => (
|
||||
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
|
||||
{p.status}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Total', getter: p => formatBytes(p.size) },
|
||||
{ label: 'Used', getter: p => formatBytes(p.allocated) },
|
||||
{ label: 'Free', getter: p => formatBytes(p.free) },
|
||||
{
|
||||
label: 'Used %',
|
||||
getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
|
||||
},
|
||||
]}
|
||||
data={poolStats}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{poolStatsError && (
|
||||
<SectionBox title="Pool Capacity Unavailable">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Error',
|
||||
value: <StatusLabel status="warning">{poolStatsError}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Note',
|
||||
value: 'Check your TrueNAS API key and server address in plugin settings.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Provisioned capacity by pool (from Prometheus metrics —
|
||||
shown when TrueNAS API not configured) */}
|
||||
{poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && (
|
||||
<SectionBox title="Provisioned Capacity by Pool">
|
||||
<NameValueTable
|
||||
rows={[...capacityByPool.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([pool, bytes]) => ({
|
||||
name: pool,
|
||||
value: formatBytes(bytes),
|
||||
}))}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Non-bound PVCs warning */}
|
||||
{nonBoundPvcs.length > 0 && (
|
||||
<SectionBox title="Attention: Non-Bound PVCs">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
|
||||
{ label: 'Name', getter: pvc => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (pvc) => (
|
||||
getter: pvc => (
|
||||
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
|
||||
{pvc.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
|
||||
{ label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={nonBoundPvcs}
|
||||
/>
|
||||
@@ -270,11 +369,16 @@ function parseStorageToBytes(storage: string): number {
|
||||
const suffix = match[2] ?? '';
|
||||
const multipliers: Record<string, number> = {
|
||||
'': 1,
|
||||
K: 1e3, Ki: 1024,
|
||||
M: 1e6, Mi: 1024 ** 2,
|
||||
G: 1e9, Gi: 1024 ** 3,
|
||||
T: 1e12, Ti: 1024 ** 4,
|
||||
P: 1e15, Pi: 1024 ** 5,
|
||||
K: 1e3,
|
||||
Ki: 1024,
|
||||
M: 1e6,
|
||||
Mi: 1024 ** 2,
|
||||
G: 1e9,
|
||||
Gi: 1024 ** 3,
|
||||
T: 1e12,
|
||||
Ti: 1024 ** 4,
|
||||
P: 1e15,
|
||||
Pi: 1024 ** 5,
|
||||
};
|
||||
return value * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSamplePV, makeSamplePVC } from '../test-helpers';
|
||||
import PVCDetailSection from './PVCDetailSection';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('PVCDetailSection', () => {
|
||||
it('returns null when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
const { container } = render(
|
||||
<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('returns null when PVC is not in filtered list', () => {
|
||||
mockContext({ persistentVolumeClaims: [] });
|
||||
const { container } = render(
|
||||
<PVCDetailSection resource={{ metadata: { name: 'other-pvc', namespace: 'default' } }} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('returns null when PVC has no bound PV', () => {
|
||||
const pvc = makeSamplePVC({ metadata: { name: 'orphan-pvc', namespace: 'default' } });
|
||||
mockContext({
|
||||
persistentVolumeClaims: [pvc],
|
||||
persistentVolumes: [], // no PVs to match
|
||||
});
|
||||
const { container } = render(
|
||||
<PVCDetailSection resource={{ metadata: { name: 'orphan-pvc', namespace: 'default' } }} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders storage details when PVC and PV are found', () => {
|
||||
const pvc = makeSamplePVC();
|
||||
const pv = makeSamplePV();
|
||||
mockContext({
|
||||
persistentVolumeClaims: [pvc],
|
||||
persistentVolumes: [pv],
|
||||
});
|
||||
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
|
||||
expect(screen.getByText('TNS-CSI Storage Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('NFS')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom volume attributes (excluding protocol and server)', () => {
|
||||
const pv = makeSamplePV({
|
||||
spec: {
|
||||
...makeSamplePV().spec,
|
||||
csi: {
|
||||
driver: 'tns.csi.io',
|
||||
volumeHandle: 'tank/vol-001',
|
||||
volumeAttributes: {
|
||||
protocol: 'nfs',
|
||||
server: '10.0.0.1',
|
||||
pool: 'tank',
|
||||
customAttr: 'customValue',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const pvc = makeSamplePVC();
|
||||
mockContext({
|
||||
persistentVolumeClaims: [pvc],
|
||||
persistentVolumes: [pv],
|
||||
});
|
||||
render(<PVCDetailSection resource={{ metadata: { name: 'my-pvc', namespace: 'default' } }} />);
|
||||
expect(screen.getByText('pool')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank')).toBeInTheDocument();
|
||||
expect(screen.getByText('customAttr')).toBeInTheDocument();
|
||||
expect(screen.getByText('customValue')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,10 @@
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { findBoundPv, formatProtocol } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
interface PVCDetailSectionProps {
|
||||
resource: {
|
||||
@@ -52,10 +49,9 @@ export default function PVCDetailSection({ resource }: PVCDetailSectionProps) {
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
{ name: 'Storage Class', value: boundPv.spec.storageClassName ?? '—' },
|
||||
{ name: 'Volume Handle', value: boundPv.spec.csi?.volumeHandle ?? '—' },
|
||||
...(Object.entries(attrs)
|
||||
...Object.entries(attrs)
|
||||
.filter(([k]) => !['protocol', 'server'].includes(k))
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
|
||||
),
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
{
|
||||
name: 'PV Name',
|
||||
value: boundPv.metadata.name,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* PVDetailSection — injected into Headlamp's PersistentVolume detail view.
|
||||
*
|
||||
* Shown only when the PV uses tns.csi.io as the CSI driver.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
|
||||
|
||||
interface PVDetailSectionProps {
|
||||
resource: {
|
||||
kind?: string;
|
||||
metadata?: { name?: string; namespace?: string };
|
||||
spec?: {
|
||||
csi?: {
|
||||
driver?: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: Record<string, string>;
|
||||
};
|
||||
storageClassName?: string;
|
||||
capacity?: { storage?: string };
|
||||
persistentVolumeReclaimPolicy?: string;
|
||||
};
|
||||
// KubeObject instance — raw JSON lives under jsonData
|
||||
jsonData?: {
|
||||
spec?: {
|
||||
csi?: {
|
||||
driver?: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: Record<string, string>;
|
||||
};
|
||||
storageClassName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function PVDetailSection({ resource }: PVDetailSectionProps) {
|
||||
// Extract from jsonData (KubeObject instance) or fall back to direct properties
|
||||
const spec = resource?.jsonData?.spec ?? resource?.spec;
|
||||
const csi = spec?.csi;
|
||||
|
||||
if (!csi || csi.driver !== TNS_CSI_PROVISIONER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attrs = csi.volumeAttributes ?? {};
|
||||
const protocol = formatProtocol(attrs['protocol']);
|
||||
const otherAttrs = Object.entries(attrs).filter(
|
||||
([k]) => !['protocol', 'server', 'pool'].includes(k)
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionBox title="TNS-CSI Storage Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: TNS_CSI_PROVISIONER },
|
||||
{ name: 'Protocol', value: protocol },
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||
{ name: 'Volume Handle', value: csi.volumeHandle ?? '—' },
|
||||
{ name: 'Storage Class', value: spec?.storageClassName ?? '—' },
|
||||
...otherAttrs.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSampleSnapshot, makeSampleSnapshotClass } from '../test-helpers';
|
||||
import SnapshotsPage from './SnapshotsPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('SnapshotsPage', () => {
|
||||
it('shows loader when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading snapshots...');
|
||||
});
|
||||
|
||||
it('shows error state', () => {
|
||||
mockContext({ error: 'something broke' });
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('something broke')).toBeInTheDocument();
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows notice when snapshot CRD is not available', () => {
|
||||
mockContext({ snapshotCrdAvailable: false });
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('Volume Snapshot CRDs Not Installed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/VolumeSnapshot CRDs.*not found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty message when snapshots list is empty', () => {
|
||||
mockContext({ snapshotCrdAvailable: true, volumeSnapshots: [] });
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('No tns-csi VolumeSnapshots found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders snapshot classes when available', () => {
|
||||
const vsc = makeSampleSnapshotClass();
|
||||
mockContext({
|
||||
snapshotCrdAvailable: true,
|
||||
volumeSnapshotClasses: [vsc],
|
||||
volumeSnapshots: [],
|
||||
});
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('Snapshot Classes (1)')).toBeInTheDocument();
|
||||
expect(screen.getByText('tns-snap-class')).toBeInTheDocument();
|
||||
expect(screen.getByText('tns.csi.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders populated snapshots with readyToUse=true', () => {
|
||||
const snap = makeSampleSnapshot();
|
||||
mockContext({
|
||||
snapshotCrdAvailable: true,
|
||||
volumeSnapshots: [snap],
|
||||
});
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('snap-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('my-pvc')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.getByText('100Gi')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders snapshot with readyToUse=false', () => {
|
||||
const snap = makeSampleSnapshot({
|
||||
status: { readyToUse: false, restoreSize: '50Gi' },
|
||||
});
|
||||
mockContext({
|
||||
snapshotCrdAvailable: true,
|
||||
volumeSnapshots: [snap],
|
||||
});
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders snapshot with readyToUse=undefined as Unknown', () => {
|
||||
const snap = makeSampleSnapshot({
|
||||
status: { readyToUse: undefined },
|
||||
});
|
||||
mockContext({
|
||||
snapshotCrdAvailable: true,
|
||||
volumeSnapshots: [snap],
|
||||
});
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render snapshot classes section when empty', () => {
|
||||
mockContext({
|
||||
snapshotCrdAvailable: true,
|
||||
volumeSnapshotClasses: [],
|
||||
volumeSnapshots: [makeSampleSnapshot()],
|
||||
});
|
||||
render(<SnapshotsPage />);
|
||||
expect(screen.queryByText(/Snapshot Classes/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { VolumeSnapshot } from '../api/k8s';
|
||||
import { formatAge } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
export default function SnapshotsPage() {
|
||||
const { volumeSnapshots, volumeSnapshotClasses, snapshotCrdAvailable, loading, error } =
|
||||
@@ -27,7 +27,9 @@ export default function SnapshotsPage() {
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Snapshots" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
@@ -51,7 +53,11 @@ export default function SnapshotsPage() {
|
||||
{
|
||||
name: 'Documentation',
|
||||
value: (
|
||||
<a href="https://github.com/fenio/tns-csi" target="_blank" rel="noopener noreferrer">
|
||||
<a
|
||||
href="https://github.com/fenio/tns-csi"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
See tns-csi documentation for snapshot setup instructions
|
||||
</a>
|
||||
),
|
||||
@@ -71,10 +77,10 @@ export default function SnapshotsPage() {
|
||||
<SectionBox title={`Snapshot Classes (${volumeSnapshotClasses.length})`}>
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (vsc) => vsc.metadata.name },
|
||||
{ label: 'Driver', getter: (vsc) => vsc.driver ?? '—' },
|
||||
{ label: 'Deletion Policy', getter: (vsc) => vsc.deletionPolicy ?? '—' },
|
||||
{ label: 'Age', getter: (vsc) => formatAge(vsc.metadata.creationTimestamp) },
|
||||
{ label: 'Name', getter: vsc => vsc.metadata.name },
|
||||
{ label: 'Driver', getter: vsc => vsc.driver ?? '—' },
|
||||
{ label: 'Deletion Policy', getter: vsc => vsc.deletionPolicy ?? '—' },
|
||||
{ label: 'Age', getter: vsc => formatAge(vsc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={volumeSnapshotClasses}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
let mockHash = '';
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useLocation: () => ({ pathname: '/tns-csi/storage-classes', hash: mockHash }),
|
||||
useHistory: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSamplePV, makeSampleStorageClass } from '../test-helpers';
|
||||
import StorageClassesPage from './StorageClassesPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('StorageClassesPage', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockHash = '';
|
||||
});
|
||||
|
||||
it('shows loader when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading storage classes...');
|
||||
});
|
||||
|
||||
it('shows error state', () => {
|
||||
mockContext({ error: 'fetch failed' });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText('fetch failed')).toBeInTheDocument();
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty message when no storage classes', () => {
|
||||
mockContext({ storageClasses: [] });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText('No tns-csi StorageClasses found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table with all columns populated', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
const pv = makeSamplePV();
|
||||
mockContext({
|
||||
storageClasses: [sc],
|
||||
persistentVolumes: [pv],
|
||||
});
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText('tns-nfs')).toBeInTheDocument();
|
||||
expect(screen.getByText('NFS')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank')).toBeInTheDocument();
|
||||
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument(); // expansion
|
||||
expect(screen.getByText('1')).toBeInTheDocument(); // PV count
|
||||
});
|
||||
|
||||
it('opens detail panel when clicking SC name', () => {
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc] });
|
||||
render(<StorageClassesPage />);
|
||||
fireEvent.click(screen.getByText('tns-nfs'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes#tns-nfs');
|
||||
});
|
||||
|
||||
it('renders detail panel when hash is set', () => {
|
||||
mockHash = '#tns-nfs';
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText('StorageClass Details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes panel via close button', () => {
|
||||
mockHash = '#tns-nfs';
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
fireEvent.click(screen.getByLabelText('Close panel'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
|
||||
});
|
||||
|
||||
it('closes panel via backdrop click', () => {
|
||||
mockHash = '#tns-nfs';
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
|
||||
});
|
||||
|
||||
it('closes panel on Escape key', () => {
|
||||
mockHash = '#tns-nfs';
|
||||
const sc = makeSampleStorageClass();
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/storage-classes');
|
||||
});
|
||||
|
||||
it('shows NFS protocol notes in detail panel', () => {
|
||||
mockHash = '#tns-nfs';
|
||||
const sc = makeSampleStorageClass({
|
||||
parameters: { protocol: 'nfs', pool: 'tank', server: '10.0.0.1' },
|
||||
});
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText('Protocol Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText(/nfs-common/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows NVMe-oF protocol notes', () => {
|
||||
mockHash = '#tns-nvmeof';
|
||||
const sc = makeSampleStorageClass({
|
||||
metadata: { name: 'tns-nvmeof' },
|
||||
parameters: { protocol: 'nvmeof', pool: 'tank', server: '10.0.0.1' },
|
||||
});
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText(/nvme-cli/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows iSCSI protocol notes', () => {
|
||||
mockHash = '#tns-iscsi';
|
||||
const sc = makeSampleStorageClass({
|
||||
metadata: { name: 'tns-iscsi' },
|
||||
parameters: { protocol: 'iscsi', pool: 'tank', server: '10.0.0.1' },
|
||||
});
|
||||
mockContext({ storageClasses: [sc], persistentVolumes: [] });
|
||||
render(<StorageClassesPage />);
|
||||
expect(screen.getByText(/open-iscsi/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows PV count for each storage class', () => {
|
||||
const sc1 = makeSampleStorageClass({ metadata: { name: 'sc-a' } });
|
||||
const sc2 = makeSampleStorageClass({ metadata: { name: 'sc-b' } });
|
||||
const pv1 = makeSamplePV({ spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' } });
|
||||
const pv2 = makeSamplePV({
|
||||
metadata: { name: 'pv-2' },
|
||||
spec: { ...makeSamplePV().spec, storageClassName: 'sc-a' },
|
||||
});
|
||||
mockContext({
|
||||
storageClasses: [sc1, sc2],
|
||||
persistentVolumes: [pv1, pv2],
|
||||
});
|
||||
render(<StorageClassesPage />);
|
||||
const cells = screen.getAllByRole('cell');
|
||||
const pvCells = cells.filter(c => c.textContent === '2');
|
||||
expect(pvCells.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { TnsCsiStorageClass } from '../api/k8s';
|
||||
import { formatProtocol } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail drawer
|
||||
@@ -55,7 +55,14 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
|
||||
}
|
||||
`}</style>
|
||||
<div className={drawerClass}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
|
||||
{sc.metadata.name}
|
||||
</h2>
|
||||
@@ -64,7 +71,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
aria-label={isMaximized ? 'Minimize panel' : 'Maximize panel'}
|
||||
title={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{isMaximized ? '⊟' : '⊡'}
|
||||
</button>
|
||||
@@ -72,7 +87,15 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
title="Close"
|
||||
style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -90,16 +113,21 @@ function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPan
|
||||
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
|
||||
{
|
||||
name: 'Allow Volume Expansion',
|
||||
value: <StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
|
||||
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
|
||||
</StatusLabel>,
|
||||
value: (
|
||||
<StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
|
||||
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ name: 'Delete Strategy', value: params.deleteStrategy ?? '—' },
|
||||
{
|
||||
name: 'Encryption',
|
||||
value: params.encryption === 'true'
|
||||
? <StatusLabel status="success">Enabled</StatusLabel>
|
||||
: <StatusLabel status="warning">Disabled</StatusLabel>,
|
||||
value:
|
||||
params.encryption === 'true' ? (
|
||||
<StatusLabel status="success">Enabled</StatusLabel>
|
||||
) : (
|
||||
<StatusLabel status="warning">Disabled</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ name: 'Provisioner', value: sc.provisioner },
|
||||
{ name: 'Bound PVs', value: String(pvCount) },
|
||||
@@ -122,13 +150,19 @@ function protocolNotes(protocol: string): Array<{ name: string; value: React.Rea
|
||||
const lower = protocol.toLowerCase();
|
||||
if (lower === 'nfs') {
|
||||
return [
|
||||
{ name: 'Prerequisite', value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes' },
|
||||
{
|
||||
name: 'Prerequisite',
|
||||
value: 'nfs-common (Debian/Ubuntu) or nfs-utils (RHEL/Fedora) required on all nodes',
|
||||
},
|
||||
{ name: 'Access Modes', value: 'Supports RWO, RWX, RWOP' },
|
||||
];
|
||||
}
|
||||
if (lower === 'nvmeof') {
|
||||
return [
|
||||
{ name: 'Prerequisite', value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes' },
|
||||
{
|
||||
name: 'Prerequisite',
|
||||
value: 'nvme-cli + kernel modules nvme-tcp and nvme-fabrics required on all nodes',
|
||||
},
|
||||
{ name: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
|
||||
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
|
||||
];
|
||||
@@ -151,9 +185,7 @@ export default function StorageClassesPage() {
|
||||
const history = useHistory();
|
||||
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
|
||||
|
||||
const [selectedName, setSelectedName] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(location.hash.slice(1) || null);
|
||||
@@ -164,10 +196,10 @@ export default function StorageClassesPage() {
|
||||
history.push(`${location.pathname}#${name}`);
|
||||
};
|
||||
|
||||
const closeSc = () => {
|
||||
const closeSc = useCallback(() => {
|
||||
setSelectedName(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
}, [history, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedName) return;
|
||||
@@ -176,8 +208,7 @@ export default function StorageClassesPage() {
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedName]);
|
||||
}, [selectedName, closeSc]);
|
||||
|
||||
if (loading) return <Loader title="Loading storage classes..." />;
|
||||
|
||||
@@ -186,7 +217,9 @@ export default function StorageClassesPage() {
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Storage Classes" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
@@ -199,7 +232,9 @@ export default function StorageClassesPage() {
|
||||
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null;
|
||||
const selectedSc = selectedName
|
||||
? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -212,16 +247,30 @@ export default function StorageClassesPage() {
|
||||
getter: (sc: TnsCsiStorageClass) => (
|
||||
<button
|
||||
onClick={() => openSc(sc.metadata.name)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{sc.metadata.name}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ label: 'Protocol', getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol) },
|
||||
{
|
||||
label: 'Protocol',
|
||||
getter: (sc: TnsCsiStorageClass) => formatProtocol(sc.parameters?.protocol),
|
||||
},
|
||||
{ label: 'Pool', getter: (sc: TnsCsiStorageClass) => sc.parameters?.pool ?? '—' },
|
||||
{ label: 'Server', getter: (sc: TnsCsiStorageClass) => sc.parameters?.server ?? '—' },
|
||||
{ label: 'Reclaim Policy', getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—' },
|
||||
{
|
||||
label: 'Reclaim Policy',
|
||||
getter: (sc: TnsCsiStorageClass) => sc.reclaimPolicy ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Expansion',
|
||||
getter: (sc: TnsCsiStorageClass) => (
|
||||
@@ -245,7 +294,15 @@ export default function StorageClassesPage() {
|
||||
<div
|
||||
onClick={closeSc}
|
||||
aria-label="Close panel backdrop"
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 1100,
|
||||
}}
|
||||
/>
|
||||
<StorageClassDetailPanel
|
||||
sc={selectedSc}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* TnsCsiSettings — plugin settings page.
|
||||
*
|
||||
* Lets users configure the TrueNAS API key and (optionally) a server address
|
||||
* override. When configured, the plugin fetches real pool capacity data via
|
||||
* the TrueNAS WebSocket JSON-RPC API (pool.query) and displays it on the
|
||||
* Overview page.
|
||||
*
|
||||
* Settings are persisted via Headlamp's ConfigStore (Redux-backed).
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useState } from 'react';
|
||||
import { fetchTruenasPoolStats, getTnsCsiConfig, setTnsCsiConfig } from '../api/truenas';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
data?: Record<string, string | number | boolean>;
|
||||
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||
}
|
||||
|
||||
const INPUT_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
color: 'var(--mui-palette-text-primary, #000)',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const HINT_STYLE: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
marginTop: '4px',
|
||||
};
|
||||
|
||||
export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsProps) {
|
||||
const saved = getTnsCsiConfig();
|
||||
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(data?.truenasApiKey as string) ?? saved.truenasApiKey ?? ''
|
||||
);
|
||||
const [serverOverride, setServerOverride] = useState<string>(
|
||||
(data?.truenasServerOverride as string) ?? saved.truenasServerOverride ?? ''
|
||||
);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
function handleApiKeyChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setApiKey(val);
|
||||
setTnsCsiConfig({ truenasApiKey: val });
|
||||
onDataChange?.({ ...data, truenasApiKey: val });
|
||||
}
|
||||
|
||||
function handleServerOverrideChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setServerOverride(val);
|
||||
setTnsCsiConfig({ truenasServerOverride: val });
|
||||
onDataChange?.({ ...data, truenasServerOverride: val });
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const server = serverOverride.trim() || '(from StorageClass)';
|
||||
if (!serverOverride.trim()) {
|
||||
setTesting(false);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Enter a Server Address to test the connection.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setTesting(false);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Enter an API key to test the connection.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pools = await fetchTruenasPoolStats(serverOverride.trim(), apiKey.trim());
|
||||
const names = pools.map(p => p.name).join(', ');
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to ${server}. Found ${pools.length} pool(s): ${names || '(none)'}`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: String(err instanceof Error ? err.message : err),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title="TrueNAS API (Optional)">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'API Key',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
placeholder="Paste your TrueNAS API key here"
|
||||
style={INPUT_STYLE}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
Generate in TrueNAS UI → Credentials → API Keys. Required for real pool capacity
|
||||
data on the Overview page.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Server Address',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={serverOverride}
|
||||
onChange={handleServerOverrideChange}
|
||||
placeholder="e.g. 192.168.1.100 or truenas.local"
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
TrueNAS host/IP. If blank, the plugin uses the <code>server</code> parameter from
|
||||
your tns-csi StorageClass.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Connection Test',
|
||||
value: (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => void testConnection()}
|
||||
disabled={testing}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: testing
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: testing
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: testing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{testing ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<StatusLabel status={testResult.success ? 'success' : 'error'}>
|
||||
{testResult.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock(
|
||||
'@kinvolk/headlamp-plugin/lib/CommonComponents',
|
||||
async () => await import('./__mocks__/commonComponents')
|
||||
);
|
||||
|
||||
let mockHash = '';
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useLocation: () => ({ pathname: '/tns-csi/volumes', hash: mockHash }),
|
||||
useHistory: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/TnsCsiDataContext');
|
||||
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import { defaultContext, makeSamplePV } from '../test-helpers';
|
||||
import VolumesPage from './VolumesPage';
|
||||
|
||||
function mockContext(overrides?: Parameters<typeof defaultContext>[0]) {
|
||||
vi.mocked(useTnsCsiContext).mockReturnValue(defaultContext(overrides));
|
||||
}
|
||||
|
||||
describe('VolumesPage', () => {
|
||||
beforeEach(() => {
|
||||
mockPush.mockClear();
|
||||
mockHash = '';
|
||||
});
|
||||
|
||||
it('shows loader when loading', () => {
|
||||
mockContext({ loading: true });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByTestId('loader')).toHaveTextContent('Loading volumes...');
|
||||
});
|
||||
|
||||
it('shows error state', () => {
|
||||
mockContext({ error: 'api error' });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('api error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty message when no PVs', () => {
|
||||
mockContext({ persistentVolumes: [] });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('No tns-csi PersistentVolumes found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PV table with claim ref', () => {
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('pv-test-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('default/my-pvc')).toBeInTheDocument();
|
||||
expect(screen.getByText('NFS')).toBeInTheDocument();
|
||||
expect(screen.getByText('100Gi')).toBeInTheDocument();
|
||||
expect(screen.getByText('RWO')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bound')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders "—" for PV without claimRef', () => {
|
||||
const pv = makeSamplePV({
|
||||
spec: {
|
||||
...makeSamplePV().spec,
|
||||
claimRef: undefined,
|
||||
},
|
||||
});
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
const cells = screen.getAllByRole('cell');
|
||||
const dashCells = cells.filter(c => c.textContent === '—');
|
||||
expect(dashCells.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('opens detail panel when clicking PV name', () => {
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
fireEvent.click(screen.getByText('pv-test-001'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes#pv-test-001');
|
||||
});
|
||||
|
||||
it('renders detail panel with CSI attributes', () => {
|
||||
mockHash = '#pv-test-001';
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv], persistentVolumeClaims: [] });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('Volume Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('CSI Attributes')).toBeInTheDocument();
|
||||
expect(screen.getByText('tank/vol-001')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Bound PVC section in detail panel when claimRef exists', () => {
|
||||
mockHash = '#pv-test-001';
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('Bound PVC')).toBeInTheDocument();
|
||||
expect(screen.getByText('my-pvc')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Adoption section when annotation is present', () => {
|
||||
mockHash = '#pv-adoptable';
|
||||
const pv = makeSamplePV({
|
||||
metadata: {
|
||||
name: 'pv-adoptable',
|
||||
annotations: { 'tns-csi.io/adoptable': 'true' },
|
||||
},
|
||||
});
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
expect(screen.getByText('Adoption')).toBeInTheDocument();
|
||||
expect(screen.getByText(/adopted cross-cluster/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes panel on Escape key', () => {
|
||||
mockHash = '#pv-test-001';
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
|
||||
});
|
||||
|
||||
it('closes panel via backdrop click', () => {
|
||||
mockHash = '#pv-test-001';
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
fireEvent.click(screen.getByLabelText('Close panel backdrop'));
|
||||
expect(mockPush).toHaveBeenCalledWith('/tns-csi/volumes');
|
||||
});
|
||||
|
||||
it('renders maximize/minimize button in panel', () => {
|
||||
mockHash = '#pv-test-001';
|
||||
const pv = makeSamplePV();
|
||||
mockContext({ persistentVolumes: [pv] });
|
||||
render(<VolumesPage />);
|
||||
const maxBtn = screen.getByLabelText('Maximize');
|
||||
expect(maxBtn).toBeInTheDocument();
|
||||
fireEvent.click(maxBtn);
|
||||
expect(screen.getByLabelText('Minimize')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import type { TnsCsiPersistentVolume } from '../api/k8s';
|
||||
import { findBoundPv, formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||
import { formatAccessModes, formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail panel
|
||||
@@ -47,13 +47,46 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
|
||||
}
|
||||
`}</style>
|
||||
<div className={drawerClass}>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>{pv.metadata.name}</h2>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, color: 'var(--mui-palette-text-primary)' }}>
|
||||
{pv.metadata.name}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button onClick={() => setIsMaximized(!isMaximized)} aria-label={isMaximized ? 'Minimize' : 'Maximize'} style={{ border: 'none', background: 'transparent', fontSize: '20px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
|
||||
<button
|
||||
onClick={() => setIsMaximized(!isMaximized)}
|
||||
aria-label={isMaximized ? 'Minimize' : 'Maximize'}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
{isMaximized ? '⊟' : '⊡'}
|
||||
</button>
|
||||
<button onClick={onClose} aria-label="Close panel" style={{ border: 'none', background: 'transparent', fontSize: '24px', cursor: 'pointer', padding: '4px 8px', color: 'var(--mui-palette-text-secondary, #666)', borderRadius: '4px' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,10 +131,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
|
||||
{ name: 'Volume Handle', value: csi?.volumeHandle ?? '—' },
|
||||
{ name: 'Protocol', value: formatProtocol(attrs['protocol']) },
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
...(Object.entries(attrs)
|
||||
...Object.entries(attrs)
|
||||
.filter(([k]) => !['protocol', 'server'].includes(k))
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' }))
|
||||
),
|
||||
.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
@@ -110,10 +142,16 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
|
||||
{pv.metadata.annotations?.['tns-csi.io/adoptable'] === 'true' && (
|
||||
<SectionBox title="Adoption">
|
||||
<NameValueTable
|
||||
rows={[{
|
||||
name: 'Adoptable',
|
||||
value: <StatusLabel status="success">This volume can be adopted cross-cluster</StatusLabel>,
|
||||
}]}
|
||||
rows={[
|
||||
{
|
||||
name: 'Adoptable',
|
||||
value: (
|
||||
<StatusLabel status="success">
|
||||
This volume can be adopted cross-cluster
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
@@ -129,11 +167,9 @@ function VolumeDetailPanel({ pv, onClose }: VolumeDetailPanelProps) {
|
||||
export default function VolumesPage() {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const { persistentVolumes, persistentVolumeClaims, loading, error } = useTnsCsiContext();
|
||||
const { persistentVolumes, loading, error } = useTnsCsiContext();
|
||||
|
||||
const [selectedName, setSelectedName] = useState<string | null>(
|
||||
location.hash.slice(1) || null
|
||||
);
|
||||
const [selectedName, setSelectedName] = useState<string | null>(location.hash.slice(1) || null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(location.hash.slice(1) || null);
|
||||
@@ -144,10 +180,10 @@ export default function VolumesPage() {
|
||||
history.push(`${location.pathname}#${name}`);
|
||||
};
|
||||
|
||||
const closeVolume = () => {
|
||||
const closeVolume = useCallback(() => {
|
||||
setSelectedName(null);
|
||||
history.push(location.pathname);
|
||||
};
|
||||
}, [history, location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedName) return;
|
||||
@@ -156,8 +192,7 @@ export default function VolumesPage() {
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedName]);
|
||||
}, [selectedName, closeVolume]);
|
||||
|
||||
if (loading) return <Loader title="Loading volumes..." />;
|
||||
|
||||
@@ -166,7 +201,9 @@ export default function VolumesPage() {
|
||||
<>
|
||||
<SectionHeader title="TNS-CSI — Volumes" />
|
||||
<SectionBox title="Error">
|
||||
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
|
||||
<NameValueTable
|
||||
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
|
||||
/>
|
||||
</SectionBox>
|
||||
</>
|
||||
);
|
||||
@@ -187,7 +224,15 @@ export default function VolumesPage() {
|
||||
getter: (pv: TnsCsiPersistentVolume) => (
|
||||
<button
|
||||
onClick={() => openVolume(pv.metadata.name)}
|
||||
style={{ border: 'none', background: 'transparent', color: 'var(--link-color, #1976d2)', cursor: 'pointer', textDecoration: 'underline', padding: 0, font: 'inherit' }}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--link-color, #1976d2)',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{pv.metadata.name}
|
||||
</button>
|
||||
@@ -240,7 +285,15 @@ export default function VolumesPage() {
|
||||
<div
|
||||
onClick={closeVolume}
|
||||
aria-label="Close panel backdrop"
|
||||
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 1100 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 1100,
|
||||
}}
|
||||
/>
|
||||
<VolumeDetailPanel pv={selectedPv} onClose={closeVolume} />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Lightweight mock implementations of @kinvolk/headlamp-plugin/lib/CommonComponents.
|
||||
* Used via vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => commonComponentsMock).
|
||||
*
|
||||
* Uses React.createElement instead of JSX since this file is .ts (not .tsx).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
type RC = React.ReactNode;
|
||||
|
||||
export const Loader = ({ title }: { title?: string }) =>
|
||||
React.createElement('div', { 'data-testid': 'loader' }, title);
|
||||
|
||||
export const SectionBox = ({ title, children }: { title?: string; children?: RC }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'section-box', 'data-title': title },
|
||||
title ? React.createElement('h3', null, title) : null,
|
||||
children
|
||||
);
|
||||
|
||||
export const SectionHeader = ({ title }: { title: string }) =>
|
||||
React.createElement('h1', { 'data-testid': 'section-header' }, title);
|
||||
|
||||
export const SimpleTable = ({
|
||||
columns,
|
||||
data,
|
||||
emptyMessage,
|
||||
}: {
|
||||
columns: Array<{ label: string; getter: (item: unknown) => RC }>;
|
||||
data: unknown[];
|
||||
emptyMessage?: string;
|
||||
}) => {
|
||||
if (data.length === 0 && emptyMessage) {
|
||||
return React.createElement('div', { 'data-testid': 'empty-table' }, emptyMessage);
|
||||
}
|
||||
return React.createElement(
|
||||
'table',
|
||||
{ 'data-testid': 'simple-table' },
|
||||
React.createElement(
|
||||
'thead',
|
||||
null,
|
||||
React.createElement(
|
||||
'tr',
|
||||
null,
|
||||
columns.map(col => React.createElement('th', { key: col.label }, col.label))
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
'tbody',
|
||||
null,
|
||||
data.map((item, i) =>
|
||||
React.createElement(
|
||||
'tr',
|
||||
{ key: i },
|
||||
columns.map(col => React.createElement('td', { key: col.label }, col.getter(item)))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const NameValueTable = ({ rows }: { rows: Array<{ name: string; value: RC }> }) =>
|
||||
React.createElement(
|
||||
'table',
|
||||
{ 'data-testid': 'name-value-table' },
|
||||
React.createElement(
|
||||
'tbody',
|
||||
null,
|
||||
rows.map(row =>
|
||||
React.createElement(
|
||||
'tr',
|
||||
{ key: row.name },
|
||||
React.createElement('td', null, row.name),
|
||||
React.createElement('td', null, row.value)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
export const StatusLabel = ({ status, children }: { status: string; children?: RC }) =>
|
||||
React.createElement('span', { 'data-testid': 'status-label', 'data-status': status }, children);
|
||||
|
||||
export const PercentageBar = ({
|
||||
data,
|
||||
}: {
|
||||
data: Array<{ name: string; value: number }>;
|
||||
total: number;
|
||||
}) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'percentage-bar' },
|
||||
data.map(d => React.createElement('span', { key: d.name }, `${d.name}: ${d.value}`))
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* StorageClassBenchmarkButton — registerDetailsViewHeaderAction for StorageClass pages.
|
||||
*
|
||||
* Adds a "Benchmark" button to the detail page header of tns-csi StorageClasses.
|
||||
* Navigates to /tns-csi/benchmark so the user can run a FIO benchmark
|
||||
* against that storage class.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { TNS_CSI_PROVISIONER } from '../../api/k8s';
|
||||
|
||||
interface StorageClassBenchmarkButtonProps {
|
||||
resource: {
|
||||
provisioner?: string;
|
||||
metadata?: { name?: string };
|
||||
// KubeObject instance — provisioner may be a direct getter or under jsonData
|
||||
jsonData?: {
|
||||
provisioner?: string;
|
||||
metadata?: { name?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function StorageClassBenchmarkButton({
|
||||
resource,
|
||||
}: StorageClassBenchmarkButtonProps) {
|
||||
const history = useHistory();
|
||||
|
||||
// provisioner is one of the fields Headlamp's StorageClass class exposes as a getter,
|
||||
// so it's accessible directly. jsonData fallback for safety.
|
||||
const provisioner = resource?.provisioner ?? resource?.jsonData?.provisioner;
|
||||
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scName = resource?.metadata?.name ?? resource?.jsonData?.metadata?.name ?? '';
|
||||
|
||||
const handleClick = () => {
|
||||
// Navigate to benchmark page; user selects the SC in the benchmark form.
|
||||
// Pass the SC name via hash so BenchmarkPage can pre-select it if desired.
|
||||
history.push(`/tns-csi/benchmark#${scName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '6px 16px',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid currentColor',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
opacity: 0.85,
|
||||
}}
|
||||
aria-label={`Run benchmark on ${scName}`}
|
||||
title={`Run FIO benchmark on storage class ${scName}`}
|
||||
>
|
||||
<span>⚡</span>
|
||||
<span>Benchmark</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* StorageClassColumns — registerResourceTableColumnsProcessor for StorageClass and PV tables.
|
||||
*
|
||||
* Adds Protocol/Pool/Server columns to the native /storage-classes table and
|
||||
* Protocol/Pool columns to the native /persistent-volumes table.
|
||||
* Pool on PVs is derived from the first segment of volumeAttributes.datasetName.
|
||||
*
|
||||
* Items in column processors are KubeObject class instances from Headlamp.
|
||||
* Raw Kubernetes JSON fields (parameters, spec, status) must be accessed
|
||||
* via .jsonData — only fields with explicit getters (provisioner, reclaimPolicy, etc.)
|
||||
* are accessible as direct properties.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { formatProtocol, TNS_CSI_PROVISIONER } from '../../api/k8s';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: extract a field from either a KubeObject instance or a plain object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getField(item: unknown, ...path: string[]): unknown {
|
||||
if (!item || typeof item !== 'object') return undefined;
|
||||
const obj = item as Record<string, unknown>;
|
||||
|
||||
// KubeObject instance — raw K8s JSON is under .jsonData
|
||||
const raw: Record<string, unknown> =
|
||||
'jsonData' in obj && obj['jsonData'] && typeof obj['jsonData'] === 'object'
|
||||
? (obj['jsonData'] as Record<string, unknown>)
|
||||
: obj;
|
||||
|
||||
let cur: unknown = raw;
|
||||
for (const key of path) {
|
||||
if (!cur || typeof cur !== 'object') return undefined;
|
||||
cur = (cur as Record<string, unknown>)[key];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StorageClass column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns extra columns for the native StorageClass table.
|
||||
* For non-tns-csi rows, cells show "—" (never undefined/null visible).
|
||||
*/
|
||||
export function buildStorageClassColumns() {
|
||||
return [
|
||||
{
|
||||
label: 'Protocol',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'protocol');
|
||||
return typeof p === 'string' ? formatProtocol(p) : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
|
||||
return <span>{formatProtocol(protocol)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'pool');
|
||||
return typeof p === 'string' ? p : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const pool = getField(sc, 'parameters', 'pool') as string | undefined;
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Server',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'server');
|
||||
return typeof p === 'string' ? p : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const server = getField(sc, 'parameters', 'server') as string | undefined;
|
||||
return <span>{server ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PersistentVolume column definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns extra columns for the native PersistentVolume table.
|
||||
* For non-tns-csi PVs, cells show "—".
|
||||
*/
|
||||
export function buildPVColumns() {
|
||||
return [
|
||||
{
|
||||
label: 'Protocol',
|
||||
getValue: (pv: unknown): string | null => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol');
|
||||
return typeof p === 'string' ? formatProtocol(p) : null;
|
||||
},
|
||||
render: (pv: unknown) => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as
|
||||
| string
|
||||
| undefined;
|
||||
return <span>{formatProtocol(protocol)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Pool',
|
||||
getValue: (pv: unknown): string | null => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return null;
|
||||
// tns-csi stores pool as the first segment of datasetName (e.g. "tank/pvc-abc")
|
||||
const d = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName');
|
||||
if (typeof d !== 'string') return null;
|
||||
return d.split('/')[0] ?? null;
|
||||
},
|
||||
render: (pv: unknown) => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as
|
||||
| string
|
||||
| undefined;
|
||||
const pool = dataset?.split('/')[0];
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
+98
-36
@@ -1,34 +1,45 @@
|
||||
/**
|
||||
* headlamp-tns-csi-plugin — entry point.
|
||||
*
|
||||
* Registers sidebar entries, routes, detail view section, and plugin settings
|
||||
* for the tns-csi CSI driver Headlamp plugin.
|
||||
* Registers sidebar entries, routes, detail view sections, table column
|
||||
* processors, header actions, and app bar action for the tns-csi CSI driver.
|
||||
*/
|
||||
|
||||
import {
|
||||
registerDetailsViewHeaderAction,
|
||||
registerDetailsViewSection,
|
||||
registerPluginSettings,
|
||||
registerResourceTableColumnsProcessor,
|
||||
registerRoute,
|
||||
registerSidebarEntry,
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
|
||||
import BenchmarkPage from './components/BenchmarkPage';
|
||||
import DriverPodDetailSection from './components/DriverPodDetailSection';
|
||||
import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
|
||||
import {
|
||||
buildPVColumns,
|
||||
buildStorageClassColumns,
|
||||
} from './components/integrations/StorageClassColumns';
|
||||
import MetricsPage from './components/MetricsPage';
|
||||
import OverviewPage from './components/OverviewPage';
|
||||
import PVCDetailSection from './components/PVCDetailSection';
|
||||
import PVDetailSection from './components/PVDetailSection';
|
||||
import SnapshotsPage from './components/SnapshotsPage';
|
||||
import StorageClassesPage from './components/StorageClassesPage';
|
||||
import TnsCsiSettings from './components/TnsCsiSettings';
|
||||
import VolumesPage from './components/VolumesPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar entries
|
||||
// Sidebar entries (trimmed from 6 to 4 — Storage Classes and Volumes now
|
||||
// surface via native Headlamp tables with injected columns/sections)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: null,
|
||||
name: 'tns-csi',
|
||||
label: 'TNS CSI',
|
||||
label: 'TrueNAS (tns-csi)',
|
||||
url: '/tns-csi',
|
||||
icon: 'mdi:database-cog',
|
||||
});
|
||||
@@ -41,22 +52,6 @@ registerSidebarEntry({
|
||||
icon: 'mdi:view-dashboard',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-storage-classes',
|
||||
label: 'Storage Classes',
|
||||
url: '/tns-csi/storage-classes',
|
||||
icon: 'mdi:database',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-volumes',
|
||||
label: 'Volumes',
|
||||
url: '/tns-csi/volumes',
|
||||
icon: 'mdi:harddisk',
|
||||
});
|
||||
|
||||
registerSidebarEntry({
|
||||
parent: 'tns-csi',
|
||||
name: 'tns-csi-snapshots',
|
||||
@@ -82,7 +77,7 @@ registerSidebarEntry({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Routes
|
||||
// Routes (keep all routes so direct links still work)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerRoute({
|
||||
@@ -97,9 +92,11 @@ registerRoute({
|
||||
),
|
||||
});
|
||||
|
||||
// Routes for storage-classes and volumes are kept for direct URL access
|
||||
// but are no longer in the sidebar — native Headlamp tables have tns-csi columns.
|
||||
registerRoute({
|
||||
path: '/tns-csi/storage-classes',
|
||||
sidebar: 'tns-csi-storage-classes',
|
||||
sidebar: 'tns-csi-overview',
|
||||
name: 'tns-csi-storage-classes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
@@ -111,7 +108,7 @@ registerRoute({
|
||||
|
||||
registerRoute({
|
||||
path: '/tns-csi/volumes',
|
||||
sidebar: 'tns-csi-volumes',
|
||||
sidebar: 'tns-csi-overview',
|
||||
name: 'tns-csi-volumes',
|
||||
exact: true,
|
||||
component: () => (
|
||||
@@ -158,7 +155,7 @@ registerRoute({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PVC detail view injection
|
||||
// Detail view section — PVC pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
@@ -171,19 +168,84 @@ registerDetailsViewSection(({ resource }) => {
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail view section — PV pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'PersistentVolume') return null;
|
||||
return <PVDetailSection resource={resource} />;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Detail view section — Pod pages (tns-csi driver pods only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerDetailsViewSection(({ resource }) => {
|
||||
if (resource?.kind !== 'Pod') return null;
|
||||
return <DriverPodDetailSection resource={resource} />;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table column processors — native StorageClass and PV tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Merges incoming columns into existing ones by label.
|
||||
// If a column with the same label already exists, the incoming getValue/render
|
||||
// takes priority and falls back to the existing one (for mixed-driver tables).
|
||||
function mergeColumns<T>(
|
||||
existing: T[],
|
||||
incoming: Array<{
|
||||
label: string;
|
||||
getValue: (r: unknown) => unknown;
|
||||
render: (r: unknown) => React.ReactNode;
|
||||
}>
|
||||
): T[] {
|
||||
type ObjCol = {
|
||||
label: string;
|
||||
getValue: (r: unknown) => unknown;
|
||||
render: (r: unknown) => React.ReactNode;
|
||||
};
|
||||
const isObjCol = (c: unknown): c is ObjCol => typeof c === 'object' && c !== null && 'label' in c;
|
||||
const result = [...existing];
|
||||
const toAppend: typeof incoming = [];
|
||||
for (const col of incoming) {
|
||||
const idx = result.findIndex(c => isObjCol(c) && (c as ObjCol).label === col.label);
|
||||
if (idx !== -1) {
|
||||
const prev = result[idx] as ObjCol;
|
||||
result[idx] = {
|
||||
label: col.label,
|
||||
getValue: (r: unknown) => col.getValue(r) ?? prev.getValue(r),
|
||||
render: (r: unknown) => (col.getValue(r) !== null ? col.render(r) : prev.render(r)),
|
||||
} as unknown as T;
|
||||
} else {
|
||||
toAppend.push(col);
|
||||
}
|
||||
}
|
||||
return [...result, ...(toAppend as unknown as T[])];
|
||||
}
|
||||
|
||||
registerResourceTableColumnsProcessor(({ id, columns }) => {
|
||||
if (id === 'headlamp-storageclasses') {
|
||||
return mergeColumns(columns, buildStorageClassColumns());
|
||||
}
|
||||
if (id === 'headlamp-persistentvolumes') {
|
||||
return mergeColumns(columns, buildPVColumns());
|
||||
}
|
||||
return columns;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header action — StorageClass detail page Benchmark shortcut
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
registerDetailsViewHeaderAction(({ resource }) => {
|
||||
if (resource?.kind !== 'StorageClass') return null;
|
||||
return <StorageClassBenchmarkButton resource={resource} />;
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TnsCsiSettings() {
|
||||
return (
|
||||
<div style={{ padding: '16px' }}>
|
||||
<p style={{ color: 'var(--mui-palette-text-secondary)' }}>
|
||||
TNS-CSI plugin settings. Configure defaults below.
|
||||
</p>
|
||||
{/* Future: default namespace, metrics refresh interval, auto-cleanup setting */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
registerPluginSettings('headlamp-tns-csi-plugin', TnsCsiSettings, true);
|
||||
registerPluginSettings('tns-csi', TnsCsiSettings, true);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Shared test helpers: mock factories, fixtures, and context setup
|
||||
* for component tests.
|
||||
*/
|
||||
|
||||
import { vi } from 'vitest';
|
||||
import type {
|
||||
CSIDriver,
|
||||
TnsCsiPersistentVolume,
|
||||
TnsCsiPersistentVolumeClaim,
|
||||
TnsCsiPod,
|
||||
TnsCsiStorageClass,
|
||||
VolumeSnapshot,
|
||||
VolumeSnapshotClass,
|
||||
} from './api/k8s';
|
||||
import type { TnsCsiMetrics } from './api/metrics';
|
||||
import type { TnsCsiContextValue } from './api/TnsCsiDataContext';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default context value (everything empty / zeroed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function defaultContext(overrides?: Partial<TnsCsiContextValue>): TnsCsiContextValue {
|
||||
return {
|
||||
csiDriver: null,
|
||||
driverInstalled: false,
|
||||
storageClasses: [],
|
||||
persistentVolumes: [],
|
||||
persistentVolumeClaims: [],
|
||||
controllerPods: [],
|
||||
nodePods: [],
|
||||
volumeSnapshots: [],
|
||||
volumeSnapshotClasses: [],
|
||||
snapshotCrdAvailable: false,
|
||||
poolStats: [],
|
||||
poolStatsError: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const sampleCSIDriver: CSIDriver = {
|
||||
metadata: { name: 'tns.csi.io' },
|
||||
spec: {
|
||||
attachRequired: false,
|
||||
podInfoOnMount: true,
|
||||
volumeLifecycleModes: ['Persistent'],
|
||||
},
|
||||
};
|
||||
|
||||
export function makeSampleStorageClass(
|
||||
overrides?: Partial<TnsCsiStorageClass>
|
||||
): TnsCsiStorageClass {
|
||||
return {
|
||||
metadata: { name: 'tns-nfs', creationTimestamp: '2025-01-01T00:00:00Z' },
|
||||
provisioner: 'tns.csi.io',
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'Immediate',
|
||||
allowVolumeExpansion: true,
|
||||
parameters: {
|
||||
protocol: 'nfs',
|
||||
pool: 'tank',
|
||||
server: '10.0.0.1',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const sampleStorageClass = makeSampleStorageClass();
|
||||
|
||||
export function makeSamplePV(overrides?: Partial<TnsCsiPersistentVolume>): TnsCsiPersistentVolume {
|
||||
return {
|
||||
metadata: {
|
||||
name: 'pv-test-001',
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
spec: {
|
||||
csi: {
|
||||
driver: 'tns.csi.io',
|
||||
volumeHandle: 'tank/vol-001',
|
||||
volumeAttributes: {
|
||||
protocol: 'nfs',
|
||||
server: '10.0.0.1',
|
||||
pool: 'tank',
|
||||
},
|
||||
},
|
||||
capacity: { storage: '100Gi' },
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
persistentVolumeReclaimPolicy: 'Delete',
|
||||
storageClassName: 'tns-nfs',
|
||||
claimRef: { name: 'my-pvc', namespace: 'default' },
|
||||
},
|
||||
status: { phase: 'Bound' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const samplePV = makeSamplePV();
|
||||
|
||||
export function makeSamplePVC(
|
||||
overrides?: Partial<TnsCsiPersistentVolumeClaim>
|
||||
): TnsCsiPersistentVolumeClaim {
|
||||
return {
|
||||
metadata: {
|
||||
name: 'my-pvc',
|
||||
namespace: 'default',
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
spec: {
|
||||
storageClassName: 'tns-nfs',
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: '100Gi' } },
|
||||
volumeName: 'pv-test-001',
|
||||
},
|
||||
status: {
|
||||
phase: 'Bound',
|
||||
capacity: { storage: '100Gi' },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const samplePVC = makeSamplePVC();
|
||||
|
||||
export function makeSamplePod(overrides?: Partial<TnsCsiPod> & { name?: string }): TnsCsiPod {
|
||||
const name = overrides?.name ?? overrides?.metadata?.name ?? 'tns-csi-controller-abc';
|
||||
return {
|
||||
metadata: {
|
||||
name,
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
...overrides?.metadata,
|
||||
},
|
||||
spec: {
|
||||
nodeName: 'node-1',
|
||||
...overrides?.spec,
|
||||
},
|
||||
status: {
|
||||
phase: 'Running',
|
||||
conditions: [{ type: 'Ready', status: 'True' }],
|
||||
containerStatuses: [
|
||||
{
|
||||
name: 'tns-csi',
|
||||
ready: true,
|
||||
restartCount: 0,
|
||||
image: 'fenio/tns-csi:v0.5.0',
|
||||
},
|
||||
],
|
||||
...overrides?.status,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const samplePod = makeSamplePod();
|
||||
|
||||
export function makeSampleSnapshot(overrides?: Partial<VolumeSnapshot>): VolumeSnapshot {
|
||||
return {
|
||||
metadata: {
|
||||
name: 'snap-001',
|
||||
namespace: 'default',
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
spec: {
|
||||
source: { persistentVolumeClaimName: 'my-pvc' },
|
||||
volumeSnapshotClassName: 'tns-snap-class',
|
||||
},
|
||||
status: {
|
||||
readyToUse: true,
|
||||
restoreSize: '100Gi',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSampleSnapshotClass(
|
||||
overrides?: Partial<VolumeSnapshotClass>
|
||||
): VolumeSnapshotClass {
|
||||
return {
|
||||
metadata: {
|
||||
name: 'tns-snap-class',
|
||||
creationTimestamp: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
driver: 'tns.csi.io',
|
||||
deletionPolicy: 'Delete',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSampleMetrics(overrides?: Partial<TnsCsiMetrics>): TnsCsiMetrics {
|
||||
return {
|
||||
websocketConnected: 1,
|
||||
websocketReconnectsTotal: 3,
|
||||
websocketMessagesTotal: [{ labels: {}, value: 100 }],
|
||||
websocketMessageDurationSeconds: [],
|
||||
volumeOperationsTotal: [
|
||||
{ labels: { protocol: 'nfs' }, value: 10 },
|
||||
{ labels: { protocol: 'iscsi' }, value: 5 },
|
||||
],
|
||||
volumeOperationsDurationSeconds: [],
|
||||
volumeCapacityBytes: [{ labels: { volume_id: 'tank/vol-001' }, value: 107374182400 }],
|
||||
csiOperationsTotal: [
|
||||
{ labels: { method: 'CreateVolume' }, value: 10 },
|
||||
{ labels: { method: 'DeleteVolume' }, value: 2 },
|
||||
],
|
||||
csiOperationsDurationSeconds: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
"module": "esnext",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user