Files
headlamp-tns-csi-plugin/src/components/MetricsPage.tsx
T
DevContainer User c1c5e8a37d fix: resolve bugs in benchmark lifecycle, snapshot filtering, and dark mode
- Fix PVC bind loop leak on unmount via cancellation ref
- Fix DeleteOptions body structure for proper foreground propagation
- Filter snapshots to tns-csi driver only (was showing all drivers)
- Fix stale closures in Escape key handlers with useCallback
- Add loading state to cleanup delete button, remove window.confirm/alert
- Use CSS custom properties for protocol chart colors (dark mode support)
- Fix all 35 ESLint warnings (import sort, indent, boolean attrs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:47:33 +00:00

242 lines
7.6 KiB
TypeScript

/**
* MetricsPage — Prometheus metrics from the tns-csi controller pod.
* Fetches metrics via API proxy and displays in structured cards.
*/
import {
Loader,
NameValueTable,
SectionBox,
SectionHeader,
StatusLabel,
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import React, { useCallback, useEffect, useState } from 'react';
import type { TnsCsiMetrics } from '../api/metrics';
import { fetchControllerMetrics, formatBytes, groupByLabel, sumSamples } from '../api/metrics';
import { useTnsCsiContext } from '../api/TnsCsiDataContext';
function formatAuditTime(iso: string): string {
const date = new Date(iso);
const diffMs = Date.now() - date.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins} minute${mins > 1 ? 's' : ''} ago`;
const hours = Math.floor(mins / 60);
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
}
// ---------------------------------------------------------------------------
// Metrics cards
// ---------------------------------------------------------------------------
function WebSocketCard({ metrics }: { metrics: TnsCsiMetrics }) {
const connected = metrics.websocketConnected;
const reconnects = metrics.websocketReconnectsTotal;
return (
<SectionBox title="WebSocket Health">
<NameValueTable
rows={[
{
name: 'Connection Status',
value: (
<StatusLabel
status={connected === 1 ? 'success' : connected === 0 ? 'error' : 'warning'}
>
{connected === 1 ? 'Connected' : connected === 0 ? 'Disconnected' : 'Unknown'}
</StatusLabel>
),
},
{
name: 'Total Reconnects',
value: reconnects !== null ? String(reconnects) : '—',
},
{
name: 'Messages Total',
value: String(Math.round(sumSamples(metrics.websocketMessagesTotal))),
},
]}
/>
</SectionBox>
);
}
function VolumeOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byProtocol = groupByLabel(metrics.volumeOperationsTotal, 'protocol');
const totalOps = sumSamples(metrics.volumeOperationsTotal);
const totalCapacityBytes = sumSamples(metrics.volumeCapacityBytes);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total Operations', value: String(Math.round(totalOps)) },
{ name: 'Total Provisioned Capacity', value: formatBytes(totalCapacityBytes) },
];
for (const [protocol, count] of byProtocol.entries()) {
rows.push({ name: `Operations (${protocol})`, value: String(Math.round(count)) });
}
return (
<SectionBox title="Volume Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
function CsiOperationsCard({ metrics }: { metrics: TnsCsiMetrics }) {
const byMethod = groupByLabel(metrics.csiOperationsTotal, 'method');
const totalOps = sumSamples(metrics.csiOperationsTotal);
const rows: Array<{ name: string; value: React.ReactNode }> = [
{ name: 'Total CSI Calls', value: String(Math.round(totalOps)) },
];
for (const [method, count] of byMethod.entries()) {
rows.push({ name: method, value: String(Math.round(count)) });
}
return (
<SectionBox title="CSI Operations">
<NameValueTable rows={rows} />
</SectionBox>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function MetricsPage() {
const { controllerPods, driverInstalled, loading } = useTnsCsiContext();
const [metrics, setMetrics] = useState<TnsCsiMetrics | null>(null);
const [metricsLoading, setMetricsLoading] = useState(false);
const [metricsError, setMetricsError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const fetchMetrics = useCallback(async () => {
if (controllerPods.length === 0) return;
const pod = controllerPods[0];
if (!pod) return;
setMetricsLoading(true);
setMetricsError(null);
try {
const result = await fetchControllerMetrics(pod);
setMetrics(result);
setLastUpdated(new Date().toISOString());
} catch (err: unknown) {
setMetricsError(err instanceof Error ? err.message : String(err));
} finally {
setMetricsLoading(false);
}
}, [controllerPods]);
useEffect(() => {
void fetchMetrics();
}, [fetchMetrics]);
if (loading) return <Loader title="Loading tns-csi data..." />;
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
}}
>
<SectionHeader title="TNS-CSI — Metrics" />
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{lastUpdated && (
<span style={{ fontSize: '14px', color: 'var(--mui-palette-text-secondary, #666)' }}>
Updated: {formatAuditTime(lastUpdated)}
</span>
)}
<button
onClick={() => void fetchMetrics()}
disabled={metricsLoading}
aria-label="Refresh metrics"
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: metricsLoading ? 'not-allowed' : 'pointer',
fontSize: '13px',
fontWeight: 500,
opacity: metricsLoading ? 0.6 : 1,
}}
>
{metricsLoading ? 'Loading...' : 'Refresh'}
</button>
</div>
</div>
{!driverInstalled && (
<SectionBox title="Driver Not Detected">
<NameValueTable
rows={[
{
name: 'Status',
value: (
<StatusLabel status="error">TNS-CSI driver not found on this cluster</StatusLabel>
),
},
]}
/>
</SectionBox>
)}
{controllerPods.length === 0 && driverInstalled && (
<SectionBox title="Metrics Unavailable">
<NameValueTable
rows={[
{
name: 'Status',
value: <StatusLabel status="warning">No controller pod found</StatusLabel>,
},
{
name: 'Note',
value: 'Ensure controller pod is running with metrics enabled on port 8080.',
},
{
name: 'Troubleshooting',
value:
'kubectl logs -n kube-system -l app.kubernetes.io/name=tns-csi-driver,app.kubernetes.io/component=controller',
},
]}
/>
</SectionBox>
)}
{metricsError && (
<SectionBox title="Metrics Error">
<NameValueTable
rows={[
{ name: 'Error', value: <StatusLabel status="error">{metricsError}</StatusLabel> },
{
name: 'Note',
value:
'Metrics are fetched via Kubernetes API proxy to the controller pod port 8080.',
},
]}
/>
</SectionBox>
)}
{metricsLoading && !metrics && <Loader title="Fetching metrics..." />}
{metrics && (
<>
<WebSocketCard metrics={metrics} />
<VolumeOperationsCard metrics={metrics} />
<CsiOperationsCard metrics={metrics} />
</>
)}
</>
);
}