feat: add sparklines view mode for metrics visualization

Add comprehensive sparkline chart support as an alternative to progress bars
for CPU, Memory, and Disk metrics across all tables.

Features:
- Toggle between bars/trends view modes (persisted to localStorage)
- 30-second sampling with 2-hour retention window using ring buffer
- Canvas-based rendering with shared requestAnimationFrame for efficiency
- Hover tooltips showing exact values and timestamps
- Threshold reference lines (warning/critical) for context
- localStorage persistence survives page refreshes (12-hour max age)
- Dynamic width adaptation to column size
- Namespaced resource IDs prevent collisions
- Lifecycle cleanup prevents memory leaks

Performance optimizations:
- Decoupled sampling from WebSocket handler (6x reduction in recording)
- O(1) ring buffer insertions (no array cloning)
- Batched canvas rendering (single rAF for all sparklines)
- Debounced localStorage writes
- Automatic pruning of removed resources

UI improvements:
- Consistent radio toggle styling matching other filters
- Fixed column widths prevent layout shift during toggle
- Fixed row heights prevent vertical size changes
- Sparklines fill available column width proportionally
This commit is contained in:
rcourtman 2025-11-09 22:31:35 +00:00
parent f34ba0fda3
commit 886368ec44
18 changed files with 1216 additions and 35 deletions

View file

@ -34,6 +34,7 @@ import { DemoBanner } from './components/DemoBanner';
import { createTooltipSystem } from './components/shared/Tooltip';
import type { State } from '@/types/api';
import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon';
import { startMetricsSampler } from './stores/metricsSampler';
import BoxesIcon from 'lucide-solid/icons/boxes';
import MonitorIcon from 'lucide-solid/icons/monitor';
import BellIcon from 'lucide-solid/icons/bell';
@ -207,6 +208,11 @@ function App() {
};
const alertsActivation = useAlertsActivation();
// Start metrics sampler for sparklines
onMount(() => {
startMetricsSampler();
});
let hasPreloadedRoutes = false;
let hasFetchedVersionInfo = false;
const preloadLazyRoutes = () => {

View file

@ -940,19 +940,19 @@ export function Dashboard(props: DashboardProps) {
Uptime {sortKey() === 'uptime' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('cpu')}
>
CPU {sortKey() === 'cpu' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('memory')}
>
Memory {sortKey() === 'memory' && (sortDirection() === 'asc' ? '▲' : '▼')}
</th>
<th
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
class="px-2 py-1 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px] cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
onClick={() => handleSort('disk')}
>
Disk {sortKey() === 'disk' && (sortDirection() === 'asc' ? '▲' : '▼')}

View file

@ -1,6 +1,7 @@
import { Component, Show, For, createSignal, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
@ -394,6 +395,11 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Metrics View Toggle */}
<MetricsViewToggle />
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {

View file

@ -9,6 +9,7 @@ import { isGuestRunning } from '@/utils/status';
import { GuestMetadataAPI } from '@/api/guestMetadata';
import { showSuccess, showError } from '@/utils/toast';
import { logger } from '@/utils/logger';
import { buildMetricKey } from '@/utils/metricsKeys';
type Guest = VM | Container;
@ -63,6 +64,12 @@ export function GuestRow(props: GuestRowProps) {
const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());
// Create namespaced metrics key
const metricsKey = createMemo(() => {
const kind = props.guest.type === 'qemu' ? 'vm' : 'container';
return buildMetricKey(kind, guestId());
});
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
const drawerOpen = createMemo(() => currentlyExpandedGuestId() === guestId());
@ -565,7 +572,7 @@ export function GuestRow(props: GuestRowProps) {
</td>
{/* CPU */}
<td class="py-0.5 px-2 w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px]">
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
value={cpuPercent()}
@ -576,12 +583,13 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
type="cpu"
resourceId={metricsKey()}
/>
</Show>
</td>
{/* Memory */}
<td class="py-0.5 px-2 w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px]">
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
<div title={memoryTooltip() ?? undefined}>
<Show when={isRunning()} fallback={<span class="text-sm text-gray-400">-</span>}>
<MetricBar
@ -589,13 +597,14 @@ export function GuestRow(props: GuestRowProps) {
label={formatPercent(memPercent())}
sublabel={memoryUsageLabel()}
type="memory"
resourceId={metricsKey()}
/>
</Show>
</div>
</td>
{/* Disk surface usage even if guest is currently stopped so users can see last reported values */}
<td class="py-0.5 px-2 w-[100px] sm:w-[110px] lg:w-[130px] xl:w-[150px] 2xl:w-[204px]">
<td class="py-0.5 px-2 w-[160px] sm:w-[170px] lg:w-[180px] xl:w-[190px] 2xl:w-[204px]">
<Show
when={hasDiskUsage()}
fallback={
@ -613,6 +622,7 @@ export function GuestRow(props: GuestRowProps) {
: undefined
}
type="disk"
resourceId={metricsKey()}
/>
</Show>
</td>

View file

@ -1,13 +1,18 @@
import { Show, createMemo } from 'solid-js';
import { Sparkline } from '@/components/shared/Sparkline';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistory } from '@/stores/metricsHistory';
interface MetricBarProps {
value: number;
label: string;
sublabel?: string;
type?: 'cpu' | 'memory' | 'disk' | 'generic';
resourceId?: string; // Required for sparkline mode to fetch history
}
export function MetricBar(props: MetricBarProps) {
const { viewMode } = useMetricsViewMode();
const width = createMemo(() => Math.min(props.value, 100));
// Get color based on percentage and metric type (matching original)
@ -44,23 +49,57 @@ export function MetricBar(props: MetricBarProps) {
return colorMap[getColor()] || 'bg-gray-500/60 dark:bg-gray-500/50';
});
// Get metric history for sparkline
const metricHistory = createMemo(() => {
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistory(props.resourceId);
});
// Determine which metric type to use for sparkline
const sparklineMetric = (): 'cpu' | 'memory' | 'disk' => {
const type = props.type || 'cpu';
if (type === 'generic') return 'cpu';
return type;
};
return (
<div class="metric-text w-full">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.label}</span>
<Show when={props.sublabel}>
{(sublabel) => (
<span class="metric-sublabel hidden xl:inline font-normal">
({sublabel()})
</span>
)}
</Show>
</span>
<Show
when={viewMode() === 'sparklines' && props.resourceId}
fallback={
// Original progress bar mode
<div class="metric-text w-full h-6 flex items-center">
<div class="relative w-full h-3.5 rounded overflow-hidden bg-gray-200 dark:bg-gray-600">
<div class={`absolute top-0 left-0 h-full ${progressColorClass()}`} style={{ width: `${width()}%` }} />
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-gray-800 dark:text-gray-100 leading-none">
<span class="flex items-center gap-1 whitespace-nowrap px-0.5">
<span>{props.label}</span>
<Show when={props.sublabel}>
{(sublabel) => (
<span class="metric-sublabel hidden xl:inline font-normal">
({sublabel()})
</span>
)}
</Show>
</span>
</span>
</div>
</div>
}
>
{/* Sparkline mode */}
<div class="metric-text w-full h-6 flex items-center gap-1.5">
<div class="flex-1 min-w-0">
<Sparkline
data={metricHistory()}
metric={sparklineMetric()}
width={0}
height={24}
/>
</div>
<span class="text-[10px] font-medium text-gray-800 dark:text-gray-100 whitespace-nowrap flex-shrink-0 min-w-[35px]">
{props.label}
</span>
</div>
</div>
</Show>
);
}

View file

@ -1,6 +1,7 @@
import { Component, Show, For, createSignal, createMemo, onMount, createEffect, onCleanup } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import { MetricsViewToggle } from '@/components/shared/MetricsViewToggle';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { createSearchHistoryManager } from '@/utils/searchHistory';
@ -408,6 +409,9 @@ export const DockerFilter: Component<DockerFilterProps> = (props) => {
</div>
</Show>
{/* Metrics View Toggle */}
<MetricsViewToggle />
<Show when={hasActiveFilters()}>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block" aria-hidden="true"></div>
<button

View file

@ -6,6 +6,7 @@ import { renderDockerStatusBadge } from './DockerStatusBadge';
import { resolveHostRuntime } from './runtimeDisplay';
import { formatPercent, formatUptime } from '@/utils/format';
import { ScrollableTable } from '@/components/shared/ScrollableTable';
import { buildMetricKey } from '@/utils/metricsKeys';
export interface DockerHostSummary {
host: DockerHost;
@ -139,7 +140,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
return (
<Card padding="none" class="mb-4 overflow-hidden">
<ScrollableTable minWidth="720px" persistKey="docker-host-summary">
<table class="w-full border-collapse sm:whitespace-nowrap">
<table class="w-full table-fixed border-collapse sm:whitespace-nowrap">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
@ -241,6 +242,7 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
const agentOutdated = isAgentOutdated(summary.host.agentVersion);
const runtimeInfo = resolveHostRuntime(summary.host);
const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion;
const metricsKey = buildMetricKey('dockerHost', summary.host.id);
return (
<tr
@ -317,32 +319,39 @@ export const DockerHostSummaryTable: Component<DockerHostSummaryTableProps> = (p
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
<div class="flex justify-center items-center h-full w-full min-w-[180px] max-w-[220px] whitespace-nowrap">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar value={summary.cpuPercent} label={formatPercent(summary.cpuPercent)} type="cpu" />
<MetricBar
value={summary.cpuPercent}
label={formatPercent(summary.cpuPercent)}
type="cpu"
resourceId={metricsKey}
/>
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full w-full max-w-[180px] whitespace-nowrap">
<div class="flex justify-center items-center h-full w-full min-w-[180px] max-w-[220px] whitespace-nowrap">
<Show when={online} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.memoryPercent}
label={formatPercent(summary.memoryPercent)}
sublabel={summary.memoryLabel}
type="memory"
resourceId={metricsKey}
/>
</Show>
</div>
</td>
<td class="px-2 py-1 align-middle">
<div class="flex justify-center items-center h-full whitespace-nowrap">
<div class="flex justify-center items-center h-full min-w-[180px] max-w-[220px] whitespace-nowrap">
<Show when={summary.diskLabel} fallback={<span class="text-xs text-gray-400 dark:text-gray-500"></span>}>
<MetricBar
value={summary.diskPercent}
label={formatPercent(summary.diskPercent)}
sublabel={summary.diskLabel}
type="disk"
resourceId={metricsKey}
/>
</Show>
</div>

View file

@ -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()}
</span>
</td>
<td class="px-2 py-0.5 min-w-[160px]">
<td class="px-2 py-0.5 min-w-[180px]">
<Show
when={isRunning() && container.cpuPercent && container.cpuPercent > 0}
fallback={<span class="text-xs text-gray-400"></span>}
>
<MetricBar value={cpuPercent()} label={formatPercent(cpuPercent())} type="cpu" />
<MetricBar
value={cpuPercent()}
label={formatPercent(cpuPercent())}
type="cpu"
resourceId={metricsKey}
/>
</Show>
</td>
<td class="px-2 py-0.5 min-w-[220px]">
<td class="px-2 py-0.5 min-w-[240px]">
<Show
when={isRunning() && container.memoryUsageBytes && container.memoryUsageBytes > 0}
fallback={<span class="text-xs text-gray-400"></span>}
@ -1025,10 +1032,11 @@ const DockerContainerRow: Component<{
label={formatPercent(memPercent())}
type="memory"
sublabel={memUsageLabel()}
resourceId={metricsKey}
/>
</Show>
</td>
<td class="px-2 py-0.5 min-w-[200px]">
<td class="px-2 py-0.5 min-w-[220px]">
<Show when={hasDiskStats()} fallback={<span class="text-xs text-gray-400"></span>}>
<Show
when={diskPercent() !== null}
@ -1039,6 +1047,7 @@ const DockerContainerRow: Component<{
label={formatPercent(diskPercent() ?? 0)}
type="disk"
sublabel={diskSublabel() ?? diskUsageLabel()}
resourceId={metricsKey}
/>
</Show>
</Show>
@ -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<{
</td>
<td class="py-1 px-2 w-[120px]">
<Show when={cpu > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, cpu)} label={formatPercent(cpu)} type="cpu" />
<MetricBar
value={Math.min(100, cpu)}
label={formatPercent(cpu)}
type="cpu"
resourceId={taskMetricsKey}
/>
</Show>
</td>
<td class="py-1 px-2 w-[140px]">
<Show when={mem > 0} fallback={<span class="text-gray-400"></span>}>
<MetricBar value={Math.min(100, mem)} label={formatPercent(mem)} type="memory" />
<MetricBar
value={Math.min(100, mem)}
label={formatPercent(mem)}
type="memory"
resourceId={taskMetricsKey}
/>
</Show>
</td>
<td class="py-1 px-2 text-gray-600 dark:text-gray-400 whitespace-nowrap">

View file

@ -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 (
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('bars')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
viewMode() === 'bars'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Bar view"
>
Bars
</button>
<button
type="button"
onClick={() => setViewMode('sparklines')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
viewMode() === 'sparklines'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
}`}
title="Sparkline view"
>
Trends
</button>
</div>
);
};

View file

@ -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<NodeSummaryTableProps> = (props) => {
<>
<Card padding="none" class="mb-4 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full min-w-[600px] border-collapse">
<table class="w-full min-w-[600px] table-fixed border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
<th
@ -442,6 +443,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (props) => {
: undefined
}
type="cpu"
resourceId={metricsKey}
/>
</Show>
</td>
@ -605,6 +609,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
: undefined
}
type="memory"
resourceId={metricsKey}
/>
</Show>
</td>
@ -618,6 +623,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
label={formatPercent(diskPercentValue ?? 0)}
sublabel={diskSublabel}
type="disk"
resourceId={metricsKey}
/>
</Show>
</td>

View file

@ -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<SparklineProps> = (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 (
<div class="relative inline-block">
<canvas
ref={canvasRef}
class="inline-block cursor-crosshair"
style={{
width: `${width()}px`,
height: `${height()}px`,
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
/>
<Show when={hoveredPoint()}>
{(point) => (
<div
class="absolute z-50 pointer-events-none bg-gray-900 dark:bg-gray-800 text-white text-xs rounded px-2 py-1 shadow-lg border border-gray-700"
style={{
left: `${point().x}px`,
top: '-32px',
transform: 'translateX(-50%)',
}}
>
<div class="font-medium">{point().value.toFixed(1)}%</div>
<div class="text-gray-400 text-[10px]">{formatTime(point().timestamp)}</div>
</div>
)}
</Show>
</div>
);
};

View file

@ -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<string, RingBuffer>();
// Track last sample time per resource to enforce sampling interval
const lastSampleTimes = new Map<string, number>();
/**
* 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<string, MetricSnapshot[]> = {};
// 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<string, MetricSnapshot[]>)) {
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<string>): 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,
};
}

View file

@ -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,
};
}

View file

@ -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<MetricsViewMode>(
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,
};
}

View file

@ -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<string>();
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<string>();
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', {

View file

@ -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,
};
}

View file

@ -105,4 +105,7 @@ export const STORAGE_KEYS = {
// Alerts search
ALERTS_SEARCH_HISTORY: 'alertsSearchHistory',
// Metrics display
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
} as const;

View file

@ -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}:`;
}