From 869a88a8009858f28939cd27f288f271e73ab2fc Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 21 Dec 2025 11:04:18 +0000 Subject: [PATCH] feat(ui): add anomaly indicator components and hooks Add frontend infrastructure for displaying baseline anomalies: - useAnomalies hook for fetching and caching anomaly data - AnomalyCell component for displaying multiple anomalies - AnomalyIndicator/AnomalyBadge components for inline display - Update EnhancedCPUBar to accept optional anomaly prop The anomaly endpoint is polled every 30 seconds and cached. Anomaly badges show severity (color) and deviation ratio (e.g., '2.5x'). This prepares the UI for displaying real-time baseline deviations without requiring LLM interaction. --- .../src/components/Dashboard/AnomalyCell.tsx | 129 +++++++++++++ .../components/Dashboard/AnomalyIndicator.tsx | 125 ++++++++++++ .../components/Dashboard/EnhancedCPUBar.tsx | 32 +++- frontend-modern/src/hooks/useAnomalies.ts | 180 ++++++++++++++++++ 4 files changed, 464 insertions(+), 2 deletions(-) create mode 100644 frontend-modern/src/components/Dashboard/AnomalyCell.tsx create mode 100644 frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx create mode 100644 frontend-modern/src/hooks/useAnomalies.ts diff --git a/frontend-modern/src/components/Dashboard/AnomalyCell.tsx b/frontend-modern/src/components/Dashboard/AnomalyCell.tsx new file mode 100644 index 000000000..03a214de8 --- /dev/null +++ b/frontend-modern/src/components/Dashboard/AnomalyCell.tsx @@ -0,0 +1,129 @@ +import { Show, createMemo, For } from 'solid-js'; +import type { AnomalyReport, AnomalySeverity } from '@/types/aiIntelligence'; + +interface AnomalyCellProps { + anomalies: AnomalyReport[]; +} + +// Severity priority for sorting (higher = more severe) +const severityPriority: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, +}; + +// Config for each severity level +const severityConfig: Record = { + critical: { + bg: 'bg-red-100 dark:bg-red-900/40', + text: 'text-red-700 dark:text-red-300', + border: 'border-red-300 dark:border-red-700', + }, + high: { + bg: 'bg-orange-100 dark:bg-orange-900/40', + text: 'text-orange-700 dark:text-orange-300', + border: 'border-orange-300 dark:border-orange-700', + }, + medium: { + bg: 'bg-yellow-100 dark:bg-yellow-900/40', + text: 'text-yellow-700 dark:text-yellow-300', + border: 'border-yellow-300 dark:border-yellow-700', + }, + low: { + bg: 'bg-blue-100 dark:bg-blue-900/40', + text: 'text-blue-700 dark:text-blue-300', + border: 'border-blue-300 dark:border-blue-700', + }, +}; + +/** + * Cell component that displays anomaly indicators for a guest. + * Shows metric badges with severity colors and deviation info. + */ +export function AnomalyCell(props: AnomalyCellProps) { + // Sort by severity (most severe first) + const sortedAnomalies = createMemo(() => + [...props.anomalies].sort( + (a, b) => severityPriority[b.severity] - severityPriority[a.severity] + ) + ); + + // Format ratio for display + const formatRatio = (anomaly: AnomalyReport): string => { + if (anomaly.baseline_mean === 0) return '↑'; + const ratio = anomaly.current_value / anomaly.baseline_mean; + if (ratio >= 2) return `${ratio.toFixed(1)}x`; + if (ratio >= 1.5) return '↑↑'; + if (ratio > 1) return '↑'; + if (ratio <= 0.5) return '↓↓'; + return '↓'; + }; + + // Metric labels + const metricLabels: Record = { + cpu: 'CPU', + memory: 'MEM', + disk: 'DSK', + }; + + return ( + 0}> +
+ + {(anomaly) => { + const config = severityConfig[anomaly.severity]; + return ( + + {metricLabels[anomaly.metric] || anomaly.metric.toUpperCase()} + {formatRatio(anomaly)} + + ); + }} + + 3}> + + +{sortedAnomalies().length - 3} + + +
+
+ ); +} + +/** + * Small dot indicator showing if a guest has any anomalies. + * Used for compact views where space is limited. + */ +export function AnomalyDot(props: { hasAnomalies: boolean; severity?: AnomalySeverity; title?: string }) { + const dotColor = createMemo(() => { + if (!props.hasAnomalies) return ''; + switch (props.severity) { + case 'critical': + return 'bg-red-500'; + case 'high': + return 'bg-orange-500'; + case 'medium': + return 'bg-yellow-500'; + case 'low': + return 'bg-blue-400'; + default: + return 'bg-gray-400'; + } + }); + + return ( + + + + ); +} diff --git a/frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx b/frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx new file mode 100644 index 000000000..569f3810f --- /dev/null +++ b/frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx @@ -0,0 +1,125 @@ +import { Show, createMemo } from 'solid-js'; +import type { AnomalyReport, AnomalySeverity } from '@/types/aiIntelligence'; + +interface AnomalyIndicatorProps { + anomaly: AnomalyReport | null; + compact?: boolean; // Only show icon, no text +} + +/** + * Displays an anomaly indicator badge for a metric. + * Shows severity level and how much above/below baseline the current value is. + */ +export function AnomalyIndicator(props: AnomalyIndicatorProps) { + const severityConfig = createMemo(() => { + if (!props.anomaly) return null; + + const configs: Record = { + critical: { + bg: 'bg-red-500', + text: 'text-white', + icon: '🔴', + }, + high: { + bg: 'bg-orange-500', + text: 'text-white', + icon: '🟠', + }, + medium: { + bg: 'bg-yellow-500', + text: 'text-gray-800', + icon: '🟡', + }, + low: { + bg: 'bg-blue-400', + text: 'text-white', + icon: '🔵', + }, + }; + + return configs[props.anomaly.severity] || configs.medium; + }); + + // Calculate how many times above baseline + const multiplier = createMemo(() => { + if (!props.anomaly || props.anomaly.baseline_mean === 0) return null; + const ratio = props.anomaly.current_value / props.anomaly.baseline_mean; + if (ratio >= 2) { + return `${ratio.toFixed(1)}x`; + } + return null; + }); + + // Simplified label for compact display + const compactLabel = createMemo(() => { + const m = multiplier(); + if (m) return m; + if (props.anomaly) { + const zAbs = Math.abs(props.anomaly.z_score); + if (zAbs >= 4) return 'CRIT'; + if (zAbs >= 3) return 'HIGH'; + if (zAbs >= 2.5) return 'MED'; + return 'LOW'; + } + return ''; + }); + + return ( + +
+ + {severityConfig()!.icon} + + {compactLabel()} +
+
+ ); +} + +/** + * Small inline indicator for use within metric bars. + * Just shows an icon and optionally the multiplier. + */ +export function AnomalyBadge(props: { anomaly: AnomalyReport | null }) { + const multiplier = createMemo(() => { + if (!props.anomaly || props.anomaly.baseline_mean === 0) return null; + const ratio = props.anomaly.current_value / props.anomaly.baseline_mean; + if (ratio >= 1.5) { + return `${ratio.toFixed(1)}x`; + } + return null; + }); + + const severityColor = createMemo(() => { + if (!props.anomaly) return ''; + switch (props.anomaly.severity) { + case 'critical': + return 'text-red-400'; + case 'high': + return 'text-orange-400'; + case 'medium': + return 'text-yellow-400'; + case 'low': + return 'text-blue-400'; + default: + return 'text-gray-400'; + } + }); + + return ( + + + + {multiplier()}↑ + + + + ); +} diff --git a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx index 4b9fe2c67..d29a3ed6f 100644 --- a/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx +++ b/frontend-modern/src/components/Dashboard/EnhancedCPUBar.tsx @@ -1,9 +1,10 @@ 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 { useMetricsViewMode } from '@/stores/metricsViewMode'; import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory'; import { Sparkline } from '@/components/shared/Sparkline'; +import type { AnomalyReport } from '@/types/aiIntelligence'; interface EnhancedCPUBarProps { usage: number; // CPU Usage % (0-100) @@ -11,8 +12,17 @@ interface EnhancedCPUBarProps { cores?: number; // Number of cores model?: string; // CPU Model name (for tooltip) resourceId?: string; // For sparkline history + anomaly?: AnomalyReport | null; // Baseline anomaly if detected } +// Anomaly severity colors +const anomalySeverityClass: Record = { + critical: 'text-red-400', + high: 'text-orange-400', + medium: 'text-yellow-400', + low: 'text-blue-400', +}; + export function EnhancedCPUBar(props: EnhancedCPUBarProps) { const [showTooltip, setShowTooltip] = createSignal(false); const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 }); @@ -25,6 +35,15 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { return 'bg-green-500/60 dark:bg-green-500/50'; }); + // Format anomaly ratio for display + const anomalyRatio = createMemo(() => { + if (!props.anomaly || props.anomaly.baseline_mean === 0) return null; + const ratio = props.anomaly.current_value / props.anomaly.baseline_mean; + if (ratio >= 2) return `${ratio.toFixed(1)}x`; + if (ratio >= 1.5) return '↑↑'; + return '↑'; + }); + const handleMouseEnter = (e: MouseEvent) => { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); setTooltipPos({ x: rect.left + rect.width / 2, y: rect.top }); @@ -63,12 +82,21 @@ export function EnhancedCPUBar(props: EnhancedCPUBarProps) { style={{ width: `${Math.min(props.usage, 100)}%` }} /> - {/* Label */} + {/* Label with optional anomaly indicator */} {formatPercent(props.usage)} ({props.cores} cores) + {/* Anomaly indicator */} + + + {anomalyRatio()} + + diff --git a/frontend-modern/src/hooks/useAnomalies.ts b/frontend-modern/src/hooks/useAnomalies.ts new file mode 100644 index 000000000..a5ae4db57 --- /dev/null +++ b/frontend-modern/src/hooks/useAnomalies.ts @@ -0,0 +1,180 @@ +import { createSignal, createEffect } from 'solid-js'; +import { AIAPI } from '@/api/ai'; +import type { AnomalyReport, AnomaliesResponse } from '@/types/aiIntelligence'; + +// Store anomalies with their resource IDs as keys +type AnomalyStore = Map>; + +// Global store for anomaly data +const [anomalyStore, setAnomalyStore] = createSignal(new Map()); +const [isLoading, setIsLoading] = createSignal(false); +const [error, setError] = createSignal(null); +const [lastUpdate, setLastUpdate] = createSignal(null); + +// Refresh interval (30 seconds) +const REFRESH_INTERVAL = 30000; + +let refreshTimer: ReturnType | null = null; + +// Fetch anomalies from the API +async function fetchAnomalies(): Promise { + if (isLoading()) return; + + setIsLoading(true); + setError(null); + + try { + const response: AnomaliesResponse = await AIAPI.getAnomalies(); + + // Build a map of resource_id -> metric -> anomaly + const newStore: AnomalyStore = new Map(); + + for (const anomaly of response.anomalies) { + if (!newStore.has(anomaly.resource_id)) { + newStore.set(anomaly.resource_id, new Map()); + } + newStore.get(anomaly.resource_id)!.set(anomaly.metric, anomaly); + } + + setAnomalyStore(newStore); + setLastUpdate(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch anomalies'); + } finally { + setIsLoading(false); + } +} + +// Start the refresh timer +function startRefreshTimer(): void { + if (refreshTimer) return; + + // Initial fetch + fetchAnomalies(); + + // Set up interval for periodic refresh + refreshTimer = setInterval(fetchAnomalies, REFRESH_INTERVAL); +} + +// Stop the refresh timer +function stopRefreshTimer(): void { + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } +} + +/** + * Hook to get anomaly data for a specific resource and metric. + * Returns the anomaly if present, or null if the metric is within baseline. + */ +export function useAnomalyForMetric( + resourceId: () => string | undefined, + metric: () => 'cpu' | 'memory' | 'disk' +): () => AnomalyReport | null { + // Start fetching on first use + createEffect(() => { + startRefreshTimer(); + }); + + return () => { + const rid = resourceId(); + if (!rid) return null; + + const store = anomalyStore(); + const resourceAnomalies = store.get(rid); + if (!resourceAnomalies) return null; + + return resourceAnomalies.get(metric()) || null; + }; +} + +/** + * Hook to get all anomalies for a specific resource. + */ +export function useAnomaliesForResource( + resourceId: () => string | undefined +): () => AnomalyReport[] { + // Start fetching on first use + createEffect(() => { + startRefreshTimer(); + }); + + return () => { + const rid = resourceId(); + if (!rid) return []; + + const store = anomalyStore(); + const resourceAnomalies = store.get(rid); + if (!resourceAnomalies) return []; + + return Array.from(resourceAnomalies.values()); + }; +} + +/** + * Hook to get all anomalies across all resources. + */ +export function useAllAnomalies(): { + anomalies: () => AnomalyReport[]; + count: () => number; + isLoading: () => boolean; + error: () => string | null; + lastUpdate: () => Date | null; + refresh: () => void; +} { + // Start fetching on first use + createEffect(() => { + startRefreshTimer(); + }); + + return { + anomalies: () => { + const store = anomalyStore(); + const all: AnomalyReport[] = []; + for (const resourceAnomalies of store.values()) { + for (const anomaly of resourceAnomalies.values()) { + all.push(anomaly); + } + } + return all; + }, + count: () => { + const store = anomalyStore(); + let count = 0; + for (const resourceAnomalies of store.values()) { + count += resourceAnomalies.size; + } + return count; + }, + isLoading, + error, + lastUpdate, + refresh: fetchAnomalies, + }; +} + +/** + * Hook to check if a resource has any anomalies. + */ +export function useHasAnomalies(resourceId: () => string | undefined): () => boolean { + createEffect(() => { + startRefreshTimer(); + }); + + return () => { + const rid = resourceId(); + if (!rid) return false; + + const store = anomalyStore(); + const resourceAnomalies = store.get(rid); + return resourceAnomalies ? resourceAnomalies.size > 0 : false; + }; +} + +// Cleanup when the module is unloaded (for HMR) +if (import.meta.hot) { + import.meta.hot.dispose(() => { + stopRefreshTimer(); + }); +}