ui/index.html
Rama main · réplica de lectura del repositorio original.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TinyRouter status</title>
<style>
:root {
color-scheme: light dark;
--bg: #fbfbfa;
--panel: #ffffff;
--line: #e4e2dd;
--ink: #1b1a17;
--muted: #6b6862;
--ok: #2f7d4f;
--warn: #b3761b;
--bad: #b3402f;
--accent: #3d5a80;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16171a;
--panel: #1d1f23;
--line: #2e3138;
--ink: #e8e6e1;
--muted: #999691;
--ok: #6fbf8b;
--warn: #d9a441;
--bad: #e2705c;
--accent: #8fb0d9;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 1.5rem 1.25rem 3rem;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 60rem; margin: 0 auto; }
h1 { font-size: 1.25rem; margin: 0; letter-spacing: -0.01em; }
h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
margin: 0 0 0.75rem;
font-weight: 600;
}
header {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
align-items: center;
margin-bottom: 1.25rem;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
flex: 1 1 24rem;
justify-content: flex-end;
}
input, button, select {
font: inherit;
padding: 0.4rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
}
/* Shrinkable: a fixed min-width is what pushes controls off a phone. */
input { flex: 1 1 11rem; min-width: 0; }
select, button { flex: 0 0 auto; }
button { cursor: pointer; }
button:hover { border-color: var(--accent); }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr));
gap: 1rem 1.5rem;
}
.stat .n { font-size: 1.4rem; font-variant-numeric: tabular-nums; }
.stat .k { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
table { width: 100%; border-collapse: collapse; }
th, td {
text-align: left;
padding: 0.45rem 0.75rem 0.45rem 0;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 600;
white-space: nowrap;
}
tr:last-child td { border-bottom: none; }
/* A numeric column is right-aligned in the head as well as the body, or
every heading sits over the wrong edge of its own numbers. */
th.num, td.num { text-align: right; padding-right: 0; }
td.num { font-variant-numeric: tabular-nums; }
/* Anything too wide scrolls inside its panel rather than off the page. */
.scroll { overflow-x: auto; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.86em; }
.pill {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid currentColor;
}
.ok { color: var(--ok); }
.warn { color: var(--warn); }
.bad { color: var(--bad); }
.muted { color: var(--muted); }
.note { color: var(--muted); font-size: 0.85rem; margin: 0.5rem 0 0; }
#error { border-color: var(--bad); }
#error h2 { color: var(--bad); }
/* Numbers that can no longer be refreshed must not read as current: a
status page whose last known state looks live is worse than a blank one. */
body.stale .panel:not(#error) { opacity: 0.45; }
.info {
width: 1.05rem;
height: 1.05rem;
margin-left: 0.35rem;
padding: 0;
border-radius: 50%;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: 600 0.62rem/1 ui-sans-serif, system-ui, sans-serif;
cursor: help;
vertical-align: middle;
}
.info:hover, .info:focus-visible { color: var(--accent); border-color: var(--accent); }
/* One shared layer on the body. A tooltip rendered inside a panel would
be clipped by the table's own horizontal scroll container. */
#tip {
position: fixed;
z-index: 20;
max-width: min(22rem, calc(100vw - 1.5rem));
padding: 0.5rem 0.65rem;
border-radius: 7px;
background: var(--ink);
color: var(--bg);
font: 400 0.8rem/1.45 ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0;
text-transform: none;
box-shadow: 0 6px 20px rgb(0 0 0 / 0.28);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.12s ease;
}
#tip.on { opacity: 1; visibility: visible; }
.hidden { display: none; }
ul.targets { margin: 0; padding-left: 1.1rem; }
@media (max-width: 38rem) {
body { padding: 1rem 0.85rem 2.5rem; }
.panel { padding: 0.9rem; }
/* Each field on its own line, leaving the interval and the button to
share the last one, rather than one orphan control per row. */
.controls { justify-content: stretch; }
.controls input { flex: 1 1 100%; }
.controls select { flex: 1 1 auto; }
/* Six columns will not fit a phone, and clipping them loses data.
Each row becomes its own labelled block instead. */
table thead { display: none; }
table, table tbody, table tr, table td { display: block; width: 100%; }
table tr { padding: 0.6rem 0; border-bottom: 1px solid var(--line); }
table tr:last-child { border-bottom: none; }
table td {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.15rem 0;
border: none;
text-align: left;
}
table td::before {
content: attr(data-label);
flex: 0 0 auto;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
font-weight: 600;
}
table td.num { text-align: right; }
}
</style>
</head>
<body>
<main>
<header>
<h1>TinyRouter</h1>
<div class="controls">
<input id="url" type="url" spellcheck="false" placeholder="http://localhost:8080" />
<input id="key" type="password" spellcheck="false" placeholder="API key (routes only)" />
<select id="every" title="Refresh interval">
<option value="0">Paused</option>
<option value="2000">2s</option>
<option value="5000" selected>5s</option>
<option value="15000">15s</option>
</select>
<button id="now" type="button">Refresh</button>
</div>
</header>
<section class="panel hidden" id="error">
<h2>Cannot read the gateway</h2>
<p id="error-body" class="mono"></p>
<p class="note" id="error-hint"></p>
</section>
<section class="panel">
<h2>Gateway</h2>
<div class="strip" id="overview"><span class="muted">Not connected yet.</span></div>
</section>
<section class="panel">
<h2>Providers</h2>
<div id="providers"><span class="muted">—</span></div>
<p class="note">
A provider is <strong>cooling</strong> when the circuit breaker has demoted it to a last resort,
and <strong>bad bodies</strong> counts answers that started fine and then stalled or broke.
Every heading explains what it counts.
</p>
</section>
<section class="panel">
<h2>Tokens</h2>
<div id="tokens"><span class="muted">—</span></div>
</section>
<section class="panel">
<h2>Routes</h2>
<div id="routes"><span class="muted">—</span></div>
<p class="note" id="routes-note"></p>
</section>
<p class="note">
Reads <code>/readyz</code>, <code>/metrics</code> and <code>/v1/models</code>. Nothing is sent anywhere
else, and the page holds no state but the gateway address. Only the routes list needs an API key;
health and metrics need no key unless the gateway sets protect_observability.
</p>
</main>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
const urlBox = $("url");
const keyBox = $("key");
urlBox.value = localStorage.getItem("tinyrouter.url") || "http://localhost:8080";
// Session, not local: the key is a credential, and a closed tab should not
// leave it behind on a shared machine.
keyBox.value = sessionStorage.getItem("tinyrouter.key") || "";
urlBox.addEventListener("change", () => {
localStorage.setItem("tinyrouter.url", urlBox.value.trim());
refresh();
});
keyBox.addEventListener("change", () => {
sessionStorage.setItem("tinyrouter.key", keyBox.value);
refresh();
});
// ---- tooltips ----------------------------------------------------------
const tip = document.createElement("div");
tip.id = "tip";
tip.setAttribute("role", "tooltip");
document.body.append(tip);
// Refreshing replaces the tables wholesale, which would destroy whatever
// is being hovered or focused - so an explanation could not be read to
// the end at the default interval, and keyboard focus was thrown away
// with it. Scheduled refreshes wait; the Refresh button still does not.
let readingTip = false;
function showTip(target) {
// Author-written text only, but textContent regardless: nothing on this
// page assigns markup it did not write itself.
tip.textContent = target.dataset.tip;
tip.classList.add("on");
readingTip = true;
const anchor = target.getBoundingClientRect();
const box = tip.getBoundingClientRect();
const left = Math.min(
Math.max(8, anchor.left + anchor.width / 2 - box.width / 2),
window.innerWidth - box.width - 8,
);
// Above by default, below when there is no room - a heading near the top
// of the viewport would otherwise open off-screen.
const above = anchor.top - box.height - 8;
tip.style.left = `${left}px`;
tip.style.top = `${above < 8 ? anchor.bottom + 8 : above}px`;
}
const hideTip = () => {
readingTip = false;
tip.classList.remove("on");
};
const tipTarget = (event) =>
event.target instanceof Element ? event.target.closest("[data-tip]") : null;
// Hover for a mouse, focus for a keyboard, Escape to dismiss either.
document.addEventListener("pointerover", (event) => {
const target = tipTarget(event);
if (target) showTip(target);
});
document.addEventListener("pointerout", (event) => {
if (tipTarget(event)) hideTip();
});
document.addEventListener("focusin", (event) => {
const target = tipTarget(event);
if (target) showTip(target);
else hideTip();
});
document.addEventListener("focusout", hideTip);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") hideTip();
});
window.addEventListener("scroll", hideTip, { passive: true });
// ---- rendering helpers -------------------------------------------------
// Everything below builds nodes and assigns textContent. Provider and model
// labels reach /metrics from request bodies, so they are attacker-influenced
// text; innerHTML anywhere here would be an injection point.
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function info(text) {
const button = el("button", "info", "i");
button.type = "button";
button.dataset.tip = text;
// Screen readers get the explanation directly rather than the letter.
button.setAttribute("aria-label", text);
return button;
}
function table(headers, rows) {
const t = el("table");
const head = el("tr");
for (const h of headers) {
const th = el("th", h.num ? "num" : null, h.label);
if (h.tip) th.append(info(h.tip));
head.append(th);
}
const thead = el("thead");
thead.append(head);
t.append(thead);
const body = el("tbody");
for (const row of rows) {
const tr = el("tr");
row.forEach((cell, index) => {
const td = el("td", cell.num ? "num" : cell.className || null);
// Read back by CSS on narrow screens, where the header row is
// hidden and each cell has to name itself.
td.dataset.label = headers[index].label;
if (cell.node) td.append(cell.node);
else td.textContent = cell.text;
tr.append(td);
});
body.append(tr);
}
t.append(body);
const scroller = el("div", "scroll");
scroller.append(t);
return scroller;
}
function replace(target, node) {
const host = $(target);
host.replaceChildren(node);
}
const nf = new Intl.NumberFormat();
const num = (n) => nf.format(n);
function duration(seconds) {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
// ---- Prometheus text parsing ------------------------------------------
function parseMetrics(text) {
const samples = [];
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const brace = line.indexOf("{");
if (brace === -1) {
const gap = line.lastIndexOf(" ");
if (gap === -1) continue;
const value = Number(line.slice(gap + 1));
// Exposition allows +Inf, -Inf and NaN. Summing one of those turns
// a whole panel into "NaN"; dropping the sample loses far less.
if (Number.isFinite(value)) {
samples.push({ name: line.slice(0, gap), labels: {}, value });
}
continue;
}
const close = line.lastIndexOf("}");
if (close < brace) continue;
const labels = {};
const pattern = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"/g;
for (const match of line.slice(brace + 1, close).matchAll(pattern)) {
// One pass, so an escaped backslash cannot be re-read as an escape.
labels[match[1]] = match[2].replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c));
}
const value = Number(line.slice(close + 1));
if (Number.isFinite(value)) samples.push({ name: line.slice(0, brace), labels, value });
}
return samples;
}
const pick = (samples, name) => samples.filter((s) => s.name === name);
const scalar = (samples, name) => {
const found = samples.find((s) => s.name === name);
return found ? found.value : 0;
};
// ---- panels ------------------------------------------------------------
function renderOverview(ready, samples) {
const strip = el("div", "strip");
const stat = (k, v, className, tipText) => {
const box = el("div", "stat");
box.append(el("div", `n ${className || ""}`.trim(), v));
const key = el("div", "k", k);
if (tipText) key.append(info(tipText));
box.append(key);
return box;
};
strip.append(
stat(
"status",
ready.status,
ready.status === "ready" ? "ok" : "bad",
"Whether the gateway is accepting requests. It stays ready even while a provider is cooling, because a demoted provider is still tried - just last.",
),
);
strip.append(
stat(
"uptime",
duration(scalar(samples, "tinyrouter_uptime_seconds")),
undefined,
"How long this gateway process has been running. Circuit breaker state lives in memory, so a restart starts every provider clean.",
),
);
strip.append(
stat(
"in flight",
num(scalar(samples, "tinyrouter_in_flight_requests")),
undefined,
"Requests being handled right now. A streaming answer stops counting once its status line is sent, not when the stream ends.",
),
);
const requests = pick(samples, "tinyrouter_requests_total");
const total = requests.reduce((sum, s) => sum + s.value, 0);
const failed = requests
.filter((s) => !/^2\d\d$/.test(s.labels.status || ""))
.reduce((sum, s) => sum + s.value, 0);
strip.append(
stat("requests", num(total), undefined, "Requests the gateway finished, counted by the status the client received."),
);
strip.append(
stat(
"non-2xx",
num(failed),
failed > 0 ? "warn" : undefined,
"Requests that ended in an error status, including 499 - the client hung up before its answer arrived, which is nobody's fault.",
),
);
const breaker = ready.circuit_breaker;
if (breaker) {
const open = breaker.open || [];
strip.append(
breaker.enabled
? stat(
"cooling",
open.length ? String(open.length) : "0",
open.length ? "bad" : "ok",
"Providers the circuit breaker has demoted to a last resort. They are still tried, after the healthy ones.",
)
: stat(
"breaker",
"off",
"muted",
"Circuit breaking is disabled. Set routing.circuit_breaker.failures to demote a provider that keeps failing.",
),
);
}
const overflow = scalar(samples, "tinyrouter_metrics_overflow_total");
if (overflow > 0) {
strip.append(
stat(
"collapsed series",
num(overflow),
"warn",
"The gateway hit its cardinality cap and folded further label combinations into one bucket, so some counts below are grouped rather than exact.",
),
);
}
replace("overview", strip);
}
function renderProviders(ready, samples) {
const circuits = pick(samples, "tinyrouter_circuit_open");
const attempts = pick(samples, "tinyrouter_provider_attempts_total");
const bodies = pick(samples, "tinyrouter_response_bodies_total");
const names = new Set();
for (const s of circuits) names.add(s.labels.provider);
for (const s of attempts) names.add(s.labels.provider);
if (names.size === 0) {
replace("providers", el("span", "muted", "No provider activity yet."));
return;
}
const rows = [...names].sort().map((provider) => {
const circuit = circuits.find((s) => s.labels.provider === provider);
const cooling = circuit ? circuit.value === 1 : false;
const mine = attempts.filter((s) => s.labels.provider === provider);
const ok = mine.filter((s) => /^2\d\d$/.test(s.labels.status)).reduce((a, s) => a + s.value, 0);
const bad = mine.reduce((a, s) => a + s.value, 0) - ok;
const rough = bodies
.filter((s) => s.labels.provider === provider && s.labels.outcome !== "completed")
.reduce((a, s) => a + s.value, 0);
const state = el("span", `pill ${cooling ? "bad" : "ok"}`, cooling ? "cooling" : "healthy");
const models = new Set(mine.map((s) => s.labels.model));
return [
{ node: el("span", "mono", provider) },
{ node: state },
{ text: [...models].sort().join(", ") || "—", className: "mono muted" },
{ text: num(ok), num: true },
{ text: num(bad), num: true, className: bad > 0 ? "bad" : undefined },
{ text: num(rough), num: true, className: rough > 0 ? "warn" : undefined },
];
});
replace(
"providers",
table(
[
{ label: "provider", tip: "The provider id from your configuration file." },
{
label: "circuit",
tip: "Cooling means the breaker demoted this provider after that many consecutive failing requests. It closes again once the cooldown elapses, and the next request through probes it.",
},
{ label: "models", tip: "Models seen on this provider since the gateway started." },
{ label: "2xx", num: true, tip: "Attempts this provider answered successfully. One request can make several attempts." },
{
label: "errors",
num: true,
tip: "Attempts that failed, timed out, or could not connect. Retries within one request are counted separately here, but only once against the breaker.",
},
{
label: "bad bodies",
num: true,
tip: "Answers that started fine and then went wrong: the provider stalled part-way or dropped the connection. No status counter can show this, because the status line had already been sent.",
},
],
rows,
),
);
}
function renderTokens(samples) {
const tokens = pick(samples, "tinyrouter_tokens_total");
if (tokens.length === 0) {
replace("tokens", el("span", "muted", "No provider has reported usage yet."));
return;
}
const byModel = new Map();
for (const s of tokens) {
const key = `${s.labels.provider}/${s.labels.model}`;
const entry = byModel.get(key) || { prompt: 0, completion: 0 };
if (s.labels.kind === "prompt") entry.prompt += s.value;
else entry.completion += s.value;
byModel.set(key, entry);
}
const rows = [...byModel.entries()]
.sort((a, b) => b[1].prompt + b[1].completion - (a[1].prompt + a[1].completion))
.map(([target, t]) => [
{ node: el("span", "mono", target) },
{ text: num(t.prompt), num: true },
{ text: num(t.completion), num: true },
{ text: num(t.prompt + t.completion), num: true },
]);
replace(
"tokens",
table(
[
{ label: "target", tip: "The provider and model the tokens were reported against." },
{
label: "prompt",
num: true,
tip: "Input tokens, as the provider reported them. A streaming answer only carries usage when the client asks for it, so streams may be missing here.",
},
{ label: "completion", num: true, tip: "Output tokens, as the provider reported them." },
{ label: "total", num: true, tip: "Prompt plus completion. Counted as answers pass through; the gateway never alters them." },
],
rows,
),
);
}
function renderRoutes(models, note) {
$("routes-note").textContent = note || "";
if (!models) {
replace("routes", el("span", "muted", "—"));
return;
}
const routes = models.data.filter((m) => m.tinyrouter && m.tinyrouter.kind === "route");
if (routes.length === 0) {
replace("routes", el("span", "muted", "No aliases configured."));
return;
}
const rows = routes.map((route) => {
const list = el("ul", "targets");
for (const target of route.tinyrouter.targets) list.append(el("li", "mono", target));
return [{ node: el("span", "mono", route.id) }, { node: list }];
});
replace(
"routes",
table(
[
{ label: "alias", tip: "The model name a client asks for." },
{
label: "targets in order",
tip: "Tried top to bottom. A cooling provider moves to the back of this list rather than being skipped, so a request is never failed with an untried target left over.",
},
],
rows,
),
);
}
// ---- fetching ----------------------------------------------------------
let lastReadAt = null;
function showError(message, hint) {
$("error-body").textContent = message;
const age =
lastReadAt === null
? "Nothing has been read from this gateway yet."
: `Everything below was last read ${duration(Math.round((Date.now() - lastReadAt) / 1000))} ago and is no longer being updated.`;
$("error-hint").textContent = hint ? `${hint} ${age}` : age;
$("error").classList.remove("hidden");
document.body.classList.add("stale");
}
function clearError() {
lastReadAt = Date.now();
$("error").classList.add("hidden");
document.body.classList.remove("stale");
}
function base() {
return urlBox.value.trim().replace(/\/+$/, "");
}
async function getText(path, headers) {
let response;
try {
response = await fetch(base() + path, { headers: headers || {}, mode: "cors" });
} catch (cause) {
// A blocked cross-origin read and an unreachable host both land here
// as the same TypeError. Mark it so a bug in this page's own code is
// never reported to the operator as their gateway being unreachable.
const error = new Error(`Could not reach ${base()}${path}`);
error.blocked = true;
error.cause = cause;
throw error;
}
if (!response.ok) {
const error = new Error(`${path} returned ${response.status}`);
error.status = response.status;
throw error;
}
return response.text();
}
let inFlight = false;
async function refresh() {
if (inFlight || base() === "") return;
inFlight = true;
try {
// Sent to everything, not just the routes list: a gateway with
// protect_observability set answers /readyz and /metrics with 401
// too, and the key to satisfy them is already on screen.
const key = keyBox.value.trim();
const auth = key === "" ? {} : { authorization: `Bearer ${key}` };
const [readyText, metricsText] = await Promise.all([
getText("/readyz", auth),
getText("/metrics", auth),
]);
const ready = JSON.parse(readyText);
const samples = parseMetrics(metricsText);
clearError();
renderOverview(ready, samples);
renderProviders(ready, samples);
renderTokens(samples);
// Routes are always behind the gateway's API key; the panels above are
// only when the operator has asked for it.
if (key === "") {
renderRoutes(null, "Enter the gateway API key to list route aliases.");
} else {
try {
const models = JSON.parse(await getText("/v1/models", auth));
renderRoutes(models, "");
} catch (error) {
renderRoutes(
null,
error.status === 401 ? "That API key was refused." : `Could not read routes: ${error.message}`,
);
}
}
} catch (error) {
let hint = "";
if (error.blocked) {
hint = `Either the gateway is not running, or it has not been told to share with this page — add allow_origin: ["${location.origin}"] under server: in tinyrouter.yaml and restart it.`;
} else if (error.status === 401) {
hint =
keyBox.value.trim() === ""
? "This gateway has protect_observability set, so even health and metrics need its API key. Enter it above."
: "That API key was refused.";
}
showError(error.message, hint);
} finally {
inFlight = false;
}
}
let timer;
function reschedule() {
clearInterval(timer);
const every = Number($("every").value);
if (every > 0) {
timer = setInterval(() => {
if (!readingTip) refresh();
}, every);
}
}
$("every").addEventListener("change", reschedule);
$("now").addEventListener("click", refresh);
reschedule();
refresh();
</script>
</body>
</html>