Files
llm-monitor/static/index.html
T
ducoterra 48bc92b9af
Build and Push Container / build-and-push (push) Successful in 38s
initial import: read-only gufo + vLLM dashboard
Single-file FastAPI proxy plus a no-CDN vanilla-JS page for watching a gufo
server and a vLLM server side by side: one Prometheus parser (class Prom) with
per-engine field maps, measured Δcounter/Δt rates for engines that export no
rate gauge, and strict read-only GET-only routing. Engines are opt-in via
*_BASE_URL — unset means no panel, no poll, and a 404 {"configured": false}
without dialling.

Includes the container image and CI so the dashboard can be deployed as an
image rather than a checkout:

- Containerfile: uv-locked two-stage build on python:3.14-slim, non-root
  (uid 10001), binds 8090 (8000 is vLLM's port in this fleet), healthcheck on
  /api/config so a dead engine never marks the dashboard unhealthy. No frontend
  build stage — static/index.html is bundler-free by design and is copied as-is.
- .gitea/workflows/build-and-push.yml: publishes
  gitea.reeseapps.com/Experimental/llm-monitor on main + release tags.

114 tests, no network and no real engines required.

LLM-Generated: true
2026-10-16 12:49:45 -04:00

1674 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LLM Monitor</title>
<!--
Self-contained: no CDN, no build step, no external font. These engines live
on an internet-disconnected network, so a CDN sparkline library would just
be a dead request. Sparklines are hand-rolled inline SVG.
-->
<style>
:root {
--bg: #0c0f14;
--bg-2: #12161e;
--bg-3: #171d27;
--line: #232b38;
--line-soft: #1b2230;
--fg: #e6ebf2;
--fg-dim: #94a3b4;
--fg-mute: #64748b;
--ok: #34d399;
--bad: #f87171;
--warn: #fbbf24;
--gufo: #60a5fa;
--vllm: #f0883e;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
* {
box-sizing: border-box;
}
/* an engine whose *_BASE_URL was unset is taken out of the page by JS; make
`hidden` beat every grid/flex layout below it */
[hidden] {
display: none !important;
}
html,
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
body {
font: 14px/1.45 var(--mono);
padding: 18px 20px 40px;
min-height: 100vh;
}
h1,
h2,
h3,
p,
dl,
dd,
dt,
figure {
margin: 0;
padding: 0;
}
code {
font: inherit;
color: var(--fg-dim);
}
b {
font-weight: 600;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
/* ---------- top bar ---------- */
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
border-bottom: 1px solid var(--line);
padding-bottom: 12px;
margin-bottom: 16px;
}
.brand h1 {
font-size: 17px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.brand .sub {
color: var(--fg-mute);
font-size: 12px;
margin-top: 4px;
}
/* one entry per configured engine; the separator rides on the chip so an
absent engine takes its dot with it */
.brand .sub .cfg-eng::after {
content: " · ";
}
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--fg-mute);
}
button.ghost {
font: inherit;
font-size: 12px;
color: var(--fg-dim);
background: var(--bg-3);
border: 1px solid var(--line);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
}
button.ghost:hover {
color: var(--fg);
border-color: #33415a;
}
button.ghost[data-paused="1"] {
color: var(--warn);
border-color: #4a3a12;
}
/* ---------- health bits ---------- */
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--fg-mute);
flex: none;
display: inline-block;
}
.dot.ok {
background: var(--ok);
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.16);
animation: beat 2s ease-in-out infinite;
}
.dot.bad {
background: var(--bad);
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.18);
}
.dot.warn {
background: var(--warn);
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.16);
}
/* ---------- fleet totals ---------- */
.fleet {
background: linear-gradient(180deg, var(--bg-3), var(--bg-2));
border: 1px solid var(--line);
border-left: 3px solid var(--fg-mute);
border-radius: 10px;
padding: 10px 14px 12px;
margin-bottom: 12px;
}
.fleet.ok {
border-left-color: var(--ok);
}
.fleet.partial {
border-left-color: var(--warn);
}
.fleet.down {
border-left-color: var(--bad);
}
.fleet-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-mute);
}
.fleet-name {
font-weight: 600;
color: var(--fg-dim);
}
.fleet-note {
margin-left: auto;
text-transform: none;
letter-spacing: 0;
font-size: 10px;
}
.fleet .kv {
margin-top: 8px;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.fleet .kv dd {
font-size: 22px;
margin-top: 0;
}
@keyframes beat {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@media (prefers-reduced-motion: reduce) {
.dot.ok {
animation: none;
}
}
/* ---------- shared stats strip ---------- */
.strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.stat-card {
background: var(--bg-2);
border: 1px solid var(--line);
border-left: 3px solid var(--fg-mute);
border-radius: 10px;
padding: 12px 14px;
}
.stat-card[data-engine="gufo"] {
--accent: var(--gufo);
}
.stat-card[data-engine="vllm"] {
--accent: var(--vllm);
}
.stat-card.live {
border-left-color: var(--accent);
}
.stat-card.stale {
border-left-color: var(--bad);
opacity: 0.82;
}
.stat-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-dim);
margin-bottom: 10px;
}
.stat-head .tag {
margin-left: auto;
text-transform: none;
letter-spacing: 0;
color: var(--fg-mute);
font-size: 11px;
}
.kv {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px 14px;
margin-top: 10px;
}
.kv > div {
min-width: 0;
}
.kv dt {
font-size: 11px;
color: var(--fg-mute);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kv dd {
font-size: 17px;
font-variant-numeric: tabular-nums;
margin-top: 2px;
}
.kv dd small {
font-size: 11px;
color: var(--fg-mute);
font-weight: 400;
}
.na {
color: var(--fg-mute);
}
/* ---------- bars ---------- */
.bar {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.bar .track {
position: relative;
flex: 1;
height: 8px;
border-radius: 999px;
background: #1f2733;
overflow: hidden;
border: 1px solid var(--line-soft);
}
.bar .fill {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: linear-gradient(90deg, #2f7bf6, #62b6f8);
transition: width 0.35s ease-out;
}
.bar.warn .fill {
background: linear-gradient(90deg, #d9a12b, #fbbf24);
}
.bar.crit .fill {
background: linear-gradient(90deg, #d94a4a, #f87171);
}
.bar .val {
width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--fg-dim);
}
/* ---------- panels ---------- */
.panels {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
align-items: start;
}
.panel {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.panel.stale {
border-color: #4d2733;
}
.panel-head {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
padding: 12px 14px;
background: linear-gradient(180deg, #1a2230, #161c26);
border-bottom: 1px solid var(--line);
}
.panel[data-engine="gufo"] .panel-head {
border-bottom-color: rgba(96, 165, 250, 0.35);
}
.panel[data-engine="vllm"] .panel-head {
border-bottom-color: rgba(240, 136, 62, 0.35);
}
.panel-name {
font-size: 12px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-dim);
}
.banner {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.banner b {
font-size: 18px;
}
.banner .sep {
color: var(--fg-mute);
padding-right: 4px;
}
.panel-meta {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--fg-mute);
text-align: right;
}
.err {
padding: 8px 14px;
font-size: 12px;
color: var(--bad);
background: rgba(248, 113, 113, 0.07);
border-bottom: 1px solid rgba(248, 113, 113, 0.2);
word-break: break-word;
}
.err[hidden] {
display: none;
}
.panel-body {
padding: 14px;
}
.section-label {
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-mute);
margin: 4px 0 8px;
}
/* ---------- sparklines ---------- */
.sparks {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.spark {
background: var(--bg-3);
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 8px 10px 6px;
}
.spark figcaption {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
font-size: 11px;
color: var(--fg-mute);
}
.spark .spark-now {
font-size: 14px;
color: var(--fg);
font-variant-numeric: tabular-nums;
}
/* plot + y-axis gutter. The axis is a sibling of the svg on purpose:
preserveAspectRatio="none" stretches the 100x30 viewBox to the card width, so text
drawn inside it would be smeared sideways and unreadable. */
.spark-plot {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 5px;
align-items: center;
margin-top: 4px;
padding: 5px 0; /* room for the top/bottom labels overhanging the plot */
}
.spark-canvas {
position: relative;
height: 34px;
}
.spark-canvas svg {
display: block;
width: 100%;
height: 100%;
position: relative;
z-index: 1;
}
/* faint rules at the three labelled levels, so a number has a line to point at */
.spark-canvas::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(
to bottom,
var(--line) 0 1px,
transparent 1px calc(50% - 0.5px),
var(--line) calc(50% - 0.5px) calc(50% + 0.5px),
transparent calc(50% + 0.5px) calc(100% - 1px),
var(--line) calc(100% - 1px) 100%
);
}
.spark-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 34px; /* matches .spark-canvas so each label tracks its own level */
font-size: 9px;
line-height: 1;
font-weight: 400;
text-align: right;
white-space: nowrap;
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
}
/* centre a label on the level it describes: with line-height:1, 0.5em is half a label */
.spark-axis b:first-child {
margin-top: -0.5em;
}
.spark-axis b:last-child {
margin-bottom: -0.5em;
}
.spark-axis .axis-mid {
color: var(--fg-mute);
}
.spark-line {
fill: none;
stroke: var(--spark-color, #62b6f8);
stroke-width: 1.5;
vector-effect: non-scaling-stroke;
stroke-linejoin: round;
}
.spark-area {
fill: var(--spark-color, #62b6f8);
opacity: 0.14;
stroke: none;
}
.spark.empty svg {
opacity: 0.25;
}
/* ---------- gufo slot grid ---------- */
.slots {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 10px;
}
.slot {
background: var(--bg-3);
border: 1px solid var(--line-soft);
border-radius: 9px;
padding: 10px 11px;
}
.slot.busy {
border-color: rgba(52, 211, 153, 0.4);
background: linear-gradient(180deg, rgba(52, 211, 153, 0.06), transparent 60%), var(--bg-3);
}
.slot-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.slot-id {
font-size: 12px;
letter-spacing: 0.06em;
color: var(--fg-dim);
}
.slot-model {
flex: 1;
min-width: 0;
font-size: 10px;
color: var(--fg-mute);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pill {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--fg-mute);
background: #1a222d;
white-space: nowrap;
}
.pill.busy {
color: #062b1e;
background: var(--ok);
border-color: var(--ok);
}
.pill.active {
color: #06203a;
background: var(--gufo);
border-color: var(--gufo);
}
.slot dl {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
}
.slot dt {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--fg-mute);
}
.slot dd {
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.slot dd small {
display: block;
font-size: 10px;
color: var(--fg-mute);
}
.empty {
color: var(--fg-mute);
font-size: 12px;
padding: 14px 0;
}
/* ---------- vllm metric cards ---------- */
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
}
.card {
background: var(--bg-3);
border: 1px solid var(--line-soft);
border-radius: 9px;
padding: 10px 11px;
}
.card h3 {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.07em;
font-weight: 400;
color: var(--fg-mute);
}
.card .big {
font-size: 19px;
font-variant-numeric: tabular-nums;
margin-top: 3px;
}
.bad-num {
color: var(--bad) !important;
}
.kv dd.bad-num {
color: var(--bad);
}
.card .foot {
font-size: 10px;
color: var(--fg-mute);
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card.wide {
grid-column: span 2;
}
/* ---------- nothing configured ---------- */
.setup {
max-width: 74ch;
background: var(--bg-2);
border: 1px solid var(--line);
border-left: 3px solid var(--warn);
border-radius: 12px;
padding: 16px 18px 18px;
}
.setup h2 {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-dim);
}
.setup p {
color: var(--fg-dim);
margin-top: 10px;
}
.setup code {
color: var(--fg);
}
.setup pre {
margin: 12px 0 0;
padding: 10px 12px;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
color: var(--fg-dim);
font-size: 12px;
overflow-x: auto;
}
@media (max-width: 1080px) {
.panels,
.strip {
grid-template-columns: 1fr;
}
}
/* single-engine deployment: the one panel fills the row instead of sitting in a
two-column grid with a phantom gap where the absent engine would be */
.panels.single,
.strip.single {
grid-template-columns: minmax(0, 1fr);
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand">
<h1>LLM Monitor</h1>
<p class="sub">
<span class="cfg-eng" id="hd-gufo" hidden>gufo <code id="cfg-gufo">…</code></span>
<span class="cfg-eng" id="hd-vllm" hidden>vLLM <code id="cfg-vllm">…</code></span>
<span id="cfg-none" hidden>no engine configured ·</span>
poll <span id="cfg-poll">1000</span>ms · read-only
</p>
</div>
<div class="topbar-right">
<span id="clock" aria-label="local time">--:--:--</span>
<button class="ghost" id="pause" type="button" data-paused="0" title="Pause polling (space)">
pause
</button>
</div>
</header>
<!-- shown when neither *_BASE_URL was set: nothing to poll, so say what to set -->
<section class="setup" id="no-engines" hidden aria-label="no engines configured">
<h2>no engines configured</h2>
<p>
This page only shows the engines the server was told about at startup, and neither
base URL is set. Point it at the one you run (or both) and restart — config is read
once, at import, so <code>--reload</code> will not pick it up.
</p>
<pre>GUFO_BASE_URL=http://127.0.0.1:8731 VLLM_BASE_URL=http://127.0.0.1:8000 \
uv run uvicorn app:app --host 127.0.0.1 --port 8090</pre>
<p>
Set only what exists: an unset <code>GUFO_BASE_URL</code> leaves the gufo section off
the page and <code>/api/gufo/*</code> answers 404, an unset <code>VLLM_BASE_URL</code>
does the same to vLLM. Optional <code>GUFO_API_KEY</code> / <code>VLLM_API_KEY</code>
add a bearer token. It is read-only either way.
</p>
</section>
<!-- fleet totals: one number per metric, summed over the engines answering right now.
Hidden by JS when fewer than two engines are configured — see renderFleet(). -->
<section class="fleet" id="fleet" aria-label="fleet total">
<div class="fleet-head">
<span class="dot" id="fleet-dot"></span>
<span class="fleet-name">all engines</span>
<span class="tag" id="fleet-count">2 engines · no data yet</span>
<span class="fleet-note">sums only engines answering now</span>
</div>
<dl class="kv">
<div>
<dt>running</dt>
<dd id="fleet-running" class="na">n/a</dd>
</div>
<div>
<dt>waiting</dt>
<dd id="fleet-waiting" class="na">n/a</dd>
</div>
<div>
<dt title="gufo counts newly-computed prompt tokens only (cache hits are a separate counter); vLLM's prompt counter includes cached prefix tokens.">prompt tok/s</dt>
<dd id="fleet-ptps" class="na">n/a</dd>
</div>
<div>
<dt title="Every slot on every engine: Δ tokens per second over the rate window.">decode tok/s</dt>
<dd id="fleet-dtps" class="na">n/a</dd>
</div>
<div>
<dt>total tokens</dt>
<dd id="fleet-tokens" class="na">n/a</dd>
</div>
</dl>
</section>
<!-- shared stats strip -->
<section class="strip" aria-label="engine summary">
<div class="stat-card" data-engine="gufo" id="strip-gufo">
<div class="stat-head">
<span class="dot" data-dot="gufo"></span> gufo <span class="tag" data-stamp="gufo">–</span>
</div>
<div class="bar" id="gufo-kv-bar">
<span class="lbl">KV cache</span>
<span class="track"><span class="fill"></span></span>
<span class="val" id="gufo-kv-pct">n/a</span>
</div>
<dl class="kv">
<div>
<dt title="All slots combined: Δprompt_tokens_total/Δt. Excludes cache hits — gufo tracks those separately. Hover a number for the per-engine breakdown.">prompt tok/s</dt>
<dd id="gufo-ptps">n/a</dd>
</div>
<div>
<dt title="All slots combined: Δtokens_predicted_total/Δt. gufo's own speed gauge only reports the last decode pass of one request, so it under-reports whenever two slots decode at once.">decode tok/s</dt>
<dd id="gufo-dtps">n/a</dd>
</div>
<div>
<dt>total tokens</dt>
<dd id="gufo-tokens">n/a</dd>
</div>
<div>
<dt>slots busy</dt>
<dd id="gufo-slotcount">n/a</dd>
</div>
<div>
<dt>prompt cache</dt>
<dd id="gufo-cachehit">n/a</dd>
</div>
<div>
<dt>spec accept</dt>
<dd id="gufo-specaccept">n/a</dd>
</div>
<div>
<dt>peak ctx</dt>
<dd id="gufo-peakctx">n/a</dd>
</div>
<div>
<dt>gpu losses</dt>
<dd id="gufo-devicelost">n/a</dd>
</div>
</dl>
</div>
<div class="stat-card" data-engine="vllm" id="strip-vllm">
<div class="stat-head">
<span class="dot" data-dot="vllm"></span> vLLM <span class="tag" data-stamp="vllm">–</span>
</div>
<div class="bar" id="vllm-kv-bar">
<span class="lbl">GPU cache</span>
<span class="track"><span class="fill"></span></span>
<span class="val" id="vllm-kv-pct">n/a</span>
</div>
<dl class="kv">
<div>
<dt>prompt tok/s</dt>
<dd id="vllm-ptps">n/a</dd>
</div>
<div>
<dt>decode tok/s</dt>
<dd id="vllm-dtps">n/a</dd>
</div>
<div>
<dt>total tokens</dt>
<dd id="vllm-tokens">n/a</dd>
</div>
<div>
<dt>per-seq tok/s</dt>
<dd id="vllm-perseq">n/a</dd>
</div>
<div>
<dt>prefill avg</dt>
<dd id="vllm-prefillavg">n/a</dd>
</div>
</dl>
</div>
</section>
<main class="panels">
<!-- ================= gufo ================= -->
<section class="panel" data-engine="gufo" id="panel-gufo">
<header class="panel-head">
<span class="dot" data-dot="gufo"></span>
<span class="panel-name">gufo</span>
<span class="banner">
Running: <b id="gufo-running" class="na">n/a</b> reqs<span class="sep">,</span>Waiting:
<b id="gufo-waiting" class="na">n/a</b>
</span>
<span class="panel-meta">
<span id="gufo-stamp2">no data yet</span>
</span>
</header>
<p class="err" id="gufo-err" hidden></p>
<div class="panel-body">
<div class="sparks">
<figure class="spark empty" data-spark="gufo-decode" style="--spark-color: #62b6f8">
<figcaption><span>decode tok/s · all slots · last <span id="cfg-hist1">60</span></span><b class="spark-now" id="spark-gufo-decode-now">n/a</b></figcaption>
<div class="spark-plot">
<div class="spark-axis">
<b class="axis-hi na">n/a</b>
<b class="axis-mid na">n/a</b>
<b class="axis-lo na">n/a</b>
</div>
<div class="spark-canvas">
<svg viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-area" d=""></path>
<polyline class="spark-line" points=""></polyline>
</svg>
</div>
</div>
</figure>
<figure class="spark empty" data-spark="gufo-kv" style="--spark-color: #a78bfa">
<figcaption><span>KV cache usage · last <span id="cfg-hist2">60</span></span><b class="spark-now" id="spark-gufo-kv-now">n/a</b></figcaption>
<div class="spark-plot">
<div class="spark-axis">
<b class="axis-hi na">n/a</b>
<b class="axis-mid na">n/a</b>
<b class="axis-lo na">n/a</b>
</div>
<div class="spark-canvas">
<svg viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-area" d=""></path>
<polyline class="spark-line" points=""></polyline>
</svg>
</div>
</div>
</figure>
</div>
<p class="section-label">sessions (GET /slots)</p>
<div class="slots" id="gufo-slots"><p class="empty">waiting for /api/gufo/slots…</p></div>
<p class="empty" id="gufo-slots-note" hidden>
queued (deferred) requests are not in /slots — see Waiting above
</p>
</div>
</section>
<!-- ================= vLLM ================= -->
<section class="panel" data-engine="vllm" id="panel-vllm">
<header class="panel-head">
<span class="dot" data-dot="vllm"></span>
<span class="panel-name">vLLM</span>
<span class="banner">
Running: <b id="vllm-running" class="na">n/a</b> reqs<span class="sep">,</span>Waiting:
<b id="vllm-waiting" class="na">n/a</b>
</span>
<span class="panel-meta">
<span id="vllm-stamp2">no data yet</span>
</span>
</header>
<p class="err" id="vllm-err" hidden></p>
<div class="panel-body">
<div class="sparks">
<figure class="spark empty" data-spark="vllm-decode" style="--spark-color: #f0883e">
<figcaption><span>decode tok/s · all slots · last <span id="cfg-hist3">60</span></span><b class="spark-now" id="spark-vllm-decode-now">n/a</b></figcaption>
<div class="spark-plot">
<div class="spark-axis">
<b class="axis-hi na">n/a</b>
<b class="axis-mid na">n/a</b>
<b class="axis-lo na">n/a</b>
</div>
<div class="spark-canvas">
<svg viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-area" d=""></path>
<polyline class="spark-line" points=""></polyline>
</svg>
</div>
</div>
</figure>
<figure class="spark empty" data-spark="vllm-kv" style="--spark-color: #34d399">
<figcaption><span>GPU cache usage · last <span id="cfg-hist4">60</span></span><b class="spark-now" id="spark-vllm-kv-now">n/a</b></figcaption>
<div class="spark-plot">
<div class="spark-axis">
<b class="axis-hi na">n/a</b>
<b class="axis-mid na">n/a</b>
<b class="axis-lo na">n/a</b>
</div>
<div class="spark-canvas">
<svg viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true">
<path class="spark-area" d=""></path>
<polyline class="spark-line" points=""></polyline>
</svg>
</div>
</div>
</figure>
</div>
<p class="section-label">engine metrics (GET /metrics)</p>
<div class="cards">
<div class="card">
<h3>waiting · capacity</h3>
<p class="big na" id="vllm-wait-cap">n/a</p>
<p class="foot" id="vllm-wait-reasons">reason breakdown n/a</p>
</div>
<div class="card wide">
<h3>GPU cache usage</h3>
<p class="big na" id="vllm-gpu-pct">n/a</p>
<div class="bar" id="vllm-gpu-bar">
<span class="track"><span class="fill"></span></span>
</div>
</div>
<div class="card">
<h3>TTFT (mean)</h3>
<p class="big na" id="vllm-ttft">n/a</p>
<p class="foot" id="vllm-ttft-p95">p95 n/a</p>
</div>
<div class="card">
<h3>TPOT (mean)</h3>
<p class="big na" id="vllm-tpot">n/a</p>
<p class="foot" id="vllm-tpot-p95">p95 n/a</p>
</div>
<div class="card">
<h3>queue time (mean)</h3>
<p class="big na" id="vllm-queue">n/a</p>
<p class="foot" id="vllm-queue-p95">p95 n/a</p>
</div>
<div class="card">
<h3>prompt tokens</h3>
<p class="big na" id="vllm-ptok">n/a</p>
<p class="foot">vllm:prompt_tokens_total</p>
</div>
<div class="card">
<h3>generation tokens</h3>
<p class="big na" id="vllm-gtok">n/a</p>
<p class="foot">vllm:generation_tokens_total</p>
</div>
<div class="card">
<h3>prefix cache hit</h3>
<p class="big na" id="vllm-prefixhit">n/a</p>
<p class="foot">vllm:prefix_cache_hits/queries</p>
</div>
<div class="card">
<h3>preemptions</h3>
<p class="big na" id="vllm-preempt">n/a</p>
<p class="foot">running reqs evicted by KV pressure</p>
</div>
<div class="card">
<h3>series scraped</h3>
<p class="big na" id="vllm-series">n/a</p>
<p class="foot" id="vllm-upstream">–</p>
</div>
</div>
<p class="empty">
vLLM exposes no per-slot JSON — the metrics above are its equivalent of the session grid.
</p>
</div>
</section>
</main>
<script>
"use strict";
/* =====================================================================
State. Each engine is tracked independently so one dead engine can
never stop the other from updating.
===================================================================== */
const UI = {
pollMs: 1000,
historyLen: 60,
paused: false,
};
/* Which engines this deployment runs at all — decided by /api/config, i.e. by which
*_BASE_URL env vars were set. An absent engine is not an outage: it gets no panel,
no strip card, no poll, and no share of the fleet totals. Both are assumed until
the config answers so a failed config fetch degrades to the old two-engine page
instead of falsely claiming nothing is configured. */
const ENGINE_IDS = ["gufo", "vllm"];
const ENGINE_LABEL = { gufo: "gufo", vllm: "vLLM" };
let ENGINES = [...ENGINE_IDS];
const isEnabled = (engine) => ENGINES.includes(engine);
const state = {
gufo: { ok: false, jobs: {}, lastOkAt: 0, hist: { decode: [], kv: [] }, slots: [] },
vllm: { ok: false, jobs: {}, lastOkAt: 0, hist: { decode: [], kv: [] }, metrics: null },
};
const $ = (id) => document.getElementById(id);
const els = {};
const cacheEls = () => {
[
"cfg-gufo", "cfg-vllm", "cfg-poll", "cfg-hist1", "cfg-hist2", "cfg-hist3", "cfg-hist4",
"hd-gufo", "hd-vllm", "cfg-none", "no-engines",
"clock", "pause",
"strip-gufo", "strip-vllm", "panel-gufo", "panel-vllm",
"gufo-err", "vllm-err", "gufo-stamp2", "vllm-stamp2",
"gufo-running", "gufo-waiting", "gufo-kv-bar", "gufo-kv-pct", "gufo-ptps", "gufo-dtps",
"gufo-tokens", "gufo-slotcount", "gufo-cachehit", "gufo-specaccept", "gufo-peakctx",
"gufo-devicelost", "gufo-slots", "gufo-slots-note",
"fleet", "fleet-dot", "fleet-count", "fleet-running", "fleet-waiting", "fleet-ptps",
"fleet-dtps", "fleet-tokens",
"vllm-running", "vllm-waiting", "vllm-kv-bar", "vllm-kv-pct", "vllm-ptps", "vllm-dtps",
"vllm-tokens", "vllm-perseq", "vllm-prefillavg", "vllm-wait-cap", "vllm-wait-reasons",
"vllm-gpu-bar",
"vllm-gpu-pct", "vllm-prefixhit", "vllm-preempt", "vllm-ttft", "vllm-ttft-p95",
"vllm-tpot", "vllm-tpot-p95",
"vllm-queue", "vllm-queue-p95", "vllm-ptok", "vllm-gtok", "vllm-series", "vllm-upstream",
].forEach((id) => (els[id] = $(id)));
document.querySelectorAll("[data-dot]").forEach((el) => {
const e = el.getAttribute("data-dot");
(els[`dots-${e}`] ||= []).push(el);
});
document.querySelectorAll("[data-stamp]").forEach((el) => {
els[`stamp-${el.getAttribute("data-stamp")}`] = el;
});
};
/* ---------- formatting: absent data always renders as "n/a" ---------- */
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
// engines report "nothing pending" as -1: that is unknown, not a count of -1
const nonNeg = (v) => (isNum(v) && v >= 0 ? v : null);
const numFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const txt = (v, digits = 0) =>
!isNum(v) ? "n/a" : numFmt.format(digits ? +v.toFixed(digits) : Math.round(v));
const pct = (r) => (!isNum(r) ? "n/a" : `${(r * 100).toFixed(r >= 0.1 ? 0 : 1)}%`);
const secs = (s) => {
if (!isNum(s)) return "n/a";
if (s < 1) return `${(s * 1000).toFixed(s < 0.1 ? 1 : 0)} ms`;
if (s < 60) return `${s.toFixed(2)} s`;
return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
};
const clockStr = (ts) => new Date(ts).toLocaleTimeString([], { hour12: false });
const ago = (ts) => {
const d = Math.max(0, Math.round((Date.now() - ts) / 1000));
if (d < 60) return `${d}s`;
if (d < 3600) return `${Math.floor(d / 60)}m ${d % 60}s`;
return `${Math.floor(d / 3600)}h ${Math.floor((d % 3600) / 60)}m`;
};
/* ---------- tiny in-place DOM writers (no innerHTML churn) ---------- */
const setTxt = (el, v, naVal) => {
if (!el) return;
const s = String(v);
if (el.textContent !== s) el.textContent = s;
el.classList.toggle("na", !isNum(naVal));
};
const setBar = (el, ratio) => {
if (!el) return;
const fill = el.querySelector(".fill");
const known = isNum(ratio);
const p = known ? Math.min(1, Math.max(0, ratio)) : 0;
if (fill) fill.style.width = `${(p * 100).toFixed(1)}%`;
el.classList.toggle("warn", known && p >= 0.75 && p < 0.9);
el.classList.toggle("crit", known && p >= 0.9);
};
/* ---------- sparklines: inline SVG, right-aligned growth ---------- */
const SPARK_W = 100;
const SPARK_H = 30;
function drawSpark(name, values, format, fixedMax) {
const fig = document.querySelector(`.spark[data-spark="${name}"]`);
if (!fig) return;
const line = fig.querySelector(".spark-line");
const area = fig.querySelector(".spark-area");
const now = fig.querySelector(".spark-now");
// y-axis reference numbers. The plot autoscales, so without these a 1% drift and a
// 2x spike are the identical squiggle. Rendered with the chart's own `format`, so a
// ratio chart reads 100%/50%/0% and a tok/s chart reads tok/s. Absent -> n/a.
const axis = (cls, v, fmt) => {
const el = fig.querySelector(cls);
if (el) setTxt(el, isNum(v) ? (fmt || format)(v) : "n/a", v);
};
const axisBlank = () => {
axis(".axis-hi", null);
axis(".axis-mid", null);
axis(".axis-lo", null);
};
const last = values.length ? values[values.length - 1] : null;
if (now) now.textContent = format(last);
if (values.length < 2) {
fig.classList.add("empty");
line.setAttribute("points", "");
area.setAttribute("d", "");
axisBlank();
return;
}
const nums = values.filter(isNum);
if (!nums.length) {
axisBlank();
return;
}
let lo = fixedMax ? 0 : Math.min(...nums);
let hi = fixedMax != null ? fixedMax : Math.max(...nums);
// a flat window has no range; the pad keeps the line mid-plot (plotting unchanged)
// but it is an artifact, not a scale, so the axis must not report it as one
const flat = hi - lo < 1e-9 ? hi : null;
if (flat !== null) {
const pad = Math.max(Math.abs(hi) * 0.1, 1e-3);
lo -= pad;
hi += pad;
}
// exactly the scale the polyline was plotted against: top / middle / bottom
const levels = flat !== null ? [flat, flat, flat] : [hi, (lo + hi) / 2, lo];
const nf = (d) =>
new Intl.NumberFormat("en-US", { minimumFractionDigits: d, maximumFractionDigits: d });
let fmtAxis;
if (fixedMax != null) {
// ratio axes span 0..fixedMax so the levels never collide — just stop pct()
// rendering "0.0%" differently from "100%" and "50%" on the same axis
fmtAxis = (v) => format(v).replace(/\.0%$/, "%");
} else if (flat !== null) {
// every level reads the one value the window holds: an idle box says 0 / 0 / 0,
// not the +/-0.001 the pad happens to produce
fmtAxis = (v) => nf(Number.isInteger(v) ? 0 : 2).format(v);
} else {
// enough decimals that the three labels are distinguishable: the shared txt()
// rounds to integers, which made a 1..2 count axis read "2 / 2 / 1"
let d = 0;
while (d < 3 && new Set(levels.map((v) => nf(d).format(v))).size < 3) d += 1;
const f = nf(d);
fmtAxis = (v) => f.format(v);
}
axis(".axis-hi", levels[0], fmtAxis);
axis(".axis-mid", levels[1], fmtAxis);
axis(".axis-lo", levels[2], fmtAxis);
const step = SPARK_W / (UI.historyLen - 1);
const pts = values.map((v, i) => {
const x = SPARK_W - (values.length - 1 - i) * step;
const y = isNum(v) ? SPARK_H - 1 - ((v - lo) / (hi - lo)) * (SPARK_H - 2) : SPARK_H - 1;
return `${x.toFixed(2)},${y.toFixed(2)}`;
});
fig.classList.remove("empty");
line.setAttribute("points", pts.join(" "));
area.setAttribute("d", `M${pts[0]} ${pts.slice(1).join(" ")} L${SPARK_W},${SPARK_H} L0,${SPARK_H} Z`);
}
/* ---------- gufo slot cards: keyed, created once, updated in place ---------- */
const SLOT_FIELDS = [
["req", "request"],
["prompt", "prompt tok"],
["cache", "cache hit"],
["decoded", "generated"],
["remain", "remaining"],
["ctx", "context"],
];
const slotCards = new Map();
function buildSlotCard(id) {
const root = document.createElement("div");
root.className = "slot";
const top = document.createElement("div");
top.className = "slot-top";
const label = document.createElement("span");
label.className = "slot-id";
label.textContent = `slot ${id}`;
const model = document.createElement("span");
model.className = "slot-model";
const pill = document.createElement("span");
pill.className = "pill";
pill.textContent = "idle";
top.append(label, model, pill);
const dl = document.createElement("dl");
const refs = {};
for (const [key, title] of SLOT_FIELDS) {
const dt = document.createElement("dt");
dt.textContent = title;
const dd = document.createElement("dd");
dd.className = "na";
dd.textContent = "n/a";
const sub = document.createElement("small");
dd.append(sub);
dl.append(dt, dd);
refs[key] = { dd, sub };
}
root.append(top, dl);
return { root, pill, model, refs };
}
function renderSlots(slots) {
const grid = els["gufo-slots"];
if (!Array.isArray(slots)) return;
if (!slots.length) {
slotCards.forEach((c) => c.root.remove());
slotCards.clear();
if (!grid.querySelector(".empty")) {
const p = document.createElement("p");
p.className = "empty";
p.textContent = "engine reports 0 sessions";
grid.append(p);
}
return;
}
const emptyMsg = grid.querySelector(".empty");
if (emptyMsg) emptyMsg.remove();
const seen = new Set();
let busy = 0;
slots.forEach((s, i) => {
const id = isNum(s.id) ? s.id : i;
seen.add(id);
let card = slotCards.get(id);
if (!card) {
card = buildSlotCard(id);
slotCards.set(id, card);
}
// appendChild moves an existing node, which keeps DOM order == slot order
grid.appendChild(card.root);
// gufo exposes both `is_processing` (authoritative) and a legacy numeric
// `state` (0 idle / 1 processing); prefer the boolean when present.
const rawState = isNum(s.state) ? s.state : null;
const processing =
typeof s.is_processing === "boolean" ? s.is_processing : rawState === 1;
const other = !processing && rawState !== null && rawState !== 0;
if (processing) busy += 1;
card.root.classList.toggle("busy", processing);
card.pill.classList.toggle("busy", processing);
card.pill.classList.toggle("active", other);
const pillText = processing ? "processing" : other ? `state ${rawState}` : "idle";
if (card.pill.textContent !== pillText) card.pill.textContent = pillText;
const nt = Array.isArray(s.next_token) ? s.next_token[0] : null;
const prompt = isNum(s.n_prompt_tokens) ? s.n_prompt_tokens : null;
const cached = isNum(s.n_prompt_tokens_cache) ? s.n_prompt_tokens_cache : null;
const set = (key, v, sub, naVal) => {
const cell = card.refs[key];
setTxt(cell.dd, v, naVal);
const t = sub || "";
if (cell.sub.textContent !== t) cell.sub.textContent = t;
};
const task = nonNeg(s.id_task) !== null ? nonNeg(s.id_task) : nonNeg(s.task_id);
set("req", task !== null ? `#${task}` : "idle", null, task);
set(
"prompt",
txt(prompt),
isNum(s.n_prompt_tokens_processed) && s.n_prompt_tokens_processed !== prompt
? `processed ${txt(s.n_prompt_tokens_processed)}`
: null,
prompt
);
const hitRate = prompt > 0 && isNum(cached) ? Math.min(1, cached / prompt) : null;
set("cache", txt(cached), isNum(hitRate) ? `${pct(hitRate)} of prompt` : null, cached);
const decoded = nt && isNum(nt.n_decoded) ? nt.n_decoded : null;
set("decoded", txt(decoded), nt && isNum(nt.prob) ? `p=${nt.prob.toFixed(2)}` : null, decoded);
const remain =
nonNeg(s.n_remain) !== null ? nonNeg(s.n_remain) : nt ? nonNeg(nt.n_remain) : null;
set("remain", txt(remain), null, remain);
const nctx = nonNeg(s.n_ctx);
set("ctx", txt(nctx), null, nctx);
const model = typeof s.model === "string" ? s.model : "";
const modelTxt = s.speculative === true && model ? `${model} +spec` : model;
if (card.model.textContent !== modelTxt) card.model.textContent = modelTxt;
});
slotCards.forEach((card, id) => {
if (!seen.has(id)) {
card.root.remove();
slotCards.delete(id);
}
});
els["gufo-slotcount"].classList.toggle("na", false);
setTxt(els["gufo-slotcount"], `${busy} / ${slots.length}`, 0);
els["gufo-slots-note"].hidden = false;
}
/* ---------- metric cell helpers ---------- */
const val = (cell) => (cell && isNum(cell.value) ? cell.value : null);
const histMean = (cell) => (cell && isNum(cell.mean) ? cell.mean : null);
const histP95 = (cell) => (cell && isNum(cell.p95) ? cell.p95 : null);
/**
* A throughput number plus an honest tag for where it came from: a scraped gauge
* gets no tag, a Δcounter gets its window, an estimate says so. Hover for the
* exact computed: source.
*/
function setThroughput(el, cell) {
if (!el) return;
const v = val(cell);
setTxt(el, txt(v, 1), v);
let tag = "";
if (cell && cell.derived && isNum(v)) {
const src = cell.source || "";
if (isNum(cell.window)) tag = `Δ${cell.window < 10 ? cell.window.toFixed(1) : Math.round(cell.window)}s`;
else if (src.includes("prefill_time")) tag = "lifetime avg";
else if (src.includes("tpot")) tag = "from TPOT";
else tag = "derived";
}
const small = document.createElement("small");
small.textContent = tag ? ` ${tag}` : "";
el.append(small);
el.title = (cell && cell.source) || "series absent";
}
const sourceNote = (cell) => {
if (!cell) return "series absent → n/a";
if (!isNum(cell.value)) return "series absent → n/a";
return cell.derived ? `${cell.source} (derived)` : cell.source || "";
};
/* ---------- renderers ---------- */
function renderGufo() {
const st = state.gufo;
const m = (st.metrics && st.metrics.metrics) || {};
const running = val(m.requests_processing);
const waiting = val(m.requests_deferred);
const kv = val(m.kv_cache_usage_ratio);
setTxt(els["gufo-running"], txt(running), running);
setTxt(els["gufo-waiting"], txt(waiting), waiting);
setBar(els["gufo-kv-bar"], kv);
setTxt(els["gufo-kv-pct"], pct(kv), kv);
// derived (Δprompt_tokens_total/Δt) like decode, so it carries the provenance tag
setThroughput(els["gufo-ptps"], m.prompt_tokens_per_second);
// setThroughput, not setTxt: this cell is derived (Δcounter/Δt) and must carry its
// provenance tag + source tooltip; it falls back to the gauge with no tag.
setThroughput(els["gufo-dtps"], m.decode_tokens_per_second);
setTxt(els["gufo-tokens"], txt(val(m.tokens_total)), val(m.tokens_total));
const cacheHit = val(m.prompt_cache_hit_ratio);
setTxt(els["gufo-cachehit"], pct(cacheHit), cacheHit);
const accept = val(m.spec_accept_rate);
setTxt(els["gufo-specaccept"], pct(accept), accept);
const peak = val(m.n_tokens_max);
setTxt(els["gufo-peakctx"], txt(peak), peak);
// a non-zero GPU-context loss count is an incident, not a statistic
const lost = val(m.device_lost_total);
setTxt(els["gufo-devicelost"], txt(lost), lost);
els["gufo-devicelost"].classList.toggle("bad-num", isNum(lost) && lost > 0);
renderSlots(st.slots || []);
drawSpark("gufo-decode", st.hist.decode, (v) => txt(v, 1));
drawSpark("gufo-kv", st.hist.kv, pct, 1);
}
function renderVllm() {
const st = state.vllm;
const m = (st.metrics && st.metrics.metrics) || {};
const running = val(m.num_requests_running);
const waiting = val(m.num_requests_waiting);
const gpu = val(m.gpu_cache_usage);
setTxt(els["vllm-running"], txt(running), running);
setTxt(els["vllm-waiting"], txt(waiting), waiting);
setBar(els["vllm-kv-bar"], gpu);
setTxt(els["vllm-kv-pct"], pct(gpu), gpu);
setBar(els["vllm-gpu-bar"], gpu);
setTxt(els["vllm-gpu-pct"], pct(gpu), gpu);
const reasons = (m.waiting_by_reason && m.waiting_by_reason.value) || null;
const cap = val(m.waiting_capacity);
setTxt(els["vllm-wait-cap"], txt(cap), cap);
const rTxt =
reasons && Object.keys(reasons).length
? Object.entries(reasons)
.sort((a, b) => b[1] - a[1])
.map(([k, v]) => `${k} ${txt(v)}`)
.join(" · ")
: "reason breakdown n/a";
setTxt(els["vllm-wait-reasons"], rTxt, cap);
const ttft = m.time_to_first_token_seconds || {};
const tpot = m.time_per_output_token_seconds || {};
const queue = m.request_queue_time_seconds || {};
setTxt(els["vllm-ttft"], secs(histMean(ttft)), histMean(ttft));
setTxt(els["vllm-ttft-p95"], `p95 ${secs(histP95(ttft))}`, histP95(ttft));
setTxt(els["vllm-tpot"], secs(histMean(tpot)), histMean(tpot));
setTxt(els["vllm-tpot-p95"], `p95 ${secs(histP95(tpot))}`, histP95(tpot));
setTxt(els["vllm-queue"], secs(histMean(queue)), histMean(queue));
setTxt(els["vllm-queue-p95"], `p95 ${secs(histP95(queue))}`, histP95(queue));
const pt = val(m.prompt_tokens_total);
const gt = val(m.generation_tokens_total);
setTxt(els["vllm-ptok"], txt(pt), pt);
setTxt(els["vllm-gtok"], txt(gt), gt);
const prefixHit = val(m.prefix_cache_hit_ratio);
setTxt(els["vllm-prefixhit"], pct(prefixHit), prefixHit);
const preempt = val(m.num_preemptions_total);
setTxt(els["vllm-preempt"], txt(preempt), preempt);
els["vllm-preempt"].classList.toggle("bad-num", isNum(preempt) && preempt > 0);
setTxt(els["vllm-tokens"], txt(val(m.tokens_total)), val(m.tokens_total));
setThroughput(els["vllm-ptps"], m.prompt_tokens_per_second);
setThroughput(els["vllm-dtps"], m.decode_tokens_per_second);
setThroughput(els["vllm-perseq"], m.per_request_decode_tokens_per_second);
setThroughput(els["vllm-prefillavg"], m.prefill_tokens_per_second_lifetime);
const series = st.metrics && isNum(st.metrics.series) ? st.metrics.series : null;
setTxt(els["vllm-series"], txt(series), series);
const unparsed = st.metrics && isNum(st.metrics.unparsed) ? st.metrics.unparsed : 0;
const foot = `${(st.metrics && st.metrics.upstream) || "–"}${unparsed ? ` · ${unparsed} unparsed` : ""}`;
setTxt(els["vllm-upstream"], foot, series);
els["vllm-upstream"].title = unparsed
? `${unparsed} upstream line(s) were not parseable as Prometheus samples`
: "";
drawSpark("vllm-decode", st.hist.decode, (v) => txt(v, 1));
drawSpark("vllm-kv", st.hist.kv, pct, 1);
}
/* ---------- fleet totals ----------
One sum per metric across both engines. An engine only counts while its own
/metrics job is answering: a dead engine keeps showing its last good sample in its
own panel (correct there — it is labelled stale), but adding that frozen number to a
fleet total would pass off hours-old data as current, so it is dropped and the
header says which engine is missing. Each cell also itemises its parts on hover.
*/
const FLEET_FIELDS = [
{ id: "fleet-running", label: "running", key: { gufo: "requests_processing", vllm: "num_requests_running" } },
{ id: "fleet-waiting", label: "waiting", key: { gufo: "requests_deferred", vllm: "num_requests_waiting" } },
{ id: "fleet-ptps", label: "prompt tok/s", key: { gufo: "prompt_tokens_per_second", vllm: "prompt_tokens_per_second" } },
{ id: "fleet-dtps", label: "decode tok/s", key: { gufo: "decode_tokens_per_second", vllm: "decode_tokens_per_second" } },
{ id: "fleet-tokens", label: "total tokens", key: { gufo: "tokens_total", vllm: "tokens_total" } },
];
const engineLive = (engine) => {
const job = state[engine].jobs.metrics;
return Boolean(job && !job.error && state[engine].metrics);
};
function renderFleet() {
const live = ENGINES.filter(engineLive);
const down = ENGINES.filter((e) => !live.includes(e));
const cls = (el, name, on) => el && el.classList.toggle(name, on);
cls(els["fleet"], "ok", live.length === ENGINES.length);
cls(els["fleet"], "partial", live.length > 0 && live.length < ENGINES.length);
cls(els["fleet"], "down", live.length === 0);
cls(els["fleet-dot"], "ok", live.length === ENGINES.length);
cls(els["fleet-dot"], "warn", live.length > 0 && live.length < ENGINES.length);
cls(els["fleet-dot"], "bad", live.length === 0);
setTxt(
els["fleet-count"],
live.length
? `${live.length} of ${ENGINES.length} engines`
: `${ENGINES.length} engines · no data yet`,
live.length ? 1 : null
);
els["fleet-count"].title = down.length
? live.length
? `${down.map((e) => ENGINE_LABEL[e]).join(", ")} not answering — excluded from every total`
: "neither engine has answered yet"
: "both engines answering";
for (const f of FLEET_FIELDS) {
const el = els[f.id];
if (!el) continue;
const parts = [];
for (const e of live) {
const m = (state[e].metrics && state[e].metrics.metrics) || {};
const cell = m[f.key[e]];
const v = val(cell);
if (isNum(v)) parts.push({ e, v, derived: Boolean(cell && cell.derived) });
}
if (!parts.length) {
setTxt(el, "n/a", null);
el.title = live.length ? `no answering engine reports ${f.label}` : "no engine answering";
continue;
}
const total = parts.reduce((sum, p) => sum + p.v, 0);
setTxt(el, txt(total), total);
el.title =
parts
.map((p) => `${ENGINE_LABEL[p.e]} ${txt(p.v)}${p.derived ? " (measured)" : ""}`)
.join(" + ") +
(down.length ? ` · excluded: ${down.map((e) => ENGINE_LABEL[e]).join(", ")}` : "");
}
}
/* ---------- which engines exist ----------
Called once from boot(), before the first poll: an engine this deployment does
not run is taken out of the layout entirely rather than left as a permanently red
half-page for a server the operator never installed. */
function applyEngines() {
ENGINE_IDS.forEach((engine) => {
const on = isEnabled(engine);
[`panel-${engine}`, `strip-${engine}`, `hd-${engine}`].forEach((id) => {
if (els[id]) els[id].hidden = !on;
});
});
const multi = ENGINES.length > 1;
// one engine left: fill the row, and drop the fleet block — a cross-engine sum of
// one engine just duplicates that engine's own card
document.querySelectorAll(".panels, .strip").forEach((el) => el.classList.toggle("single", !multi));
els["fleet"].hidden = !multi;
els["cfg-none"].hidden = ENGINES.length > 0;
els["no-engines"].hidden = ENGINES.length > 0;
document.title = ENGINES.length
? `LLM Monitor · ${ENGINES.map((e) => ENGINE_LABEL[e]).join(" + ")}`
: "LLM Monitor";
}
function renderHealth(engine) {
if (!isEnabled(engine)) return; // section is not on the page: nothing to light up
const st = state[engine];
const errs = Object.values(st.jobs).filter((j) => j && j.error);
st.ok = errs.length === 0 && st.lastOkAt > 0;
const panel = els[`panel-${engine}`];
const strip = els[`strip-${engine}`];
const errEl = els[`${engine}-err`];
(els[`dots-${engine}`] || []).forEach((d) => {
d.classList.toggle("ok", st.ok);
d.classList.toggle("bad", !st.ok && st.lastOkAt > 0);
});
// stale = we still show the last good sample, but it is not updating
panel.classList.toggle("stale", !st.ok);
strip.classList.toggle("stale", !st.ok);
strip.classList.toggle("live", st.ok);
if (errs.length) {
errEl.hidden = false;
const msg = errs.map((j) => `${j.key}: ${j.error}`).join(" | ");
const line = `upstream down — holding last good data (${ago(st.lastOkAt || Date.now())} old) · ${msg}`;
if (errEl.textContent !== line) errEl.textContent = line;
} else {
errEl.hidden = true;
}
const stamp = st.lastOkAt ? `${clockStr(st.lastOkAt)}${st.ok ? "" : ` · stale ${ago(st.lastOkAt)}`}` : "no data yet";
setTxt(els[`stamp-${engine}`], st.ok ? clockStr(st.lastOkAt) : stamp, 1);
setTxt(els[`${engine}-stamp2`], st.ok ? `updated ${clockStr(st.lastOkAt)} · poll ${UI.pollMs}ms` : stamp, 1);
}
/* ---------- polling: 4 endpoints, independent, overlapping-safe ---------- */
function pushHist(arr, v) {
arr.push(v);
while (arr.length > UI.historyLen) arr.shift();
}
const JOBS = [
{
engine: "gufo",
key: "slots",
url: "/api/gufo/slots",
apply(body) {
// the proxy passes gufo's JSON through untouched, and gufo returns a bare
// array of slots; older/other builds wrap it as {"slots": [...]}.
state.gufo.slots = Array.isArray(body)
? body
: body && Array.isArray(body.slots)
? body.slots
: [];
renderGufo();
},
},
{
engine: "gufo",
key: "metrics",
url: "/api/gufo/metrics",
apply(body) {
state.gufo.metrics = body;
const m = body.metrics || {};
pushHist(state.gufo.hist.decode, val(m.decode_tokens_per_second));
pushHist(state.gufo.hist.kv, val(m.kv_cache_usage_ratio));
renderGufo();
},
},
{
engine: "vllm",
key: "metrics",
url: "/api/vllm/metrics",
apply(body) {
state.vllm.metrics = body;
const m = body.metrics || {};
pushHist(state.vllm.hist.decode, val(m.decode_tokens_per_second));
pushHist(state.vllm.hist.kv, val(m.gpu_cache_usage));
renderVllm();
},
},
];
async function runJob(job) {
if (job.inFlight) return; // never let a slow upstream pile up
job.inFlight = true;
const slot = (state[job.engine].jobs[job.key] ||= { key: job.key, error: null });
try {
const res = await fetch(job.url, { cache: "no-store", headers: { accept: "application/json" } });
const body = await res.json().catch(() => null);
if (!res.ok) {
const detail = body && (body.detail || body.error);
throw new Error(typeof detail === "string" && detail ? detail : `HTTP ${res.status}`);
}
slot.error = null;
state[job.engine].lastOkAt = Date.now();
job.apply(body);
} catch (e) {
// Keep the last good sample; only the health state changes.
slot.error = String(e && e.message ? e.message : e);
} finally {
job.inFlight = false;
renderHealth(job.engine);
renderFleet(); // after health, so a failure re-sums the fleet without the dead engine
}
}
function tick() {
if (UI.paused) return;
// fire-and-forget: one hung engine cannot delay the other. An engine that is not
// configured is never asked — its endpoint would only answer 404.
JOBS.forEach((job) => {
if (isEnabled(job.engine)) void runJob(job);
});
}
function setPaused(paused) {
UI.paused = paused;
els["pause"].dataset.paused = paused ? "1" : "0";
els["pause"].textContent = paused ? "resume" : "pause";
}
async function boot() {
cacheEls();
setPaused(false);
els["gufo-slots"].setAttribute("role", "list");
let cfg = {};
try {
cfg = await (await fetch("/api/config", { cache: "no-store" })).json();
UI.pollMs = isNum(cfg.poll_ms) ? cfg.poll_ms : UI.pollMs;
UI.historyLen = isNum(cfg.history_len) ? cfg.history_len : UI.historyLen;
// the backend owns the "does this engine exist" answer; only trust it once it
// actually speaks the flags, so an old/cached config keeps both panels
if (ENGINE_IDS.every((e) => typeof cfg[`${e}_enabled`] === "boolean")) {
ENGINES = ENGINE_IDS.filter((e) => cfg[`${e}_enabled`] === true);
}
} catch (e) {
ENGINE_IDS.forEach((engine) => setTxt(els[`cfg-${engine}`], "config unavailable", null));
}
applyEngines();
ENGINE_IDS.forEach((engine) => {
const url = cfg[`${engine}_base_url`];
if (typeof url === "string" && url) {
setTxt(els[`cfg-${engine}`], url + (cfg[`${engine}_auth`] ? " · auth" : ""), 1);
}
});
setTxt(els["cfg-poll"], String(UI.pollMs), 1);
["cfg-hist1", "cfg-hist2", "cfg-hist3", "cfg-hist4"].forEach((id) =>
setTxt(els[id], String(UI.historyLen), 1)
);
setInterval(() => setTxt(els["clock"], clockStr(Date.now()), 1), 500);
setInterval(tick, UI.pollMs);
els["pause"].addEventListener("click", () => setPaused(!UI.paused));
addEventListener("keydown", (ev) => {
if (ev.code === "Space" && ev.target === document.body) {
ev.preventDefault();
setPaused(!UI.paused);
}
});
// the setup card covers "nothing to connect to", so only the live engines announce
ENGINES.forEach((engine) => setTxt(els[`${engine}-stamp2`], "connecting…", 1));
tick();
}
boot();
</script>
</body>
</html>