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.
This commit is contained in:
rcourtman 2025-12-21 11:04:18 +00:00
parent d9f1f7accd
commit 869a88a800
4 changed files with 464 additions and 2 deletions

View file

@ -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<AnomalySeverity, number> = {
critical: 4,
high: 3,
medium: 2,
low: 1,
};
// Config for each severity level
const severityConfig: Record<AnomalySeverity, { bg: string; text: string; border: string }> = {
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<string, string> = {
cpu: 'CPU',
memory: 'MEM',
disk: 'DSK',
};
return (
<Show when={props.anomalies.length > 0}>
<div class="flex items-center gap-0.5 flex-wrap justify-center">
<For each={sortedAnomalies().slice(0, 3)}>
{(anomaly) => {
const config = severityConfig[anomaly.severity];
return (
<span
class={`inline-flex items-center px-1 py-0.5 rounded text-[8px] font-bold ${config.bg} ${config.text} border ${config.border}`}
title={anomaly.description}
>
<span>{metricLabels[anomaly.metric] || anomaly.metric.toUpperCase()}</span>
<span class="ml-0.5 opacity-75">{formatRatio(anomaly)}</span>
</span>
);
}}
</For>
<Show when={sortedAnomalies().length > 3}>
<span
class="text-[8px] text-gray-500 dark:text-gray-400"
title={`${sortedAnomalies().length - 3} more anomalies`}
>
+{sortedAnomalies().length - 3}
</span>
</Show>
</div>
</Show>
);
}
/**
* 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 (
<Show when={props.hasAnomalies}>
<span
class={`inline-block w-1.5 h-1.5 rounded-full ${dotColor()} animate-pulse`}
title={props.title || 'Baseline anomaly detected'}
/>
</Show>
);
}

View file

@ -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<AnomalySeverity, { bg: string; text: string; icon: string }> = {
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 (
<Show when={props.anomaly && severityConfig()}>
<div
class={`inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[9px] font-bold ${severityConfig()!.bg
} ${severityConfig()!.text} animate-pulse`}
title={props.anomaly!.description}
>
<Show when={!props.compact}>
<span>{severityConfig()!.icon}</span>
</Show>
<span>{compactLabel()}</span>
</div>
</Show>
);
}
/**
* 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 (
<Show when={props.anomaly}>
<span
class={`ml-1 ${severityColor()} font-bold text-[9px] animate-pulse`}
title={props.anomaly!.description}
>
<Show when={multiplier()} fallback="⚠">
{multiplier()}
</Show>
</span>
</Show>
);
}

View file

@ -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<string, string> = {
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 */}
<span class="absolute inset-0 flex items-center justify-center text-[10px] font-semibold text-gray-700 dark:text-gray-100 leading-none pointer-events-none">
{formatPercent(props.usage)}
<Show when={props.cores}>
<span class="font-normal text-gray-500 dark:text-gray-300 ml-1">({props.cores} cores)</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>
<span
class={`ml-1 font-bold animate-pulse ${anomalySeverityClass[props.anomaly!.severity] || 'text-yellow-400'}`}
title={props.anomaly!.description}
>
{anomalyRatio()}
</span>
</Show>
</span>
</div>

View file

@ -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<string, Map<string, AnomalyReport>>;
// Global store for anomaly data
const [anomalyStore, setAnomalyStore] = createSignal<AnomalyStore>(new Map());
const [isLoading, setIsLoading] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [lastUpdate, setLastUpdate] = createSignal<Date | null>(null);
// Refresh interval (30 seconds)
const REFRESH_INTERVAL = 30000;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
// Fetch anomalies from the API
async function fetchAnomalies(): Promise<void> {
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();
});
}