feat: native Headlamp integration, TrueNAS API, docs, and CI for v0.2.0

Native Headlamp integrations:
- registerResourceTableColumnsProcessor: add Protocol/Pool/Server columns to
  native StorageClass table and Protocol/Volume Handle to PV table
- registerDetailsViewSection: inject TNS-CSI section into PV detail pages
- registerDetailsViewSection: inject driver role/status into tns-csi Pod pages
- registerDetailsViewHeaderAction: Benchmark shortcut on StorageClass detail
- registerAppBarAction: driver health badge (N/Nc M/Mn, color-coded)
- Trim sidebar from 6 → 4 entries (Overview, Snapshots, Metrics, Benchmark)

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

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

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

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-18 16:37:56 -05:00
parent f2f3c3a87e
commit f1feb5c2f7
30 changed files with 3540 additions and 44 deletions
@@ -0,0 +1,73 @@
/**
* StorageClassBenchmarkButton — registerDetailsViewHeaderAction for StorageClass pages.
*
* Adds a "Benchmark" button to the detail page header of tns-csi StorageClasses.
* Navigates to /tns-csi/benchmark so the user can run a FIO benchmark
* against that storage class.
*/
import React from 'react';
import { useHistory } from 'react-router-dom';
import { TNS_CSI_PROVISIONER } from '../../api/k8s';
interface StorageClassBenchmarkButtonProps {
resource: {
provisioner?: string;
metadata?: { name?: string };
// KubeObject instance — provisioner may be a direct getter or under jsonData
jsonData?: {
provisioner?: string;
metadata?: { name?: string };
};
};
}
export default function StorageClassBenchmarkButton({ resource }: StorageClassBenchmarkButtonProps) {
const history = useHistory();
// provisioner is one of the fields Headlamp's StorageClass class exposes as a getter,
// so it's accessible directly. jsonData fallback for safety.
const provisioner =
resource?.provisioner ??
resource?.jsonData?.provisioner;
if (provisioner !== TNS_CSI_PROVISIONER) {
return null;
}
const scName =
resource?.metadata?.name ??
resource?.jsonData?.metadata?.name ??
'';
const handleClick = () => {
// Navigate to benchmark page; user selects the SC in the benchmark form.
// Pass the SC name via hash so BenchmarkPage can pre-select it if desired.
history.push(`/tns-csi/benchmark#${scName}`);
};
return (
<button
onClick={handleClick}
style={{
cursor: 'pointer',
padding: '6px 16px',
borderRadius: '4px',
border: '1px solid currentColor',
backgroundColor: 'transparent',
color: 'inherit',
fontSize: '0.875rem',
fontWeight: 500,
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
opacity: 0.85,
}}
aria-label={`Run benchmark on ${scName}`}
title={`Run FIO benchmark on storage class ${scName}`}
>
<span></span>
<span>Benchmark</span>
</button>
);
}
@@ -0,0 +1,149 @@
/**
* StorageClassColumns — registerResourceTableColumnsProcessor for StorageClass and PV tables.
*
* Adds Protocol/Pool/Server columns to the native /storage-classes table and
* Protocol/Volume Handle columns to the native /persistent-volumes table.
*
* Items in column processors are KubeObject class instances from Headlamp.
* Raw Kubernetes JSON fields (parameters, spec, status) must be accessed
* via .jsonData — only fields with explicit getters (provisioner, reclaimPolicy, etc.)
* are accessible as direct properties.
*/
import React from 'react';
import { formatProtocol, TNS_CSI_PROVISIONER } from '../../api/k8s';
// ---------------------------------------------------------------------------
// Helper: extract a field from either a KubeObject instance or a plain object
// ---------------------------------------------------------------------------
function getField(item: unknown, ...path: string[]): unknown {
if (!item || typeof item !== 'object') return undefined;
const obj = item as Record<string, unknown>;
// KubeObject instance — raw K8s JSON is under .jsonData
const raw: Record<string, unknown> =
'jsonData' in obj && obj['jsonData'] && typeof obj['jsonData'] === 'object'
? (obj['jsonData'] as Record<string, unknown>)
: obj;
let cur: unknown = raw;
for (const key of path) {
if (!cur || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
// ---------------------------------------------------------------------------
// StorageClass column definitions
// ---------------------------------------------------------------------------
/**
* Returns extra columns for the native StorageClass table.
* For non-tns-csi rows, cells show "—" (never undefined/null visible).
*/
export function buildStorageClassColumns() {
return [
{
label: 'Protocol',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'protocol');
return typeof p === 'string' ? formatProtocol(p) : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(sc, 'parameters', 'protocol') as string | undefined;
return <span>{formatProtocol(protocol)}</span>;
},
},
{
label: 'Pool',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'pool');
return typeof p === 'string' ? p : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const pool = getField(sc, 'parameters', 'pool') as string | undefined;
return <span>{pool ?? '—'}</span>;
},
},
{
label: 'Server',
getValue: (sc: unknown): string | null => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return null;
const p = getField(sc, 'parameters', 'server');
return typeof p === 'string' ? p : null;
},
render: (sc: unknown) => {
const provisioner =
getField(sc, 'provisioner') ??
(sc as Record<string, unknown>)?.['provisioner'];
if (provisioner !== TNS_CSI_PROVISIONER) return <span></span>;
const server = getField(sc, 'parameters', 'server') as string | undefined;
return <span>{server ?? '—'}</span>;
},
},
];
}
// ---------------------------------------------------------------------------
// PersistentVolume column definitions
// ---------------------------------------------------------------------------
/**
* Returns extra columns for the native PersistentVolume table.
* For non-tns-csi PVs, cells show "—".
*/
export function buildPVColumns() {
return [
{
label: 'Protocol',
getValue: (pv: unknown): string | null => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return null;
const p = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol');
return typeof p === 'string' ? formatProtocol(p) : null;
},
render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const protocol = getField(pv, 'spec', 'csi', 'volumeAttributes', 'protocol') as string | undefined;
return <span>{formatProtocol(protocol)}</span>;
},
},
{
label: 'Volume Handle',
getValue: (pv: unknown): string | null => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return null;
const h = getField(pv, 'spec', 'csi', 'volumeHandle');
return typeof h === 'string' ? h : null;
},
render: (pv: unknown) => {
const driver = getField(pv, 'spec', 'csi', 'driver') as string | undefined;
if (driver !== TNS_CSI_PROVISIONER) return <span></span>;
const handle = getField(pv, 'spec', 'csi', 'volumeHandle') as string | undefined;
return <span style={{ fontFamily: 'monospace', fontSize: '0.85em' }}>{handle ?? '—'}</span>;
},
},
];
}