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:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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,143 @@
|
||||
/**
|
||||
* DriverPodDetailSection — injected into Headlamp's Pod detail view.
|
||||
*
|
||||
* Shown only for tns-csi driver pods (identified by
|
||||
* app.kubernetes.io/name=tns-csi-driver label). Returns null for all other pods.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatAge, isPodReady, getPodRestarts, TnsCsiPod } from '../api/k8s';
|
||||
|
||||
interface DriverPodDetailSectionProps {
|
||||
resource: {
|
||||
kind?: string;
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec?: { nodeName?: string };
|
||||
status?: {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: Array<{
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
// KubeObject instance: raw JSON lives under jsonData;
|
||||
// metadata here only exposes what the class getter provides (labels, creationTimestamp).
|
||||
// The jsonData.metadata has the full shape.
|
||||
jsonData?: {
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
spec?: { nodeName?: string };
|
||||
status?: {
|
||||
phase?: string;
|
||||
conditions?: Array<{ type: string; status: string }>;
|
||||
containerStatuses?: Array<{
|
||||
name: string;
|
||||
ready: boolean;
|
||||
restartCount: number;
|
||||
image?: string;
|
||||
state?: {
|
||||
running?: { startedAt?: string };
|
||||
waiting?: { reason?: string };
|
||||
terminated?: { exitCode?: number; reason?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function DriverPodDetailSection({ resource }: DriverPodDetailSectionProps) {
|
||||
// Extract from jsonData (KubeObject instance) or fall back to direct props.
|
||||
// jsonData.metadata has the full shape including name/namespace; resource.metadata
|
||||
// only exposes fields that the Headlamp class getter provides (labels, creationTimestamp).
|
||||
const meta = (resource?.jsonData?.metadata ?? resource?.metadata) as {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
labels?: Record<string, string>;
|
||||
creationTimestamp?: string;
|
||||
} | undefined;
|
||||
const spec = resource?.jsonData?.spec ?? resource?.spec;
|
||||
const status = resource?.jsonData?.status ?? resource?.status;
|
||||
const labels = meta?.labels ?? {};
|
||||
|
||||
// Guard: only tns-csi driver pods
|
||||
if (labels['app.kubernetes.io/name'] !== 'tns-csi-driver') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const component = labels['app.kubernetes.io/component'] ?? 'unknown';
|
||||
const roleLabel = component === 'controller' ? 'Controller' : component === 'node' ? 'Node' : component;
|
||||
|
||||
// Build a minimal pod shape that isPodReady / getPodRestarts can consume
|
||||
const podShape: TnsCsiPod = {
|
||||
metadata: {
|
||||
name: meta?.name ?? '',
|
||||
namespace: meta?.namespace,
|
||||
creationTimestamp: meta?.creationTimestamp,
|
||||
labels,
|
||||
},
|
||||
spec: { nodeName: spec?.nodeName },
|
||||
status: status as TnsCsiPod['status'],
|
||||
};
|
||||
|
||||
const ready = isPodReady(podShape);
|
||||
const restarts = getPodRestarts(podShape);
|
||||
const phase = status?.phase ?? '—';
|
||||
const nodeName = spec?.nodeName ?? '—';
|
||||
const age = formatAge(meta?.creationTimestamp);
|
||||
|
||||
// Container statuses
|
||||
const containerStatuses = status?.containerStatuses ?? [];
|
||||
const containerRows = containerStatuses.map(cs => {
|
||||
let stateText = 'Unknown';
|
||||
if (cs.state?.running) {
|
||||
stateText = `Running since ${cs.state.running.startedAt ? formatAge(cs.state.running.startedAt) : '?'} ago`;
|
||||
} else if (cs.state?.waiting) {
|
||||
stateText = `Waiting: ${cs.state.waiting.reason ?? 'unknown'}`;
|
||||
} else if (cs.state?.terminated) {
|
||||
stateText = `Terminated (exit ${cs.state.terminated.exitCode ?? '?'}): ${cs.state.terminated.reason ?? ''}`;
|
||||
}
|
||||
return {
|
||||
name: cs.name,
|
||||
value: `${cs.ready ? '✓ Ready' : '✗ Not Ready'} — ${stateText} — ${cs.restartCount} restart(s)`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<SectionBox title="TNS-CSI Driver Info">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Role', value: roleLabel },
|
||||
{ name: 'Phase', value: phase },
|
||||
{ name: 'Ready', value: ready ? 'Yes' : 'No' },
|
||||
{ name: 'Restarts', value: String(restarts) },
|
||||
{ name: 'Node', value: nodeName },
|
||||
{ name: 'Age', value: age },
|
||||
...containerRows,
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,8 @@ export default function OverviewPage() {
|
||||
persistentVolumeClaims,
|
||||
controllerPods,
|
||||
nodePods,
|
||||
poolStats,
|
||||
poolStatsError,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
@@ -109,6 +111,27 @@ export default function OverviewPage() {
|
||||
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' }}>
|
||||
@@ -234,6 +257,66 @@ export default function OverviewPage() {
|
||||
/>
|
||||
</SectionBox>
|
||||
|
||||
{/* Pool capacity — real data from TrueNAS API when configured */}
|
||||
{poolStats.length > 0 && (
|
||||
<SectionBox title="Pool Capacity">
|
||||
<SimpleTable
|
||||
columns={[
|
||||
{ label: 'Pool', getter: (p) => p.name },
|
||||
{
|
||||
label: 'Status',
|
||||
getter: (p) => (
|
||||
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
|
||||
{p.status}
|
||||
</StatusLabel>
|
||||
),
|
||||
},
|
||||
{ label: 'Total', getter: (p) => formatBytes(p.size) },
|
||||
{ label: 'Used', getter: (p) => formatBytes(p.allocated) },
|
||||
{ label: 'Free', getter: (p) => formatBytes(p.free) },
|
||||
{
|
||||
label: 'Used %',
|
||||
getter: (p) => p.size > 0
|
||||
? `${Math.round((p.allocated / p.size) * 100)}%`
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
data={poolStats}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{poolStatsError && (
|
||||
<SectionBox title="Pool Capacity Unavailable">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'Error',
|
||||
value: <StatusLabel status="warning">{poolStatsError}</StatusLabel>,
|
||||
},
|
||||
{
|
||||
name: 'Note',
|
||||
value: 'Check your TrueNAS API key and server address in plugin settings.',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Provisioned capacity by pool (from Prometheus metrics — shown when TrueNAS API not configured) */}
|
||||
{poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && (
|
||||
<SectionBox title="Provisioned Capacity by Pool">
|
||||
<NameValueTable
|
||||
rows={[...capacityByPool.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([pool, bytes]) => ({
|
||||
name: pool,
|
||||
value: formatBytes(bytes),
|
||||
}))}
|
||||
/>
|
||||
</SectionBox>
|
||||
)}
|
||||
|
||||
{/* Non-bound PVCs warning */}
|
||||
{nonBoundPvcs.length > 0 && (
|
||||
<SectionBox title="Attention: Non-Bound PVCs">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* PVDetailSection — injected into Headlamp's PersistentVolume detail view.
|
||||
*
|
||||
* Shown only when the PV uses tns.csi.io as the CSI driver.
|
||||
* Uses registerDetailsViewSection in index.tsx.
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React from 'react';
|
||||
import { formatProtocol, TNS_CSI_PROVISIONER } from '../api/k8s';
|
||||
|
||||
interface PVDetailSectionProps {
|
||||
resource: {
|
||||
kind?: string;
|
||||
metadata?: { name?: string; namespace?: string };
|
||||
spec?: {
|
||||
csi?: {
|
||||
driver?: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: Record<string, string>;
|
||||
};
|
||||
storageClassName?: string;
|
||||
capacity?: { storage?: string };
|
||||
persistentVolumeReclaimPolicy?: string;
|
||||
};
|
||||
// KubeObject instance — raw JSON lives under jsonData
|
||||
jsonData?: {
|
||||
spec?: {
|
||||
csi?: {
|
||||
driver?: string;
|
||||
volumeHandle?: string;
|
||||
volumeAttributes?: Record<string, string>;
|
||||
};
|
||||
storageClassName?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export default function PVDetailSection({ resource }: PVDetailSectionProps) {
|
||||
// Extract from jsonData (KubeObject instance) or fall back to direct properties
|
||||
const spec = resource?.jsonData?.spec ?? resource?.spec;
|
||||
const csi = spec?.csi;
|
||||
|
||||
if (!csi || csi.driver !== TNS_CSI_PROVISIONER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attrs = csi.volumeAttributes ?? {};
|
||||
const protocol = formatProtocol(attrs['protocol']);
|
||||
const otherAttrs = Object.entries(attrs).filter(
|
||||
([k]) => !['protocol', 'server', 'pool'].includes(k)
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionBox title="TNS-CSI Storage Details">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{ name: 'Driver', value: TNS_CSI_PROVISIONER },
|
||||
{ name: 'Protocol', value: protocol },
|
||||
{ name: 'Server', value: attrs['server'] ?? '—' },
|
||||
{ name: 'Pool', value: attrs['pool'] ?? '—' },
|
||||
{ name: 'Volume Handle', value: csi.volumeHandle ?? '—' },
|
||||
{ name: 'Storage Class', value: spec?.storageClassName ?? '—' },
|
||||
...otherAttrs.map(([k, v]) => ({ name: k, value: v ?? '—' })),
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* TnsCsiSettings — plugin settings page.
|
||||
*
|
||||
* Lets users configure the TrueNAS API key and (optionally) a server address
|
||||
* override. When configured, the plugin fetches real pool capacity data via
|
||||
* the TrueNAS WebSocket JSON-RPC API (pool.query) and displays it on the
|
||||
* Overview page.
|
||||
*
|
||||
* Settings are persisted via Headlamp's ConfigStore (Redux-backed).
|
||||
*/
|
||||
|
||||
import {
|
||||
NameValueTable,
|
||||
SectionBox,
|
||||
StatusLabel,
|
||||
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
|
||||
import React, { useState } from 'react';
|
||||
import { fetchTruenasPoolStats, getTnsCsiConfig, setTnsCsiConfig } from '../api/truenas';
|
||||
|
||||
interface PluginSettingsProps {
|
||||
data?: Record<string, string | number | boolean>;
|
||||
onDataChange?: (data: Record<string, string | number | boolean>) => void;
|
||||
}
|
||||
|
||||
const INPUT_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--mui-palette-divider, #e0e0e0)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
backgroundColor: 'var(--mui-palette-background-paper, #fff)',
|
||||
color: 'var(--mui-palette-text-primary, #000)',
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
const HINT_STYLE: React.CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
marginTop: '4px',
|
||||
};
|
||||
|
||||
export default function TnsCsiSettings({ data, onDataChange }: PluginSettingsProps) {
|
||||
const saved = getTnsCsiConfig();
|
||||
|
||||
const [apiKey, setApiKey] = useState<string>(
|
||||
(data?.truenasApiKey as string) ?? saved.truenasApiKey ?? ''
|
||||
);
|
||||
const [serverOverride, setServerOverride] = useState<string>(
|
||||
(data?.truenasServerOverride as string) ?? saved.truenasServerOverride ?? ''
|
||||
);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
function handleApiKeyChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setApiKey(val);
|
||||
setTnsCsiConfig({ truenasApiKey: val });
|
||||
onDataChange?.({ ...data, truenasApiKey: val });
|
||||
}
|
||||
|
||||
function handleServerOverrideChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value;
|
||||
setServerOverride(val);
|
||||
setTnsCsiConfig({ truenasServerOverride: val });
|
||||
onDataChange?.({ ...data, truenasServerOverride: val });
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
const server = serverOverride.trim() || '(from StorageClass)';
|
||||
if (!serverOverride.trim()) {
|
||||
setTesting(false);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Enter a Server Address to test the connection.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!apiKey.trim()) {
|
||||
setTesting(false);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: 'Enter an API key to test the connection.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pools = await fetchTruenasPoolStats(serverOverride.trim(), apiKey.trim());
|
||||
const names = pools.map(p => p.name).join(', ');
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: `Connected to ${server}. Found ${pools.length} pool(s): ${names || '(none)'}`,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: String(err instanceof Error ? err.message : err),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionBox title="TrueNAS API (Optional)">
|
||||
<NameValueTable
|
||||
rows={[
|
||||
{
|
||||
name: 'API Key',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
placeholder="Paste your TrueNAS API key here"
|
||||
style={INPUT_STYLE}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
Generate in TrueNAS UI → Credentials → API Keys.
|
||||
Required for real pool capacity data on the Overview page.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Server Address',
|
||||
value: (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={serverOverride}
|
||||
onChange={handleServerOverrideChange}
|
||||
placeholder="e.g. 192.168.1.100 or truenas.local"
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<div style={HINT_STYLE}>
|
||||
TrueNAS host/IP. If blank, the plugin uses the{' '}
|
||||
<code>server</code> parameter from your tns-csi StorageClass.
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Connection Test',
|
||||
value: (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => void testConnection()}
|
||||
disabled={testing}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
backgroundColor: testing
|
||||
? 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
|
||||
: 'var(--mui-palette-primary-main, #1976d2)',
|
||||
color: testing
|
||||
? 'var(--mui-palette-action-disabled, #9e9e9e)'
|
||||
: 'var(--mui-palette-primary-contrastText, #fff)',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: testing ? 'not-allowed' : 'pointer',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{testing ? 'Testing…' : 'Test Connection'}
|
||||
</button>
|
||||
{testResult && (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
<StatusLabel status={testResult.success ? 'success' : 'error'}>
|
||||
{testResult.message}
|
||||
</StatusLabel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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>;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user