Build and Push Container / build-and-push (push) Successful in 38s
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
1674 lines
61 KiB
HTML
1674 lines
61 KiB
HTML
<!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>
|