From 6988eb746a5c993ccc143c883ca94db70a0089f8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Dec 2025 00:49:32 +0000 Subject: [PATCH] Frontend updates --- .../src/components/Alerts/ActivationModal.tsx | 32 ++++--- .../Alerts/InvestigateAlertButton.tsx | 5 +- .../src/components/Dashboard/Dashboard.tsx | 13 +-- .../components/Dashboard/EnhancedCPUBar.tsx | 6 +- .../src/components/Dashboard/GuestRow.tsx | 18 ++-- .../src/components/Dashboard/MetricBar.tsx | 6 +- .../components/shared/MetricsViewToggle.tsx | 86 +++++++++++------- .../src/components/shared/Sparkline.tsx | 37 +------- frontend-modern/src/stores/metricsHistory.ts | 37 +++++++- frontend-modern/src/stores/metricsViewMode.ts | 74 +++++++++++++++- frontend-modern/src/utils/alertFormatters.ts | 88 +++++++++++++++++++ frontend-modern/src/utils/localStorage.ts | 1 + 12 files changed, 290 insertions(+), 113 deletions(-) create mode 100644 frontend-modern/src/utils/alertFormatters.ts diff --git a/frontend-modern/src/components/Alerts/ActivationModal.tsx b/frontend-modern/src/components/Alerts/ActivationModal.tsx index 03e0652..6a04a3c 100644 --- a/frontend-modern/src/components/Alerts/ActivationModal.tsx +++ b/frontend-modern/src/components/Alerts/ActivationModal.tsx @@ -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 { {(alert) => (
{alert.level} @@ -288,7 +287,7 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element {

{alert.message}

- 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()}

@@ -303,11 +302,10 @@ export function ActivationModal(props: ActivationModalProps): JSX.Element { Notification channels

{channelSummary().message}

+ ))} +
+ + + {/* View Mode Toggle */} +
+ - + + }`} + title="Sparkline view" + > + + + + + Trends + +
); }; diff --git a/frontend-modern/src/components/shared/Sparkline.tsx b/frontend-modern/src/components/shared/Sparkline.tsx index 5a6e538..bfce324 100644 --- a/frontend-modern/src/components/shared/Sparkline.tsx +++ b/frontend-modern/src/components/shared/Sparkline.tsx @@ -142,34 +142,10 @@ export const Sparkline: Component = (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 = (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 diff --git a/frontend-modern/src/stores/metricsHistory.ts b/frontend-modern/src/stores/metricsHistory.ts index 83737b5..8725511 100644 --- a/frontend-modern/src/stores/metricsHistory.ts +++ b/frontend-modern/src/stores/metricsHistory.ts @@ -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(); @@ -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 */ diff --git a/frontend-modern/src/stores/metricsViewMode.ts b/frontend-modern/src/stores/metricsViewMode.ts index df157e9..a3979c5 100644 --- a/frontend-modern/src/stores/metricsViewMode.ts +++ b/frontend-modern/src/stores/metricsViewMode.ts @@ -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( getInitialViewMode() ); +const [metricsTimeRange, setMetricsTimeRange] = createSignal( + 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, }; } diff --git a/frontend-modern/src/utils/alertFormatters.ts b/frontend-modern/src/utils/alertFormatters.ts new file mode 100644 index 0000000..8ae4f08 --- /dev/null +++ b/frontend-modern/src/utils/alertFormatters.ts @@ -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); +} diff --git a/frontend-modern/src/utils/localStorage.ts b/frontend-modern/src/utils/localStorage.ts index e1279ef..1b273f6 100644 --- a/frontend-modern/src/utils/localStorage.ts +++ b/frontend-modern/src/utils/localStorage.ts @@ -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',