// 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 (
);
}
// ── 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) => (
setHmMetric(k)}>{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 (
{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 });