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:
parent
9aad21169d
commit
db7b385287
3 changed files with 60 additions and 3 deletions
|
|
@ -522,9 +522,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
const cpuAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'cpu');
|
const cpuAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'cpu');
|
||||||
const memoryAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'memory');
|
const memoryAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'memory');
|
||||||
const diskAnomaly = useAnomalyForMetric(() => props.guest.id, () => 'disk');
|
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 [customUrl, setCustomUrl] = createSignal<string | undefined>(props.customUrl);
|
||||||
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
const [shouldAnimateIcon, setShouldAnimateIcon] = createSignal(false);
|
||||||
|
|
||||||
|
|
@ -960,6 +958,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
swapUsed={props.guest.memory?.swapUsed || 0}
|
swapUsed={props.guest.memory?.swapUsed || 0}
|
||||||
swapTotal={props.guest.memory?.swapTotal || 0}
|
swapTotal={props.guest.memory?.swapTotal || 0}
|
||||||
resourceId={metricsKey()}
|
resourceId={metricsKey()}
|
||||||
|
anomaly={memoryAnomaly()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -1002,6 +1001,7 @@ export function GuestRow(props: GuestRowProps) {
|
||||||
<StackedDiskBar
|
<StackedDiskBar
|
||||||
disks={props.guest.disks}
|
disks={props.guest.disks}
|
||||||
aggregateDisk={props.guest.disk}
|
aggregateDisk={props.guest.disk}
|
||||||
|
anomaly={diskAnomaly()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,25 @@ import { Show, For, createMemo, createSignal, onMount, onCleanup } from 'solid-j
|
||||||
import { Portal } from 'solid-js/web';
|
import { Portal } from 'solid-js/web';
|
||||||
import type { Disk } from '@/types/api';
|
import type { Disk } from '@/types/api';
|
||||||
import { formatBytes, formatPercent } from '@/utils/format';
|
import { formatBytes, formatPercent } from '@/utils/format';
|
||||||
|
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||||
|
|
||||||
interface StackedDiskBarProps {
|
interface StackedDiskBarProps {
|
||||||
/** Array of disk objects - if empty/undefined, falls back to aggregate */
|
/** Array of disk objects - if empty/undefined, falls back to aggregate */
|
||||||
disks?: Disk[];
|
disks?: Disk[];
|
||||||
/** Aggregate disk data (fallback when disks array unavailable) */
|
/** Aggregate disk data (fallback when disks array unavailable) */
|
||||||
aggregateDisk?: Disk;
|
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
|
// Color palette for disk segments - distinct colors for visual differentiation
|
||||||
const SEGMENT_COLORS = [
|
const SEGMENT_COLORS = [
|
||||||
'rgba(34, 197, 94, 0.6)', // green
|
'rgba(34, 197, 94, 0.6)', // green
|
||||||
|
|
@ -52,6 +63,15 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||||
onCleanup(() => observer.disconnect());
|
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
|
// Determine if we have multiple disks or should use aggregate
|
||||||
const hasMultipleDisks = createMemo(() => {
|
const hasMultipleDisks = createMemo(() => {
|
||||||
const disks = props.disks;
|
const disks = props.disks;
|
||||||
|
|
@ -239,6 +259,15 @@ export function StackedDiskBar(props: StackedDiskBarProps) {
|
||||||
[{props.disks?.length}]
|
[{props.disks?.length}]
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</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>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { formatBytes, formatPercent } from '@/utils/format';
|
||||||
import { Sparkline } from '@/components/shared/Sparkline';
|
import { Sparkline } from '@/components/shared/Sparkline';
|
||||||
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
import { useMetricsViewMode } from '@/stores/metricsViewMode';
|
||||||
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
|
import { getMetricHistoryForRange, getMetricsVersion } from '@/stores/metricsHistory';
|
||||||
|
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||||
|
|
||||||
interface StackedMemoryBarProps {
|
interface StackedMemoryBarProps {
|
||||||
used: number;
|
used: number;
|
||||||
|
|
@ -12,8 +13,17 @@ interface StackedMemoryBarProps {
|
||||||
swapTotal?: number;
|
swapTotal?: number;
|
||||||
balloon?: number;
|
balloon?: number;
|
||||||
resourceId?: string; // Required for sparkline mode to fetch history
|
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
|
// Colors for memory segments
|
||||||
const MEMORY_COLORS = {
|
const MEMORY_COLORS = {
|
||||||
active: 'rgba(34, 197, 94, 0.6)', // green (base, overridden by threshold)
|
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());
|
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 [containerWidth, setContainerWidth] = createSignal(100);
|
||||||
const [showTooltip, setShowTooltip] = createSignal(false);
|
const [showTooltip, setShowTooltip] = createSignal(false);
|
||||||
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
const [tooltipPos, setTooltipPos] = createSignal({ x: 0, y: 0 });
|
||||||
|
|
@ -195,6 +214,15 @@ export function StackedMemoryBar(props: StackedMemoryBarProps) {
|
||||||
({displaySublabel()})
|
({displaySublabel()})
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</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>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue