Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be5d86bc8e | |||
| 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 |
@@ -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]
|
||||
@@ -2,36 +2,12 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint --ext .ts,.tsx src/
|
||||
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
ci:
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+12
-100
@@ -4,108 +4,20 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to release (without v prefix, e.g., 0.2.0)'
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if ! echo "${{ inputs.version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::Version must be in format X.Y.Z (e.g., 0.2.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
jq --arg version "${{ inputs.version }}" '.version = $version' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
- name: Update artifacthub-pkg.yml version and URL
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/headlamp-tns-csi-plugin-${VERSION}.tar.gz"
|
||||
|
||||
sed -i "s|^version:.*|version: \"${VERSION}\"|" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package plugin
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Validate tarball name
|
||||
run: |
|
||||
EXPECTED="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
|
||||
ACTUAL=$(ls *.tar.gz)
|
||||
if [ "$EXPECTED" != "$ACTUAL" ]; then
|
||||
echo "::error::Tarball name mismatch! Expected: $EXPECTED, Got: $ACTUAL"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Tarball name validated: $ACTUAL"
|
||||
|
||||
- name: Compute checksum
|
||||
id: compute_checksum
|
||||
run: |
|
||||
TARBALL="headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz"
|
||||
CHECKSUM=$(sha256sum "$TARBALL" | awk '{print $1}')
|
||||
echo "checksum=${CHECKSUM}" >> $GITHUB_OUTPUT
|
||||
echo "Checksum: sha256:${CHECKSUM}"
|
||||
|
||||
- name: Update checksum in metadata
|
||||
run: |
|
||||
CHECKSUM="${{ steps.compute_checksum.outputs.checksum }}"
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: \"sha256:${CHECKSUM}\"|" artifacthub-pkg.yml
|
||||
|
||||
- name: Commit version bump and metadata
|
||||
run: |
|
||||
git add package.json artifacthub-pkg.yml
|
||||
git commit -m "chore: release v${{ inputs.version }}"
|
||||
git push origin main
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git tag "v${{ inputs.version }}"
|
||||
git push origin "v${{ inputs.version }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: "v${{ inputs.version }}"
|
||||
files: headlamp-tns-csi-plugin-${{ inputs.version }}.tar.gz
|
||||
fail_on_unmatched_files: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "✓ Version bumped to ${{ inputs.version }}"
|
||||
echo "✓ Metadata updated with checksum sha256:${{ steps.compute_checksum.outputs.checksum }}"
|
||||
echo "✓ Tag v${{ inputs.version }} created"
|
||||
echo "✓ GitHub release published with tarball"
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
|
||||
secrets:
|
||||
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
version: ${{ inputs.version }}
|
||||
upstream-repo: fenio/tns-csi
|
||||
|
||||
@@ -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');
|
||||
+82
-1
@@ -7,6 +7,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
@@ -47,6 +122,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- TypeScript strict mode with zero `any` types
|
||||
- ESLint + Prettier code quality tooling
|
||||
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.7...v1.0.0
|
||||
[0.2.6]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.5...v0.2.6
|
||||
[0.2.4]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.3...v0.2.4
|
||||
[0.2.3]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.2...v0.2.3
|
||||
[0.2.2]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.1...v0.2.2
|
||||
[0.2.1]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.2.0...v0.2.1
|
||||
[0.2.0]: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/compare/v0.1.0...v0.2.0
|
||||
[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
|
||||
```
|
||||
|
||||
|
||||
+6
-6
@@ -27,7 +27,7 @@ This project follows a standard code of conduct:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20 or later
|
||||
- Node.js 22 or later
|
||||
- npm
|
||||
- Access to a Kubernetes cluster with Headlamp and tns-csi installed (for end-to-end testing)
|
||||
- Git
|
||||
@@ -53,7 +53,7 @@ This project follows a standard code of conduct:
|
||||
|
||||
4. **Run tests:**
|
||||
```bash
|
||||
npm test # 67 unit tests
|
||||
npm test # 159 unit tests
|
||||
npm run tsc # TypeScript type-check
|
||||
```
|
||||
|
||||
@@ -196,7 +196,7 @@ Add `Co-Authored-By` for pair programming or AI assistance:
|
||||
```
|
||||
feat: add NVMe-oF protocol notes to StorageClass detail panel
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
||||
Co-Authored-By: Happy <yesreply@happy.engineering>
|
||||
```
|
||||
|
||||
@@ -209,7 +209,7 @@ Co-Authored-By: Happy <yesreply@happy.engineering>
|
||||
npm run build # Verify build succeeds
|
||||
npm run lint # Check for linting errors
|
||||
npm run tsc # Type-check TypeScript
|
||||
npm test # Run 67 unit tests
|
||||
npm test # Run 159 unit tests
|
||||
```
|
||||
|
||||
2. **Update documentation:**
|
||||
@@ -219,7 +219,7 @@ Co-Authored-By: Happy <yesreply@happy.engineering>
|
||||
|
||||
3. **Write/update tests:**
|
||||
- Add unit tests for new functions/components
|
||||
- Ensure all 67 tests (plus yours) pass
|
||||
- Ensure all 159 tests (plus yours) pass
|
||||
|
||||
### Creating a PR
|
||||
|
||||
@@ -319,7 +319,7 @@ npm run tsc # TypeScript check
|
||||
|
||||
### Unit Tests (Required)
|
||||
|
||||
All 67 tests must pass before committing:
|
||||
All 159 tests must pass before committing:
|
||||
|
||||
```bash
|
||||
npm test # vitest run
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Headlamp TNS-CSI Plugin
|
||||
|
||||
[](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)
|
||||
[](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)
|
||||
[](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/actions/workflows/ci.yaml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
|
||||
@@ -47,9 +47,14 @@ The tns-csi driver must be deployed in `kube-system` with the standard `app.kube
|
||||
|
||||
## Installing
|
||||
|
||||
### Option 1: Headlamp Plugin Manager (Recommended)
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin). Install via the Headlamp UI:
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin). Configure Headlamp via Helm:
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Click **Catalog** tab
|
||||
3. Search for "TNS CSI" or "TrueNAS"
|
||||
4. Click **Install**
|
||||
|
||||
Or configure Headlamp via Helm:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
@@ -57,39 +62,13 @@ config:
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
|
||||
```
|
||||
|
||||
Or install via the Headlamp UI:
|
||||
|
||||
1. Go to **Settings → Plugins**
|
||||
2. Click **Catalog** tab
|
||||
3. Search for "TNS CSI" or "TrueNAS"
|
||||
4. Click **Install**
|
||||
|
||||
### Option 2: Manual Tarball Install
|
||||
|
||||
Download the `.tar.gz` from the [GitHub releases page](https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases), then extract into Headlamp's plugin directory:
|
||||
|
||||
```bash
|
||||
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.0/headlamp-tns-csi-plugin-0.2.0.tar.gz
|
||||
tar xzf headlamp-tns-csi-plugin-0.2.0.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/privilegedescalation/headlamp-tns-csi-plugin.git
|
||||
cd headlamp-tns-csi-plugin
|
||||
npm install
|
||||
npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/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. The Benchmark page additionally creates and deletes Jobs and PVCs.
|
||||
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
|
||||
|
||||
@@ -111,6 +90,10 @@ rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/proxy"]
|
||||
verbs: ["get"]
|
||||
resourceNames: ["pods"]
|
||||
```
|
||||
|
||||
### Additional permissions for Benchmark page
|
||||
@@ -126,13 +109,13 @@ rules:
|
||||
|
||||
### Metrics access
|
||||
|
||||
The plugin fetches Prometheus metrics from the tns-csi controller pod via the Kubernetes pod proxy sub-resource. Grant `get` on `pods/proxy` in `kube-system`:
|
||||
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"]
|
||||
# Optionally scope to the controller pod namespace
|
||||
# Scope to kube-system where the tns-csi controller runs
|
||||
```
|
||||
|
||||
Apply the role and bind it to your Headlamp service account with a ClusterRoleBinding.
|
||||
@@ -185,10 +168,10 @@ npm start # Opens Headlamp at http://localhost:4466
|
||||
|
||||
# Build for production
|
||||
npm run build # outputs dist/main.js
|
||||
npm run package # creates headlamp-tns-csi-plugin-<version>.tar.gz
|
||||
npm run package # creates tns-csi-<version>.tar.gz
|
||||
|
||||
# Run tests
|
||||
npm test # 67 unit tests
|
||||
npm test # 159 unit tests
|
||||
npm run test:watch # watch mode
|
||||
|
||||
# Code quality
|
||||
@@ -264,7 +247,7 @@ Releases are automated via **GitHub Actions**. To cut a release:
|
||||
|
||||
This triggers the **GitHub Actions** release workflow (`.github/workflows/release.yaml`):
|
||||
|
||||
1. Build the plugin in a `node:20` container
|
||||
1. Build the plugin in a `node:22` container
|
||||
2. Update `package.json` and `artifacthub-pkg.yml` with the new version
|
||||
3. Package a `.tar.gz` tarball
|
||||
4. Compute SHA256 checksum and update `artifacthub-pkg.yml`
|
||||
@@ -286,7 +269,7 @@ Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
## Links
|
||||
|
||||
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)** — Source code, issues, releases
|
||||
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)** — Plugin catalog listing
|
||||
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin)** — Plugin catalog listing
|
||||
- **[Headlamp](https://headlamp.dev/)** — Kubernetes web UI
|
||||
- **[tns-csi driver](https://github.com/fenio/tns-csi)** — TrueNAS CSI driver
|
||||
- **[kbench](https://github.com/longhorn/kbench)** — Storage benchmark tool
|
||||
|
||||
+4
-4
@@ -91,7 +91,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system # adjust to your Headlamp namespace
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
@@ -143,7 +143,7 @@ The Kubernetes API server performs the pod proxy hop, so policies should permit
|
||||
|
||||
### Service Account (Default)
|
||||
|
||||
Headlamp runs with a dedicated service account (`headlamp` in `kube-system`). All users share the same RBAC permissions.
|
||||
Headlamp runs with a dedicated service account (`headlamp` in 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
|
||||
@@ -187,7 +187,7 @@ Report security vulnerabilities via:
|
||||
|
||||
The project uses:
|
||||
- **npm audit**: Runs automatically during `npm install`
|
||||
- **GitHub Dependabot**: Monitors dependencies and creates PRs for updates
|
||||
- **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.
|
||||
|
||||
@@ -223,7 +223,7 @@ All API requests are logged in Kubernetes API audit logs (if enabled). Pod proxy
|
||||
"verb": "get",
|
||||
"requestURI": "/api/v1/namespaces/kube-system/pods/<controller-pod>/proxy/metrics",
|
||||
"user": {
|
||||
"username": "system:serviceaccount:kube-system:headlamp"
|
||||
"username": "system:serviceaccount:<your-namespace>:headlamp"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+16
-12
@@ -1,4 +1,4 @@
|
||||
version: "0.2.1"
|
||||
version: "1.0.3"
|
||||
name: headlamp-tns-csi-plugin
|
||||
displayName: TrueNAS CSI (tns-csi)
|
||||
description: >-
|
||||
@@ -13,7 +13,7 @@ license: Apache-2.0
|
||||
category: storage
|
||||
|
||||
homeURL: https://github.com/privilegedescalation/headlamp-tns-csi-plugin
|
||||
appVersion: "0.1.0"
|
||||
appVersion: "0.17.4"
|
||||
|
||||
keywords:
|
||||
- headlamp
|
||||
@@ -48,18 +48,22 @@ links:
|
||||
|
||||
changes:
|
||||
- kind: added
|
||||
description: "Overview dashboard: driver health, storage summary, protocol distribution, non-Bound PVC alerts"
|
||||
description: "Stable v1.0.0 release marking production readiness"
|
||||
- kind: added
|
||||
description: "Storage Classes, Volumes, Snapshots pages with slide-in detail panels"
|
||||
- kind: added
|
||||
description: "Metrics page: Prometheus WebSocket health, volume ops, CSI ops from controller pod"
|
||||
- kind: added
|
||||
description: "Benchmark page: kbench Job+PVC lifecycle, FIO log parser, results with IOPS/bandwidth/latency cards"
|
||||
- kind: added
|
||||
description: "PVC detail injection: TNS-CSI section on Headlamp PVC detail pages"
|
||||
description: "Missing devDependencies for test infrastructure (@mui/material, @types/react, @types/react-dom, notistack, vitest upgraded to ^3.2.4)"
|
||||
- kind: changed
|
||||
description: "vitest.config.mts: added define block to set process.env.NODE_ENV=test, fixing act() errors in all component tests"
|
||||
- kind: changed
|
||||
description: "Renovate config extended from org-level preset"
|
||||
- kind: changed
|
||||
description: "GitHub Actions SHA pinning via pinDigests in Renovate"
|
||||
- kind: changed
|
||||
description: "Dual-approval caller workflow added to CI"
|
||||
- kind: changed
|
||||
description: "GitHub App token secrets passed to release workflow"
|
||||
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.2.1/headlamp-tns-csi-plugin-0.2.1.tar.gz"
|
||||
headlamp/plugin/archive-checksum: "sha256:9d53ae89996be6edbfbdb239c95d9d808fcab2de19c23b8f7b8d9abe1c51877b"
|
||||
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,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -67,7 +67,7 @@ Welcome to the Headlamp TNS-CSI Plugin documentation.
|
||||
## External Links
|
||||
|
||||
- **[GitHub Repository](https://github.com/privilegedescalation/headlamp-tns-csi-plugin)**
|
||||
- **[Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin)**
|
||||
- **[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)
|
||||
@@ -28,7 +28,7 @@ The TNS-CSI plugin is a single-page React application bundled as a Headlamp plug
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Headlamp Pod (kube-system) │
|
||||
│ Headlamp Pod (headlamp namespace) │
|
||||
│ │
|
||||
│ Headlamp UI server + API proxy │
|
||||
│ (forwards requests using service account token │
|
||||
|
||||
+13
-13
@@ -9,11 +9,11 @@ helm repo add headlamp https://headlamp-k8s.github.io/headlamp/
|
||||
helm repo update
|
||||
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
--create-namespace \
|
||||
--set config.pluginsDir=/headlamp/plugins \
|
||||
--set pluginsManager.sources[0].name=headlamp-tns-csi-plugin \
|
||||
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
--set pluginsManager.sources[0].name=tns-csi \
|
||||
--set pluginsManager.sources[0].url=https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
## Complete values.yaml Example
|
||||
@@ -26,8 +26,8 @@ config:
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
|
||||
serviceAccount:
|
||||
name: headlamp
|
||||
@@ -44,7 +44,7 @@ Apply:
|
||||
|
||||
```bash
|
||||
helm install headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
-f headlamp-values.yaml
|
||||
```
|
||||
|
||||
@@ -55,7 +55,7 @@ apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: HelmRepository
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
interval: 12h
|
||||
url: https://headlamp-k8s.github.io/headlamp/
|
||||
@@ -64,7 +64,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
interval: 1h
|
||||
chart:
|
||||
@@ -74,14 +74,14 @@ spec:
|
||||
sourceRef:
|
||||
kind: HelmRepository
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
values:
|
||||
config:
|
||||
pluginsDir: /headlamp/plugins
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
- 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)
|
||||
@@ -122,7 +122,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
@@ -136,7 +136,7 @@ To upgrade to a new plugin version, update the `url` in your values and apply:
|
||||
|
||||
```bash
|
||||
helm upgrade headlamp headlamp/headlamp \
|
||||
--namespace kube-system \
|
||||
--namespace <your-namespace> \
|
||||
-f headlamp-values.yaml
|
||||
```
|
||||
|
||||
|
||||
+11
-11
@@ -2,7 +2,7 @@
|
||||
|
||||
## Test Suite Overview
|
||||
|
||||
The plugin has 67 unit tests across 4 test files:
|
||||
The plugin has **159 unit tests** across 12 test files:
|
||||
|
||||
| File | Tests | Coverage |
|
||||
| ---- | ----- | -------- |
|
||||
@@ -10,6 +10,14 @@ The plugin has 67 unit tests across 4 test files:
|
||||
| `src/api/metrics.test.ts` | Prometheus text format parser | metrics.ts |
|
||||
| `src/api/kbench.test.ts` | FIO log parser, manifest builders, format helpers | kbench.ts |
|
||||
| `src/api/TnsCsiDataContext.test.tsx` | Context provider integration | TnsCsiDataContext.tsx |
|
||||
| `src/components/OverviewPage.test.tsx` | Overview dashboard rendering | OverviewPage.tsx |
|
||||
| `src/components/StorageClassesPage.test.tsx` | StorageClass list and detail panel | StorageClassesPage.tsx |
|
||||
| `src/components/VolumesPage.test.tsx` | PV list and detail panel | VolumesPage.tsx |
|
||||
| `src/components/SnapshotsPage.test.tsx` | VolumeSnapshot list | SnapshotsPage.tsx |
|
||||
| `src/components/MetricsPage.test.tsx` | Prometheus metrics display | MetricsPage.tsx |
|
||||
| `src/components/BenchmarkPage.test.tsx` | kbench runner UI | BenchmarkPage.tsx |
|
||||
| `src/components/DriverStatusCard.test.tsx` | Driver health card | DriverStatusCard.tsx |
|
||||
| `src/components/PVCDetailSection.test.tsx` | PVC detail injection | PVCDetailSection.tsx |
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -136,14 +144,6 @@ if (typeof localStorage === 'undefined') {
|
||||
|
||||
## CI Test Enforcement
|
||||
|
||||
The GitHub Actions CI workflow runs tests on every push and pull request:
|
||||
The GitHub Actions CI workflow runs lint, typecheck, and test as three parallel jobs on every push and PR. A fourth `build` job gates on all three passing. The test job uses a JUnit reporter that posts test summaries directly on PRs.
|
||||
|
||||
```yaml
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Type-check
|
||||
run: npx tsc --noEmit
|
||||
```
|
||||
|
||||
Both must pass for the PR to merge.
|
||||
All three checks must pass for the PR to merge.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Method 1: Headlamp Plugin Manager (Recommended)
|
||||
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/headlamp-tns-csi-plugin/headlamp-tns-csi-plugin).
|
||||
The plugin is published on [Artifact Hub](https://artifacthub.io/packages/headlamp/tns-csi/headlamp-tns-csi-plugin).
|
||||
|
||||
**Via Headlamp UI:**
|
||||
|
||||
@@ -21,8 +21,8 @@ config:
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
**Via FluxCD HelmRelease:**
|
||||
@@ -32,7 +32,7 @@ apiVersion: helm.toolkit.fluxcd.io/v2
|
||||
kind: HelmRelease
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
chart:
|
||||
spec:
|
||||
@@ -45,8 +45,8 @@ spec:
|
||||
pluginsDir: /headlamp/plugins
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
### Method 2: Manual Tarball Install
|
||||
@@ -55,16 +55,13 @@ Download and extract the plugin directly:
|
||||
|
||||
```bash
|
||||
# Download the release tarball
|
||||
wget https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
|
||||
# Verify the checksum
|
||||
echo "14a3e8c13d0b894a41aa1cfccbcb1f6af09dcbb8fd95c7040a540987ea2096a7 headlamp-tns-csi-plugin-0.1.0.tar.gz" | sha256sum --check
|
||||
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 headlamp-tns-csi-plugin-0.1.0.tar.gz -C /headlamp/plugins/
|
||||
tar xzf tns-csi-1.0.0.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
The plugin directory should appear as `/headlamp/plugins/headlamp-tns-csi-plugin/`.
|
||||
The plugin directory should appear as `/headlamp/plugins/tns-csi/`.
|
||||
|
||||
Restart Headlamp (or the pod) after extracting.
|
||||
|
||||
@@ -81,7 +78,7 @@ initContainers:
|
||||
- -c
|
||||
- |
|
||||
wget -O /tmp/plugin.tar.gz \
|
||||
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v1.0.0/tns-csi-1.0.0.tar.gz
|
||||
tar xzf /tmp/plugin.tar.gz -C /headlamp/plugins/
|
||||
volumeMounts:
|
||||
- name: plugins
|
||||
@@ -98,10 +95,10 @@ cd headlamp-tns-csi-plugin
|
||||
npm install
|
||||
npm run build
|
||||
npm run package
|
||||
# Produces headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
# Produces tns-csi-<version>.tar.gz
|
||||
|
||||
# Extract to your Headlamp plugins directory
|
||||
tar xzf headlamp-tns-csi-plugin-0.1.0.tar.gz -C /headlamp/plugins/
|
||||
tar xzf tns-csi-<version>.tar.gz -C /headlamp/plugins/
|
||||
```
|
||||
|
||||
Or use `headlamp-plugin extract` for automatic placement:
|
||||
@@ -129,7 +126,7 @@ For Plugin Manager installs, the catalog will show available updates.
|
||||
Remove the plugin directory from your Headlamp plugins directory:
|
||||
|
||||
```bash
|
||||
rm -rf /headlamp/plugins/headlamp-tns-csi-plugin/
|
||||
rm -rf /headlamp/plugins/tns-csi/
|
||||
```
|
||||
|
||||
Or via the Headlamp UI: **Settings → Plugins → headlamp-tns-csi-plugin → Uninstall**.
|
||||
Or via the Headlamp UI: **Settings → Plugins → tns-csi → Uninstall**.
|
||||
|
||||
@@ -27,14 +27,14 @@ config:
|
||||
|
||||
pluginsManager:
|
||||
sources:
|
||||
- name: headlamp-tns-csi-plugin
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/v0.1.0/headlamp-tns-csi-plugin-0.1.0.tar.gz
|
||||
- name: tns-csi
|
||||
url: https://github.com/privilegedescalation/headlamp-tns-csi-plugin/releases/download/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 kube-system
|
||||
helm upgrade headlamp headlamp/headlamp -f values.yaml -n <your-namespace>
|
||||
```
|
||||
|
||||
## Step 2: Configure RBAC
|
||||
@@ -70,7 +70,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
@@ -78,7 +78,7 @@ roleRef:
|
||||
EOF
|
||||
```
|
||||
|
||||
Adjust `name: headlamp` and `namespace: kube-system` to match your Headlamp service account.
|
||||
Adjust `name: headlamp` and `namespace: <your-namespace>` to match your Headlamp service account.
|
||||
|
||||
## Step 3: Verify
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ If a page shows a loading spinner indefinitely:
|
||||
|
||||
1. **Check browser console** for errors (F12 → Console)
|
||||
2. **Check network tab** for failed API requests (look for 403, 404, 500)
|
||||
3. **Check Headlamp pod logs**: `kubectl logs -n kube-system -l app.kubernetes.io/name=headlamp`
|
||||
3. **Check Headlamp pod logs**: `kubectl logs -n <your-namespace> -l app.kubernetes.io/name=headlamp`
|
||||
4. **Try refreshing** — the watch connection may have been interrupted
|
||||
|
||||
## Common API Errors
|
||||
@@ -102,7 +102,7 @@ Look for errors related to `tns-csi`, `headlamp-plugin`, or Kubernetes API paths
|
||||
**Headlamp pod logs:**
|
||||
|
||||
```bash
|
||||
kubectl logs -n kube-system -l app.kubernetes.io/name=headlamp --tail=100
|
||||
kubectl logs -n <your-namespace> -l app.kubernetes.io/name=headlamp --tail=100
|
||||
```
|
||||
|
||||
**tns-csi controller logs:**
|
||||
|
||||
@@ -8,10 +8,10 @@ The Benchmark page requires permissions to create and delete Jobs and PVCs:
|
||||
|
||||
```bash
|
||||
kubectl auth can-i create jobs -n <benchmark-namespace> \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
kubectl auth can-i create persistentvolumeclaims -n <benchmark-namespace> \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
Apply the additional permissions if missing — see [RBAC Issues](rbac.md) or [SECURITY.md](../../SECURITY.md).
|
||||
|
||||
@@ -47,7 +47,7 @@ This requires `get` on `pods/proxy` in `kube-system`:
|
||||
```bash
|
||||
kubectl auth can-i get pods/proxy \
|
||||
-n kube-system \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
### 5. Network Policies
|
||||
|
||||
@@ -11,16 +11,16 @@ Use `kubectl auth can-i` to check specific permissions:
|
||||
```bash
|
||||
# Check if the Headlamp service account can list StorageClasses
|
||||
kubectl auth can-i list storageclasses \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
# Check pod proxy access (for metrics)
|
||||
kubectl auth can-i get pods/proxy \
|
||||
-n kube-system \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
|
||||
# Check snapshot access
|
||||
kubectl auth can-i list volumesnapshots \
|
||||
--as=system:serviceaccount:kube-system:headlamp
|
||||
--as=system:serviceaccount:<your-namespace>:headlamp
|
||||
```
|
||||
|
||||
### Applying the Required RBAC
|
||||
|
||||
@@ -47,7 +47,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp # adjust to your Headlamp service account name
|
||||
namespace: kube-system # adjust to your Headlamp namespace
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: ClusterRole
|
||||
name: headlamp-tns-csi-reader
|
||||
@@ -99,7 +99,7 @@ metadata:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: headlamp
|
||||
namespace: kube-system
|
||||
namespace: <your-namespace>
|
||||
roleRef:
|
||||
kind: Role
|
||||
name: headlamp-tns-csi-benchmark
|
||||
|
||||
Generated
-18188
File diff suppressed because it is too large
Load Diff
+30
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headlamp-tns-csi-plugin",
|
||||
"version": "0.2.1",
|
||||
"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"]
|
||||
}
|
||||
|
||||
@@ -21,10 +21,14 @@ vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
},
|
||||
},
|
||||
ConfigStore: class {
|
||||
get() { return {}; }
|
||||
get() {
|
||||
return {};
|
||||
}
|
||||
set() {}
|
||||
update() {}
|
||||
useConfig() { return () => ({}); }
|
||||
useConfig() {
|
||||
return () => ({});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
filterTnsCsiPersistentVolumes,
|
||||
filterTnsCsiPVCs,
|
||||
filterTnsCsiStorageClasses,
|
||||
filterTnsCsiVolumeSnapshots,
|
||||
isKubeList,
|
||||
isTnsCsiVolumeSnapshotClass,
|
||||
TNS_CSI_PROVISIONER,
|
||||
TnsCsiPersistentVolume,
|
||||
TnsCsiPersistentVolumeClaim,
|
||||
TnsCsiPod,
|
||||
TnsCsiStorageClass,
|
||||
TNS_CSI_PROVISIONER,
|
||||
VolumeSnapshot,
|
||||
VolumeSnapshotClass,
|
||||
} from './k8s';
|
||||
@@ -109,9 +111,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
try {
|
||||
// CSIDriver
|
||||
try {
|
||||
const driver = await ApiProxy.request(
|
||||
const driver = (await ApiProxy.request(
|
||||
`/apis/storage.k8s.io/v1/csidrivers/${TNS_CSI_PROVISIONER}`
|
||||
) as CSIDriver;
|
||||
)) as CSIDriver;
|
||||
if (!cancelled) setCsiDriver(driver);
|
||||
} catch {
|
||||
if (!cancelled) setCsiDriver(null);
|
||||
@@ -151,14 +153,19 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshotclasses'
|
||||
);
|
||||
if (!cancelled && isKubeList(vscList)) {
|
||||
setVolumeSnapshotClasses(vscList.items as VolumeSnapshotClass[]);
|
||||
const allSnapshotClasses = vscList.items as VolumeSnapshotClass[];
|
||||
const tnsCsiSnapshotClasses = allSnapshotClasses.filter(isTnsCsiVolumeSnapshotClass);
|
||||
setVolumeSnapshotClasses(tnsCsiSnapshotClasses);
|
||||
setSnapshotCrdAvailable(true);
|
||||
|
||||
const tnsCsiClassNames = new Set(tnsCsiSnapshotClasses.map(c => c.metadata.name));
|
||||
|
||||
const vsList = await ApiProxy.request(
|
||||
'/apis/snapshot.storage.k8s.io/v1/volumesnapshots'
|
||||
);
|
||||
if (!cancelled && isKubeList(vsList)) {
|
||||
setVolumeSnapshots(vsList.items as VolumeSnapshot[]);
|
||||
const allSnapshots = vsList.items as VolumeSnapshot[];
|
||||
setVolumeSnapshots(filterTnsCsiVolumeSnapshots(allSnapshots, tnsCsiClassNames));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -203,7 +210,9 @@ export function TnsCsiDataProvider({ children }: { children: React.ReactNode })
|
||||
}
|
||||
|
||||
void fetchAsync();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshKey]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+6
-5
@@ -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';
|
||||
|
||||
+22
-17
@@ -70,10 +70,7 @@ export interface PoolStats {
|
||||
* @param apiKey - TrueNAS API key
|
||||
* @returns Array of pool stats
|
||||
*/
|
||||
export function fetchTruenasPoolStats(
|
||||
server: string,
|
||||
apiKey: string
|
||||
): Promise<PoolStats[]> {
|
||||
export function fetchTruenasPoolStats(server: string, apiKey: string): Promise<PoolStats[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// TrueNAS WebSocket endpoint — supports both SCALE and CORE
|
||||
const url = `wss://${server}/api/current`;
|
||||
@@ -99,12 +96,14 @@ export function fetchTruenasPoolStats(
|
||||
|
||||
ws.onopen = () => {
|
||||
phase = 'authenticating';
|
||||
ws.send(JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'auth.login_with_api_key',
|
||||
params: [apiKey],
|
||||
}));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'auth.login_with_api_key',
|
||||
params: [apiKey],
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
@@ -124,12 +123,14 @@ export function fetchTruenasPoolStats(
|
||||
return;
|
||||
}
|
||||
phase = 'querying';
|
||||
ws.send(JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'pool.query',
|
||||
params: [],
|
||||
}));
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: msgId++,
|
||||
msg: 'method',
|
||||
method: 'pool.query',
|
||||
params: [],
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,7 +163,11 @@ export function fetchTruenasPoolStats(
|
||||
ws.onerror = () => {
|
||||
if (phase !== 'done') {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`));
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket error connecting to ${server} — check the server address and that TrueNAS is reachable`
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* AppBarDriverBadge — registerAppBarAction driver health badge.
|
||||
*
|
||||
* Displays "tns-csi: N/N" in the Headlamp top nav bar showing
|
||||
* ready controller + node pod counts. Color-coded:
|
||||
* green = all pods ready
|
||||
* orange = some pods degraded
|
||||
* red = no pods ready or driver missing
|
||||
*
|
||||
* Returns null if the driver is not installed (no CSIDriver object) --
|
||||
* no clutter in clusters where tns-csi is absent.
|
||||
*
|
||||
* Wrapped in TnsCsiDataProvider at registration time (index.tsx).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { isPodReady, TnsCsiPod } from '../api/k8s';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
|
||||
function countReady(pods: TnsCsiPod[]): number {
|
||||
return pods.filter(isPodReady).length;
|
||||
}
|
||||
|
||||
function getBadgeColor(ready: number, total: number): string {
|
||||
if (total === 0) return '#9e9e9e';
|
||||
if (ready === total) return '#4caf50';
|
||||
if (ready > 0) return '#ff9800';
|
||||
return '#f44336';
|
||||
}
|
||||
|
||||
export default function AppBarDriverBadge() {
|
||||
const { driverInstalled, controllerPods, nodePods, loading } = useTnsCsiContext();
|
||||
const history = useHistory();
|
||||
|
||||
if (loading || !driverInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controllerReady = countReady(controllerPods);
|
||||
const controllerTotal = controllerPods.length;
|
||||
const nodeReady = countReady(nodePods);
|
||||
const nodeTotal = nodePods.length;
|
||||
|
||||
const totalReady = controllerReady + nodeReady;
|
||||
const totalPods = controllerTotal + nodeTotal;
|
||||
|
||||
const color = getBadgeColor(totalReady, totalPods);
|
||||
|
||||
const handleClick = () => {
|
||||
history.push('/tns-csi');
|
||||
};
|
||||
|
||||
const labelText = `tns-csi: ${controllerReady}/${controllerTotal}c ${nodeReady}/${nodeTotal}n`;
|
||||
const ariaLabel = `TNS-CSI driver: ${controllerReady} of ${controllerTotal} controller pods ready, ${nodeReady} of ${nodeTotal} node pods ready`;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginRight: '8px',
|
||||
padding: '4px 12px',
|
||||
borderRadius: '16px',
|
||||
border: 'none',
|
||||
backgroundColor: color,
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
>
|
||||
<span>tns-csi: {controllerReady}/{controllerTotal}c {nodeReady}/{nodeTotal}n</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s';
|
||||
import { formatAge, getPodRestarts, isPodReady, TnsCsiPod } from '../api/k8s';
|
||||
|
||||
interface DriverPodDetailSectionProps {
|
||||
resource: {
|
||||
@@ -72,12 +69,14 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
|
||||
// Extract from jsonData (KubeObject instance) or fall back to direct props.
|
||||
// jsonData.metadata has the full shape including name/namespace; resource.metadata
|
||||
// only exposes fields that the Headlamp class getter provides (labels, creationTimestamp).
|
||||
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
} | undefined;
|
||||
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as
|
||||
| {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
}
|
||||
| undefined;
|
||||
const spec = resource?.jsonData?.spec ?? resource?.spec;
|
||||
const status = resource?.jsonData?.status ?? resource?.status;
|
||||
const labels = meta?.labels ?? {};
|
||||
@@ -88,7 +87,8 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
|
||||
}
|
||||
|
||||
const component = labels['app.kubernetes.io/component'] ?? 'unknown';
|
||||
const roleLabel = component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
|
||||
const roleLabel =
|
||||
component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
|
||||
|
||||
// Build a minimal pod shape that isPodReady / getPodRestarts can consume
|
||||
const podShape: TnsCsiPod = {
|
||||
@@ -113,15 +113,21 @@ export default function DriverPodDetailSection({ resource }: DriverPodDetailSect
|
||||
const containerRows = containerStatuses.map(cs => {
|
||||
let stateText = 'Unknown';
|
||||
if (cs.state?.running) {
|
||||
stateText = `Running since ${cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'} ago`;
|
||||
stateText = `Running since ${
|
||||
cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'
|
||||
} ago`;
|
||||
} else if (cs.state?.waiting) {
|
||||
stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`;
|
||||
} else if (cs.state?.terminated) {
|
||||
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${cs.state.terminated.reason ?? ''}`;
|
||||
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${
|
||||
cs.state.terminated.reason ?? ''
|
||||
}`;
|
||||
}
|
||||
return {
|
||||
name: cs.name,
|
||||
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'} — ${stateText} — ${cs.restartCount} restart(s)`,
|
||||
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'} — ${stateText} — ${
|
||||
cs.restartCount
|
||||
} restart(s)`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
SimpleTable,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
|
||||
import type { TnsCsiMetrics } from '../api/metrics';
|
||||
import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics';
|
||||
import { fetchControllerMetrics } from '../api/metrics';
|
||||
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
|
||||
import DriverStatusCard from './DriverStatusCard';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,10 +26,10 @@ import DriverStatusCard from './DriverStatusCard';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
NFS: '#1976d2',
|
||||
'NVMe-oF': '#9c27b0',
|
||||
iSCSI: '#f57c00',
|
||||
Other: '#9e9e9e',
|
||||
NFS: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
'NVMe-oF': 'var(--mui-palette-secondary-main, #9c27b0)',
|
||||
iSCSI: 'var(--mui-palette-warning-main, #f57c00)',
|
||||
Other: 'var(--mui-palette-action-disabled, #9e9e9e)',
|
||||
};
|
||||
|
||||
function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) {
|
||||
@@ -85,6 +85,24 @@ export default function OverviewPage() {
|
||||
void fetchMetrics();
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const capacityByPool: Map<string, number> = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!metrics) return map;
|
||||
const handleToPool = new Map<string, string>();
|
||||
for (const pv of persistentVolumes) {
|
||||
const handle = pv.spec.csi?.volumeHandle;
|
||||
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
|
||||
if (handle && pool) handleToPool.set(handle, pool);
|
||||
}
|
||||
for (const sample of metrics.volumeCapacityBytes) {
|
||||
const volumeId = sample.labels['volume_id'];
|
||||
if (!volumeId) continue;
|
||||
const pool = handleToPool.get(volumeId) ?? 'unknown';
|
||||
map.set(pool, (map.get(pool) ?? 0) + sample.value);
|
||||
}
|
||||
return map;
|
||||
}, [metrics, persistentVolumes]);
|
||||
|
||||
if (loading) {
|
||||
return <Loader title="Loading TNS-CSI data..." />;
|
||||
}
|
||||
@@ -104,37 +122,21 @@ export default function OverviewPage() {
|
||||
else pvcStatusCounts.Other++;
|
||||
}
|
||||
|
||||
const nonBoundPvcs = persistentVolumeClaims.filter(
|
||||
pvc => pvc.status?.phase !== 'Bound'
|
||||
);
|
||||
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
|
||||
|
||||
const chartData = protocolChartData(storageClasses);
|
||||
const totalScs = storageClasses.length;
|
||||
|
||||
// Capacity by pool: join volumeCapacityBytes samples (volume_id, protocol)
|
||||
// with PV volumeHandle → pool name from volumeAttributes.
|
||||
const capacityByPool: Map<string, number> = React.useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
if (!metrics) return map;
|
||||
// Build lookup: volumeHandle → pool name
|
||||
const handleToPool = new Map<string, string>();
|
||||
for (const pv of persistentVolumes) {
|
||||
const handle = pv.spec.csi?.volumeHandle;
|
||||
const pool = pv.spec.csi?.volumeAttributes?.['pool'];
|
||||
if (handle && pool) handleToPool.set(handle, pool);
|
||||
}
|
||||
for (const sample of metrics.volumeCapacityBytes) {
|
||||
const volumeId = sample.labels['volume_id'];
|
||||
if (!volumeId) continue;
|
||||
const pool = handleToPool.get(volumeId) ?? 'unknown';
|
||||
map.set(pool, (map.get(pool) ?? 0) + sample.value);
|
||||
}
|
||||
return map;
|
||||
}, [metrics, persistentVolumes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<SectionHeader title="TNS-CSI — Overview" />
|
||||
<button
|
||||
onClick={refresh}
|
||||
@@ -177,11 +179,16 @@ export default function OverviewPage() {
|
||||
rows={[
|
||||
{
|
||||
name: 'Status',
|
||||
value: <StatusLabel status="error">CSIDriver tns.csi.io not found on this cluster</StatusLabel>,
|
||||
value: (
|
||||
<StatusLabel status="error">
|
||||
CSIDriver tns.csi.io not found on this cluster
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Install',
|
||||
value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
|
||||
value:
|
||||
'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -226,7 +233,13 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Storage Summary">
|
||||
{totalScs > 0 && chartData.length > 0 && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<div style={{ marginBottom: '8px', fontSize: '14px', color: 'var(--mui-palette-text-secondary)' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '14px',
|
||||
color: 'var(--mui-palette-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Protocol Distribution
|
||||
</div>
|
||||
<PercentageBar data={chartData} total={totalScs} />
|
||||
@@ -242,16 +255,20 @@ export default function OverviewPage() {
|
||||
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
|
||||
},
|
||||
...(pvcStatusCounts.Pending > 0
|
||||
? [{
|
||||
name: 'PVCs (Pending)',
|
||||
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
|
||||
}]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Pending)',
|
||||
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(pvcStatusCounts.Lost > 0
|
||||
? [{
|
||||
name: 'PVCs (Lost)',
|
||||
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
|
||||
}]
|
||||
? [
|
||||
{
|
||||
name: 'PVCs (Lost)',
|
||||
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
@@ -262,23 +279,21 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Pool Capacity">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Pool', getter: (p) => p.name },
|
||||
{ label: 'Pool', getter: p => p.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (p) => (
|
||||
getter: p => (
|
||||
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
|
||||
{p.status}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Total', getter: (p) => formatBytes(p.size) },
|
||||
{ label: 'Used', getter: (p) => formatBytes(p.allocated) },
|
||||
{ label: 'Free', getter: (p) => formatBytes(p.free) },
|
||||
{ label: 'Total', getter: p => formatBytes(p.size) },
|
||||
{ label: 'Used', getter: p => formatBytes(p.allocated) },
|
||||
{ label: 'Free', getter: p => formatBytes(p.free) },
|
||||
{
|
||||
label: 'Used %',
|
||||
getter: (p) => p.size > 0
|
||||
? `${Math.round((p.allocated / p.size) * 100)}%`
|
||||
: '—',
|
||||
getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
|
||||
},
|
||||
]}
|
||||
data={poolStats}
|
||||
@@ -303,7 +318,8 @@ export default function OverviewPage() {
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Provisioned capacity by pool (from Prometheus metrics — shown when TrueNAS API not configured) */}
|
||||
{/* Provisioned capacity by pool (from Prometheus metrics —
|
||||
shown when TrueNAS API not configured) */}
|
||||
{poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && (
|
||||
<SectionBox title="Provisioned Capacity by Pool">
|
||||
<NameValueTable
|
||||
@@ -322,17 +338,17 @@ export default function OverviewPage() {
|
||||
<SectionBox title="Attention: Non-Bound PVCs">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Name', getter: (pvc) => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' },
|
||||
{ label: 'Name', getter: pvc => pvc.metadata.name },
|
||||
{ label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (pvc) => (
|
||||
getter: pvc => (
|
||||
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
|
||||
{pvc.status?.phase ?? 'Unknown'}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) },
|
||||
{ label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
|
||||
]}
|
||||
data={nonBoundPvcs}
|
||||
/>
|
||||
@@ -353,11 +369,16 @@ function parseStorageToBytes(storage: string): number {
|
||||
const suffix = match[2] ?? '';
|
||||
const multipliers: Record<string, number> = {
|
||||
'': 1,
|
||||
K: 1e3, Ki: 1024,
|
||||
M: 1e6, Mi: 1024 ** 2,
|
||||
G: 1e9, Gi: 1024 ** 3,
|
||||
T: 1e12, Ti: 1024 ** 4,
|
||||
P: 1e15, Pi: 1024 ** 5,
|
||||
K: 1e3,
|
||||
Ki: 1024,
|
||||
M: 1e6,
|
||||
Mi: 1024 ** 2,
|
||||
G: 1e9,
|
||||
Gi: 1024 ** 3,
|
||||
T: 1e12,
|
||||
Ti: 1024 ** 4,
|
||||
P: 1e15,
|
||||
Pi: 1024 ** 5,
|
||||
};
|
||||
return value * (multipliers[suffix] ?? 1);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import { NameValueTable, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -119,8 +119,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
Generate in TrueNAS UI → Credentials → API Keys.
|
||||
Required for real pool capacity data on the Overview page.
|
||||
Generate in TrueNAS UI → Credentials → API Keys. Required for real pool capacity
|
||||
data on the Overview page.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
@@ -137,8 +137,8 @@ export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsPro
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
TrueNAS host/IP. If blank, the plugin uses the{' '}
|
||||
<code>server</code> parameter from your tns-csi StorageClass.
|
||||
TrueNAS host/IP. If blank, the plugin uses the <code>server</code> parameter from
|
||||
your tns-csi StorageClass.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -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}`))
|
||||
);
|
||||
@@ -22,23 +22,20 @@ interface StorageClassBenchmarkButtonProps {
|
||||
};
|
||||
}
|
||||
|
||||
export default function StorageClassBenchmarkButton({ resource }: StorageClassBenchmarkButtonProps) {
|
||||
export default function StorageClassBenchmarkButton({
|
||||
resource,
|
||||
}: StorageClassBenchmarkButtonProps) {
|
||||
const history = useHistory();
|
||||
|
||||
// provisioner is one of the fields Headlamp's StorageClass class exposes as a getter,
|
||||
// so it's accessible directly. jsonData fallback for safety.
|
||||
const provisioner =
|
||||
resource?.provisioner ??
|
||||
resource?.jsonData?.provisioner;
|
||||
const provisioner = resource?.provisioner ?? resource?.jsonData?.provisioner;
|
||||
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scName =
|
||||
resource?.metadata?.name ??
|
||||
resource?.jsonData?.metadata?.name ??
|
||||
'';
|
||||
const scName = resource?.metadata?.name ?? resource?.jsonData?.metadata?.name ?? '';
|
||||
|
||||
const handleClick = () => {
|
||||
// Navigate to benchmark page; user selects the SC in the benchmark form.
|
||||
|
||||
@@ -50,16 +50,14 @@ export function buildStorageClassColumns() {
|
||||
label: 'Protocol',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'protocol');
|
||||
return typeof p === 'string' ? formatProtocol(p) : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
|
||||
return <span>{formatProtocol(protocol)}</span>;
|
||||
@@ -69,16 +67,14 @@ export function buildStorageClassColumns() {
|
||||
label: 'Pool',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'pool');
|
||||
return typeof p === 'string' ? p : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const pool = getField(sc, 'parameters', 'pool') as string | undefined;
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
@@ -88,16 +84,14 @@ export function buildStorageClassColumns() {
|
||||
label: 'Server',
|
||||
getValue: (sc: unknown): string | null => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return null;
|
||||
const p = getField(sc, 'parameters', 'server');
|
||||
return typeof p === 'string' ? p : null;
|
||||
},
|
||||
render: (sc: unknown) => {
|
||||
const provisioner =
|
||||
getField(sc, 'provisioner') ??
|
||||
(sc as Record<string, unknown>)?.['provisioner'];
|
||||
getField(sc, 'provisioner') ?? (sc as Record<string, unknown>)?.['provisioner'];
|
||||
if (provisioner !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const server = getField(sc, 'parameters', 'server') as string | undefined;
|
||||
return <span>{server ?? '—'}</span>;
|
||||
@@ -127,7 +121,9 @@ export function buildPVColumns() {
|
||||
render: (pv: unknown) => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as string | undefined;
|
||||
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as
|
||||
| string
|
||||
| undefined;
|
||||
return <span>{formatProtocol(protocol)}</span>;
|
||||
},
|
||||
},
|
||||
@@ -144,7 +140,9 @@ export function buildPVColumns() {
|
||||
render: (pv: unknown) => {
|
||||
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
|
||||
if (driver !== TNS_CSI_PROVISIONER) return <span>—</span>;
|
||||
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as string | undefined;
|
||||
const dataset = getField(pv, 'spec', 'csi', 'volumeAttributes', 'datasetName') as
|
||||
| string
|
||||
| undefined;
|
||||
const pool = dataset?.split('/')[0];
|
||||
return <span>{pool ?? '—'}</span>;
|
||||
},
|
||||
|
||||
+43
-5
@@ -15,17 +15,20 @@ import {
|
||||
} from '@kinvolk/headlamp-plugin/lib';
|
||||
import React from 'react';
|
||||
import { TnsCsiDataProvider } from './api/TnsCsiDataContext';
|
||||
import TnsCsiSettings from './components/TnsCsiSettings';
|
||||
import BenchmarkPage from './components/BenchmarkPage';
|
||||
import DriverPodDetailSection from './components/DriverPodDetailSection';
|
||||
import { buildPVColumns, buildStorageClassColumns } from './components/integrations/StorageClassColumns';
|
||||
import StorageClassBenchmarkButton from './components/integrations/StorageClassBenchmarkButton';
|
||||
import {
|
||||
buildPVColumns,
|
||||
buildStorageClassColumns,
|
||||
} from './components/integrations/StorageClassColumns';
|
||||
import MetricsPage from './components/MetricsPage';
|
||||
import OverviewPage from './components/OverviewPage';
|
||||
import PVCDetailSection from './components/PVCDetailSection';
|
||||
import PVDetailSection from './components/PVDetailSection';
|
||||
import SnapshotsPage from './components/SnapshotsPage';
|
||||
import StorageClassesPage from './components/StorageClassesPage';
|
||||
import TnsCsiSettings from './components/TnsCsiSettings';
|
||||
import VolumesPage from './components/VolumesPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -187,12 +190,47 @@ registerDetailsViewSection(({ 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 [...columns, ...buildStorageClassColumns()];
|
||||
return mergeColumns(columns, buildStorageClassColumns());
|
||||
}
|
||||
if (id === 'headlamp-persistentvolumes') {
|
||||
return [...columns, ...buildPVColumns()];
|
||||
return mergeColumns(columns, buildPVColumns());
|
||||
}
|
||||
return columns;
|
||||
});
|
||||
@@ -210,4 +248,4 @@ registerDetailsViewHeaderAction(({ resource }) => {
|
||||
// Plugin settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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