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