Files
headlamp-tns-csi-plugin/src/components/OverviewPage.tsx
T
Gandalf the Greybeard 29f19e2346 fix: apply prettier formatting to pass CI format check
Three files had formatting inconsistencies causing the format:check
CI step to fail on main since 2026-03-04.

Fixes #3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:55:19 +00:00

393 lines
12 KiB
TypeScript

/**
* OverviewPage — main dashboard for tns-csi plugin.
*
* Shows: driver health, storage summary (SC/PV/PVC counts + protocol breakdown),
* and any PVCs in non-Bound state.
*/
import {
Loader,
NameValueTable,
PercentageBar,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s';
import type { TnsCsiMetrics } from '../api/metrics';
import { fetchControllerMetrics } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
import DriverStatusCard from './DriverStatusCard';
// ---------------------------------------------------------------------------
// Protocol breakdown chart
// ---------------------------------------------------------------------------
const PROTOCOL_COLORS: Record<string, string> = {
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 } }>) {
const counts = new Map<string, number>();
for (const sc of storageClasses) {
const proto = formatProtocol(sc.parameters?.protocol);
counts.set(proto, (counts.get(proto) ?? 0) + 1);
}
return [...counts.entries()].map(([name, value]) => ({
name,
value,
fill: PROTOCOL_COLORS[name] ?? PROTOCOL_COLORS['Other'],
}));
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function OverviewPage() {
const {
csiDriver,
driverInstalled,
storageClasses,
persistentVolumes,
persistentVolumeClaims,
controllerPods,
nodePods,
poolStats,
poolStatsError,
loading,
error,
refresh,
} = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsError, setMetricsError] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setMetricsError(null);
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
}
}, [controllerPods]);
useEffect(() => {
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..." />;
}
// Compute storage summary
const totalCapacityBytes = persistentVolumes.reduce((sum, pv) => {
const cap = pv.spec.capacity?.storage ?? '0';
return sum + parseStorageToBytes(cap);
}, 0);
const pvcStatusCounts = { Bound: 0, Pending: 0, Lost: 0, Other: 0 };
for (const pvc of persistentVolumeClaims) {
const phase = pvc.status?.phase ?? 'Other';
if (phase === 'Bound') pvcStatusCounts.Bound++;
else if (phase === 'Pending') pvcStatusCounts.Pending++;
else if (phase === 'Lost') pvcStatusCounts.Lost++;
else pvcStatusCounts.Other++;
}
const nonBoundPvcs = persistentVolumeClaims.filter(pvc => pvc.status?.phase !== 'Bound');
const chartData = protocolChartData(storageClasses);
const totalScs = storageClasses.length;
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Overview" />
<button
onClick={refresh}
aria-label="Refresh tns-csi data"
style={{
padding: '6px 16px',
backgroundColor: 'transparent',
color: 'var(--mui-palette-primary-main, #1976d2)',
border: '1px solid var(--mui-palette-primary-main, #1976d2)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
}}
>
Refresh
</button>
</div>
{/* Early development banner */}
<SectionBox title="Notice">
<NameValueTable
rows={[
{
name: 'Development Status',
value: (
<StatusLabel status="warning">
tns-csi is in active early development not production-ready
</StatusLabel>
),
},
]}
/>
</SectionBox>
{/* Driver not detected */}
{!driverInstalled && !loading && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
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',
},
]}
/>
</SectionBox>
)}
{/* Error state */}
{error && (
<SectionBox title="Error">
<NameValueTable
rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]}
/>
</SectionBox>
)}
{/* Driver status */}
<DriverStatusCard
csiDriver={csiDriver}
controllerPods={controllerPods}
nodePods={nodePods}
metrics={metrics}
/>
{metricsError && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="warning">{metricsError}</StatusLabel>,
},
{
name: 'Note',
value: 'Ensure controller pod is running with metrics enabled (port 8080).',
},
]}
/>
</SectionBox>
)}
{/* Storage summary */}
<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)',
}}
>
Protocol Distribution
</div>
<PercentageBar data={chartData} total={totalScs} />
</div>
)}
<NameValueTable
rows={[
{ name: 'Storage Classes', value: String(totalScs) },
{ name: 'Persistent Volumes', value: String(persistentVolumes.length) },
{ name: 'Total Capacity', value: formatBytes(totalCapacityBytes) },
{
name: 'PVCs (Bound)',
value: <StatusLabel status="success">{pvcStatusCounts.Bound}</StatusLabel>,
},
...(pvcStatusCounts.Pending > 0
? [
{
name: 'PVCs (Pending)',
value: <StatusLabel status="warning">{pvcStatusCounts.Pending}</StatusLabel>,
},
]
: []),
...(pvcStatusCounts.Lost > 0
? [
{
name: 'PVCs (Lost)',
value: <StatusLabel status="error">{pvcStatusCounts.Lost}</StatusLabel>,
},
]
: []),
]}
/>
</SectionBox>
{/* Pool capacity — real data from TrueNAS API when configured */}
{poolStats.length > 0 && (
<SectionBox title="Pool Capacity">
<SimpleTable
columns={[
{ label: 'Pool', getter: p => p.name },
{
label: 'Status',
getter: p => (
<StatusLabel status={p.status === 'ONLINE' ? 'success' : 'warning'}>
{p.status}
</StatusLabel>
),
},
{ label: 'Total', getter: p => formatBytes(p.size) },
{ label: 'Used', getter: p => formatBytes(p.allocated) },
{ label: 'Free', getter: p => formatBytes(p.free) },
{
label: 'Used %',
getter: p => (p.size > 0 ? `${Math.round((p.allocated / p.size) * 100)}%` : '—'),
},
]}
data={poolStats}
/>
</SectionBox>
)}
{poolStatsError && (
<SectionBox title="Pool Capacity Unavailable">
<NameValueTable
rows={[
{
name: 'Error',
value: <StatusLabel status="warning">{poolStatsError}</StatusLabel>,
},
{
name: 'Note',
value: 'Check your TrueNAS API key and server address in plugin settings.',
},
]}
/>
</SectionBox>
)}
{/* Provisioned capacity by pool (from Prometheus metrics —
shown when TrueNAS API not configured) */}
{poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && (
<SectionBox title="Provisioned Capacity by Pool">
<NameValueTable
rows={[...capacityByPool.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([pool, bytes]) => ({
name: pool,
value: formatBytes(bytes),
}))}
/>
</SectionBox>
)}
{/* Non-bound PVCs warning */}
{nonBoundPvcs.length > 0 && (
<SectionBox title="Attention: Non-Bound PVCs">
<SimpleTable
columns={[
{ label: 'Name', getter: pvc => pvc.metadata.name },
{ label: 'Namespace', getter: pvc => pvc.metadata.namespace ?? '—' },
{
label: 'Status',
getter: pvc => (
<StatusLabel status={phaseToStatus(pvc.status?.phase)}>
{pvc.status?.phase ?? 'Unknown'}
</StatusLabel>
),
},
{ label: 'Age', getter: pvc => formatAge(pvc.metadata.creationTimestamp) },
]}
data={nonBoundPvcs}
/>
</SectionBox>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseStorageToBytes(storage: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(Ki|Mi|Gi|Ti|Pi|K|M|G|T|P)?$/.exec(storage.trim());
if (!match) return 0;
const value = parseFloat(match[1]);
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,
};
return value * (multipliers[suffix] ?? 1);
}
function formatBytes(bytes: number): string {
if (bytes >= 1024 ** 4) return `${(bytes / 1024 ** 4).toFixed(1)} TiB`;
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GiB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MiB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${bytes} B`;
}