Files
headlamp-tns-csi-plugin/src/api/metrics.test.ts
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

145 lines
5.1 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
extractTnsCsiMetrics,
filterByLabel,
formatBytes,
groupByLabel,
parsePrometheusText,
sumSamples,
} from './metrics';
const SAMPLE_METRICS = `
# HELP tns_websocket_connected WebSocket connection status
# TYPE tns_websocket_connected gauge
tns_websocket_connected 1
# HELP tns_websocket_reconnects_total Total WebSocket reconnects
# TYPE tns_websocket_reconnects_total counter
tns_websocket_reconnects_total 3
# HELP tns_volume_operations_total Total volume operations
# TYPE tns_volume_operations_total counter
tns_volume_operations_total{protocol="nfs",operation="create",status="success"} 42
tns_volume_operations_total{protocol="nfs",operation="delete",status="success"} 10
tns_volume_operations_total{protocol="iscsi",operation="create",status="success"} 5
# HELP tns_volume_capacity_bytes Total provisioned capacity
# TYPE tns_volume_capacity_bytes gauge
tns_volume_capacity_bytes{volume_id="vol1",protocol="nfs"} 10737418240
tns_volume_capacity_bytes{volume_id="vol2",protocol="nfs"} 21474836480
# HELP tns_csi_operations_total CSI gRPC operations
# TYPE tns_csi_operations_total counter
tns_csi_operations_total{method="CreateVolume",grpc_status_code="OK"} 42
tns_csi_operations_total{method="DeleteVolume",grpc_status_code="OK"} 10
`.trim();
describe('parsePrometheusText', () => {
it('parses scalar gauges', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ws = families.get('tns_websocket_connected');
expect(ws).toBeDefined();
expect(ws?.samples).toHaveLength(1);
expect(ws?.samples[0]?.value).toBe(1);
});
it('parses counters with labels', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ops = families.get('tns_volume_operations_total');
expect(ops?.samples).toHaveLength(3);
});
it('parses labels correctly', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ops = families.get('tns_volume_operations_total');
const firstSample = ops?.samples[0];
expect(firstSample?.labels['protocol']).toBe('nfs');
expect(firstSample?.labels['operation']).toBe('create');
expect(firstSample?.labels['status']).toBe('success');
expect(firstSample?.value).toBe(42);
});
it('handles empty input gracefully', () => {
const families = parsePrometheusText('');
expect(families.size).toBe(0);
});
it('skips comment lines', () => {
const families = parsePrometheusText('# HELP foo bar\n# TYPE foo gauge\n');
expect(families.size).toBe(0);
});
});
describe('extractTnsCsiMetrics', () => {
it('extracts websocket connected status', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const metrics = extractTnsCsiMetrics(families);
expect(metrics.websocketConnected).toBe(1);
});
it('extracts reconnect total', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const metrics = extractTnsCsiMetrics(families);
expect(metrics.websocketReconnectsTotal).toBe(3);
});
it('extracts volume operations samples', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const metrics = extractTnsCsiMetrics(families);
expect(metrics.volumeOperationsTotal).toHaveLength(3);
});
it('extracts CSI operations samples', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const metrics = extractTnsCsiMetrics(families);
expect(metrics.csiOperationsTotal).toHaveLength(2);
});
it('returns null for missing metrics', () => {
const families = parsePrometheusText('');
const metrics = extractTnsCsiMetrics(families);
expect(metrics.websocketConnected).toBeNull();
expect(metrics.websocketReconnectsTotal).toBeNull();
expect(metrics.volumeOperationsTotal).toHaveLength(0);
});
});
describe('sumSamples', () => {
it('sums all sample values', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
expect(sumSamples(ops)).toBe(57); // 42 + 10 + 5
});
it('returns 0 for empty array', () => {
expect(sumSamples([])).toBe(0);
});
});
describe('groupByLabel', () => {
it('groups samples by label key and sums values', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
const byProtocol = groupByLabel(ops, 'protocol');
expect(byProtocol.get('nfs')).toBe(52); // 42 + 10
expect(byProtocol.get('iscsi')).toBe(5);
});
});
describe('filterByLabel', () => {
it('filters samples by label value', () => {
const families = parsePrometheusText(SAMPLE_METRICS);
const ops = families.get('tns_volume_operations_total')?.samples ?? [];
const iscsiOps = filterByLabel(ops, 'protocol', 'iscsi');
expect(iscsiOps).toHaveLength(1);
expect(iscsiOps[0]?.value).toBe(5);
});
});
describe('formatBytes', () => {
it('formats GB', () => expect(formatBytes(1e9)).toBe('1.0 GB'));
it('formats MB', () => expect(formatBytes(1.5e6)).toBe('1.5 MB'));
it('formats KB', () => expect(formatBytes(2e3)).toBe('2.0 KB'));
it('formats bytes', () => expect(formatBytes(512)).toBe('512 B'));
});