feat(phase-c): drift-adaptive anomaly detection + smarter eviction scoring

serve_v2: Welford online stats per node (hot_pct, hit_rate, fs_pct,
chunk_miss_rate) updated on every poller tick. Anomaly score = sum of z²;
warn at 9, alert at 16. Exposed via GET /api/v2/anomalies. Score embedded
in MetricSample so sparklines can also surface it.

hot: Replace pure-LRU gc_by_space with size×age eviction scoring.
Candidates are ranked by ln(size) × ln(age_secs); pinned projects immune.
Evicts the most space with the least cost rather than just the oldest entry.

dashboard: Fleet anomaly banner in CommandCenter (red/amber) + per-node
"anomaly"/"drift" badge on NodeCard. Anomalies polled every 10s alongside
the fleet endpoint.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 17:10:09 +00:00
co-authored by Claude Sonnet 4.6
parent 3fdf46d416
commit 70ad863006
5 changed files with 228 additions and 16 deletions
+40 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { api, FleetSnapshot, fmtBytes, fmtAge } from '../lib/api';
import { api, FleetSnapshot, AnomalyStatus, fmtBytes, fmtAge } from '../lib/api';
import { NodeCard } from '../components/NodeCard';
import { ProjectsPanel } from '../components/ProjectsPanel';
@@ -7,6 +7,7 @@ import { ProjectsPanel } from '../components/ProjectsPanel';
// Polls the aggregator's /api/v2/fleet every 10 s.
export function CommandCenter() {
const [fleet, setFleet] = useState<FleetSnapshot | null>(null);
const [anomalies, setAnomalies] = useState<AnomalyStatus[]>([]);
const [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0);
@@ -18,6 +19,7 @@ export function CommandCenter() {
setErr(null);
})
.catch((e) => setErr(String(e)));
api.anomalies().then(setAnomalies).catch(() => {});
}, [tick]);
useEffect(() => {
@@ -39,6 +41,13 @@ export function CommandCenter() {
)
: null;
// Build a node→level lookup for NodeCard props.
const anomalyMap = Object.fromEntries(
anomalies.map((a) => [a.node, a.level] as const)
);
const alertNodes = anomalies.filter((a) => a.level === 'alert');
const warnNodes = anomalies.filter((a) => a.level === 'warn');
return (
<div className="space-y-6">
<div>
@@ -65,6 +74,31 @@ export function CommandCenter() {
</div>
)}
{/* Anomaly banners (only shown once the ring buffer has 10+ samples) */}
{alertNodes.length > 0 && (
<div className="rounded border border-red-800 bg-red-950/40 px-4 py-3 text-sm flex items-start gap-3">
<span className="text-red-400 font-bold mt-0.5"></span>
<div>
<span className="text-red-300 font-semibold">Metric anomaly detected </span>
<span className="text-red-200">
{alertNodes.map((a) => a.node).join(', ')} deviating &gt;2σ from baseline
{alertNodes.length === 1 && ` (score ${alertNodes[0].score.toFixed(1)})`}
</span>
</div>
</div>
)}
{alertNodes.length === 0 && warnNodes.length > 0 && (
<div className="rounded border border-amber-800 bg-amber-950/40 px-4 py-3 text-sm flex items-start gap-3">
<span className="text-amber-400 font-bold mt-0.5"></span>
<div>
<span className="text-amber-300 font-semibold">Metric drift </span>
<span className="text-amber-200">
{warnNodes.map((a) => a.node).join(', ')} showing unusual patterns
</span>
</div>
</div>
)}
{totals && totals.diskTotal > 0 && (
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between">
<div>
@@ -89,7 +123,11 @@ export function CommandCenter() {
<h2 className="text-lg font-semibold text-slate-100 mb-3">Nodes</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{fleet?.nodes.map((n) => (
<NodeCard key={n.node_name} node={n} />
<NodeCard
key={n.node_name}
node={n}
anomalyLevel={anomalyMap[n.node_name]}
/>
))}
{!fleet &&
[1, 2, 3].map((i) => (