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:
parent
f34ba0fda3
commit
886368ec44
18 changed files with 1216 additions and 35 deletions
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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' ? '▲' : '▼')}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
42
frontend-modern/src/components/shared/MetricsViewToggle.tsx
Normal file
42
frontend-modern/src/components/shared/MetricsViewToggle.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
286
frontend-modern/src/components/shared/Sparkline.tsx
Normal file
286
frontend-modern/src/components/shared/Sparkline.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
347
frontend-modern/src/stores/metricsHistory.ts
Normal file
347
frontend-modern/src/stores/metricsHistory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
181
frontend-modern/src/stores/metricsSampler.ts
Normal file
181
frontend-modern/src/stores/metricsSampler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
75
frontend-modern/src/stores/metricsViewMode.ts
Normal file
75
frontend-modern/src/stores/metricsViewMode.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
69
frontend-modern/src/utils/canvasRenderQueue.ts
Normal file
69
frontend-modern/src/utils/canvasRenderQueue.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -105,4 +105,7 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// Alerts search
|
||||
ALERTS_SEARCH_HISTORY: 'alertsSearchHistory',
|
||||
|
||||
// Metrics display
|
||||
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
|
||||
} as const;
|
||||
|
|
|
|||
40
frontend-modern/src/utils/metricsKeys.ts
Normal file
40
frontend-modern/src/utils/metricsKeys.ts
Normal 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}:`;
|
||||
}
|
||||
Loading…
Reference in a new issue