/** * 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, useState } from 'react'; import { useTnsCsiContext } from '../api/TnsCsiDataContext'; import { formatAge, formatProtocol, phaseToStatus } from '../api/k8s'; import type { TnsCsiMetrics } from '../api/metrics'; import { extractTnsCsiMetrics, fetchControllerMetrics, parsePrometheusText } from '../api/metrics'; import DriverStatusCard from './DriverStatusCard'; // --------------------------------------------------------------------------- // Protocol breakdown chart // --------------------------------------------------------------------------- const PROTOCOL_COLORS: Record = { NFS: '#1976d2', 'NVMe-oF': '#9c27b0', iSCSI: '#f57c00', Other: '#9e9e9e', }; function protocolChartData(storageClasses: Array<{ parameters?: { protocol?: string } }>) { const counts = new Map(); 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(null); const [metricsError, setMetricsError] = useState(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]); if (loading) { return ; } // 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; // Capacity by pool: join volumeCapacityBytes samples (volume_id, protocol) // with PV volumeHandle → pool name from volumeAttributes. const capacityByPool: Map = React.useMemo(() => { const map = new Map(); if (!metrics) return map; // Build lookup: volumeHandle → pool name const handleToPool = new Map(); 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 ( <>
{/* Early development banner */} tns-csi is in active early development — not production-ready ), }, ]} /> {/* Driver not detected */} {!driverInstalled && !loading && ( CSIDriver tns.csi.io not found on this cluster, }, { name: 'Install', value: 'helm install tns-csi oci://registry-1.docker.io/fenio/tns-csi --namespace kube-system', }, ]} /> )} {/* Error state */} {error && ( {error} }]} /> )} {/* Driver status */} {metricsError && ( {metricsError}, }, { name: 'Note', value: 'Ensure controller pod is running with metrics enabled (port 8080).', }, ]} /> )} {/* Storage summary */} {totalScs > 0 && chartData.length > 0 && (
Protocol Distribution
)} {pvcStatusCounts.Bound}, }, ...(pvcStatusCounts.Pending > 0 ? [{ name: 'PVCs (Pending)', value: {pvcStatusCounts.Pending}, }] : []), ...(pvcStatusCounts.Lost > 0 ? [{ name: 'PVCs (Lost)', value: {pvcStatusCounts.Lost}, }] : []), ]} />
{/* Pool capacity — real data from TrueNAS API when configured */} {poolStats.length > 0 && ( p.name }, { label: 'Status', getter: (p) => ( {p.status} ), }, { 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} /> )} {poolStatsError && ( {poolStatsError}, }, { name: 'Note', value: 'Check your TrueNAS API key and server address in plugin settings.', }, ]} /> )} {/* Provisioned capacity by pool (from Prometheus metrics — shown when TrueNAS API not configured) */} {poolStats.length === 0 && !poolStatsError && capacityByPool.size > 0 && ( a.localeCompare(b)) .map(([pool, bytes]) => ({ name: pool, value: formatBytes(bytes), }))} /> )} {/* Non-bound PVCs warning */} {nonBoundPvcs.length > 0 && ( pvc.metadata.name }, { label: 'Namespace', getter: (pvc) => pvc.metadata.namespace ?? '—' }, { label: 'Status', getter: (pvc) => ( {pvc.status?.phase ?? 'Unknown'} ), }, { label: 'Age', getter: (pvc) => formatAge(pvc.metadata.creationTimestamp) }, ]} data={nonBoundPvcs} /> )} ); } // --------------------------------------------------------------------------- // 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 = { '': 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`; }