This repository has been archived on 2026-06-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
headlamp-tns-csi-plugin/src/components/StorageClassesPage.tsx
T
Chris Farhood e5d1fcb11c Implement headlamp-tns-csi-plugin
Full plugin implementation with 6 pages, K8s resource filtering,
Prometheus metrics parsing, kbench benchmark runner, and 67 unit tests.

## Pages
- Overview: driver health, storage summary, protocol distribution chart, non-Bound PVC alerts
- Storage Classes: tns-csi SC table with slide-in detail panel + protocol notes
- Volumes: PV table with full CSI attribute detail panel
- Snapshots: VolumeSnapshot CRDs with graceful degradation if not installed
- Metrics: Prometheus text format parser + WebSocket/Volume/CSI operation cards
- Benchmark: kbench Job+PVC lifecycle, FIO log parser, past benchmarks list

## API modules
- k8s.ts: typed resource shapes, filtering helpers, formatting utilities
- metrics.ts: Prometheus text format parser, tns-csi metric extraction
- kbench.ts: Job/PVC manifests, lifecycle management, FIO summary parser
- TnsCsiDataContext.tsx: shared React context with memoized filtered resources

## Quality
- TypeScript strict mode, zero any, discriminated union for benchmark state
- 67 tests passing (vitest + @testing-library/react)
- registerDetailsViewSection injects TNS-CSI details on PVC pages
- Graceful degradation for missing CSIDriver and VolumeSnapshot CRDs

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>
2026-02-18 07:45:19 -05:00

260 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* StorageClassesPage — lists tns-csi StorageClasses with a slide-in detail panel.
*
* Pattern mirrors headlamp-polaris-plugin's NamespacesListView:
* click row → detail drawer, Escape to close, URL hash state.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
SimpleTable,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { 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';
// ---------------------------------------------------------------------------
// Detail drawer
// ---------------------------------------------------------------------------
interface StorageClassDetailPanelProps {
sc: TnsCsiStorageClass;
pvCount: number;
onClose: () => void;
}
function StorageClassDetailPanel({ sc, pvCount, onClose }: StorageClassDetailPanelProps) {
const [isMaximized, setIsMaximized] = React.useState(false);
const params = sc.parameters ?? {};
const protocol = formatProtocol(params.protocol);
const drawerClass = `tns-csi-sc-drawer-${sc.metadata.name}`;
return (
<>
<style>{`
.${drawerClass} {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: ${isMaximized ? 'calc(100vw - 240px)' : '900px'};
background-color: var(--mui-palette-background-default, #fafafa);
color: var(--mui-palette-text-primary);
box-shadow: -2px 0 8px rgba(0,0,0,0.15);
overflow-y: auto;
z-index: 1200;
padding: 20px;
transition: width 0.3s ease;
}
`}</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)' }}>
{sc.metadata.name}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
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' }}
>
{isMaximized ? '⊟' : '⊡'}
</button>
<button
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' }}
>
×
</button>
</div>
</div>
<SectionBox title="StorageClass Details">
<NameValueTable
rows={[
{ name: 'Name', value: sc.metadata.name },
{ name: 'Protocol', value: protocol },
{ name: 'Pool', value: params.pool ?? '—' },
{ name: 'Server', value: params.server ?? '—' },
{ name: 'Reclaim Policy', value: sc.reclaimPolicy ?? '—' },
{ name: 'Volume Binding Mode', value: sc.volumeBindingMode ?? '—' },
{
name: 'Allow Volume Expansion',
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>,
},
{ name: 'Provisioner', value: sc.provisioner },
{ name: 'Bound PVs', value: String(pvCount) },
]}
/>
</SectionBox>
{/* Protocol-specific notes */}
{params.protocol && (
<SectionBox title="Protocol Notes">
<NameValueTable rows={protocolNotes(params.protocol)} />
</SectionBox>
)}
</div>
</>
);
}
function protocolNotes(protocol: string): Array<{ name: string; value: React.ReactNode }> {
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: '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: 'Networking', value: 'Static IP required — DHCP is not supported for NVMe-oF' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
];
}
if (lower === 'iscsi') {
return [
{ name: 'Prerequisite', value: 'open-iscsi required on all nodes' },
{ name: 'Access Modes', value: 'Supports RWO, RWOP' },
];
}
return [];
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function StorageClassesPage() {
const location = useLocation();
const history = useHistory();
const { storageClasses, persistentVolumes, loading, error } = useTnsCsiContext();
const [selectedName, setSelectedName] = useState<string | null>(
location.hash.slice(1) || null
);
useEffect(() => {
setSelectedName(location.hash.slice(1) || null);
}, [location.hash]);
const openSc = (name: string) => {
setSelectedName(name);
history.push(`${location.pathname}#${name}`);
};
const closeSc = () => {
setSelectedName(null);
history.push(location.pathname);
};
useEffect(() => {
if (!selectedName) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeSc();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName]);
if (loading) return <Loader title="Loading storage classes..." />;
if (error) {
return (
<>
<SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox title="Error">
<NameValueTable rows={[{ name: 'Status', value: <StatusLabel status="error">{error}</StatusLabel> }]} />
</SectionBox>
</>
);
}
// Build PV count per StorageClass
const pvCountBySc = new Map<string, number>();
for (const pv of persistentVolumes) {
const scName = pv.spec.storageClassName ?? '';
pvCountBySc.set(scName, (pvCountBySc.get(scName) ?? 0) + 1);
}
const selectedSc = selectedName ? storageClasses.find(sc => sc.metadata.name === selectedName) ?? null : null;
return (
<>
<SectionHeader title="TNS-CSI — Storage Classes" />
<SectionBox>
<SimpleTable
columns={[
{
label: 'Name',
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' }}
>
{sc.metadata.name}
</button>
),
},
{ 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: 'Expansion',
getter: (sc: TnsCsiStorageClass) => (
<StatusLabel status={sc.allowVolumeExpansion ? 'success' : 'warning'}>
{sc.allowVolumeExpansion ? 'Yes' : 'No'}
</StatusLabel>
),
},
{
label: 'PVs',
getter: (sc: TnsCsiStorageClass) => String(pvCountBySc.get(sc.metadata.name) ?? 0),
},
]}
data={storageClasses}
emptyMessage="No tns-csi StorageClasses found."
/>
</SectionBox>
{selectedSc && (
<>
<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 }}
/>
<StorageClassDetailPanel
sc={selectedSc}
pvCount={pvCountBySc.get(selectedSc.metadata.name) ?? 0}
onClose={closeSc}
/>
</>
)}
</>
);
}