Ctrl K
ComunidadGuías…

ui/index.html

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!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">&mdash;</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">&mdash;</span></div>
      </section>

      <section class="panel">
        <h2>Routes</h2>
        <div id="routes"><span class="muted">&mdash;</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>