mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-26 07:18:27 +00:00
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:
parent
d9f1f7accd
commit
869a88a800
4 changed files with 464 additions and 2 deletions
129
frontend-modern/src/components/Dashboard/AnomalyCell.tsx
Normal file
129
frontend-modern/src/components/Dashboard/AnomalyCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx
Normal file
125
frontend-modern/src/components/Dashboard/AnomalyIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
180
frontend-modern/src/hooks/useAnomalies.ts
Normal file
180
frontend-modern/src/hooks/useAnomalies.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue