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

View file

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

View file

@ -1094,21 +1094,24 @@ export function Dashboard(props: DashboardProps) {
return ( return (
<th <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'} ${isFirst() ? 'pl-4 pr-2 text-left' : 'px-2 text-center'}
${isSortable ? 'cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600' : ''}`} ${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!)} onClick={() => isSortable && handleSort(sortKeyForCol!)}
title={col.icon ? col.label : undefined} 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 ? ( {col.icon ? (
<span innerHTML={col.icon} /> <span class="flex items-center" innerHTML={col.icon} />
) : ( ) : (
col.label col.label
)} )}
{isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} {isSorted() && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</span> </div>
</th> </th>
); );
}} }}

View file

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

View file

@ -432,20 +432,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [
{ id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' },
// Secondary - visible on md+ (768px), user toggleable - use icons // 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: '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" 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: '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" 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: '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 // 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: '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" 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: '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 // Detailed - visible on xl+ (1280px), user toggleable
{ id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true }, { 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: '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" 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: '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" 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: '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" 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: '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 { interface GuestRowProps {

View file

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

View file

@ -2,48 +2,72 @@
* Metrics View Mode Toggle * Metrics View Mode Toggle
* *
* Toggle button to switch between progress bars and sparkline views for metrics. * 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. * This is a global setting that affects all tables throughout the app.
*/ */
import { Component } from 'solid-js'; import { Component, Show } from 'solid-js';
import { useMetricsViewMode } from '@/stores/metricsViewMode'; import { useMetricsViewMode, TIME_RANGE_OPTIONS } from '@/stores/metricsViewMode';
import type { TimeRange } from '@/api/charts';
export const MetricsViewToggle: Component = () => { export const MetricsViewToggle: Component = () => {
const { viewMode, setViewMode } = useMetricsViewMode(); const { viewMode, setViewMode, timeRange, setTimeRange } = useMetricsViewMode();
return ( return (
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5"> <div class="inline-flex items-center gap-2">
<button {/* Time Range Selector - only shown in sparklines mode */}
type="button" <Show when={viewMode() === 'sparklines'}>
onClick={() => setViewMode('bars')} <div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
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' {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' ? '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' : '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" title="Bar view"
> >
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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="3" y="12" width="4" height="9" rx="1" />
<rect x="10" y="6" width="4" height="15" rx="1" /> <rect x="10" y="6" width="4" height="15" rx="1" />
<rect x="17" y="3" width="4" height="18" rx="1" /> <rect x="17" y="3" width="4" height="18" rx="1" />
</svg> </svg>
Bars Bars
</button> </button>
<button <button
type="button" type="button"
onClick={() => setViewMode('sparklines')} 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' 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' ? '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' : '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" title="Sparkline view"
> >
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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="3 17 9 11 13 15 21 7" />
<polyline points="17 7 21 7 21 11" /> <polyline points="17 7 21 7 21 11" />
</svg> </svg>
Trends Trends
</button> </button>
</div>
</div> </div>
); );
}; };

View file

@ -142,34 +142,10 @@ export const Sparkline: Component<SparklineProps> = (props) => {
points.push({ x, y }); 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 // Draw gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, h); const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, `${color}40`); // 25% opacity at top gradient.addColorStop(0, `${color}5A`); // 35% opacity at top
gradient.addColorStop(1, `${color}10`); // 6% opacity at bottom gradient.addColorStop(1, `${color}1F`); // 12% opacity at bottom
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.beginPath(); ctx.beginPath();
@ -193,15 +169,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
} }
}); });
ctx.stroke(); 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 // Redraw when data or dimensions change

View file

@ -23,11 +23,28 @@ interface RingBuffer {
} }
// Configuration // 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 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_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 // Store - map of resourceId to ring buffer
const metricsHistoryMap = new Map<string, RingBuffer>(); 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[] { export function getMetricHistory(resourceId: string): MetricSnapshot[] {
const ring = metricsHistoryMap.get(resourceId); const ring = metricsHistoryMap.get(resourceId);
@ -409,6 +426,18 @@ export function getMetricHistory(resourceId: string): MetricSnapshot[] {
return getRingBufferData(ring, cutoffTime); 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 * Clear history for a specific resource
*/ */

View file

@ -2,16 +2,26 @@
* Metrics View Mode Store * Metrics View Mode Store
* *
* Global preference for displaying metrics as progress bars or sparklines. * 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. * This affects all tables: node summaries, guest tables, and container tables.
*/ */
import { createSignal } from 'solid-js'; import { createSignal } from 'solid-js';
import { STORAGE_KEYS } from '@/utils/localStorage'; 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'; 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 => { const getInitialViewMode = (): MetricsViewMode => {
if (typeof window === 'undefined') return 'bars'; if (typeof window === 'undefined') return 'bars';
@ -27,11 +37,31 @@ const getInitialViewMode = (): MetricsViewMode => {
return 'bars'; // Default to bars (current behavior) 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>( const [metricsViewMode, setMetricsViewMode] = createSignal<MetricsViewMode>(
getInitialViewMode() getInitialViewMode()
); );
const [metricsTimeRange, setMetricsTimeRange] = createSignal<TimeRange>(
getInitialTimeRange()
);
/** /**
* Get the current metrics view mode * Get the current metrics view mode
*/ */
@ -39,6 +69,13 @@ export function getMetricsViewMode(): MetricsViewMode {
return metricsViewMode(); return metricsViewMode();
} }
/**
* Get the current metrics time range
*/
export function getMetricsTimeRange(): TimeRange {
return metricsTimeRange();
}
/** /**
* Set the metrics view mode and persist to localStorage * 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 // When switching to sparklines, seed historical data from backend
if (mode === 'sparklines') { if (mode === 'sparklines') {
// Fire and forget - don't block the UI // 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 // Errors are already logged in seedFromBackend
}); });
} }
@ -80,5 +144,7 @@ export function useMetricsViewMode() {
viewMode: metricsViewMode, viewMode: metricsViewMode,
setViewMode: setMetricsViewModePreference, setViewMode: setMetricsViewModePreference,
toggle: toggleMetricsViewMode, 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 display
METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines' METRICS_VIEW_MODE: 'metricsViewMode', // 'bars' | 'sparklines'
METRICS_TIME_RANGE: 'metricsTimeRange', // '15m' | '1h' | '4h' | '24h'
// Column visibility // Column visibility
DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns', DASHBOARD_HIDDEN_COLUMNS: 'dashboardHiddenColumns',