= (p
|
- |
- |
-
+
—}>
diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
index 47ec0b2..40eb474 100644
--- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
+++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx
@@ -10,6 +10,7 @@ import { DockerMetadataAPI } from '@/api/dockerMetadata';
import { resolveHostRuntime } from './runtimeDisplay';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
+import { buildMetricKey } from '@/utils/metricsKeys';
const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']);
const DEGRADED_HOST_STATUSES = new Set([
@@ -828,6 +829,7 @@ const DockerContainerRow: Component<{
const cpuPercent = () => Math.max(0, Math.min(100, container.cpuPercent ?? 0));
const memPercent = () => Math.max(0, Math.min(100, container.memoryPercent ?? 0));
+ const metricsKey = buildMetricKey('dockerContainer', container.id);
const memUsageLabel = () => {
if (!container.memoryUsageBytes) return undefined;
const used = formatBytes(container.memoryUsageBytes, 0);
@@ -1007,15 +1009,20 @@ const DockerContainerRow: Component<{
{statusLabel()}
|
-
+ |
0}
fallback={—}
>
-
+
|
-
+ |
0}
fallback={—}
@@ -1025,10 +1032,11 @@ const DockerContainerRow: Component<{
label={formatPercent(memPercent())}
type="memory"
sublabel={memUsageLabel()}
+ resourceId={metricsKey}
/>
|
-
+ |
—}>
@@ -1800,6 +1809,7 @@ const DockerServiceRow: Component<{
return label;
};
const state = toLower(task.currentState ?? task.desiredState ?? 'unknown');
+ const taskMetricsKey = container?.id ? buildMetricKey('dockerContainer', container.id) : undefined;
const stateClass = () => {
if (state === 'running') {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
@@ -1837,12 +1847,22 @@ const DockerServiceRow: Component<{
|
0} fallback={—}>
-
+
|
0} fallback={—}>
-
+
|
diff --git a/frontend-modern/src/components/shared/MetricsViewToggle.tsx b/frontend-modern/src/components/shared/MetricsViewToggle.tsx
new file mode 100644
index 0000000..597175a
--- /dev/null
+++ b/frontend-modern/src/components/shared/MetricsViewToggle.tsx
@@ -0,0 +1,42 @@
+/**
+ * Metrics View Mode Toggle
+ *
+ * Toggle button to switch between progress bars and sparkline views for metrics.
+ * This is a global setting that affects all tables throughout the app.
+ */
+
+import { Component } from 'solid-js';
+import { useMetricsViewMode } from '@/stores/metricsViewMode';
+
+export const MetricsViewToggle: Component = () => {
+ const { viewMode, setViewMode } = useMetricsViewMode();
+
+ return (
+
+
+
+
+ );
+};
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx
index b7de817..aed209b 100644
--- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx
+++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx
@@ -8,6 +8,7 @@ import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
import { getCpuTemperature } from '@/utils/temperature';
import { useAlertsActivation } from '@/stores/alertsActivation';
+import { buildMetricKey } from '@/utils/metricsKeys';
interface NodeSummaryTableProps {
nodes: Node[];
@@ -353,7 +354,7 @@ export const NodeSummaryTable: Component = (props) => {
<>
-
+
= (props) => {
const isSelected = () => props.selectedNode === nodeId;
// Use the full resource ID for alert matching
const resourceId = isPVE ? node!.id || node!.name : pbs!.id || pbs!.name;
+ // Use namespaced metric key for sparklines
+ const metricsKey = buildMetricKey('node', resourceId);
const alertStyles = createMemo(() =>
getAlertStyles(resourceId, activeAlerts, alertsEnabled()),
);
@@ -586,6 +589,7 @@ export const NodeSummaryTable: Component = (props) => {
: undefined
}
type="cpu"
+ resourceId={metricsKey}
/>
@@ -605,6 +609,7 @@ export const NodeSummaryTable: Component = (props) => {
: undefined
}
type="memory"
+ resourceId={metricsKey}
/>
@@ -618,6 +623,7 @@ export const NodeSummaryTable: Component = (props) => {
label={formatPercent(diskPercentValue ?? 0)}
sublabel={diskSublabel}
type="disk"
+ resourceId={metricsKey}
/>
diff --git a/frontend-modern/src/components/shared/Sparkline.tsx b/frontend-modern/src/components/shared/Sparkline.tsx
new file mode 100644
index 0000000..a4531af
--- /dev/null
+++ b/frontend-modern/src/components/shared/Sparkline.tsx
@@ -0,0 +1,286 @@
+/**
+ * Sparkline Component
+ *
+ * Lightweight canvas-based sparkline chart for displaying metric trends.
+ * Optimized for rendering many sparklines simultaneously in tables.
+ */
+
+import { onMount, onCleanup, createEffect, createSignal, Component, Show } from 'solid-js';
+import type { MetricSnapshot } from '@/stores/metricsHistory';
+import { scheduleSparkline } from '@/utils/canvasRenderQueue';
+
+interface SparklineProps {
+ data: MetricSnapshot[];
+ metric: 'cpu' | 'memory' | 'disk';
+ width?: number;
+ height?: number;
+ color?: string;
+ thresholds?: {
+ warning: number; // Yellow threshold
+ critical: number; // Red threshold
+ };
+}
+
+export const Sparkline: Component = (props) => {
+ let canvasRef: HTMLCanvasElement | undefined;
+ let unregister: (() => void) | null = null;
+
+ // Hover state for tooltip
+ const [hoveredPoint, setHoveredPoint] = createSignal<{
+ value: number;
+ timestamp: number;
+ x: number;
+ y: number;
+ } | null>(null);
+
+ // If width is 0, use container width; otherwise use provided width
+ const width = () => {
+ if (props.width === 0 && canvasRef?.parentElement) {
+ return canvasRef.parentElement.clientWidth;
+ }
+ return props.width || 120;
+ };
+ const height = () => props.height || 24;
+
+ // Default thresholds based on metric type
+ const getDefaultThresholds = () => {
+ switch (props.metric) {
+ case 'cpu':
+ return { warning: 80, critical: 90 };
+ case 'memory':
+ return { warning: 75, critical: 85 };
+ case 'disk':
+ return { warning: 80, critical: 90 };
+ default:
+ return { warning: 75, critical: 90 };
+ }
+ };
+
+ const thresholds = () => props.thresholds || getDefaultThresholds();
+
+ // Get color based on latest value and thresholds
+ const getColor = (value: number): string => {
+ if (props.color) return props.color;
+
+ const t = thresholds();
+ if (value >= t.critical) return '#ef4444'; // red-500
+ if (value >= t.warning) return '#eab308'; // yellow-500
+ return '#22c55e'; // green-500
+ };
+
+ const drawSparkline = () => {
+ if (!canvasRef) return;
+
+ const canvas = canvasRef;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const data = props.data;
+ const w = width();
+ const h = height();
+ const metric = props.metric;
+
+ // Set canvas size (accounting for device pixel ratio for sharp rendering)
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = w * dpr;
+ canvas.height = h * dpr;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ ctx.scale(dpr, dpr);
+
+ // Clear canvas
+ ctx.clearRect(0, 0, w, h);
+
+ if (!data || data.length === 0) {
+ // No data - show empty state
+ ctx.strokeStyle = '#d1d5db'; // gray-300
+ ctx.lineWidth = 1;
+ ctx.setLineDash([2, 2]);
+ ctx.beginPath();
+ ctx.moveTo(0, h / 2);
+ ctx.lineTo(w, h / 2);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ return;
+ }
+
+ // Extract values for the selected metric
+ const values = data.map(d => d[metric]);
+
+ // Get latest value for color
+ const latestValue = values[values.length - 1] || 0;
+ const color = getColor(latestValue);
+
+ // Find min/max for scaling
+ const minValue = 0; // Always anchor at 0
+ const maxValue = Math.max(100, ...values); // Use 100 as minimum max
+
+ // Calculate points
+ const points: Array<{ x: number; y: number }> = [];
+ const xStep = w / Math.max(values.length - 1, 1);
+
+ values.forEach((value, i) => {
+ const x = i * xStep;
+ // Invert y because canvas coordinates are top-down
+ const y = h - ((value - minValue) / (maxValue - minValue)) * h;
+ points.push({ x, y });
+ });
+
+ // Draw threshold reference lines
+ const t = thresholds();
+ ctx.strokeStyle = '#94a3b8'; // gray-400
+ ctx.lineWidth = 0.5;
+ ctx.globalAlpha = 0.3;
+ ctx.setLineDash([2, 2]);
+
+ // Warning threshold line
+ const warningY = h - ((t.warning - minValue) / (maxValue - minValue)) * h;
+ ctx.beginPath();
+ ctx.moveTo(0, warningY);
+ ctx.lineTo(w, warningY);
+ ctx.stroke();
+
+ // Critical threshold line
+ const criticalY = h - ((t.critical - minValue) / (maxValue - minValue)) * h;
+ ctx.beginPath();
+ ctx.moveTo(0, criticalY);
+ ctx.lineTo(w, criticalY);
+ ctx.stroke();
+
+ ctx.setLineDash([]);
+ ctx.globalAlpha = 1;
+
+ // Draw gradient fill
+ const gradient = ctx.createLinearGradient(0, 0, 0, h);
+ gradient.addColorStop(0, `${color}40`); // 25% opacity at top
+ gradient.addColorStop(1, `${color}10`); // 6% opacity at bottom
+
+ ctx.fillStyle = gradient;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, h); // Start at bottom left
+ points.forEach(p => ctx.lineTo(p.x, p.y));
+ ctx.lineTo(points[points.length - 1].x, h); // Close at bottom right
+ ctx.closePath();
+ ctx.fill();
+
+ // Draw line
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ points.forEach((p, i) => {
+ if (i === 0) {
+ ctx.moveTo(p.x, p.y);
+ } else {
+ ctx.lineTo(p.x, p.y);
+ }
+ });
+ ctx.stroke();
+
+ // Draw current value dot
+ if (points.length > 0) {
+ const lastPoint = points[points.length - 1];
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ };
+
+ // Redraw when data or dimensions change
+ createEffect(() => {
+ // Capture current values to avoid stale closures
+ const currentData = props.data;
+ const currentWidth = width();
+ const currentHeight = height();
+ const currentMetric = props.metric;
+
+ // Unregister previous draw callback if it exists
+ if (unregister) {
+ unregister();
+ }
+
+ // Schedule draw via shared render queue
+ unregister = scheduleSparkline(drawSparkline);
+ });
+
+ onCleanup(() => {
+ if (unregister) {
+ unregister();
+ }
+ });
+
+ // Handle mouse move to find nearest point
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!canvasRef || !props.data || props.data.length === 0) return;
+
+ const rect = canvasRef.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const w = width();
+ const h = height();
+
+ const data = props.data;
+ const values = data.map(d => d[props.metric]);
+ const xStep = w / Math.max(values.length - 1, 1);
+
+ // Find nearest point
+ let nearestIndex = Math.round(mouseX / xStep);
+ nearestIndex = Math.max(0, Math.min(nearestIndex, values.length - 1));
+
+ const value = values[nearestIndex];
+ const timestamp = data[nearestIndex].timestamp;
+
+ // Calculate y position for visual indicator
+ const minValue = 0;
+ const maxValue = Math.max(100, ...values);
+ const y = h - ((value - minValue) / (maxValue - minValue)) * h;
+
+ setHoveredPoint({
+ value,
+ timestamp,
+ x: nearestIndex * xStep,
+ y: rect.top + y,
+ });
+ };
+
+ const handleMouseLeave = () => {
+ setHoveredPoint(null);
+ };
+
+ // Format timestamp for tooltip
+ const formatTime = (timestamp: number) => {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+ };
+
+ return (
+
+
+
+ {(point) => (
+
+ {point().value.toFixed(1)}%
+ {formatTime(point().timestamp)}
+
+ )}
+
+
+ );
+};
diff --git a/frontend-modern/src/stores/metricsHistory.ts b/frontend-modern/src/stores/metricsHistory.ts
new file mode 100644
index 0000000..95afefc
--- /dev/null
+++ b/frontend-modern/src/stores/metricsHistory.ts
@@ -0,0 +1,347 @@
+/**
+ * Metrics History Store (Ring Buffer Implementation)
+ *
+ * Tracks time-series data for sparkline visualizations using a ring buffer
+ * for O(1) insertions and minimal GC pressure.
+ */
+
+import { logger } from '@/utils/logger';
+
+export interface MetricSnapshot {
+ timestamp: number; // Unix timestamp in ms
+ cpu: number; // 0-100
+ memory: number; // 0-100
+ disk: number; // 0-100
+}
+
+interface RingBuffer {
+ buffer: MetricSnapshot[];
+ head: number; // Index of oldest item
+ size: number; // Number of items currently stored
+}
+
+// Configuration
+const MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
+const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
+const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~240 points
+const STORAGE_KEY = 'pulse_metrics_history';
+const STORAGE_VERSION = 1;
+
+// Store - map of resourceId to ring buffer
+const metricsHistoryMap = new Map();
+
+// Track last sample time per resource to enforce sampling interval
+const lastSampleTimes = new Map();
+
+/**
+ * Create a new ring buffer
+ */
+function createRingBuffer(): RingBuffer {
+ return {
+ buffer: new Array(MAX_POINTS),
+ head: 0,
+ size: 0,
+ };
+}
+
+/**
+ * Add a metric snapshot to the ring buffer (O(1) operation)
+ */
+function pushToRingBuffer(ring: RingBuffer, snapshot: MetricSnapshot): void {
+ const index = (ring.head + ring.size) % MAX_POINTS;
+ ring.buffer[index] = snapshot;
+
+ if (ring.size < MAX_POINTS) {
+ ring.size++;
+ } else {
+ // Buffer is full, advance head (overwrite oldest)
+ ring.head = (ring.head + 1) % MAX_POINTS;
+ }
+}
+
+/**
+ * Get all snapshots from ring buffer, ordered oldest to newest
+ */
+function getRingBufferData(ring: RingBuffer, cutoffTime: number): MetricSnapshot[] {
+ const result: MetricSnapshot[] = [];
+
+ for (let i = 0; i < ring.size; i++) {
+ const index = (ring.head + i) % MAX_POINTS;
+ const snapshot = ring.buffer[index];
+
+ // Filter out old snapshots beyond MAX_AGE_MS
+ if (snapshot && snapshot.timestamp >= cutoffTime) {
+ result.push(snapshot);
+ }
+ }
+
+ return result;
+}
+
+/**
+ * Save metrics history to localStorage
+ */
+function saveToLocalStorage(): void {
+ try {
+ const cutoffTime = Date.now() - MAX_AGE_MS;
+ const serialized: Record = {};
+
+ // Only save non-expired data
+ for (const [resourceId, ring] of metricsHistoryMap.entries()) {
+ const data = getRingBufferData(ring, cutoffTime);
+ if (data.length > 0) {
+ serialized[resourceId] = data;
+ }
+ }
+
+ const payload = {
+ version: STORAGE_VERSION,
+ timestamp: Date.now(),
+ data: serialized,
+ };
+
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+ } catch (error) {
+ logger.error('[MetricsHistory] Failed to save to localStorage', { error });
+ }
+}
+
+/**
+ * Load metrics history from localStorage
+ */
+function loadFromLocalStorage(): void {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (!stored) return;
+
+ const payload = JSON.parse(stored);
+
+ // Check version and age
+ if (payload.version !== STORAGE_VERSION) {
+ logger.warn('[MetricsHistory] Storage version mismatch, clearing');
+ localStorage.removeItem(STORAGE_KEY);
+ return;
+ }
+
+ // Discard if data is too old (older than 12 hours)
+ const maxStorageAge = 12 * 60 * 60 * 1000;
+ if (Date.now() - payload.timestamp > maxStorageAge) {
+ logger.info('[MetricsHistory] Storage data too old, clearing');
+ localStorage.removeItem(STORAGE_KEY);
+ return;
+ }
+
+ const cutoffTime = Date.now() - MAX_AGE_MS;
+
+ // Restore data
+ for (const [resourceId, snapshots] of Object.entries(payload.data as Record)) {
+ const ring = createRingBuffer();
+
+ // Only restore non-expired snapshots
+ for (const snapshot of snapshots) {
+ if (snapshot.timestamp >= cutoffTime) {
+ pushToRingBuffer(ring, snapshot);
+ }
+ }
+
+ if (ring.size > 0) {
+ metricsHistoryMap.set(resourceId, ring);
+ }
+ }
+
+ logger.info('[MetricsHistory] Loaded from localStorage', {
+ resourceCount: metricsHistoryMap.size,
+ });
+ } catch (error) {
+ logger.error('[MetricsHistory] Failed to load from localStorage', { error });
+ localStorage.removeItem(STORAGE_KEY);
+ }
+}
+
+/**
+ * Add a metric snapshot for a resource
+ */
+export function recordMetric(
+ resourceId: string,
+ cpu: number,
+ memory: number,
+ disk: number,
+): void {
+ const now = Date.now();
+
+ // Enforce sampling interval - don't record if we sampled too recently
+ const lastSample = lastSampleTimes.get(resourceId) || 0;
+ if (now - lastSample < SAMPLE_INTERVAL_MS) {
+ return;
+ }
+
+ lastSampleTimes.set(resourceId, now);
+
+ const snapshot: MetricSnapshot = {
+ timestamp: now,
+ cpu: Math.round(cpu * 10) / 10, // Round to 1 decimal
+ memory: Math.round(memory * 10) / 10,
+ disk: Math.round(disk * 10) / 10,
+ };
+
+ // Get or create ring buffer
+ let ring = metricsHistoryMap.get(resourceId);
+ if (!ring) {
+ ring = createRingBuffer();
+ metricsHistoryMap.set(resourceId, ring);
+ }
+
+ pushToRingBuffer(ring, snapshot);
+
+ // Save to localStorage periodically (debounced)
+ debouncedSave();
+}
+
+// Debounced save to avoid excessive localStorage writes
+let saveTimeout: number | null = null;
+function debouncedSave() {
+ if (saveTimeout !== null) {
+ clearTimeout(saveTimeout);
+ }
+ saveTimeout = window.setTimeout(() => {
+ saveToLocalStorage();
+ saveTimeout = null;
+ }, 5000); // Save 5 seconds after last change
+}
+
+/**
+ * Get metric history for a resource
+ */
+export function getMetricHistory(resourceId: string): MetricSnapshot[] {
+ const ring = metricsHistoryMap.get(resourceId);
+ if (!ring) return [];
+
+ const cutoffTime = Date.now() - MAX_AGE_MS;
+ return getRingBufferData(ring, cutoffTime);
+}
+
+/**
+ * Clear history for a specific resource
+ */
+export function clearMetricHistory(resourceId: string): void {
+ metricsHistoryMap.delete(resourceId);
+ lastSampleTimes.delete(resourceId);
+}
+
+/**
+ * Clear all history
+ */
+export function clearAllMetricsHistory(): void {
+ metricsHistoryMap.clear();
+ lastSampleTimes.clear();
+}
+
+/**
+ * Prune metrics for resources that are no longer present
+ * @param prefix - Resource kind prefix (e.g., "vm:", "node:")
+ * @param validIds - Set of IDs that should be kept (WITHOUT prefix)
+ */
+export function pruneMetricsByPrefix(prefix: string, validIds: Set): void {
+ const keysToDelete: string[] = [];
+
+ for (const key of metricsHistoryMap.keys()) {
+ if (key.startsWith(prefix)) {
+ // Extract the ID part (everything after prefix)
+ const id = key.slice(prefix.length);
+
+ if (!validIds.has(id)) {
+ keysToDelete.push(key);
+ }
+ }
+ }
+
+ // Delete stale entries
+ for (const key of keysToDelete) {
+ clearMetricHistory(key);
+ }
+
+ if (keysToDelete.length > 0) {
+ logger.debug('[MetricsHistory] Pruned stale entries', {
+ prefix,
+ count: keysToDelete.length,
+ });
+ }
+}
+
+/**
+ * Cleanup old data across all resources
+ * Call this periodically to remove expired snapshots from ring buffers
+ */
+export function cleanupOldMetrics(): void {
+ const now = Date.now();
+ const cutoff = now - MAX_AGE_MS;
+ const keysToDelete: string[] = [];
+
+ for (const [resourceId, ring] of metricsHistoryMap.entries()) {
+ // Count valid (non-expired) snapshots
+ let validCount = 0;
+ for (let i = 0; i < ring.size; i++) {
+ const index = (ring.head + i) % MAX_POINTS;
+ const snapshot = ring.buffer[index];
+ if (snapshot && snapshot.timestamp >= cutoff) {
+ validCount++;
+ }
+ }
+
+ // If no valid snapshots remain, mark for deletion
+ if (validCount === 0) {
+ keysToDelete.push(resourceId);
+ }
+ }
+
+ // Delete empty entries
+ for (const key of keysToDelete) {
+ clearMetricHistory(key);
+ }
+}
+
+// Initialize: Load from localStorage on startup
+if (typeof window !== 'undefined') {
+ loadFromLocalStorage();
+
+ // Auto-cleanup every 5 minutes
+ setInterval(cleanupOldMetrics, 5 * 60 * 1000);
+
+ // Save to localStorage before page unload
+ window.addEventListener('beforeunload', () => {
+ saveToLocalStorage();
+ });
+
+ // Periodic save every 2 minutes as backup
+ setInterval(saveToLocalStorage, 2 * 60 * 1000);
+}
+
+// Expose stats for debugging
+export function getMetricsStats() {
+ const resourceCount = metricsHistoryMap.size;
+ let totalPoints = 0;
+
+ for (const ring of metricsHistoryMap.values()) {
+ totalPoints += ring.size;
+ }
+
+ const avgPointsPerResource = resourceCount > 0 ? totalPoints / resourceCount : 0;
+
+ return {
+ resourceCount,
+ totalPoints,
+ avgPointsPerResource,
+ maxAge: MAX_AGE_MS,
+ sampleInterval: SAMPLE_INTERVAL_MS,
+ maxPointsPerResource: MAX_POINTS,
+ };
+}
+
+if (import.meta.env.DEV) {
+ // Expose for debugging in dev mode
+ (window as any).__metricsHistory = {
+ getStats: getMetricsStats,
+ clear: clearAllMetricsHistory,
+ getHistory: getMetricHistory,
+ };
+}
diff --git a/frontend-modern/src/stores/metricsSampler.ts b/frontend-modern/src/stores/metricsSampler.ts
new file mode 100644
index 0000000..7f6d853
--- /dev/null
+++ b/frontend-modern/src/stores/metricsSampler.ts
@@ -0,0 +1,181 @@
+/**
+ * Metrics Sampler
+ *
+ * Dedicated 30-second ticker that samples metrics from WebSocket state
+ * and records them to the metrics history store.
+ *
+ * This decouples metric sampling from the WebSocket message handler,
+ * reducing CPU waste and ensuring consistent sampling regardless of
+ * WebSocket message frequency.
+ */
+
+import { getGlobalWebSocketStore } from './websocket-global';
+import { recordMetric } from './metricsHistory';
+import { getMetricsViewMode } from './metricsViewMode';
+import { buildMetricKey } from '@/utils/metricsKeys';
+import { logger } from '@/utils/logger';
+import type { Node, VM, Container, DockerHost } from '@/types/api';
+
+const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
+
+let samplerInterval: number | null = null;
+let isRunning = false;
+
+/**
+ * Sample metrics from current WebSocket state
+ */
+function sampleMetrics(): void {
+ const wsStore = getGlobalWebSocketStore();
+ const state = wsStore.state;
+
+ let sampledCount = 0;
+
+ // Sample Proxmox/PBS nodes
+ if (state.nodes) {
+ for (const node of state.nodes) {
+ if (!node.id) continue;
+
+ const cpu = (node.cpu || 0) * 100;
+ const memory = node.memory?.usage || 0;
+ const disk = node.disk && node.disk.total > 0
+ ? (node.disk.used / node.disk.total) * 100
+ : 0;
+
+ const key = buildMetricKey('node', node.id);
+ recordMetric(key, cpu, memory, disk);
+ sampledCount++;
+ }
+ }
+
+ // Sample VMs
+ if (state.vms) {
+ for (const vm of state.vms) {
+ if (!vm.id) continue;
+
+ const cpu = (vm.cpu || 0) * 100;
+ const memory = vm.memory?.usage || 0;
+ const disk = vm.disk && vm.disk.total > 0
+ ? (vm.disk.used / vm.disk.total) * 100
+ : 0;
+
+ const key = buildMetricKey('vm', vm.id);
+ recordMetric(key, cpu, memory, disk);
+ sampledCount++;
+ }
+ }
+
+ // Sample LXC containers
+ if (state.containers) {
+ for (const container of state.containers) {
+ if (!container.id) continue;
+
+ const cpu = (container.cpu || 0) * 100;
+ const memory = container.memory?.usage || 0;
+ const disk = container.disk && container.disk.total > 0
+ ? (container.disk.used / container.disk.total) * 100
+ : 0;
+
+ const key = buildMetricKey('container', container.id);
+ recordMetric(key, cpu, memory, disk);
+ sampledCount++;
+ }
+ }
+
+ // Sample Docker hosts
+ if (state.dockerHosts) {
+ for (const host of state.dockerHosts) {
+ if (!host.id) continue;
+
+ const cpu = host.cpuUsagePercent || 0;
+ const memory = host.memory?.usage || 0;
+ const disk = host.disks && host.disks.length > 0
+ ? host.disks.reduce((acc, d) => {
+ if (d.total > 0) {
+ acc.used += d.used || 0;
+ acc.total += d.total || 0;
+ }
+ return acc;
+ }, { used: 0, total: 0 })
+ : { used: 0, total: 0 };
+ const diskPercent = disk.total > 0 ? (disk.used / disk.total) * 100 : 0;
+
+ const hostKey = buildMetricKey('dockerHost', host.id);
+ recordMetric(hostKey, cpu, memory, diskPercent);
+ sampledCount++;
+
+ // Sample Docker containers within this host
+ if (host.containers) {
+ for (const container of host.containers) {
+ if (!container.id) continue;
+
+ const contCpu = container.cpuPercent || 0;
+ const contMem = container.memoryPercent || 0;
+ const contDisk = container.disk && container.disk.total > 0
+ ? (container.disk.used / container.disk.total) * 100
+ : 0;
+
+ const containerKey = buildMetricKey('dockerContainer', container.id);
+ recordMetric(containerKey, contCpu, contMem, contDisk);
+ sampledCount++;
+ }
+ }
+ }
+ }
+
+ if (import.meta.env.DEV) {
+ logger.debug('[MetricsSampler] Sampled metrics', { count: sampledCount });
+ }
+}
+
+/**
+ * Start the metrics sampler
+ * Only runs when view mode is 'sparklines'
+ */
+export function startMetricsSampler(): void {
+ if (isRunning) {
+ logger.warn('[MetricsSampler] Already running');
+ return;
+ }
+
+ isRunning = true;
+
+ // Create interval that checks view mode before sampling
+ samplerInterval = window.setInterval(() => {
+ const viewMode = getMetricsViewMode();
+
+ if (viewMode === 'sparklines') {
+ sampleMetrics();
+ }
+ // If in 'bars' mode, skip sampling to save CPU
+ }, SAMPLE_INTERVAL_MS);
+
+ logger.info('[MetricsSampler] Started', { intervalMs: SAMPLE_INTERVAL_MS });
+
+ // Take initial sample immediately
+ if (getMetricsViewMode() === 'sparklines') {
+ sampleMetrics();
+ }
+}
+
+/**
+ * Stop the metrics sampler
+ */
+export function stopMetricsSampler(): void {
+ if (samplerInterval !== null) {
+ window.clearInterval(samplerInterval);
+ samplerInterval = null;
+ }
+
+ isRunning = false;
+ logger.info('[MetricsSampler] Stopped');
+}
+
+/**
+ * Get sampler status
+ */
+export function getSamplerStatus() {
+ return {
+ isRunning,
+ intervalMs: SAMPLE_INTERVAL_MS,
+ };
+}
diff --git a/frontend-modern/src/stores/metricsViewMode.ts b/frontend-modern/src/stores/metricsViewMode.ts
new file mode 100644
index 0000000..dc40af6
--- /dev/null
+++ b/frontend-modern/src/stores/metricsViewMode.ts
@@ -0,0 +1,75 @@
+/**
+ * Metrics View Mode Store
+ *
+ * Global preference for displaying metrics as progress bars or sparklines.
+ * This affects all tables: node summaries, guest tables, and container tables.
+ */
+
+import { createSignal } from 'solid-js';
+import { STORAGE_KEYS } from '@/utils/localStorage';
+
+export type MetricsViewMode = 'bars' | 'sparklines';
+
+// Read initial value from localStorage
+const getInitialViewMode = (): MetricsViewMode => {
+ if (typeof window === 'undefined') return 'bars';
+
+ try {
+ const stored = localStorage.getItem(STORAGE_KEYS.METRICS_VIEW_MODE);
+ if (stored === 'sparklines' || stored === 'bars') {
+ return stored;
+ }
+ } catch (err) {
+ // Ignore localStorage errors
+ }
+
+ return 'bars'; // Default to bars (current behavior)
+};
+
+// Create signal
+const [metricsViewMode, setMetricsViewMode] = createSignal(
+ getInitialViewMode()
+);
+
+/**
+ * Get the current metrics view mode
+ */
+export function getMetricsViewMode(): MetricsViewMode {
+ return metricsViewMode();
+}
+
+/**
+ * Set the metrics view mode and persist to localStorage
+ */
+export function setMetricsViewModePreference(mode: MetricsViewMode): void {
+ setMetricsViewMode(mode);
+
+ if (typeof window !== 'undefined') {
+ try {
+ localStorage.setItem(STORAGE_KEYS.METRICS_VIEW_MODE, mode);
+ } catch (err) {
+ // Ignore localStorage errors
+ console.warn('Failed to save metrics view mode preference', err);
+ }
+ }
+}
+
+/**
+ * Toggle between bars and sparklines
+ */
+export function toggleMetricsViewMode(): void {
+ const current = metricsViewMode();
+ const next: MetricsViewMode = current === 'bars' ? 'sparklines' : 'bars';
+ setMetricsViewModePreference(next);
+}
+
+/**
+ * Hook for components to use the view mode
+ */
+export function useMetricsViewMode() {
+ return {
+ viewMode: metricsViewMode,
+ setViewMode: setMetricsViewModePreference,
+ toggle: toggleMetricsViewMode,
+ };
+}
diff --git a/frontend-modern/src/stores/websocket.ts b/frontend-modern/src/stores/websocket.ts
index 076d3ad..b870a55 100644
--- a/frontend-modern/src/stores/websocket.ts
+++ b/frontend-modern/src/stores/websocket.ts
@@ -17,6 +17,8 @@ import { POLLING_INTERVALS, WEBSOCKET } from '@/constants';
import { notificationStore } from './notifications';
import { eventBus } from './events';
import { ALERTS_ACTIVATION_EVENT, isAlertsActivationEnabled } from '@/utils/alertsActivation';
+import { pruneMetricsByPrefix } from './metricsHistory';
+import { getMetricKeyPrefix } from '@/utils/metricsKeys';
// Type-safe WebSocket store
export function createWebSocketStore(url: string) {
@@ -335,6 +337,10 @@ export function createWebSocketStore(url: string) {
count: message.data.nodes?.length || 0,
});
setState('nodes', message.data.nodes);
+
+ // Lifecycle cleanup: remove metrics for nodes that disappeared
+ const currentIds = new Set(message.data.nodes?.map((n: any) => n.id).filter(Boolean) || []);
+ pruneMetricsByPrefix(getMetricKeyPrefix('node'), currentIds);
}
if (message.data.vms !== undefined) {
// Transform tags from comma-separated strings to arrays
@@ -364,6 +370,10 @@ export function createWebSocketStore(url: string) {
};
});
setState('vms', transformedVMs);
+
+ // Lifecycle cleanup: remove metrics for VMs that disappeared
+ const vmIds = new Set(transformedVMs.map((vm: VM) => vm.id).filter(Boolean));
+ pruneMetricsByPrefix(getMetricKeyPrefix('vm'), vmIds);
}
if (message.data.containers !== undefined) {
// Transform tags from comma-separated strings to arrays
@@ -393,6 +403,10 @@ export function createWebSocketStore(url: string) {
};
});
setState('containers', transformedContainers);
+
+ // Lifecycle cleanup: remove metrics for containers that disappeared
+ const containerIds = new Set(transformedContainers.map((c: Container) => c.id).filter(Boolean));
+ pruneMetricsByPrefix(getMetricKeyPrefix('container'), containerIds);
}
if (message.data.dockerHosts !== undefined && message.data.dockerHosts !== null) {
// Only update if dockerHosts is present and not null
@@ -410,7 +424,19 @@ export function createWebSocketStore(url: string) {
logger.debug('[WebSocket] Updating dockerHosts', {
count: incomingHosts.length,
});
- setState('dockerHosts', mergeDockerHostRevocations(incomingHosts));
+ const merged = mergeDockerHostRevocations(incomingHosts);
+ setState('dockerHosts', merged);
+
+ // Lifecycle cleanup for Docker hosts and containers
+ const hostIds = new Set(merged.map((h: DockerHost) => h.id).filter(Boolean));
+ const dockerContainerIds = new Set();
+ merged.forEach((h: DockerHost) => {
+ h.containers?.forEach((c: any) => {
+ if (c.id) dockerContainerIds.add(c.id);
+ });
+ });
+ pruneMetricsByPrefix(getMetricKeyPrefix('dockerHost'), hostIds);
+ pruneMetricsByPrefix(getMetricKeyPrefix('dockerContainer'), dockerContainerIds);
} else {
logger.debug('[WebSocket] Skipping transient empty dockerHosts payload', {
streak: consecutiveEmptyDockerUpdates,
@@ -422,7 +448,19 @@ export function createWebSocketStore(url: string) {
logger.debug('[WebSocket] Updating dockerHosts', {
count: incomingHosts.length,
});
- setState('dockerHosts', mergeDockerHostRevocations(incomingHosts));
+ const merged = mergeDockerHostRevocations(incomingHosts);
+ setState('dockerHosts', merged);
+
+ // Lifecycle cleanup: prune metrics for removed hosts/containers
+ const hostIds = new Set(merged.map((h: DockerHost) => h.id).filter(Boolean));
+ const dockerContainerIds = new Set();
+ merged.forEach((h: DockerHost) => {
+ h.containers?.forEach((c: any) => {
+ if (c.id) dockerContainerIds.add(c.id);
+ });
+ });
+ pruneMetricsByPrefix(getMetricKeyPrefix('dockerHost'), hostIds);
+ pruneMetricsByPrefix(getMetricKeyPrefix('dockerContainer'), dockerContainerIds);
}
} else {
logger.warn('[WebSocket] Received non-array dockerHosts payload', {
diff --git a/frontend-modern/src/utils/canvasRenderQueue.ts b/frontend-modern/src/utils/canvasRenderQueue.ts
new file mode 100644
index 0000000..3a4af70
--- /dev/null
+++ b/frontend-modern/src/utils/canvasRenderQueue.ts
@@ -0,0 +1,69 @@
+/**
+ * Canvas Render Queue
+ *
+ * Shared requestAnimationFrame scheduler for batching canvas redraws.
+ * Prevents each sparkline from scheduling its own rAF cycle.
+ */
+
+import { logger } from './logger';
+
+let rafId: number | null = null;
+const pending = new Set<() => void>();
+
+/**
+ * Flush all pending render callbacks in a single rAF cycle
+ */
+const flush = (): void => {
+ rafId = null;
+ const count = pending.size;
+
+ pending.forEach((draw) => {
+ try {
+ draw();
+ } catch (error) {
+ logger.error('[CanvasRenderQueue] Draw callback failed', { error });
+ }
+ });
+
+ pending.clear();
+
+ if (import.meta.env.DEV) {
+ logger.debug('[CanvasRenderQueue] Flushed render queue', { count });
+ }
+};
+
+/**
+ * Schedule a canvas redraw
+ * Returns an unregister callback for cleanup
+ *
+ * @param draw - The draw callback to execute on next animation frame
+ * @returns Cleanup function to unregister the callback
+ */
+export function scheduleSparkline(draw: () => void): () => void {
+ pending.add(draw);
+
+ if (rafId === null) {
+ rafId = requestAnimationFrame(flush);
+ }
+
+ return () => {
+ pending.delete(draw);
+ };
+}
+
+/**
+ * Get render queue stats for debugging
+ */
+export function getRenderQueueStats() {
+ return {
+ pendingCount: pending.size,
+ rafScheduled: rafId !== null,
+ };
+}
+
+if (import.meta.env.DEV) {
+ // Expose for debugging in dev mode
+ (window as any).__canvasRenderQueue = {
+ getStats: getRenderQueueStats,
+ };
+}
diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts
index ba9afbc..1e0af9b 100644
--- a/frontend-modern/src/utils/localStorage.ts
+++ b/frontend-modern/src/utils/localStorage.ts
@@ -105,4 +105,7 @@ export const STORAGE_KEYS = {
// Alerts search
ALERTS_SEARCH_HISTORY: 'alertsSearchHistory',
+
+ // Metrics display
+ METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
} as const;
diff --git a/frontend-modern/src/utils/metricsKeys.ts b/frontend-modern/src/utils/metricsKeys.ts
new file mode 100644
index 0000000..1f2ffec
--- /dev/null
+++ b/frontend-modern/src/utils/metricsKeys.ts
@@ -0,0 +1,40 @@
+/**
+ * Metrics Key Utilities
+ *
+ * Centralized helper for building namespaced metric keys to prevent ID collisions
+ * across different resource types.
+ */
+
+export type MetricResourceKind = 'node' | 'vm' | 'container' | 'dockerHost' | 'dockerContainer';
+
+/**
+ * Build a namespaced metric key for a resource
+ * Format: {kind}:{id}
+ *
+ * This prevents collisions if different resource types happen to share the same ID.
+ */
+export function buildMetricKey(kind: MetricResourceKind, id: string): string {
+ return `${kind}:${id}`;
+}
+
+/**
+ * Parse a metric key back into its components
+ * Returns null if the key format is invalid
+ */
+export function parseMetricKey(key: string): { kind: MetricResourceKind; id: string } | null {
+ const colonIndex = key.indexOf(':');
+ if (colonIndex === -1) return null;
+
+ const kind = key.slice(0, colonIndex) as MetricResourceKind;
+ const id = key.slice(colonIndex + 1);
+
+ return { kind, id };
+}
+
+/**
+ * Extract the prefix from a metric resource kind
+ * Used for bulk operations on a specific resource type
+ */
+export function getMetricKeyPrefix(kind: MetricResourceKind): string {
+ return `${kind}:`;
+}
| |