Frontend updates

This commit is contained in:
rcourtman 2025-12-07 00:49:32 +00:00
parent c0da4768bb
commit 6988eb746a
12 changed files with 290 additions and 113 deletions

View file

@ -5,6 +5,7 @@ import type { JSX } from 'solid-js';
import type { Alert } from '@/types/api';
import type { AlertConfig, AlertThresholds, HysteresisThreshold } from '@/types/alerts';
import { showError, showSuccess } from '@/utils/toast';
import { formatAlertValue, formatAlertThreshold } from '@/utils/alertFormatters';
interface ActivationModalProps {
isOpen: boolean;
@ -85,7 +86,7 @@ const summarizeThresholds = (config: AlertConfig | null): ThresholdSummary[] =>
...nodeItems,
{
label: 'Temperature',
value: formatThreshold(extractTrigger(config.nodeDefaults?.temperature)),
value: formatAlertThreshold(extractTrigger(config.nodeDefaults?.temperature), 'temperature'),
},
];
summaries.push({ heading: 'Node thresholds', items: nodeWithTemperature });
@ -263,20 +264,18 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
<For each={violations()}>
{(alert) => (
<div
class={`border rounded-md p-3 text-sm transition-colors ${
alert.level === 'critical'
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
}`}
class={`border rounded-md p-3 text-sm transition-colors ${alert.level === 'critical'
? 'border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20'
: 'border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20'
}`}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${
alert.level === 'critical'
? 'bg-red-600 text-white'
: 'bg-yellow-500 text-gray-900'
}`}
class={`px-2 py-0.5 rounded-full text-xs font-semibold uppercase ${alert.level === 'critical'
? 'bg-red-600 text-white'
: 'bg-yellow-500 text-gray-900'
}`}
>
{alert.level}
</span>
@ -288,7 +287,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
</div>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-300">{alert.message}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Threshold {alert.threshold}% Current {alert.value}% Since{' '}
Threshold {formatAlertValue(alert.threshold, alert.type)} Current {formatAlertValue(alert.value, alert.type)} Since{' '}
{new Date(alert.startTime).toLocaleString()}
</p>
</div>
@ -303,11 +302,10 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {
Notification channels
</h3>
<div
class={`mt-3 rounded-md border p-4 ${
channelSummary().status === 'configured'
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
}`}
class={`mt-3 rounded-md border p-4 ${channelSummary().status === 'configured'
? 'border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/20'
: 'border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20'
}`}
>
<p class="text-sm text-gray-800 dark:text-gray-100">{channelSummary().message}</p>
<button

View file

@ -1,6 +1,7 @@
import { Show, createSignal } from 'solid-js';
import { aiChatStore } from '@/stores/aiChat';
import type { Alert } from '@/types/api';
import { formatAlertValue } from '@/utils/alertFormatters';
interface InvestigateAlertButtonProps {
alert: Alert;
@ -37,8 +38,8 @@ export function InvestigateAlertButton(props: InvestigateAlertButtonProps) {
**Resource:** ${props.alert.resourceName}
**Alert Type:** ${props.alert.type}
**Current Value:** ${props.alert.value.toFixed(1)}%
**Threshold:** ${props.alert.threshold.toFixed(1)}%
**Current Value:** ${formatAlertValue(props.alert.value, props.alert.type)}
**Threshold:** ${formatAlertValue(props.alert.threshold, props.alert.type)}
**Duration:** ${durationStr}
${props.alert.node ? `**Node:** ${props.alert.node}` : ''}

View file

@ -1094,21 +1094,24 @@ export function Dashboard(props: DashboardProps) {
return (
<th
class={`py-1 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
class={`py-2 text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap
${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`}
style={col.width ? { "min-width": col.width } : undefined}
style={{
...(col.width ? { "min-width": col.width } : {}),
"vertical-align": 'middle'
}}
onClick={() => isSortable && handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined}
>
<span class="inline-flex items-center justify-center gap-0.5">
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
{col.icon ? (
<span innerHTML={col.icon} />
<span class="flex items-center" innerHTML={col.icon} />
) : (
col.label
)}
{isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</span>
</div>
</th>
);
}}

View file

@ -2,7 +2,7 @@ import { Show, createMemo, createSignal } from 'solid-js';
import { Portal } from 'solid-js/web';
import { formatPercent } from '@/utils/format';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistory } from '@/stores/metricsHistory';
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
import { Sparkline } from '@/components/shared/Sparkline';
interface EnhancedCPUBarProps {
@ -35,12 +35,12 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) {
setShowTooltip(false);
};
const { viewMode } = useMetricsViewMode();
const { viewMode, timeRange } = useMetricsViewMode();
// Get metric history for sparkline
const metricHistory = createMemo(() => {
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistory(props.resourceId);
return getMetricHistoryForRange(props.resourceId, timeRange());
});
return (

View file

@ -432,20 +432,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
// Secondary - visible on md+ (768px), user toggleable - use icons
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'secondary', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
{ id: 'ip', label: 'IP', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>', priority: 'secondary', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>', priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>', priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
// Supplementary - visible on lg+ (1024px), user toggleable
{ id: 'backup', label: 'Backup', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>', priority: 'supplementary', width: '60px', toggleable: true },
{ id: 'backup', label: 'Backup', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>', priority: 'supplementary', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>', priority: 'supplementary', width: '60px', toggleable: true },
// Detailed - visible on xl+ (1280px), user toggleable
{ id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true },
{ id: 'diskRead', label: 'D Read', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: '<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
{ id: 'diskRead', label: 'D Read', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: '<svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>', priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
];
interface GuestRowProps {

View file

@ -1,7 +1,7 @@
import { Show, createMemo, createSignal, onMount, onCleanup } from 'solid-js';
import { Sparkline } from '@/components/shared/Sparkline';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistory } from '@/stores/metricsHistory';
import { getMetricHistoryForRange } from '@/stores/metricsHistory';
interface MetricBarProps {
value: number;
@ -19,7 +19,7 @@ const estimateTextWidth = (text: string): number => {
};
export function MetricBar(props: MetricBarProps) {
const { viewMode } = useMetricsViewMode();
const { viewMode, timeRange } = useMetricsViewMode();
const width = createMemo(() => Math.min(props.value, 100));
// Track container width
@ -88,7 +88,7 @@ export function MetricBar(props: MetricBarProps) {
// Get metric history for sparkline
const metricHistory = createMemo(() => {
if (viewMode() !== 'sparklines' || !props.resourceId) return [];
return getMetricHistory(props.resourceId);
return getMetricHistoryForRange(props.resourceId, timeRange());
});
// Determine which metric type to use for sparkline

View file

@ -2,48 +2,72 @@
* Metrics View Mode Toggle
*
* Toggle button to switch between progress bars and sparkline views for metrics.
* When in sparklines mode, also shows time range selection buttons.
* This is a global setting that affects all tables throughout the app.
*/
import { Component } from 'solid-js';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { Component, Show } from 'solid-js';
import { useMetricsViewMode, TIME_RANGE_OPTIONS } from '@/stores/metricsViewMode';
import type { TimeRange } from '@/api/charts';
export const MetricsViewToggle: Component = () => {
const { viewMode, setViewMode } = useMetricsViewMode();
const { viewMode, setViewMode, timeRange, setTimeRange } = 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={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'bars'
<div class="inline-flex items-center gap-2">
{/* Time Range Selector - only shown in sparklines mode */}
<Show when={viewMode() === 'sparklines'}>
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
{TIME_RANGE_OPTIONS.map((option) => (
<button
type="button"
onClick={() => setTimeRange(option.value as TimeRange)}
class={`px-2 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${timeRange() === option.value
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title={`Show last ${option.label} of data`}
>
{option.label}
</button>
))}
</div>
</Show>
{/* View Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button
type="button"
onClick={() => setViewMode('bars')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'bars'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Bar view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="6" width="4" height="15" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
Bars
</button>
<button
type="button"
onClick={() => setViewMode('sparklines')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'sparklines'
}`}
title="Bar view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="6" width="4" height="15" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" />
</svg>
Bars
</button>
<button
type="button"
onClick={() => setViewMode('sparklines')}
class={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-md transition-all duration-150 active:scale-95 ${viewMode() === 'sparklines'
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-gray-200 dark:ring-gray-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-600/50'
}`}
title="Sparkline view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 17 9 11 13 15 21 7" />
<polyline points="17 7 21 7 21 11" />
</svg>
Trends
</button>
}`}
title="Sparkline view"
>
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 17 9 11 13 15 21 7" />
<polyline points="17 7 21 7 21 11" />
</svg>
Trends
</button>
</div>
</div>
);
};

View file

@ -142,34 +142,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
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
gradient.addColorStop(0, `${color}5A`); // 35% opacity at top
gradient.addColorStop(1, `${color}1F`); // 12% opacity at bottom
ctx.fillStyle = gradient;
ctx.beginPath();
@ -193,15 +169,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
}
});
ctx.stroke();
// Draw current value dot with opacity
if (points.length > 0) {
const lastPoint = points[points.length - 1];
ctx.fillStyle = colorWithOpacity;
ctx.beginPath();
ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2);
ctx.fill();
}
};
// Redraw when data or dimensions change

View file

@ -23,11 +23,28 @@ interface RingBuffer {
}
// Configuration
const MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours (to support all time ranges)
const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~240 points
const MAX_POINTS = Math.ceil(MAX_AGE_MS / SAMPLE_INTERVAL_MS); // ~2880 points
const STORAGE_KEY = 'pulse_metrics_history';
const STORAGE_VERSION = 1;
const STORAGE_VERSION = 2; // Bumped version due to increased buffer size
/**
* Convert TimeRange string to milliseconds
*/
function timeRangeToMs(range: TimeRange): number {
switch (range) {
case '5m': return 5 * 60 * 1000;
case '15m': return 15 * 60 * 1000;
case '30m': return 30 * 60 * 1000;
case '1h': return 60 * 60 * 1000;
case '4h': return 4 * 60 * 60 * 1000;
case '12h': return 12 * 60 * 60 * 1000;
case '24h': return 24 * 60 * 60 * 1000;
case '7d': return 7 * 24 * 60 * 60 * 1000;
default: return 60 * 60 * 1000; // Default 1h
}
}
// Store - map of resourceId to ring buffer
const metricsHistoryMap = new Map<string, RingBuffer>();
@ -399,7 +416,7 @@ export function hasSeedData(): boolean {
/**
* Get metric history for a resource
* Get metric history for a resource (full history)
*/
export function getMetricHistory(resourceId: string): MetricSnapshot[] {
const ring = metricsHistoryMap.get(resourceId);
@ -409,6 +426,18 @@ export function getMetricHistory(resourceId: string): MetricSnapshot[] {
return getRingBufferData(ring, cutoffTime);
}
/**
* Get metric history for a resource filtered by time range
*/
export function getMetricHistoryForRange(resourceId: string, range: TimeRange): MetricSnapshot[] {
const ring = metricsHistoryMap.get(resourceId);
if (!ring) return [];
const rangeMs = timeRangeToMs(range);
const cutoffTime = Date.now() - rangeMs;
return getRingBufferData(ring, cutoffTime);
}
/**
* Clear history for a specific resource
*/

View file

@ -2,16 +2,26 @@
* Metrics View Mode Store
*
* Global preference for displaying metrics as progress bars or sparklines.
* Also manages the time range for sparkline data.
* This affects all tables: node summaries, guest tables, and container tables.
*/
import { createSignal } from 'solid-js';
import { STORAGE_KEYS } from '@/utils/localStorage';
import { seedFromBackend } from './metricsHistory';
import { seedFromBackend, resetSeedingState } from './metricsHistory';
import type { TimeRange } from '@/api/charts';
export type MetricsViewMode = 'bars' | 'sparklines';
// Read initial value from localStorage
// Available time ranges for sparklines
export const TIME_RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
{ value: '15m', label: '15m' },
{ value: '1h', label: '1h' },
{ value: '4h', label: '4h' },
{ value: '24h', label: '24h' },
];
// Read initial view mode from localStorage
const getInitialViewMode = (): MetricsViewMode => {
if (typeof window === 'undefined') return 'bars';
@ -27,11 +37,31 @@ const getInitialViewMode = (): MetricsViewMode => {
return 'bars'; // Default to bars (current behavior)
};
// Create signal
// Read initial time range from localStorage
const getInitialTimeRange = (): TimeRange => {
if (typeof window === 'undefined') return '1h';
try {
const stored = localStorage.getItem(STORAGE_KEYS.METRICS_TIME_RANGE);
if (stored && ['5m', '15m', '30m', '1h', '4h', '12h', '24h', '7d'].includes(stored)) {
return stored as TimeRange;
}
} catch (_err) {
// Ignore localStorage errors
}
return '1h'; // Default to 1 hour
};
// Create signals
const [metricsViewMode, setMetricsViewMode] = createSignal<MetricsViewMode>(
getInitialViewMode()
);
const [metricsTimeRange, setMetricsTimeRange] = createSignal<TimeRange>(
getInitialTimeRange()
);
/**
* Get the current metrics view mode
*/
@ -39,6 +69,13 @@ export function getMetricsViewMode(): MetricsViewMode {
return metricsViewMode();
}
/**
* Get the current metrics time range
*/
export function getMetricsTimeRange(): TimeRange {
return metricsTimeRange();
}
/**
* Set the metrics view mode and persist to localStorage
*/
@ -57,7 +94,34 @@ export function setMetricsViewModePreference(mode: MetricsViewMode): void {
// When switching to sparklines, seed historical data from backend
if (mode === 'sparklines') {
// Fire and forget - don't block the UI
seedFromBackend('1h').catch(() => {
seedFromBackend(metricsTimeRange()).catch(() => {
// Errors are already logged in seedFromBackend
});
}
}
/**
* Set the metrics time range and persist to localStorage
*/
export function setMetricsTimeRangePreference(range: TimeRange): void {
const previousRange = metricsTimeRange();
setMetricsTimeRange(range);
if (typeof window !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEYS.METRICS_TIME_RANGE, range);
} catch (err) {
// Ignore localStorage errors
console.warn('Failed to save metrics time range preference', err);
}
}
// If we're in sparklines mode and range changed, re-seed from backend
if (metricsViewMode() === 'sparklines' && range !== previousRange) {
// Reset seeding state to force a fresh fetch
resetSeedingState();
// Fire and forget - don't block the UI
seedFromBackend(range).catch(() => {
// Errors are already logged in seedFromBackend
});
}
@ -80,5 +144,7 @@ export function useMetricsViewMode() {
viewMode: metricsViewMode,
setViewMode: setMetricsViewModePreference,
toggle: toggleMetricsViewMode,
timeRange: metricsTimeRange,
setTimeRange: setMetricsTimeRangePreference,
};
}

View file

@ -0,0 +1,88 @@
/**
* Utility functions for formatting alert values based on their metric type.
* Temperature metrics should show °C, not percentages.
*/
/**
* Metric types that are measured in degrees Celsius rather than percentages.
*/
const TEMPERATURE_METRIC_TYPES = new Set(['temperature', 'temp']);
/**
* Metric types that are measured in MB/s rather than percentages.
*/
const THROUGHPUT_METRIC_TYPES = new Set(['diskRead', 'diskWrite', 'networkIn', 'networkOut']);
/**
* Returns the appropriate unit suffix for a given metric type.
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
* @returns The unit suffix to append to values (e.g., '%', '°C', ' MB/s')
*/
export function getAlertUnit(metricType?: string): string {
if (!metricType) return '%';
const typeLower = metricType.toLowerCase();
if (TEMPERATURE_METRIC_TYPES.has(typeLower)) {
return '°C';
}
if (THROUGHPUT_METRIC_TYPES.has(typeLower)) {
return ' MB/s';
}
return '%';
}
/**
* Formats an alert value with the appropriate unit based on metric type.
* @param value The numeric value to format
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
* @param decimals Number of decimal places (default: 1)
* @returns Formatted string with appropriate unit (e.g., '82.5%', '74.0°C')
*/
export function formatAlertValue(
value: number | undefined,
metricType?: string,
decimals = 1
): string {
if (value === undefined || !Number.isFinite(value)) {
return 'N/A';
}
return `${value.toFixed(decimals)}${getAlertUnit(metricType)}`;
}
/**
* Formats a threshold value with the appropriate unit based on metric type.
* Returns 'Disabled' for values <= 0 and 'Not configured' for undefined values.
* @param value The threshold value to format
* @param metricType The alert's metric type (e.g., 'cpu', 'memory', 'temperature')
* @returns Formatted threshold string
*/
export function formatAlertThreshold(
value: number | undefined,
metricType?: string
): string {
if (value === undefined || Number.isNaN(value)) {
return 'Not configured';
}
if (value <= 0) {
return 'Disabled';
}
return `${value}${getAlertUnit(metricType)}`;
}
/**
* Checks if a metric type represents a temperature measurement.
*/
export function isTemperatureMetric(metricType?: string): boolean {
if (!metricType) return false;
return TEMPERATURE_METRIC_TYPES.has(metricType.toLowerCase());
}
/**
* Checks if a metric type represents a throughput measurement (MB/s).
*/
export function isThroughputMetric(metricType?: string): boolean {
if (!metricType) return false;
return THROUGHPUT_METRIC_TYPES.has(metricType);
}

View file

@ -108,6 +108,7 @@ export const STORAGE_KEYS = {
// Metrics display
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
METRICS_TIME_RANGE: 'metricsTimeRange', // '15m' | '1h' | '4h' | '24h'
// Column visibility
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',