// AI-MONITOR · Page sections. // Hero → Cluster → Nodes → Models → Telemetry → Footer const { NODES, MODELS, live, totals, subscribe } = window.AIData; // Tiny helper: subscribe to live tick and re-render function useLive() { const [, force] = useState(0); useEffect(() => subscribe(() => force((n) => n + 1)), []); } function fmtBytes(gb) { if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`; return `${Math.round(gb)} GB`; } function fmtDuration(sec) { const d = Math.floor(sec / 86400); const h = Math.floor((sec % 86400) / 3600); const m = Math.floor((sec % 3600) / 60); return `${d}d ${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}`; } function fmtNumber(n) { if (n >= 1e6) return (n / 1e6).toFixed(2) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"; return Math.round(n).toString(); } // ── NAV ───────────────────────────────────────────────────────────────── function Nav({ onOpenCmd }) { const [clock, setClock] = useState(() => new Date()); const [menuOpen, setMenuOpen] = useState(false); const [, force] = useState(0); useEffect(() => { const id = setInterval(() => setClock(new Date()), 1000); return () => clearInterval(id); }, []); useEffect(() => subscribe(() => force((n) => n + 1)), []); // 时区: 默认浏览器本地; hearth.yaml display.timezone 声明则覆盖 // (helper 在 data.js, 静态 IANA 时区由 /api/config 一次性加载) const time = window.AIData.formatTime(clock); const date = window.AIData.formatDate(clock); const mode = window.AIData.mode; const status = window.AIData.status; const isLive = status === "live"; const isReconnect = status === "reconnecting" || status === "connecting"; const { t } = useLang(); const close = () => setMenuOpen(false); // 真实在线节点计数(后端权威 up)——不再写死 "5/5" const _nids = Object.keys(live.nodes); const _total = _nids.length; const _online = _nids.filter((id) => live.nodes[id].up !== false).length; const _degraded = _online < _total; return (
{t("Hearth")}
{isLive ? `${_online}/${_total} ${t("nodes online")}` : isReconnect ? t("reconnecting…") : mode === "mock" ? t("mock data · no backend") : `${_online}/${_total} ${t("nodes online")}`} {isLive ? "LIVE" : "MOCK"} {date} · {time} {/* 手机端汉堡菜单按钮: 桌面 display:none, 手机 ≤640 显示 */}
); } // ── HERO ──────────────────────────────────────────────────────────────── function Hero() { useLive(); const { t } = useLang(); const cluster = live.cluster; // For the headline ticker: rolling running tokens count return (
LIVE · 1.2 s {t("Home AI Compute Center")}

{t("Home")}
{t("Compute Center")}

{t("A unified telemetry surface for your home AI compute cluster — auto-discovers models, surfaces real metrics from vLLM / llama.cpp / LiteLLM, honestly labels what backends don't expose. Every token, every watt — in real time.")}

{t("UPTIME")} {fmtDuration(cluster.uptimeSec)} {t("REQUESTS")} {fmtNumber(cluster.reqTotal)} {t("TOKENS SERVED")} {fmtNumber(cluster.tokTotal)} {t("SCRAPE")} 15 s · Prometheus 2.55
{Math.round(totals.totalFp16)} TFLOPS BF16} /> { // 按 hearth.yaml `kind` 字段动态分类: discrete = 消费级独显, 其他 = 数据中心 SoC (Spark/Apple Silicon 等) const discrete = NODES.filter((n) => (n.kind || "discrete") === "discrete").length; const datacenter = NODES.length - discrete; const parts = []; if (discrete > 0) parts.push(`${discrete}× consumer`); if (datacenter > 0) parts.push(`${datacenter}× datacenter`); return <>{NODES.length} GPUs{parts.length ? " · " + parts.join(" + ") : ""}; })()} /> {totals.totalThreads} threads · 1× x86 + 4× ARM} /> DDR5 + LPDDR5X unified} /> 5× 4 TB NVMe Gen4 · 38% used} />
Live throughput TOKENS/SEC {cluster.tpsNow.toFixed(0)} REQUESTS/SEC {cluster.rpsNow.toFixed(2)} KV-CACHE {cluster.kvNow.toFixed(0)}% CLUSTER POWER {(cluster.powNow / 1000).toFixed(2)} kW HOTTEST GPU {cluster.tempNow.toFixed(0)}°C
); } function HeroStat({ label, value, decimals = 0, unit, spark, color, sub, fake }) { const [ref, v] = useCountUp(value, 1400); return (
{label}
{v.toFixed(decimals)} {unit}
{sub}
{fake ? : }
); } // Static "system" sparkline for fields that don't have time-series (cores/ram/disk) function FakeSpark({ color }) { const [ref, { w }] = useElementSize(); const H = 28; const data = useMemo(() => Array.from({ length: 28 }, (_, i) => 50 + Math.sin(i * 0.7) * 6 + Math.cos(i * 0.3) * 4), []); if (!w) return
; return
; } // ── CLUSTER ──────────────────────────────────────────────────────────── function Cluster() { useLive(); const { t } = useLang(); const [hmMetric, setHmMetric] = useState("GPU"); const hmKey = hmMetric === "VRAM" ? "vram" : "gpu"; // Build aggregate utilization series and per-resource breakdown // GPU utilization aggregate const tps = live.cluster.tps; const pow = live.cluster.pow; const temp = live.cluster.temp; const kv = live.cluster.kv; // Resource pool composition (VRAM) const usedVram = NODES.reduce((a, n) => a + n.gpu.mem * (live.nodes[n.id].vram.now / 100), 0); const freeVram = totals.totalVram - usedVram; // Build heatmap rows const hmRows = NODES.map((n) => ({ label: n.name, data: live.nodes[n.id][hmKey].hist, })); return (
02{t("Cluster · live telemetry")}

{t("Real-time pulse of the ")}{t("entire fabric.")}

{t("Aggregated utilization, throughput, and thermals across all {n} nodes — sampled at 1.2 s, retained for 7 days in Prometheus, surfaced here as a single coherent picture.", { n: NODES.length })}

{t("Per-node")} {hmMetric} · {t("heatmap")}
{hmMetric === "VRAM" ? t("VRAM usage · last 60 ticks · 0–100%") : t("Accelerator activity · last 60 ticks · 0–100%")}
{["GPU","VRAM"].map((k) => ( ))}
60 ticks ago 0–35 35–65 65–85 85+ critical now
{t("Resource pools")}
{t("Aggregate capacity vs. live usage")}
m.state === "serving").map((m, i) => ({ label: m.id, value: m.vram, color: ["var(--accent)","var(--violet)","var(--teal)","var(--hot)","var(--ok)","var(--pink)","#5ac8fa","#ffd60a"][i % 8], })), { label: "free", value: Math.max(0, totals.totalVram - MODELS.filter(m=>m.state==="serving").reduce((a,m)=>a+m.vram,0)), color: "rgba(255,255,255,.08)" }, ]} />
); } function cluster_peak() { return Math.round(Math.max(...NODES.map((n) => live.nodes[n.id].tempGpu.now))); } function weightedP95() { const serving = MODELS.filter((m) => m.state === "serving" && m.kind === "chat"); const totalRps = serving.reduce((a, m) => a + live.models[m.id].rps.now, 0) || 1; return serving.reduce((a, m) => a + m.p95 * live.models[m.id].rps.now / totalRps, 0); } function Swatch({ hue }) { return ; } function PulseCard({ title, sub, data, color, unit, max }) { const now = data[data.length - 1] || 0; return (
{title}
{sub}
{now.toFixed(unit === "%" ? 0 : 1)}{unit}
); } function PoolBar({ label, used, cap, unit, segs }) { const pct = (used / cap) * 100; return (
{label} {used.toFixed(used < 10 ? 2 : 0)} / {cap.toFixed(0)}{unit} {pct.toFixed(0)}%
{segs.filter((s) => s.label !== "free").map((s, i) => ( {s.label} {s.value.toFixed(s.value < 10 ? 1 : 0)} ))}
); } function SmallStat({ label, value, unit, sub, color }) { return (
{label}
{value}{unit}
{sub}
); } Object.assign(window, { Nav, Hero, Cluster, useLive, fmtBytes, fmtDuration, fmtNumber });