feat(ui): wire memory and disk anomaly indicators

Complete the anomaly indicator integration for all three metrics:
- CPU: EnhancedCPUBar (already done)
- Memory: StackedMemoryBar (new)
- Disk: StackedDiskBar (new)

All three metric bars now show a pulsing indicator (e.g., '2.5x↑')
when the current value is significantly above the learned baseline.

Severity colors:
- Critical (>4σ): red
- High (3-4σ): orange
- Medium (2.5-3σ): yellow
- Low (2-2.5σ): blue

This is 100% deterministic - no LLM involved. The indicators appear
automatically based on statistical deviation from learned baselines.
This commit is contained in:
rcourtman 2025-12-21 11:24:42 +00:00
parent 9aad21169d
commit db7b385287
3 changed files with 60 additions and 3 deletions

View file

@ -522,9 +522,7 @@ export function GuestRow(props: GuestRowProps) {
const cpuAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'cpu');
const memoryAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'memory');
const diskAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'disk');
// TODO: Wire memoryAnomaly and diskAnomaly to StackedMemoryBar and StackedDiskBar
void memoryAnomaly; // Prepared for future use
void diskAnomaly; // Prepared for future use
const [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
@ -960,6 +958,7 @@ export function GuestRow(props: GuestRowProps) {
swapUsed={props.guest.memory?.swapUsed || 0}
swapTotal={props.guest.memory?.swapTotal || 0}
resourceId={metricsKey()}
anomaly={memoryAnomaly()}
/>
}
>
@ -1002,6 +1001,7 @@ export function GuestRow(props: GuestRowProps) {
<StackedDiskBar
disks={props.guest.disks}
aggregateDisk={props.guest.disk}
anomaly={diskAnomaly()}
/>
}
>

View file

@ -2,14 +2,25 @@ import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-j
import { Portal } from 'solid-js/web';
import type { Disk } from '@/types/api';
import { formatBytes, formatPercent } from '@/utils/format';
import type { AnomalyReport } from '@/types/aiIntelligence';
interface StackedDiskBarProps {
/** Array of disk objects - if empty/undefined, falls back to aggregate */
disks?: Disk[];
/** Aggregate disk data (fallback when disks array unavailable) */
aggregateDisk?: Disk;
/** Baseline anomaly if detected */
anomaly?: AnomalyReport | null;
}
// Anomaly severity colors
const anomalySeverityClass: Record<string, string> = {
critical: 'text-red-400',
high: 'text-orange-400',
medium: 'text-yellow-400',
low: 'text-blue-400',
};
// Color palette for disk segments - distinct colors for visual differentiation
const SEGMENT_COLORS = [
'rgba(34, 197, 94, 0.6)', // green
@ -52,6 +63,15 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
onCleanup(() => observer.disconnect());
});
// 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 '↑';
});
// Determine if we have multiple disks or should use aggregate
const hasMultipleDisks = createMemo(() => {
const disks = props.disks;
@ -239,6 +259,15 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
[{props.disks?.length}]
</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>
<span
class={`ml-0.5 font-bold animate-pulse ${anomalySeverityClass[props.anomaly!.severity] || 'text-yellow-400'}`}
title={props.anomaly!.description}
>
{anomalyRatio()}
</span>
</Show>
</span>
</span>
</div>

View file

@ -4,6 +4,7 @@ import { formatBytes, formatPercent } from '@/utils/format';
import { Sparkline } from '@/components/shared/Sparkline';
import { useMetricsViewMode } from '@/stores/metricsViewMode';
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
import type { AnomalyReport } from '@/types/aiIntelligence';
interface StackedMemoryBarProps {
used: number;
@ -12,8 +13,17 @@ interface StackedMemoryBarProps {
swapTotal?: number;
balloon?: number;
resourceId?: string; // Required for sparkline mode to fetch 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',
};
// Colors for memory segments
const MEMORY_COLORS = {
active: 'rgba(34, 197, 94, 0.6)', // green (base, overridden by threshold)
@ -40,6 +50,15 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
return getMetricHistoryForRange(props.resourceId, timeRange());
});
// 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 [containerWidth, setContainerWidth] = createSignal(100);
const [showTooltip, setShowTooltip] = createSignal(false);
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
@ -195,6 +214,15 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
({displaySublabel()})
</span>
</Show>
{/* Anomaly indicator */}
<Show when={props.anomaly && anomalyRatio()}>
<span
class={`ml-0.5 font-bold animate-pulse ${anomalySeverityClass[props.anomaly!.severity] || 'text-yellow-400'}`}
title={props.anomaly!.description}
>
{anomalyRatio()}
</span>
</Show>
</span>
</span>
</div>