#!/usr/bin/env python3
"""ferry-dash — a live local dashboard for the ferry LAN AI relay.

Serves an auto-refreshing web page (default http://localhost:8091) for the
litellm route proxy that `ferry up --route` runs. It shows:

  * ferry UP/DOWN + host/port + served model groups
  * the orchestrator topology (primary + strict fallback chain) from litellm.yaml
  * the gemini worker pool
  * recent request activity parsed from the proxy log (rate, status, clients)
  * an on-demand "Test backends" probe (the ONLY thing that spends tokens)

Auto-refresh costs nothing: it reads the local proxy log plus litellm's
/health/liveliness and /v1/models (no model calls). Standard library only, so
it runs under any python3 on macOS and Linux — no venv, no pip.

Usage:
  ferry-dash                 # serve on http://localhost:8091
  ferry-dash --open          # ...and open it in your browser
  ferry-dash --port 9000 --ferry http://127.0.0.1:8090
"""
import argparse
import datetime
import difflib
import glob
import json
import os
import re
import shutil
import socket
import tempfile
import threading
import time
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

CFG = {}                    # populated in main()
_TOPO_CACHE = {}            # (path -> (mtime, parsed)) so we re-parse only on change


# ── HTTP helper (talks to the ferry/litellm proxy) ─────────────────────────
def http_json(url, key, method="GET", body=None, timeout=8, headers=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Authorization", "Bearer %s" % key)
    if body is not None:
        req.add_header("Content-Type", "application/json")
    for hk, hv in (headers or {}).items():
        req.add_header(hk, hv)
    t0 = time.monotonic()
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            raw, code, hdrs = r.read(), r.status, r.headers
    except urllib.error.HTTPError as e:
        raw, code, hdrs = e.read(), e.code, e.headers
    except Exception as e:
        return {"ok": False, "status": 0, "ms": int((time.monotonic() - t0) * 1000),
                "error": str(e), "headers": {}}
    out = {"ok": 200 <= code < 300, "status": code,
           "ms": int((time.monotonic() - t0) * 1000),
           "headers": {k.lower(): v for k, v in dict(hdrs).items()}}
    try:
        out["json"] = json.loads(raw)
    except Exception:
        out["text"] = raw.decode("utf-8", "replace")[:400]
    return out


def http_stream(url, key, method="POST", body=None, timeout=8):
    """Like http_json, but for a request whose backend only answers on the
    streaming path (litellm's `chatgpt/` bridge, 2026-09-04: a non-streamed
    call 500s with "Unknown items in responses API response: []" even though
    the deployment is healthy). urllib's `r.read()` blocks until the SSE
    connection closes regardless, so health only needs the STATUS and the
    headers litellm sets before the first chunk — the body is drained to
    completion so the connection closes cleanly, never parsed as JSON, since
    an SSE stream is not one JSON document."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header("Authorization", "Bearer %s" % key)
    if body is not None:
        req.add_header("Content-Type", "application/json")
    t0 = time.monotonic()
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            raw, code, hdrs = r.read(), r.status, r.headers
    except urllib.error.HTTPError as e:
        raw, code, hdrs = e.read(), e.code, e.headers
    except Exception as e:
        return {"ok": False, "status": 0, "ms": int((time.monotonic() - t0) * 1000),
                "error": str(e), "headers": {}}
    ok = 200 <= code < 300
    out = {"ok": ok, "status": code, "ms": int((time.monotonic() - t0) * 1000),
           "headers": {k.lower(): v for k, v in dict(hdrs).items()}}
    if not ok:
        out["error"] = raw.decode("utf-8", "replace")[:200]
    return out


def _short_err(r):
    j = r.get("json") or {}
    if isinstance(j, dict) and isinstance(j.get("error"), dict):
        msg = j["error"].get("message", "")
        return msg[:160] if msg else None
    if r.get("error"):
        return str(r["error"])[:160]
    if r.get("text"):
        return r["text"][:160]
    return None


# ── litellm.yaml topology (tiny purpose-built parser, no PyYAML dep) ────────
def load_topology(path):
    try:
        mtime = os.path.getmtime(path)
    except Exception as e:
        return {"error": "config unreadable: %s" % e, "groups": {}, "order": [],
                "fallbacks": {}, "routing": {}}
    cached = _TOPO_CACHE.get(path)
    if cached and cached[0] == mtime:
        return cached[1]

    with open(path, errors="replace") as _f:   # close the handle (no ResourceWarning under 3.14+)
        text = _f.read()
    topo = parse_topology_text(text)
    _TOPO_CACHE[path] = (mtime, topo)
    return topo


_LIVE = None


def _live():
    """lib/ferry_live.py, loaded by path. None if it cannot be loaded.

    Every caller treats None as "no live view", so a dashboard whose sibling
    module is missing still serves the pages that predate it.
    """
    global _LIVE
    if _LIVE is None:
        try:
            import importlib.machinery
            import importlib.util
            path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "lib", "ferry_live.py")
            spec = importlib.util.spec_from_loader(
                "ferry_live",
                importlib.machinery.SourceFileLoader("ferry_live", path))
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            _LIVE = module
        except Exception:
            _LIVE = False
    return _LIVE or None


_CATALOG = None


def _catalog():
    """Optional read-only OpenRouter catalog, loaded beside this executable."""
    global _CATALOG
    if _CATALOG is None:
        try:
            import importlib.machinery
            import importlib.util
            path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "lib", "ferry_catalog.py")
            spec = importlib.util.spec_from_loader(
                "ferry_catalog", importlib.machinery.SourceFileLoader("ferry_catalog", path))
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            _CATALOG = module
        except Exception:
            _CATALOG = False
    return _CATALOG or None


def _provider_rule():
    """The provider derivation from lib/ferry_events.py, or a safe stand-in.

    Loaded by path because the sibling has no importable dotted name. Falls back
    to returning "" rather than a SECOND implementation of the rule: a divergent
    copy would join against events almost-but-not-quite, which is worse than an
    obviously empty field.
    """
    global _PROVIDER_RULE
    if _PROVIDER_RULE is None:
        try:
            import importlib.machinery
            import importlib.util
            path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "lib", "ferry_events.py")
            spec = importlib.util.spec_from_loader(
                "ferry_events",
                importlib.machinery.SourceFileLoader("ferry_events", path))
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)
            _PROVIDER_RULE = module.provider_for
        except Exception:
            _PROVIDER_RULE = lambda _model, _base: ""      # noqa: E731
    return _PROVIDER_RULE


_PROVIDER_RULE = None


def parse_topology_text(text):
    """The line scanner, split out of load_topology so the WRITER validates
    against exactly the same view of the config the dashboard renders.

    Two different mechanisms live in what it returns, and telling them apart is
    the point:
      * groups[name]["count"] > 1  -> a POOL. Several deployments share one
        model_name; litellm splits across them by usage. Order is meaningless
        and there is no fallbacks entry.
      * fallbacks[name]            -> an ordered CHAIN of other names, tried on
        failure, in the order listed.
    A lane can be both: a chain hop may itself be a pooled name."""
    topo = {"error": None, "groups": {}, "order": [], "fallbacks": {}, "routing": {}}
    section = None      # "models" | "router" | None
    cur = None
    for ln in text.splitlines():
        if re.match(r"^model_list:", ln):
            section = "models"; continue
        if re.match(r"^router_settings:", ln):
            section = "router"; continue
        if re.match(r"^\w", ln):            # any other top-level key ends the section
            section = None; continue
        if section == "models":
            m = re.match(r"^\s*-\s*model_name:\s*(\S+)", ln)
            if m:
                cur = m.group(1)
                g = topo["groups"].setdefault(
                    cur, {"count": 0, "models": [], "ids": [], "public": False,
                          "providers": [], "api_bases": []})
                g["count"] += 1
                # One slot per deployment, so ids/models/providers stay index-
                # aligned even when a deployment omits a key.
                g["ids"].append("")
                g["api_bases"].append("")
                if cur not in topo["order"]:
                    topo["order"].append(cur)
                continue
            m = re.match(r"^\s*model:\s*(\S+)", ln)
            if m and cur:
                topo["groups"][cur]["models"].append(m.group(1))
                continue
            # ── additive, for the live view ────────────────────────────────
            # `model_info.id` is what x-litellm-model-id carries on a response,
            # so it is the ONLY key that joins a live event back to a configured
            # deployment. An unset id leaves the slot empty rather than guessing:
            # litellm then generates a hash, which cannot be matched to config,
            # and saying so beats silently failing to join.
            m = re.match(r"^\s*api_base:\s*(\S+)", ln)
            if m and cur and topo["groups"][cur]["api_bases"]:
                topo["groups"][cur]["api_bases"][-1] = m.group(1)
                continue
            m = re.match(r"^\s*id:\s*(\S+)", ln)
            if m and cur and topo["groups"][cur]["ids"]:
                topo["groups"][cur]["ids"][-1] = m.group(1)
                continue
            # A trailing comment is the NORMAL shape in this config
            # (`public: true      # advertised in /v1/models`), so anchoring on
            # end-of-line after the value finds nothing at all. Caught by
            # parsing the real config instead of only the fixture.
            m = re.match(r"^\s*public:\s*(true|True|yes|Yes)\s*(#.*)?$", ln)
            if m and cur:
                topo["groups"][cur]["public"] = True
        elif section == "router":
            m = re.match(r"^\s*fallbacks:\s*(.+)$", ln)
            if m:
                try:
                    for d in json.loads(m.group(1)):
                        topo["fallbacks"].update(d)
                except Exception:
                    pass
                continue
            m = re.match(r"^\s*(routing_strategy|allowed_fails|cooldown_time|num_retries):\s*(\S+)", ln)
            if m:
                topo["routing"][m.group(1)] = m.group(2)

    # Providers come from the SAME rule an event uses. Two derivations of the
    # same fact would silently fail to match, and the live view joins topology
    # to events on exactly this value.
    provider_for = _provider_rule()
    for g in topo["groups"].values():
        bases = g.get("api_bases") or []
        g["providers"] = [
            provider_for(model, bases[i] if i < len(bases) else "")
            for i, model in enumerate(g.get("models") or [])
        ]

    # Fleets: a name containing "." belongs to the fleet named by its prefix
    # (in file order); everything else is a shared, unprefixed lane. This is
    # the SAME partition the front door's discover_fleets() computes from the
    # yaml directly — two derivations of one fact would drift, so the dash
    # reads it here from the parse it already trusts.
    topo["fleets"] = {}
    topo["shared"] = []
    for name in topo["order"]:
        if "." in name:
            topo["fleets"].setdefault(name.split(".", 1)[0], []).append(name)
        else:
            topo["shared"].append(name)
    return topo


# ── writing a chain back (the only thing here that can destroy something) ───
# A ferry litellm.yaml is roughly two-thirds commentary, and that commentary is
# the part you cannot reconstruct: the ToS note behind a real account
# suspension, a duplicate-key trap that silently conflated two providers'
# metrics, measured numbers that tell a future reader not to re-tune a parameter
# already tested on both surfaces. Every yaml library deletes all of it on
# dump(), and this file has no yaml library anyway (stdlib only, by contract).
#
# So the writer does not round-trip. It rewrites ONE anchored line and passes
# every other byte through untouched. Editing a chain only ever changes that
# line, which is why v1 edits chains and not deployments.
FALLBACKS_RE = re.compile(r"^(\s*)fallbacks:\s*\[.*\]\s*$")
CONTEXT_FALLBACKS_RE = re.compile(r"^(\s*)context_window_fallbacks:\s*\[.*\]\s*$")


class SpliceError(Exception):
    """Raised instead of writing when the config is not the shape we can edit."""


def splice_fallbacks(text, chains):
    """Return `text` with the fallbacks line rewritten to include `chains`.

    Lanes absent from `chains` keep their existing entry, so a caller editing one
    lane cannot silently drop another's failover.

    The config carries a SECOND chain map, `context_window_fallbacks` — the
    chain litellm walks when the failure is a context overflow rather than an
    error. This config keeps the two maps identical for the K3 lanes, so a
    reorder that touched only `fallbacks` would leave the overflow order
    pointing at the OLD first hop until the next hand edit. The same one-line
    splice therefore updates the cw map for exactly the lanes it already has
    an entry for: a lane being edited AND present in the cw map. No entries
    are invented (a lane with no overflow chain stays without one), and a
    config with no cw line at all is left untouched."""
    lines = text.splitlines()
    hits = [i for i, l in enumerate(lines) if FALLBACKS_RE.match(l)]
    if not hits:
        raise SpliceError(
            "no `fallbacks:` line found — refusing to invent one, because its "
            "position inside router_settings cannot be guessed safely")
    if len(hits) > 1:
        raise SpliceError(
            "%d `fallbacks:` lines found (lines %s) — refusing to guess which "
            "one litellm honours" % (len(hits), ", ".join(str(h + 1) for h in hits)))

    i = hits[0]
    indent = FALLBACKS_RE.match(lines[i]).group(1)
    merged = {}
    try:
        for entry in json.loads(lines[i].split("fallbacks:", 1)[1].strip()):
            merged.update(entry)
    except Exception as e:
        raise SpliceError("existing fallbacks line is not parseable JSON: %s" % e)
    merged.update(chains)

    body = ", ".join("{%s: %s}" % (json.dumps(k), json.dumps(v))
                     for k, v in merged.items())
    lines[i] = "%sfallbacks: [%s]" % (indent, body)

    # ── keep the context-window map consistent for lanes it already has ────
    cw_hits = [j for j, l in enumerate(lines) if CONTEXT_FALLBACKS_RE.match(l)]
    if cw_hits:
        j = cw_hits[0]
        cw_indent = CONTEXT_FALLBACKS_RE.match(lines[j]).group(1)
        try:
            cw = {}
            for entry in json.loads(
                    lines[j].split("context_window_fallbacks:", 1)[1].strip()):
                cw.update(entry)
        except Exception as e:
            raise SpliceError(
                "existing context_window_fallbacks line is not parseable "
                "JSON: %s" % e)
        touched = {k: v for k, v in chains.items() if k in cw}
        if touched:
            cw.update(touched)
            cw_body = ", ".join("{%s: %s}" % (json.dumps(k), json.dumps(v))
                                for k, v in cw.items())
            lines[j] = "%scontext_window_fallbacks: [%s]" % (cw_indent, cw_body)

    out = "\n".join(lines)
    return out + "\n" if text.endswith("\n") else out


# ── swapping two primaries back (the file half of a promote) ───────────────
# A promote trades the BACKENDS behind two names: the lane's deployment block
# and the hop's deployment block exchange their litellm_params bodies (and
# their ids, which the caller supplies — minted fresh before the file write
# so file and live router agree). Names, model_info keys, and chains are
# untouched: only the LINES under litellm_params: move, plus the `id:` value
# under model_info:, which takes the caller-supplied fresh id.
#
# Byte discipline, the point of this whole writer: lines move WITH their
# exact bytes (a trailing space moves with its line; key order inside each
# body stays the sender's own). The ONLY bytes that change anywhere in the
# file are (a) the block addresses of the moved lines and (b) the two id:
# values. Comments never move at all — they stay on their original line
# numbers (or shift only by the net line-count delta when the two bodies
# differ in length), so `diff` shows the moved lines as moved and a comment
# never lands on the wrong side of a swap.
#
# Line-surgery, not YAML round-trip (same doctrine as splice_fallbacks): the
# block boundaries come from the `  - model_name:` anchors at a shared indent,
# which this config holds. Anything else — a missing anchor, a block without
# litellm_params:, an id line that is not inside model_info: — raises instead
# of guessing, because a half-swapped primary is a lane serving the wrong
# provider under the right name.
DEPLOY_ANCHOR_RE = re.compile(r"^(\s*)-\s*model_name:\s*(\S+)\s*$")


def _deploy_blocks(lines):
    """(anchor_indent, {name: (start, end)}) over the model_list blocks.

    start is the anchor line itself, end the line before the next anchor (or
    EOF). Duplicate names raise: after a swap the file must still resolve
    each name to exactly one block."""
    hits = [(i, m.group(1), m.group(2)) for i, l in enumerate(lines)
            if (m := DEPLOY_ANCHOR_RE.match(l))]
    if not hits:
        raise SpliceError("no `model_name:` anchors found — not a shape we edit")
    indents = {h[1] for h in hits}
    if len(indents) != 1:
        raise SpliceError("deploy anchors at %d indents — refusing to guess "
                          "block boundaries" % len(indents))
    names = [h[2] for h in hits]
    dupes = {n for n in names if names.count(n) > 1}
    if dupes:
        raise SpliceError("duplicate model_name anchors: %s" % sorted(dupes))
    blocks = {}
    for k, (i, _, name) in enumerate(hits):
        end = hits[k + 1][0] if k + 1 < len(hits) else len(lines)
        blocks[name] = (i, end)
    return hits[0][1], blocks


def swap_primaries(text, lane, hop, lane_id, hop_id):
    """Return `text` with lane's and hop's backends traded.

    Moves the litellm_params: BODY lines verbatim (exact bytes, indent
    intact) and rewrites the two model_info id: values to the supplied fresh
    ids. `public:`, comments, max_input_tokens, and the fallbacks maps are
    untouched — the shape of each deployment stays the lane's own, only the
    backend behind it changes. Either block missing, or either block shaped
    unexpectedly, raises SpliceError and the file is unwritten."""
    lines = text.splitlines()
    _, blocks = _deploy_blocks(lines)
    for name in (lane, hop):
        if name not in blocks:
            raise SpliceError("no deployment block for %r" % (name,))

    def section(block_lines, key):
        """(start, end) of the indented body under `key:` within a block."""
        head = next((k for k, l in enumerate(block_lines)
                     if re.match(r"^\s*%s:\s*(#.*)?$" % key, l)), None)
        if head is None:
            return None
        key_ind = len(block_lines[head]) - len(block_lines[head].lstrip())
        end = head + 1
        while end < len(block_lines):
            l = block_lines[end]
            if l.strip() and len(l) - len(l.lstrip()) <= key_ind:
                break
            end += 1
        return head, end

    swapped = {}
    lane_fresh, hop_fresh = lane_id, hop_id
    for name, fresh in ((lane, lane_id), (hop, hop_id)):
        s, e = blocks[name]
        body = lines[s:e]
        lp = section(body, "litellm_params")
        if lp is None:
            raise SpliceError("block %r has no litellm_params: to swap" % (name,))
        mi = section(body, "model_info")
        if mi is None:
            raise SpliceError("block %r has no model_info: to re-id" % (name,))
        id_at = next((k for k in range(mi[0] + 1, mi[1])
                      if re.match(r"^\s*id:\s*\S+", body[k])), None)
        if id_at is None:
            raise SpliceError("block %r has no id: line under model_info:" % (name,))
        swapped[name] = (body, lp, mi, id_at, fresh)

    (lane_body, lane_lp, _, _, _), \
        (hop_body, hop_lp, _, _, _) = \
        swapped[lane], swapped[hop]
    lane_params = lane_body[lane_lp[0] + 1:lane_lp[1]]
    hop_params = hop_body[hop_lp[0] + 1:hop_lp[1]]

    # The id's OFFSET inside model_info: (mi[0]+1 .. mi[1]) is recomputed per
    # rebuild, so only the offset relative to the section head is carried.
    out = list(lines)
    # Rebuild each block from the ORIGINAL lines (never from `out`, which the
    # other block's splice already changed). Both spans are computed up front
    # against the original, so they are only valid until the first splice
    # moves something — hence the bottom-up order below.
    new_spans = {}
    for name, params, fresh in (
            (hop, lane_params, hop_fresh),
            (lane, hop_params, lane_fresh)):
        s, e = blocks[name]
        body = list(lines[s:e])
        lp = section(body, "litellm_params")
        new_body = body[:lp[0] + 1] + params + body[lp[1]:]
        mi2 = section(new_body, "model_info")
        id_rel = swapped[name][3] - (swapped[name][2][0] + 1)
        id_line = new_body[mi2[0] + 1:mi2[1]][id_rel]
        if not re.match(r"^\s*id:\s*\S+", id_line):
            raise SpliceError(
                "block %r: params swap moved the id: line — refusing a "
                "half-swapped primary" % (name,))
        idx = (mi2[0] + 1) + id_rel
        new_body[idx] = re.sub(r"^(\s*id:\s*)\S+",
                               lambda m: m.group(1) + fresh, new_body[idx])
        new_spans[name] = (s, e, new_body)
    # Splice the LATER block first. A slice assignment of a different length
    # shifts every index after it, so splicing in dict order (hop, then lane)
    # landed the second splice one line off whenever the hop's block sat
    # above the lane's and the two bodies differed in length. 2026-09-04:
    # that ate a blank line and doubled a comment on the promote, doubled the
    # lane's anchor and overwrote the NEXT block's on the swap back, and
    # litellm refused the file (KeyError: 'litellm_params') on the reload.
    for name, (s, e, new_body) in sorted(new_spans.items(),
                                         key=lambda kv: kv[1][0], reverse=True):
        out[s:e] = new_body
    text_out = "\n".join(out)
    return text_out + "\n" if text.endswith("\n") else text_out


def validate_promote_file(topo, lane, hop):
    """Every reason to refuse a file-side primary swap, as readable strings.

    Mirrors the live validator's trust rule on the file's view: both names
    must be real deployments, and the hop must be in the lane's configured
    chain — a promote never invents a backend. The live endpoint re-validates
    against the ROUTER anyway, so a file/live disagreement still refuses at
    apply time rather than diverging."""
    names = set(topo.get("groups") or {})
    errs = []
    if lane not in names:
        errs.append("lane %r is not a model_name in this config" % (lane,))
        return errs
    if hop not in names:
        errs.append("hop %r is not a model_name in this config — promotion "
                    "only re-seats a backend the config already runs" % (hop,))
        return errs
    if lane == hop:
        errs.append("lane %r is already its own primary" % (lane,))
        return errs
    chain = (topo.get("fallbacks") or {}).get(lane, [])
    if hop not in chain:
        errs.append("hop %r is not in %s's fallback chain — promote only "
                    "moves a trusted hop, never an unlisted backend" % (hop, lane))
    return errs


def diff_promote(path, lane, hop, lane_id, hop_id):
    """(unified diff, errors) for a proposed primary swap. Reads; never writes."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_promote_file(parse_topology_text(old), lane, hop)
    if errs:
        return "", errs
    try:
        new = swap_primaries(old, lane, hop, lane_id, hop_id)
    except SpliceError as e:
        return "", [str(e)]
    d = difflib.unified_diff(old.splitlines(), new.splitlines(),
                             "current", "proposed", lineterm="", n=2)
    return "\n".join(d), []


def apply_promote(path, lane, hop, lane_id, hop_id):
    """Validate, snapshot, swap. Returns (snapshot_path, unified_diff).

    Validation runs BEFORE the snapshot so a rejected swap leaves no debris;
    the write is the same atomic tmp+rename as apply_chains. The caller mints
    lane_id/hop_id fresh (server-side, timestamped) BEFORE calling, so the
    live promote that follows can echo the SAME ids."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_promote_file(parse_topology_text(old), lane, hop)
    if errs:
        raise SpliceError("; ".join(errs))
    try:
        new = swap_primaries(old, lane, hop, lane_id, hop_id)
    except SpliceError:
        raise
    d = "\n".join(difflib.unified_diff(old.splitlines(), new.splitlines(),
                                       "before", "after", lineterm="", n=2))
    snap = snapshot_config(path)
    tmp = path + ".ferry-dash.tmp"
    with open(tmp, "w") as f:
        f.write(new)
    os.replace(tmp, path)
    _TOPO_CACHE.pop(path, None)
    return snap, d


def _mint_file_ids(lane, hop):
    """The fresh id pair for a file-first promote, minted server-side here.

    Timestamped per name, distinct by construction (the lane/hop prefixes
    differ), and never caller-supplied — the dash sends the SAME pair to the
    live endpoint so file and router agree."""
    ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    return "%s-promoted-%s" % (lane, ts), "%s-promoted-%s" % (hop, ts)


def validate_chains(topo, chains):
    """Every reason to refuse a rewire, as a list of human-readable strings.

    The rules exist because litellm fails SILENTLY on each of them: a hop naming
    a deployment that does not exist is simply skipped, and a lane that is not a
    real model_name never matches the fallbacks map at all — its primary's error
    goes straight to the client with the chain unused."""
    names = set(topo.get("groups") or {})
    errs = []
    for lane, chain in chains.items():
        if lane not in names:
            errs.append("lane %r is not a model_name in this config — a lane "
                        "reached only by alias gets no fallback chain at all" % lane)
        if lane in chain:
            errs.append("lane %r lists itself as its own fallback" % lane)
        seen = set()
        for hop in chain:
            if hop not in names:
                errs.append("hop %r (in %s) is not a model_name in this config" % (hop, lane))
            if hop in seen:
                errs.append("hop %r appears twice in %s" % (hop, lane))
            # Same guard resolve_model applies (front/ferry_front.py: "." in model and
            # model.split(".", 1)[0] in fleets) — a dot counts as a fleet prefix only
            # when that prefix is an actual fleet in this topology, not for any dotted
            # lane name (e.g. a literal `gpt-3.5-turbo`).
            if ("." in lane and lane.split(".", 1)[0] in topo["fleets"]
                    and ("." not in hop or hop.split(".", 1)[0] != lane.split(".", 1)[0])):
                errs.append("hop %r is not in fleet %r" % (hop, lane.split(".", 1)[0]))
            seen.add(hop)
    return errs


def snapshot_config(path):
    """Copy the whole original to a UTC-timestamped sibling before any write.

    Mirrors the helper ferry already uses for the opencode takeover: the
    snapshot is what makes an unexpected edit recoverable rather than a
    post-mortem."""
    ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    snap = "%s.%s.bak" % (path, ts)
    n = 1
    while os.path.exists(snap):          # two writes inside one second
        snap = "%s.%s-%d.bak" % (path, ts, n)
        n += 1
    shutil.copy2(path, snap)
    return snap


def diff_chains(path, chains):
    """(unified diff, errors) for a proposed rewire. Reads; never writes."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_chains(parse_topology_text(old), chains)
    if errs:
        return "", errs
    new = splice_fallbacks(old, chains)
    d = difflib.unified_diff(old.splitlines(), new.splitlines(),
                             "current", "proposed", lineterm="", n=2)
    return "\n".join(d), []


def apply_chains(path, chains):
    """Validate, snapshot, write. Returns (snapshot_path, unified_diff).

    Validation runs BEFORE the snapshot so a rejected edit leaves no debris."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_chains(parse_topology_text(old), chains)
    if errs:
        raise SpliceError("; ".join(errs))
    new = splice_fallbacks(old, chains)
    d = "\n".join(difflib.unified_diff(old.splitlines(), new.splitlines(),
                                       "before", "after", lineterm="", n=2))
    snap = snapshot_config(path)
    tmp = path + ".ferry-dash.tmp"
    with open(tmp, "w") as f:
        f.write(new)
    os.replace(tmp, path)                # atomic: never a half-written config
    _TOPO_CACHE.pop(path, None)
    return snap, d


def hotswap_promote(ferry_base, key, lane, hop, lane_id=None,
                    hop_id=None, preview_only=False):
    """POST a promote to the running proxy's hot-swap endpoint.

    preview_only hits /v1/ferry/promote/preview (read-only: what the swap
    WOULD do) and returns its verdict dict for the UI to display. Otherwise
    hits /v1/ferry/promote with the file-first ids so file and router agree.
    Never raises — same contract as hotswap_reorder: the file is durable, so
    every outcome is a note."""
    path = "/v1/ferry/promote/preview" if preview_only else "/v1/ferry/promote"
    payload = {"lane": lane, "hop": hop}
    if lane_id and hop_id and not preview_only:
        payload["lane_id"] = lane_id
        payload["hop_id"] = hop_id
    try:
        r = http_json(ferry_base + path, key, "POST", payload, timeout=10)
    except Exception as e:
        return ("live proxy unreachable (%s) — restart it to pick up the new "
                "primary." % e) if not preview_only else {"error": str(e)}
    if preview_only:
        return r.get("json") or {"error": r.get("error") or
                                 "HTTP %s" % r.get("status")}
    if r.get("ok"):
        return "live proxy hot-swapped, no restart needed."
    errs = (r.get("json") or {}).get("errors") or [r.get("error") or
                                                   "HTTP %s" % r.get("status")]
    if r.get("status") in (404, 501, 503):
        return ("live hot-swap unavailable (%s) — restart the proxy to pick "
                "up the new primary." % "; ".join(errs))
    return ("live proxy REFUSED the new primary (%s) — the file is updated, "
            "so a restart would serve it; investigate the mismatch before "
            "restarting." % "; ".join(errs))


def hotswap_reorder(ferry_base, key, order_or_chains):
    """POST a reorder to the running proxy's hot-swap endpoint.

    Returns the human-readable half of the apply note: either the live chains
    now serve the new order, or exactly why they do not (and that a restart
    converges). Never raises — the file write already succeeded, so a failed
    hot-swap is a degraded note, not a failed apply.

    order_or_chains is the unified {lane: [primary, ...fallbacks]} map the UI
    sends, or {"__chains__": chains} for the legacy bare-chains shape (which
    has no position 0 to strip — order_to_chains would eat its first hop).
    """
    if isinstance(order_or_chains, dict) and "__chains__" in order_or_chains:
        payload = {"chains": order_or_chains["__chains__"]}
    else:
        payload = {"order": order_or_chains}
    try:
        r = http_json(ferry_base + "/v1/ferry/reorder", key, "POST",
                      payload, timeout=10)
    except Exception as e:
        return ("live proxy unreachable (%s) — restart it to pick up the new "
                "order." % e)
    if r.get("ok"):
        return "live proxy hot-swapped, no restart needed."
    errs = (r.get("json") or {}).get("errors") or [r.get("error") or
                                                   "HTTP %s" % r.get("status")]
    if r.get("status") in (404, 501, 503):
        # 404: proxy predates the hot-swap endpoint (plain `litellm` without
        # the ferry wrapper, or an older ferry_front.py). 503: router not
        # ready yet. Either way the file is durable — a restart converges.
        return ("live hot-swap unavailable (%s) — restart the proxy to pick "
                "up the new order." % "; ".join(errs))
    # 400/409: the LIVE router refused what the FILE accepted (it serves a
    # different model set than the file declares). The file still won — say so
    # plainly, with the router's reason, so the mismatch gets investigated
    # instead of silently winning on next restart.
    return ("live proxy REFUSED the new order (%s) — the file is updated, so "
            "a restart would serve this order; investigate the mismatch "
            "before restarting." % "; ".join(errs))


# ── unified order: [primary, ...fallbacks] as one editable list ────────────
# The Routes editor used to expose only the fallbacks tail, with the primary
# fixed. The unified model lets a client send the whole lane as one ordered
# list, position 0 first. Position 0 is VALIDATED, never written: in this
# config a lane's primary is its own model_name group in model_list, and
# "promoting" a fallback would mean renaming model_name values — swapping
# identities between multi-line, comment-dense deployment blocks. That is
# outside the one-anchored-line doctrine three ways: the comment above a block
# describes the deployment it names (a rename leaves it on the wrong side of
# the swap), `public: true` and model_info.id follow the block into a name the
# catalogue may not want to advertise, and this config carries a SECOND chain
# map (context_window_fallbacks) this parser does not own, whose hops a rename
# would silently re-point. So reordering positions 1..n is free; position 0
# must be the lane's current primary, and anything else is refused with the
# stable "primary changes not yet supported" prefix the UI can match on.
ORDER_PRIMARY_ERR = (
    "primary changes not yet supported: %r would become the primary of lane "
    "%r, but a lane's primary is its own model_name (the deployment litellm "
    "tries first); reorder positions 1..n only")


def order_to_chains(order):
    """{lane: [primary, ...fallbacks]} -> the fallbacks-map shape.

    The only transform the unified model needs: drop position 0 and the tail
    IS a chain for splice_fallbacks."""
    return {lane: list(seq[1:]) for lane, seq in order.items()}


def validate_order(topo, order):
    """Every reason to refuse a unified-order rewire, as readable strings.

    Adds the order-shape rules on top of validate_chains, which still owns the
    tail (positions 1..n) so the two validators can never disagree about what
    litellm fails on silently: list-ness, non-emptiness, position 0 == the
    lane's own primary, and no duplicate anywhere in the list (the tail's
    duplicates are validate_chains')."""
    errs = []
    tails = {}
    for lane, seq in order.items():
        if not isinstance(seq, list):
            errs.append("lane %r: order must be a list — position 0 the "
                        "primary, positions 1..n the fallbacks" % lane)
            continue
        if not seq:
            errs.append("lane %r: order may not be empty — position 0 is the "
                        "primary (the lane itself); an order of just the lane "
                        "means hard-fail, no fallbacks" % lane)
            continue
        if seq[0] != lane:
            errs.append(ORDER_PRIMARY_ERR % (seq[0], lane))
        if seq[0] in seq[1:]:
            errs.append("hop %r appears twice in %s (as primary and as a "
                        "fallback)" % (lane, lane))
        tails[lane] = seq[1:]
    return errs + validate_chains(topo, tails)


def diff_order(path, order):
    """(unified diff, errors) for a proposed rewire in the unified shape.

    Delegates to diff_chains once the order validates, so the preview the UI
    shows is produced by the same splice the apply will run."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_order(parse_topology_text(old), order)
    if errs:
        return "", errs
    return diff_chains(path, order_to_chains(order))


def apply_order(path, order):
    """Validate the unified shape, then write through apply_chains.

    Snapshot-before-write, the atomic tmp+rename, and the topology-cache
    invalidation are apply_chains' — inherited here rather than reimplemented.
    apply_chains re-reads and re-validates the tail on its own fresh view, so
    a config that changed between the two reads is still guarded."""
    with open(path, errors="replace") as f:
        old = f.read()
    errs = validate_order(parse_topology_text(old), order)
    if errs:
        raise SpliceError("; ".join(errs))
    return apply_chains(path, order_to_chains(order))


# ── proxy-log activity tail ────────────────────────────────────────────────
def _classify_tap_line(rules, line, live):
    """kind of backend event one raw proxy-log line reports, or None.

    Delegates to the single-sourced classifier in lib/ferry_live.py
    (`classify_log_line`), which both this tailer and observ/ferry-metrics-
    exporter now read instead of each carrying its own vendor-specific test.
    If that sibling module cannot be loaded, this floor is a manual COPY of
    its vendor-neutral floor (ferry_live.classify_log_line is canonical) so
    the tap still reports something rather than going dark.
    """
    if live is not None:
        return live.classify_log_line(rules, line)
    low = (line or "").lower()
    if "ratelimiterror" in low or "rate_limit" in low:
        return "rate_limited"
    if "insufficient_quota" in low or "insufficient credits" in low:
        return "quota_exhausted"
    return None


def forward_fleet(ferry_base, key, doc):
    """POST /api/fleet -> the front door's own /v1/ferry/fleet (v1.26.0).

    The dash never decides fleet membership or selection; it carries its own
    master key (the browser never sees it) and, for a client row, the
    identity header the front door resolves selections by. Only the keys the
    caller SENT travel: the front door treats {"fleet": null} as an explicit
    "clear my selection", so a body with no fleet key must reach it as-is and
    earn its 400, not be rewritten into a clear on the way through. Returns
    (status, json-able body); a transport failure is a 502 with the reason."""
    body = {k: doc[k] for k in ("fleet", "default") if k in doc}
    identity = doc.get("identity")
    headers = {"X-Ferry-Client": identity} if identity else None
    r = http_json(ferry_base + "/v1/ferry/fleet", key, "POST", body,
                  headers=headers)
    out = r.get("json")
    if out is None:
        out = {"error": _short_err(r) or "no response body"}
    return r.get("status") or 502, out


class Activity:
    # uvicorn access line: `INFO: 1.2.3.4:5678 - "POST /v1/chat/completions HTTP/1.1" 200 OK`
    ACCESS = re.compile(r'(\d+\.\d+\.\d+\.\d+):\d+ - "(\w+) (\S+) [^"]*" (\d+)')

    def __init__(self, logpath, rules=None):
        self.logpath = logpath
        self.rules = rules if isinstance(rules, dict) else {"rules": [], "ttl": {}}
        self.lock = threading.Lock()
        self.total = 0
        self.by_status = {}
        self.by_client = {}
        self.buckets = []           # per-poll counts -> sparkline of req/interval
        self.last_event = None
        self.started = time.time()
        try:                        # start at EOF: measure activity from dash launch
            self.offset = os.path.getsize(logpath) if logpath else 0
        except Exception:
            self.offset = 0

    def poll(self):
        if not self.logpath or not os.path.exists(self.logpath):
            return
        try:
            size = os.path.getsize(self.logpath)
            if size < self.offset:          # log truncated/rotated (ferry restart)
                self.offset = 0
            with open(self.logpath, "r", errors="replace") as f:
                f.seek(self.offset)
                chunk = f.read()
                self.offset = f.tell()
        except Exception:
            return
        new = 0
        live = _live()
        with self.lock:
            for line in chunk.splitlines():
                m = self.ACCESS.search(line)
                if m:
                    ip, _method, path, status = m.groups()
                    # count only real inference; skip the dash's own /models + /health polls
                    if path.startswith("/v1/chat/completions"):
                        self.total += 1
                        new += 1
                        self.by_status[status] = self.by_status.get(status, 0) + 1
                        self.by_client[ip] = self.by_client.get(ip, 0) + 1
                kind = _classify_tap_line(self.rules, line, live)
                if kind == "quota_exhausted":
                    self.last_event = {
                        "kind": "quota_exhausted",
                        "text": "a backend reported its quota or credits exhausted — check the lane",
                        "t": time.time()}
                elif kind == "rate_limited":
                    self.last_event = {"kind": "rate_limited",
                                       "text": "a backend returned 429 (rate limit)",
                                       "t": time.time()}
            self.buckets.append(new)
            self.buckets = self.buckets[-40:]

    def snapshot(self):
        with self.lock:
            clients = sorted(self.by_client.items(), key=lambda kv: -kv[1])[:6]
            return {
                "total": self.total,
                "by_status": dict(self.by_status),
                "by_client": clients,
                "spark": self.buckets[-30:],
                "last_event": self.last_event,
                "since": self.started,
                "log": self.logpath,
            }


# ── status assembly (all cheap: readiness + models + local log) ────────────
def get_status():
    ferry, key = CFG["ferry"], CFG["key"]
    live = http_json(ferry + "/health/liveliness", key, timeout=4)
    up = live.get("ok", False)
    models = []
    if up:
        m = http_json(ferry + "/v1/models", key, timeout=4)
        if m.get("ok"):
            models = [x["id"] for x in m.get("json", {}).get("data", [])]
    # The fleet document (GET /v1/ferry/fleet): who am I, my resolved fleet,
    # the host default, and every client's selection. Only asked for while
    # ferry is up — a down front door has no fleets to report, and asking
    # anyway would just be a second timeout on top of the liveliness one.
    fleet = {"error": "ferry is down"}
    if up:
        r = http_json(ferry + "/v1/ferry/fleet", key, timeout=4)
        fleet = r.get("json") if r.get("ok") else {"error": _short_err(r)}
    CFG["activity"].poll()
    topo = load_topology(CFG["config_path"])
    live = _live()
    out = {
        "ts": time.time(),
        "ferry": {"up": up, "base": ferry, "host": CFG["host"], "port": CFG["ferry_port"]},
        "models": models,
        "topology": topo,
        "fleet": fleet,
        "activity": CFG["activity"].snapshot(),
        "config_path": CFG["config_path"],
        # The live half. `tap` reports whether events are being written at all,
        # so the view can say "the tap is off" instead of drawing an empty graph
        # that looks exactly like no traffic.
        "lanes": live.lanes(topo) if live else [],
        "deployments": CFG["exhaustion"].snapshot() if CFG.get("exhaustion") else {},
        "tap": {
            "path": CFG.get("events_path") or "",
            "writing": bool(CFG.get("events_path")
                            and os.path.exists(CFG["events_path"])),
            "rules": bool((CFG.get("rules") or {}).get("rules")),
        },
    }
    return out


def probe_backends():
    """Actively call every name the config topology serves — lanes AND hops.

    A hop is not the operator's problem until it fires, so probing it too
    (cheap: a 16-token ping) is exactly what surfaces a dead fallback before a
    lane needs it. The ChatGPT-subscription driver (`heavy`/`orch`/
    `orchestrator`) is STREAMING-ONLY on litellm 1.99.0 — a non-streamed call
    500s ("Unknown items in responses API response: []") even though the
    deployment is healthy — so every probe streams. Gemini via OpenRouter can
    spend the whole `max_tokens` budget on reasoning, so `content` may come
    back null on a tiny budget while the lane is fine: health here is judged
    by HTTP status alone, never by body content.
    """
    ferry, key = CFG["ferry"], CFG["key"]
    topo = load_topology(CFG["config_path"])
    names = list(topo.get("order") or [])
    if topo.get("error") or not names:
        # The config couldn't be parsed — fall back to whatever the proxy
        # itself advertises (public lanes only; hops are never public).
        m = http_json(ferry + "/v1/models", key, timeout=4)
        if m.get("ok"):
            names = [x["id"] for x in (m.get("json") or {}).get("data", [])]
    out = {}
    for name in names:
        r = http_stream(ferry + "/v1/chat/completions", key, "POST",
                        {"model": name, "messages": [{"role": "user", "content": "ping"}],
                         "max_tokens": 16, "stream": True}, timeout=60)
        hdrs = r.get("headers", {}) or {}
        out[name] = {
            "ok": r.get("ok"), "status": r.get("status"), "ms": r.get("ms"),
            "served_by": hdrs.get("x-litellm-model-name"),
            "model_id": hdrs.get("x-litellm-model-id"),
            "fallbacks": hdrs.get("x-litellm-attempted-fallbacks"),
            "error": None if r.get("ok") else r.get("error"),
        }
    return out


# A parked SSE client costs one thread. This bounds how many a forgotten set of
# browser tabs can hold open.
MAX_STREAMS = 8


def _lane_chains():
    """lane name -> the ordered deployment ids of its hops.

    The derivation itself lives in lib/ferry_live.chains(), because the metrics
    exporter counts fallback edges from the same map — two copies that drifted
    apart would attribute a failure to a healthy backend.
    """
    live = _live()
    if live is None:
        return {}
    try:
        return live.chains(load_topology(CFG["config_path"]))
    except Exception:
        return {}


# ── HTTP server ────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
    def handle_one_request(self):
        # A browser keep-alive socket closing idle (or a probe that connects
        # and drops) resets mid-readline; stdlib's default would dump a full
        # "Exception occurred during processing" traceback per reset, which
        # spams the terminal `ferry dash` is running in. It means nothing.
        try:
            super().handle_one_request()
        except (ConnectionResetError, BrokenPipeError):
            self.close_connection = True

    def _send(self, code, body, ctype):
        b = body.encode() if isinstance(body, str) else body
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def do_GET(self):
        if self.path.split("?")[0] == "/":
            self._send(200, PAGE, "text/html; charset=utf-8")
        elif self.path.split("?")[0] == "/api/models/openrouter":
            from urllib.parse import parse_qs, urlsplit
            catalog = _catalog()
            try:
                if catalog is None:
                    raise RuntimeError("Catalog helper unavailable")
                body = catalog.get_catalog(refresh=parse_qs(urlsplit(self.path).query).get("refresh") == ["1"])
            except Exception as exc:
                body = {"models": [], "fetched_at": None, "stale": True,
                        "error": str(exc), "source": "https://openrouter.ai/api/v1/models", "total": 0}
            self._send(200, json.dumps(body), "application/json")
        elif self.path.startswith("/status"):
            self._send(200, json.dumps(get_status()), "application/json")
        elif self.path.startswith("/probe"):
            self._send(200, json.dumps(probe_backends()), "application/json")
        elif self.path.startswith("/api/events"):
            self._stream_events()
        else:
            self._send(404, "not found", "text/plain")

    def _stream_events(self):
        """Server-sent events: one frame per request as it completes.

        ThreadingHTTPServer gives every client its own thread, so a forgotten
        browser tab is a parked thread rather than a stalled dashboard — but
        only up to MAX_STREAMS, after which this refuses rather than growing
        threads without bound.

        Each event is also folded into the exhaustion state before it is sent,
        so /status and the stream cannot disagree about a deployment's health.
        """
        live = _live()
        if live is None or not CFG.get("events_path"):
            self._send(503, "event tap unavailable", "text/plain")
            return
        with CFG["stream_lock"]:
            if CFG["streams"] >= MAX_STREAMS:
                self._send(503, "too many event streams", "text/plain")
                return
            CFG["streams"] += 1
        try:
            self.send_response(200)
            self.send_header("Content-Type", "text/event-stream")
            self.send_header("Cache-Control", "no-cache")
            self.send_header("Connection", "keep-alive")
            # Some proxies buffer SSE into uselessness; this is the standard ask
            # not to. Harmless when nothing is in front of us.
            self.send_header("X-Accel-Buffering", "no")
            self.end_headers()

            tail = live.EventTail(CFG["events_path"])
            chains = _lane_chains()
            last_beat = time.time()
            while True:
                records = tail.read_new()
                for rec in records:
                    if CFG.get("exhaustion"):
                        CFG["exhaustion"].observe(
                            rec, chain=chains.get(rec.get("lane")))
                    # The per-request throughput proxy rides the frame, so the
                    # browser never re-derives it: one formula, one place.
                    bps = live.bps_of(rec)
                    if bps is not None:
                        rec["bps"] = bps
                    self.wfile.write(live.sse_frame(rec))
                if records:
                    self.wfile.flush()
                    last_beat = time.time()
                elif time.time() - last_beat > 15:
                    # A comment frame. Without it a dead client is only noticed
                    # when the next request happens to arrive, which on a quiet
                    # relay can be hours.
                    self.wfile.write(b": keepalive\n\n")
                    self.wfile.flush()
                    last_beat = time.time()
                    chains = _lane_chains()      # pick up a rewired config
                time.sleep(0.4)
        except Exception:
            pass                                  # client went away; not an error
        finally:
            with CFG["stream_lock"]:
                CFG["streams"] -= 1

    # The ONLY write path in this file. It is reachable because the server binds
    # 127.0.0.1 literally (see main(); there is no flag to widen it), so this
    # endpoint is not exposed to the LAN the way the ferry proxy itself is.
    def do_POST(self):
        route = self.path.split("?")[0]
        # The fleet control plane. A thin forward to the loopback front door's
        # own /v1/ferry/fleet — the dash never decides fleet membership or
        # selection itself, it just carries the dash's own master key (the
        # browser never sees it) and, for a client row, the identity header
        # the front door resolves selections by.
        if route == "/api/fleet":
            try:
                n = int(self.headers.get("Content-Length") or 0)
                doc = json.loads(self.rfile.read(n) or b"{}")
            except Exception as e:
                self._send(400, json.dumps({"errors": ["bad request: %s" % e]}),
                           "application/json")
                return
            if not isinstance(doc, dict):
                doc = {}
            status, out = forward_fleet(CFG["ferry"], CFG["key"], doc)
            self._send(status, json.dumps(out), "application/json")
            return
        # Legacy shape: {chains: {lane: [fallback, ...]}} (fallbacks only).
        # Unified shape: {order: {lane: [primary, ...fallbacks]}} — position 0
        # is the lane's current primary and must be sent back unchanged; only
        # positions 1..n may be reordered (see ORDER_PRIMARY_ERR).
        # Promote shape: {lane, hop} — swap the two backends, no restart.
        unified = route in ("/api/routes/order/preview", "/api/routes/order/apply")
        promote = route in ("/api/routes/promote/preview", "/api/routes/promote/apply")
        if not (unified or promote or route in ("/api/routes/preview", "/api/routes/apply")):
            self._send(404, "not found", "text/plain")
            return
        try:
            n = int(self.headers.get("Content-Length") or 0)
            doc = json.loads(self.rfile.read(n) or b"{}")
            if promote:
                req = {k: doc.get(k) for k in ("lane", "hop")}
                if not isinstance(req.get("lane"), str) or not isinstance(req.get("hop"), str):
                    raise ValueError("promote needs {lane, hop} strings")
            else:
                key = "order" if unified else "chains"
                req = doc.get(key) or {}
                if not isinstance(req, dict):
                    raise ValueError("%s must be an object" % key)
        except Exception as e:
            self._send(400, json.dumps({"errors": ["bad request: %s" % e]}),
                       "application/json")
            return

        path = CFG["config_path"]
        try:
            if route.endswith("/preview"):
                if promote:
                    lane_id, hop_id = _mint_file_ids(req["lane"], req["hop"])
                    diff, errs = diff_promote(path, req["lane"], req["hop"],
                                              lane_id, hop_id)
                    # Preview ALSO asks the live proxy what it thinks: the
                    # file/live disagreement (a hop the router does not serve)
                    # shows up before anything is written.
                    live = hotswap_promote(CFG["ferry"], CFG["key"],
                                           req["lane"], req["hop"],
                                           preview_only=True)
                    self._send(200, json.dumps(
                        {"diff": diff, "errors": errs, "live": live}),
                        "application/json")
                else:
                    diff, errs = (diff_order if unified else diff_chains)(path, req)
                    self._send(200, json.dumps({"diff": diff, "errors": errs}),
                               "application/json")
            else:
                if promote:
                    # FILE FIRST, with freshly minted ids — then the live swap
                    # echoes the SAME ids, so file and router agree. A live
                    # failure after a file success still reports ok (the file
                    # is durable; a restart converges), with the live refusal
                    # quoted verbatim so the mismatch gets investigated.
                    lane_id, hop_id = _mint_file_ids(req["lane"], req["hop"])
                    snap, diff = apply_promote(path, req["lane"], req["hop"],
                                               lane_id, hop_id)
                    live_note = hotswap_promote(
                        CFG["ferry"], CFG["key"], req["lane"], req["hop"],
                        lane_id=lane_id, hop_id=hop_id)
                    self._send(200, json.dumps({
                        "ok": True, "snapshot": snap, "diff": diff,
                        "ids": {"lane": lane_id, "hop": hop_id},
                        "note": "Config written. " + live_note,
                    }), "application/json")
                    return
                snap, diff = (apply_order if unified else apply_chains)(path, req)
                # The running proxy keeps serving the OLD chains until it
                # restarts — unless the hot-swap lands. POST the same order to
                # /v1/ferry/reorder on the loopback proxy, which swaps
                # router.fallbacks in place with no dropped connections. A
                # failed or unreachable hot-swap never fails the apply: the
                # FILE is the durable truth (a restart converges to it), so
                # the response reports both halves honestly.
                live_note = hotswap_reorder(CFG["ferry"], CFG["key"], req
                                            if unified else
                                            {"__chains__": req})
                self._send(200, json.dumps({
                    "ok": True, "snapshot": snap, "diff": diff,
                    "note": "Config written. " + live_note,
                }), "application/json")
        except SpliceError as e:
            self._send(409, json.dumps({"errors": [str(e)]}), "application/json")
        except Exception as e:
            self._send(500, json.dumps({"errors": ["%s: %s" % (type(e).__name__, e)]}),
                       "application/json")

    def log_message(self, *a):        # silence per-request stderr spam
        pass


PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ferry — Signal Studio</title>
<style>
  :root{
    --bg:#0d1117; --panel:#161b22; --line:#30363d; --fg:#e6edf3; --dim:#8b949e;
    --green:#3fb950; --red:#f85149; --amber:#d29922; --blue:#58a6ff; --accent:#1f6feb;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--fg);
       font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
  header{display:flex;align-items:center;gap:14px;flex-wrap:wrap;
         padding:14px 20px;border-bottom:1px solid var(--line);background:var(--panel)}
  header h1{font-size:16px;letter-spacing:.14em;margin:0;font-weight:700}
  .pill{padding:2px 10px;border-radius:999px;font-size:12px;font-weight:700}
  .pill.up{background:color-mix(in srgb,var(--green) 22%,transparent);color:var(--green)}
  .pill.down{background:color-mix(in srgb,var(--red) 22%,transparent);color:var(--red)}
  .muted{color:var(--dim)}
  .grow{flex:1}
  main{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;padding:16px 20px;max-width:1080px}
  @media (max-width:760px){main{grid-template-columns:minmax(0,1fr)}}
  .card{min-width:0;overflow-wrap:anywhere;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
  .card h2{margin:0 0 10px;font-size:11px;letter-spacing:.16em;color:var(--dim);text-transform:uppercase}
  .row{display:flex;align-items:center;gap:10px;padding:6px 0;border-top:1px dashed var(--line)}
  .row:first-of-type{border-top:0}
  .dot{width:9px;height:9px;border-radius:50%;flex:none;background:var(--dim)}
  .dot.green{background:var(--green)} .dot.red{background:var(--red)}
  .dot.amber{background:var(--amber)} .dot.blue{background:var(--blue)}
  .name{font-weight:600}
  .sub{color:var(--dim);font-size:12px}
  .tag{margin-left:auto;font-size:11px;color:var(--dim);white-space:nowrap}
  .tag.active{color:var(--green);font-weight:700}
  .tag.bad{color:var(--red);font-weight:700}
  .step{color:var(--dim);width:16px;text-align:right;flex:none}
  .stats{display:flex;gap:18px;flex-wrap:wrap;margin-bottom:8px}
  .stat b{font-size:20px;font-weight:700} .stat span{color:var(--dim);font-size:11px;display:block}
  .spark{display:flex;align-items:flex-end;gap:2px;height:34px;margin:8px 0}
  .spark i{flex:1;background:var(--blue);min-height:2px;border-radius:1px;opacity:.85}
  .codes{display:flex;gap:6px;flex-wrap:wrap}
  .code{padding:1px 8px;border-radius:6px;font-size:12px;border:1px solid var(--line)}
  .code.ok{color:var(--green)} .code.warn{color:var(--amber)} .code.err{color:var(--red)}
  .event{margin-top:10px;padding:8px 10px;border-radius:8px;font-size:12px;
         background:color-mix(in srgb,var(--amber) 16%,transparent);color:var(--amber)}
  button{font:inherit;cursor:pointer;background:var(--accent);color:#fff;border:0;
         border-radius:8px;padding:7px 14px;font-weight:700}
  button:disabled{opacity:.55;cursor:progress}
  /* route editor — one draggable list per lane */
  .lane{border-top:1px solid var(--line);padding:10px 0}
  .lane:first-of-type{border-top:0}
  .lane h3{margin:0 0 6px;font-size:13px;font-weight:700}
  .lane h3 .kind{font-weight:400;color:var(--dim);font-size:11px;margin-left:8px}
  h3.fleet{margin:14px 0 6px;font-size:11px;letter-spacing:.12em;
           text-transform:uppercase;color:var(--dim)}
  h3.fleet:first-child{margin-top:0}
  .hops{border-radius:8px}
  /* while dragging, ONLY the dragged hop's own lane lights up as a drop zone */
  .lane.dropping .hops{outline:1px dashed color-mix(in srgb,var(--blue) 55%,transparent);
                       outline-offset:3px}
  .hop{display:flex;align-items:center;gap:8px;padding:4px 6px;margin:2px 0;border-radius:8px;
       transition:box-shadow .12s ease,background .12s ease,opacity .12s ease}
  .hop .idx{color:var(--dim);width:18px;text-align:right;flex:none}
  .hop .who{flex:1;min-width:0}
  .hop .who b{font-weight:600}
  .hop .who span{color:var(--dim);font-size:12px;margin-left:8px}
  .hop[draggable="true"]{cursor:grab}
  .hop[draggable="true"]:active{cursor:grabbing}
  .hop.dragging{position:relative;z-index:3;opacity:.92;background:var(--panel);
                box-shadow:0 8px 22px rgba(0,0,0,.45);cursor:grabbing}
  /* the blue line shows where the drop will land; inset so nothing reflows */
  .hop.drop-before{box-shadow:inset 0 2px 0 var(--blue)}
  .hop.drop-after{box-shadow:inset 0 -2px 0 var(--blue)}
  .hop.primary{background:color-mix(in srgb,var(--blue) 9%,transparent)}
  .hop.primary .who b{color:var(--blue)}
  .star{color:var(--blue);width:14px;text-align:center;flex:none;font-size:12px}
  .grip{color:var(--dim);width:14px;text-align:center;flex:none;font-size:13px;user-select:none}
  .ptag{font-size:10px;font-weight:700;letter-spacing:.08em;color:var(--blue);flex:none;
        border:1px solid color-mix(in srgb,var(--blue) 45%,transparent);
        border-radius:5px;padding:0 5px}
  .pinhint{max-height:0;opacity:0;overflow:hidden;color:var(--amber);font-size:11px;
           transition:max-height .25s ease,opacity .25s ease}
  .pinhint.show{max-height:3em;opacity:1}
  .addhop{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:4px 0 0 26px}
  .picker{display:flex;flex-direction:column;gap:3px;padding:6px;border:1px solid var(--line);
          border-radius:8px;max-height:180px;overflow:auto}
  .picker .mini{text-align:left}
  .mini{font:inherit;font-size:11px;font-weight:700;padding:2px 7px;border-radius:6px;
        background:transparent;color:var(--dim);border:1px solid var(--line)}
  .mini:hover:not(:disabled){color:var(--fg);border-color:var(--dim)}
  .mini:disabled{opacity:.3;cursor:default}
  .mini.rm:hover{color:var(--red);border-color:var(--red)}
  .diff{background:var(--bg);border:1px solid var(--line);border-radius:8px;
        padding:10px 12px;overflow-x:auto;font-size:12px;max-height:340px}
  .diff .add{color:var(--green)} .diff .del{color:var(--red)} .diff .hunk{color:var(--dim)}
  .err{color:var(--red)} .ok{color:var(--green)}
  .dirty{color:var(--amber);font-weight:700}
  .full{grid-column:1/-1}
  footer{padding:6px 20px 22px;color:var(--dim);font-size:11px}
  a{color:var(--blue)}
  /* live view */
  .chain{display:flex;align-items:stretch;flex-wrap:wrap;margin:2px 0 6px}
  .hopb{max-width:100%;overflow-wrap:anywhere;border:1px solid var(--line);border-radius:8px;padding:5px 9px;min-width:0}
  .hopb.served{border-color:var(--green);background:color-mix(in srgb,var(--green) 14%,transparent)}
  .hopb.failed{border-color:var(--red);background:color-mix(in srgb,var(--red) 14%,transparent)}
  .hopb.missing{border-style:dashed;border-color:var(--red)}
  .hopb b{font-weight:600;font-size:12px}
  .hopb .pool{color:var(--dim);font-size:11px;margin-left:6px}
  .edge{align-self:center;color:var(--dim);padding:0 8px;font-size:11px;white-space:nowrap}
  .edge.failed{color:var(--red);font-weight:700}
  .chips{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}
  .chip{min-width:0;max-width:100%;overflow-wrap:anywhere;font-size:11px;padding:1px 6px;border-radius:6px;border:1px solid var(--line);color:var(--dim)}
  .chip.healthy{color:var(--green);border-color:var(--green)}
  .chip.rate_limited,.chip.unknown{color:var(--amber);border-color:var(--amber)}
  .chip.unreachable{color:var(--red);border-color:var(--red)}
  .chip.quota_exhausted,.chip.auth_dead{color:var(--red);border-color:var(--red);font-weight:700}
  .lanehdr{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:10px}
  .lanehdr:first-child{margin-top:0}
  .lanehdr b{font-size:13px}
  .toggle{margin-left:auto;font-size:11px;color:var(--dim);cursor:pointer;
          border-bottom:1px dotted var(--line)}
  .toggle:hover{color:var(--fg)}
  .feed{min-width:0;width:100%;max-width:100%;max-height:300px;overflow:auto;border:1px solid var(--line);border-radius:8px;
        margin-top:6px;font-size:12px}
  .fr{display:flex;flex-wrap:wrap;gap:6px 10px;padding:3px 10px;border-top:1px dashed var(--line);white-space:nowrap}
  .fr:first-child{border-top:0}
  .fr .t{color:var(--dim);flex:none}
  .fr .st{flex:none;font-weight:700}
  .fr .st.ok{color:var(--green)} .fr .st.bad{color:var(--red)}
  .fr .w{flex:1 1 180px;min-width:0;white-space:normal;overflow-wrap:anywhere}
  .fr .metrics{min-width:0;display:flex;flex-wrap:wrap;gap:4px 16px;width:100%;color:var(--dim);white-space:normal}
  .fr .metrics span{max-width:100%;white-space:normal;overflow-wrap:anywhere}

  /* Signal Studio: live model library, connected routing canvas and route summary. */
  :root{color-scheme:dark;--bg:#111614;--panel:#191f1c;--surface:#191f1c;--raised:#222b25;--line:#323d35;--fg:#ecf2eb;--ink:#ecf2eb;--dim:#a0aea3;--muted:#a0aea3;--green:#c0f78b;--mint:#c0f78b;--red:#ff9690;--amber:#eac987;--blue:#b5d7a2;--accent:#c0f78b;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
  html{scroll-behavior:smooth;scroll-padding-top:20px}body{font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body:has(dialog[open]){overflow:hidden}
  button,input,select{font:inherit}button,a,input,select,summary,[tabindex]{outline-offset:4px}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--mint)}
  button{border:1px solid var(--line);border-radius:5px;padding:9px 12px;background:transparent;color:var(--fg);font-size:12px;font-weight:500;transition:background .15s,border-color .15s}button:hover:not(:disabled){background:#2c3930;border-color:#69825b}button:disabled{opacity:.4;cursor:default}button.primary,#applybtn{background:var(--mint);border-color:var(--mint);color:#17220f;font-weight:650}button.primary:hover:not(:disabled),#applybtn:hover:not(:disabled){background:#d1ffa8}
  input,select{max-width:100%;background:#121915;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:9px 11px}a{color:var(--mint)}p{margin:0;color:var(--dim);line-height:1.7}
  .topbar{height:68px;padding:0 28px;gap:28px;flex-wrap:nowrap;background:var(--bg)}.brand{display:flex;align-items:center;gap:10px;font-size:26px;letter-spacing:-1px;font-weight:720;text-decoration:none;color:var(--fg)}.brand svg{color:var(--mint);flex:none}.edition{font:10px var(--mono);letter-spacing:1.2px;border-left:1px solid var(--line);padding-left:20px;color:var(--dim);white-space:nowrap}.toplinks{display:flex;height:100%;align-items:stretch;gap:23px}.toplinks a{display:flex;align-items:center;font-size:12px;color:var(--dim);text-decoration:none;border-bottom:2px solid transparent}.toplinks a.current{color:var(--mint);border-bottom-color:var(--mint)}.connection{margin-left:auto;display:flex;align-items:center;gap:12px;min-width:0;font:10px var(--mono)}#host{max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#updated{font-size:10px}.pill{font:10px var(--mono);padding:5px 8px;white-space:nowrap;border:1px solid var(--line);border-radius:4px}
  main{display:block;max-width:1800px;width:100%;padding:29px 28px 22px;margin:auto;min-width:0}.intro{display:flex;align-items:center;justify-content:space-between;gap:22px;margin-bottom:25px}.eyebrow{font:10px var(--mono);letter-spacing:1.8px;color:var(--mint);text-transform:uppercase}h1{font-size:33px;font-weight:530;line-height:1.2;letter-spacing:-1.25px;margin:9px 0 8px}.intro p{font-size:12px}.actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}.actions button{font-size:11px;white-space:nowrap}.studio-grid{display:grid;grid-template-columns:224px minmax(0,1fr) 200px;gap:14px;align-items:start;min-width:0}
  .library{background:#1c2420;border:1px solid #3b483d;border-radius:7px;padding:17px 13px;min-width:0;display:flex;flex-direction:column}.library h2{margin:0 0 8px;font:11px var(--mono);letter-spacing:1px;text-transform:uppercase}.library p{font-size:11px}.library input{width:100%;margin:15px 0 12px;font-size:11px}.models{display:flex;flex-direction:column;gap:7px;max-height:650px;overflow:auto;padding:2px 3px 5px 1px;scrollbar-width:thin;scrollbar-color:#52634e transparent}.model-card{display:flex;align-items:center;gap:10px;min-height:59px;width:100%;flex:none;padding:11px 9px;border-color:#354137;background:#222c25;text-align:left;cursor:grab}.model-card:active{cursor:grabbing}.model-card:after{content:'⠿';margin-left:auto;color:#82927c;font-size:16px}.model-symbol{width:21px;flex:none;color:var(--mint);font:20px var(--mono);text-align:center}.model-copy{min-width:0;flex:1}.model-copy b{display:block;font-size:11px;font-weight:550;overflow-wrap:anywhere;line-height:1.45}.model-copy span{display:block;font:9px/1.5 var(--mono);color:var(--dim);overflow-wrap:anywhere;margin-top:5px}.library-bottom{padding-top:18px;margin-top:12px;border-top:1px solid var(--line);font-size:10px;color:var(--dim);line-height:1.8}.library-bottom b{color:var(--mint);font-weight:500}
  .editor-column{min-width:0}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:60px;padding:11px 13px;border:1px solid var(--line);border-bottom:0;border-radius:7px 7px 0 0;background:var(--panel)}.segmented{display:flex;gap:4px;min-width:0;overflow:auto;scrollbar-width:thin}.fleet-tab{border:0;padding:8px 11px;color:var(--dim);font-size:11px;white-space:nowrap}.fleet-tab.selected,.fleet-tab[aria-pressed="true"]{color:var(--fg);background:#344332}.route-meta{font:9px var(--mono);letter-spacing:.5px;color:var(--dim);white-space:nowrap}.workspace{border:1px solid var(--line);padding:0 16px;background-image:radial-gradient(#354038 .7px,transparent .7px);background-size:14px 14px;min-width:0;max-height:880px;overflow-y:auto;scrollbar-width:thin;scrollbar-color:#51634f transparent}.workspace:empty{min-height:180px}
  #routesbody>.muted,#routesbody.muted{padding-top:16px;padding-bottom:16px}.lane{display:block;padding:17px 0 0;min-width:0;border-top:1px solid #303b33}.lane h3{display:flex;align-items:baseline;gap:9px;flex-wrap:wrap;margin:0;font-size:12px;font-weight:550;color:var(--fg);overflow-wrap:anywhere}.lane h3 .kind{margin:0;font:10px var(--mono);color:var(--dim)}h3.fleet{margin:18px 0 0;color:var(--mint);font:10px var(--mono);letter-spacing:1px}h3.fleet:first-child{margin-top:16px}.hops{display:flex;align-items:center;flex-wrap:nowrap;overflow-x:auto;min-height:148px;padding:15px 2px 18px;scrollbar-width:thin;scrollbar-color:#51634f transparent;border-radius:0}.hop.hoprow{position:relative;display:flex;flex-direction:column;align-items:stretch;justify-content:flex-start;gap:9px;flex:0 0 174px;min-width:174px;min-height:111px;padding:11px;margin:0;border:1px solid #46563f;border-radius:6px;background:#1a231d;box-shadow:0 4px 16px #0002;color:var(--fg)}.hop.hoprow.primary{background:#283625;border-color:#71845e}.hop .who{flex:1;min-width:0}.hop .who b{display:block;font-size:11px;font-weight:550;line-height:1.45;overflow-wrap:anywhere}.hop.primary .who b{color:var(--fg)}.hop .who span{display:block;font:9px/1.5 var(--mono);margin:5px 0 0;overflow-wrap:anywhere}.hop-top{display:flex;align-items:center;justify-content:space-between;gap:6px;font:9px var(--mono);letter-spacing:.7px;color:var(--dim)}.primary .hop-top{color:var(--mint)}.hop-actions{display:flex;gap:5px;margin-top:auto}.hop-actions .mini{padding:4px 8px;font-size:12px;line-height:1.2;border-radius:4px}.hop-actions .mini:last-child{margin-left:auto}.grip{font-size:16px}.ptag{font:8px var(--mono);color:var(--mint);letter-spacing:.5px}.pinhint.show{max-height:5em}.mini{color:var(--dim);font-weight:500;border-radius:4px}.hop.dragging{opacity:.45}
  .route-gap{position:relative;display:flex;align-items:center;justify-content:center;flex:0 0 40px;width:40px;min-width:40px;height:100px;min-height:100px;padding:0;border:0;border-radius:4px;transition:background .12s,box-shadow .12s}.route-gap:before{content:'';position:absolute;left:0;right:0;height:1px;background:#4a6043;pointer-events:none}.route-gap button{position:relative;z-index:1;display:grid;place-items:center;width:26px;min-width:26px;height:34px;min-height:34px;padding:0;background:#182219;border:1px dashed #688356;border-radius:4px;font-size:19px;line-height:1;color:#bed9a9}.route-gap.end button{height:66px}.route-gap:hover{background:#c0f78b0a}.route-gap.valid{background:#c0f78b0b;box-shadow:inset 0 0 0 1px #84a96570}.route-gap.over{background:#34482d;box-shadow:inset 0 0 0 2px var(--mint)}.route-gap.invalid{opacity:.35}.route-gap.invalid.over{background:#61332d;box-shadow:inset 0 0 0 2px var(--red)}.route-gap .ghost{position:absolute;z-index:4;left:50%;top:0;transform:translateX(-50%);width:max-content;max-width:160px;padding:5px 7px;border-radius:4px;background:#263523;color:var(--mint);font:9px var(--mono);overflow-wrap:anywhere;pointer-events:none}.workspace-foot{display:flex;gap:9px;padding:12px 14px;background:#17201b;border:1px solid var(--line);border-top:0;border-radius:0 0 7px 7px;color:var(--dim);font-size:10px;line-height:1.7}.workspace-foot b{color:var(--mint);font-weight:500}.editor-note{margin-top:14px;border:1px solid #2b372e;border-radius:6px;padding:18px;background:linear-gradient(115deg,#1b261d,#151d18)}.editor-note span{font:9px var(--mono);letter-spacing:1px;color:#b1c3a6}.editor-note p{font-size:11px;margin-top:8px}
  .inspector{border:1px solid var(--line);border-radius:7px;background:#181f1b;padding:18px 15px;min-width:0}.inspector-heading{display:flex;justify-content:space-between;gap:8px;color:var(--mint)}.inspector .eyebrow{font-size:9px;letter-spacing:1px;color:#b5c4ad}.inspector-title{font-size:21px;letter-spacing:-.7px;margin:23px 0 7px}.inspector>p{font-size:11px;line-height:1.8}.inspector-stats{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:25px}.inspector-stats strong{display:block;font-size:29px;font-weight:400;letter-spacing:-1px;line-height:1.1}.inspector-stats span{display:block;color:var(--dim);font:9px var(--mono);margin-top:8px}.inspector-divider{height:1px;background:var(--line);margin:23px 0}.inspector-label{font:9px var(--mono);letter-spacing:1px;color:var(--dim);margin-bottom:13px}.coverage-row{margin-top:15px;font-size:11px}.coverage-row>div:first-child{display:flex;justify-content:space-between;gap:8px;overflow-wrap:anywhere}.coverage-row small{font:9px var(--mono);color:var(--dim);white-space:nowrap}.coverage-pips{display:flex;gap:4px;margin-top:8px}.coverage-pips i{height:4px;background:#92bb70;flex:1;border-radius:1px}.coverage-pips i:first-child{background:var(--mint)}#editstate{color:var(--mint);font-size:11px;line-height:1.8}.inspector-note{margin-top:20px;font-size:10px;color:var(--dim);line-height:1.8}.review-area{margin-top:14px;min-width:0}.review-meta{font:10px/1.7 var(--mono);color:var(--dim);overflow-wrap:anywhere}.review-meta b{font-weight:500;color:#b5c4ad}.diff{margin:12px 0 0;padding:16px;background:#18211b;font:11px/1.75 var(--mono);border-radius:6px;max-height:360px}#routesmsg:not(:empty){padding:13px 16px;margin-top:10px;border:1px solid currentColor;border-radius:6px;font-size:12px;overflow-wrap:anywhere}
  .operations{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;margin-top:27px}.card{padding:18px 20px;border-radius:7px;min-width:0;overflow-wrap:anywhere}.card h2{font:11px var(--mono);letter-spacing:1.2px;color:var(--fg);margin-bottom:16px}.card h2 .muted{font-size:10px;letter-spacing:0}.row{padding:10px 0;gap:9px;border-top:1px solid var(--line);align-items:center}.row>div{min-width:0}.row .name{font-size:12px;overflow-wrap:anywhere}.row .sub{font:10px/1.6 var(--mono);overflow-wrap:anywhere}.row .tag{font:9px/1.5 var(--mono);white-space:normal;text-align:right;max-width:42%;flex-shrink:0}.row .step{font:10px var(--mono);width:12px}.dot{width:7px;height:7px}.sub{font-size:11px}.stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin:0 0 18px}.stat{border-right:1px solid var(--line)}.stat:last-child{border:0}.stat b{font-size:28px;font-weight:450;letter-spacing:-1px}.stat span{font:10px var(--mono);margin-top:5px}.spark{height:45px;margin:14px 0 16px}.spark i{background:#a0c77f}.codes{font-family:var(--mono)}.code{font-size:10px;border-radius:4px}.event{border-radius:5px;font-size:11px}.chain{gap:0;row-gap:8px;margin:9px 0 14px}.hopb{background:#1b251e;border-radius:5px;max-width:100%;padding:9px 11px}.hopb b{font-size:11px;overflow-wrap:anywhere}.hopb .pool{font:9px var(--mono)}.chip{font:9px/1.6 var(--mono);border-radius:3px}.lanehdr{flex-wrap:wrap}.lanehdr b{font-size:12px}.edge{font-size:10px}.feed{font:10px/1.7 var(--mono);max-height:320px;border-radius:5px}.fr{padding:7px 10px;border-top:1px solid #29362c}.feed-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;margin-top:14px}.toggle{border:0;background:transparent;padding:3px 0;font-size:11px;text-decoration:underline;text-underline-offset:3px}.probe-controls{display:flex;align-items:center;gap:13px;flex-wrap:wrap}.probe-controls span{font-size:11px}.probe-results{margin-top:13px}.footer{max-width:1800px;margin:auto;padding:0 28px 26px;font:10px/1.8 var(--mono);overflow-wrap:anywhere}.footer-brand{display:flex;justify-content:space-between;gap:14px;padding:21px 0 14px;border-top:1px solid var(--line);letter-spacing:.6px}.footer #foot{opacity:.8}
  dialog{border:1px solid #57704d;color:var(--fg);background:var(--panel);padding:24px;border-radius:10px;max-width:600px;width:calc(100% - 32px);max-height:85vh;overflow:auto;box-shadow:0 18px 90px #0009}dialog::backdrop{background:#050b08cf;backdrop-filter:blur(5px)}dialog h2{font-size:23px;font-weight:500;line-height:1.3;letter-spacing:-.5px;margin:0 0 9px;overflow-wrap:anywhere}dialog p{font-size:12px}.picker-options{display:grid;gap:8px;margin:20px 0;max-height:52vh;overflow:auto;padding:4px}.picker-options button{text-align:left;overflow-wrap:anywhere}.dialog-actions{display:flex;justify-content:flex-end;gap:8px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
  @media(min-width:1600px){.hop.hoprow{flex-basis:190px;min-width:190px}}
  @media(max-width:1200px){.studio-grid{grid-template-columns:210px minmax(0,1fr)}.inspector{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr 1.4fr;gap:10px 22px;align-items:start}.inspector-heading{grid-column:1/-1}.inspector-title{margin-top:3px}.inspector-stats{margin-top:5px}.inspector-divider{display:none}.inspector>p{grid-column:1;grid-row:3}.coverage-block{grid-column:3;grid-row:2/5}.session-block{grid-column:2;grid-row:3/5}.inspector-note{margin-top:8px}.topbar{gap:20px}.connection #host{display:none}.intro{align-items:flex-start}.actions{max-width:340px}.route-meta{display:none}}
  @media(max-width:800px){.topbar{padding:0 18px;gap:18px}.edition{display:none}.toplinks{gap:17px}.connection #updated{display:none}main{padding:24px 18px}.intro{display:block}.actions{margin-top:18px;justify-content:flex-start;max-width:none}.studio-grid{grid-template-columns:180px minmax(0,1fr);gap:11px}.library{padding:14px 10px}.model-card{padding:10px 8px;gap:7px}.model-card:after{display:none}.workspace{padding:0 11px}.toolbar{padding:10px 8px}.fleet-tab{padding:8px}.footer{padding:0 18px 23px}.operations{gap:12px}.card{padding:16px}.row{flex-wrap:wrap}.row .tag{margin-left:28px;max-width:100%;text-align:left}.operations .full{grid-column:1/-1}}
  @media(max-width:580px){.topbar{height:62px;padding:0 14px;gap:20px}.brand{font-size:23px}.brand svg{width:25px}.toplinks{gap:14px}.toplinks a{font-size:11px}.toplinks a:last-child{display:none}.connection{gap:0}.pill{font-size:9px;padding:4px 6px}main{padding:24px 12px}h1{font-size:29px}.intro p{font-size:11px}.eyebrow{font-size:9px}.actions{gap:6px}.actions button{padding:9px 10px;font-size:10px}.studio-grid{grid-template-columns:minmax(0,1fr);gap:12px}.library{padding:12px}.library h2{font-size:10px}.library input{margin:10px 0}.models{flex-direction:row;overflow:auto;max-height:none;padding-bottom:5px}.model-card{flex:0 0 170px;width:170px;min-height:58px}.library-bottom{display:none}.editor-column{grid-column:1}.workspace{max-height:760px}.hop.hoprow{flex-basis:160px;min-width:160px}.workspace-foot{font-size:10px}.editor-note{padding:15px}.inspector{grid-column:1;grid-template-columns:1fr 1fr;gap:12px 18px}.inspector>p{grid-column:1;grid-row:3}.coverage-block{grid-column:1;grid-row:4}.session-block{grid-column:2;grid-row:3/5}.inspector-stats strong{font-size:26px}.operations{grid-template-columns:minmax(0,1fr);gap:12px;margin-top:22px}.card{padding:16px 13px}.stats{gap:12px}.stat b{font-size:25px}.stat span{font-size:9px}.feed{font-size:9px}.fr{gap:7px;padding:7px}.footer{padding:0 12px 20px;font-size:9px}.footer-brand{display:block}.footer-brand span{display:block;margin-top:6px}.review-meta{font-size:9px}}
  /* Keep review evidence adjacent to the actions, before the route workspace. */
  .review-area{margin:-9px 0 17px;padding:0;min-width:0}.review-meta{display:flex;align-items:baseline;gap:6px 10px;flex-wrap:wrap;font-size:9px;line-height:1.6}.review-meta b{flex:none;font-size:8px;letter-spacing:.7px}.review-meta #routespath{min-width:0;overflow-wrap:anywhere}.snapshot-note{margin-left:auto;color:var(--dim)}
  #routesdiff{max-height:300px;margin:12px 0 0;border-color:#748966;box-shadow:inset 3px 0 0 var(--mint);background:#1b261d}#routesdiff:before{content:'REVIEW CHANGES BEFORE APPLYING';display:block;color:var(--mint);font:10px var(--mono);letter-spacing:1px;margin-bottom:13px}#routesmsg:not(:empty){background:#1b261d}
  .hop-top .star{width:auto;min-width:0;flex:none;white-space:nowrap;font:8px var(--mono);letter-spacing:.3px;color:var(--mint)}.hop-top>span:first-child{min-width:0;overflow-wrap:anywhere}.hop-top .grip{width:14px}
  @media(max-width:580px){.review-area{margin-top:-9px}.review-meta{font-size:9px;gap:4px 8px}.snapshot-note{margin-left:0;flex-basis:100%}#routesdiff{max-height:260px;padding:13px;font-size:10px}#routesdiff:before{font-size:9px;letter-spacing:.5px}}
  /* Event rows can carry additional timing and token fields without clipping. */
  .feed .fr{flex-wrap:wrap;align-items:baseline;white-space:normal;row-gap:3px}.feed .fr .t,.feed .fr .st{white-space:nowrap}.feed .fr .w{flex:1 1 180px;overflow:visible;text-overflow:clip;overflow-wrap:anywhere}.feed .fr>span{max-width:100%;overflow-wrap:anywhere}
  /* Touch layouts: tap to edit, swipe routes, and let the page own vertical scrolling. */
  @media(max-width:1200px),(pointer:coarse){
    input,select,#modelsearch{font-size:16px}
    button,input,select{min-height:44px}button{min-width:44px}.actions button{min-height:44px;padding:10px 12px}.fleet-tab{min-height:44px;padding:10px 13px}.toggle{min-height:44px;padding:10px 4px}.toplinks a{min-height:44px}
    .library{grid-column:1/-1;display:block;padding:14px}.library input{margin:10px 0 12px}.models{flex-direction:row;max-height:none;overflow-x:auto;padding:3px 3px 9px;gap:9px;scrollbar-width:auto;scrollbar-color:#759361 #18211b;-webkit-overflow-scrolling:touch}.model-card{flex:0 0 190px;width:190px;min-height:64px}.library-bottom{display:none}.editor-column{grid-column:1/-1}.inspector{grid-column:1/-1}
    .workspace{max-height:none;overflow-y:visible}.hops{padding-bottom:15px;scrollbar-width:auto;scrollbar-color:#759361 #18211b;-webkit-overflow-scrolling:touch}.hops::-webkit-scrollbar,.models::-webkit-scrollbar,.segmented::-webkit-scrollbar{height:7px}.hops::-webkit-scrollbar-thumb,.models::-webkit-scrollbar-thumb,.segmented::-webkit-scrollbar-thumb{background:#759361;border-radius:6px}.hops::-webkit-scrollbar-track,.models::-webkit-scrollbar-track,.segmented::-webkit-scrollbar-track{background:#18211b}
    .hop.hoprow{flex:0 0 190px;min-width:190px;min-height:125px}.hop-actions{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;width:100%}.hop-actions .mini{min-width:44px;min-height:44px;padding:8px 5px;white-space:normal;line-height:1.2;font-size:13px}.hop-actions .mini:last-child{margin-left:0}.route-gap{flex-basis:50px;width:50px;min-width:50px;height:100px;min-height:100px}.route-gap button{width:44px;min-width:44px;height:44px;min-height:44px;font-size:22px}.route-gap.end button{height:66px}
    .workspace-foot{display:block}.workspace-foot:after{content:'Swipe horizontally to explore each route. Tap + or the arrow buttons to edit.';display:block;margin-top:4px;color:#c1d5b4}.segmented{scrollbar-width:auto;scrollbar-color:#759361 #18211b}.picker-options button{min-height:48px}.feed{max-height:none}.review-meta{line-height:1.8}
  }
  @media(min-width:581px) and (max-width:1200px){.intro{display:block}.actions{display:flex;flex-wrap:nowrap;justify-content:flex-start;max-width:none;margin-top:18px;gap:8px}.actions button{flex:none}}
  @media(max-width:580px){.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:100%}.actions button{width:100%;font-size:11px}.model-card{flex-basis:180px;width:180px}.hop.hoprow{flex-basis:180px;min-width:180px}.workspace-foot{font-size:10px}.library{padding:12px}}
  @media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition:none!important}}
  .library input{font-size:16px;min-height:44px}.catalog-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;font:10px/1.5 var(--mono)}.catalog-toolbar button{min-height:44px;padding:8px 10px}.library #catalog-status{font:10px/1.5 var(--mono);margin:6px 0 12px;overflow-wrap:anywhere}.catalog-card{cursor:pointer}.catalog-card:active{cursor:pointer}.catalog-card:after{content:'↗';font-size:14px}.model-copy .source-badge{display:inline-block;color:var(--mint);font-size:9px;margin:0 0 5px}.catalog-card .model-copy b{font-size:12px}.catalog-card .model-copy span{font-size:10px}.catalog-facts{display:grid;grid-template-columns:100px minmax(0,1fr);gap:10px;margin:20px 0;font-size:12px}.catalog-facts dt{color:var(--dim)}.catalog-facts dd{margin:0;overflow-wrap:anywhere}.catalog-id{font:12px/1.7 var(--mono);overflow-wrap:anywhere;user-select:all}.catalog-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}.catalog-actions button,.catalog-actions a,#catalog-close{min-height:44px;display:inline-flex;align-items:center}.catalog-actions a{padding:8px;color:var(--mint)}#catalog-detail-body p{white-space:pre-wrap;overflow-wrap:anywhere}
</style></head><body>
<header class="topbar">
  <a class="brand" href="#routes" aria-label="Ferry home"><svg width="30" height="28" viewBox="0 0 30 28" fill="none" aria-hidden="true"><path d="M3 6h24M3 13h17M3 20h10" stroke="currentColor" stroke-width="4"/><path d="m21 14 6 6-6 6" stroke="currentColor" stroke-width="2"/></svg>ferry</a>
  <span class="edition">SIGNAL / STUDIO</span>
  <nav class="toplinks" aria-label="Dashboard sections"><a class="current" href="#routes">Routes</a><a href="#live">Traffic</a><a href="#probe">Backends</a></nav>
  <div class="connection"><span id="host" class="muted"></span><span id="uppill" class="pill down">Connecting…</span><span id="updated" class="muted"></span></div>
</header>
<main>
  <section id="routes" aria-labelledby="routes-heading">
    <div class="intro"><div><div class="eyebrow">Signal Studio / Routing workspace</div><h1 id="routes-heading">Your routes. Within reach.</h1><p>A model library at your side. A clear path for every request.</p></div>
      <div class="actions" id="routesact" style="display:none"><button id="undobtn" title="Undo (⌘ / Ctrl Z)" disabled>↶ Undo</button><button id="resetbtn">Discard edits</button><button id="previewbtn">Preview changes ↗</button><button id="applybtn" disabled>Apply</button></div>
    </div>
    <div class="review-area" aria-label="Review and apply route changes"><div class="review-meta"><b>ROUTE CONFIG</b> <span id="routespath"></span><span class="snapshot-note">Snapshot saved before applying.</span></div><pre id="routesdiff" class="diff" aria-label="Route configuration change preview" tabindex="0" style="display:none"></pre><div id="routesmsg" role="status" aria-live="polite"></div></div>
    <div class="studio-grid">
      <aside class="library" aria-labelledby="library-heading"><h2 id="library-heading">Model library</h2><p>Configured models: drag or tap to route.<br>OpenRouter catalog: tap to explore.</p><label class="sr-only" for="modelsearch">Search model library</label><input id="modelsearch" type="search" placeholder="Find a model…" autocomplete="off"><div class="catalog-toolbar"><span id="catalog-count">Loading library…</span><button type="button" id="catalog-refresh">Refresh</button></div><p id="catalog-status" role="status" aria-live="polite">Loading OpenRouter catalog…</p><div class="models" id="modellibrary"><p>Loading models…</p></div><div class="library-bottom"><b>One model, many routes.</b><br>Dragging between routes copies it.<br>Your primary always stays pinned.</div></aside>
      <div class="editor-column"><div class="toolbar"><div class="segmented" id="routefilters" role="group" aria-label="Filter visible routes by fleet"></div><span class="route-meta">SEQUENTIAL FAILOVER</span></div><div class="workspace" id="routesbody">Loading routes…</div><div class="workspace-foot"><span><b>+</b> Add in any slot. Drag fallbacks to reorder, or use their arrow buttons.</span></div><div class="editor-note"><span>01 / BUILD YOUR PATH</span><p>Start with a pinned primary. Add resilience one fallback at a time.</p></div></div>
      <aside class="inspector" aria-label="Route summary"><div class="inspector-heading"><span class="eyebrow">Workspace overview</span><span aria-hidden="true">↗</span></div><div class="inspector-title">Ready to flow.</div><p>Your visible routes, at a glance.</p><div class="inspector-stats"><div><strong id="routecount">—</strong><span>visible lanes</span></div><div><strong id="hopcount">—</strong><span>model hops</span></div></div><div class="inspector-divider"></div><div class="coverage-block"><div class="inspector-label">ROUTE COVERAGE</div><div id="coverage"></div></div><div class="inspector-divider"></div><div class="session-block"><div class="inspector-label">EDITING SESSION</div><div id="editstate" role="status" aria-live="polite">Loading baseline…</div><div class="inspector-note">Fallbacks run from left to right.<br>⌘ / Ctrl Z to undo.<br>Preview before applying.</div></div></aside>
    </div>
  </section>
  <div class="operations">
    <section class="card full" id="live"><h2>Live traffic <span class="muted" id="livemeta"></span></h2><div id="tapoff" class="event" style="display:none"></div><div id="tpsnote" class="sub" style="display:none"></div><div id="livelanes" class="muted">Loading…</div><div class="feed-bar"><span class="sub" id="feedmeta"></span><button type="button" class="toggle" id="showall">show all lanes</button></div><div class="feed" id="feed"></div></section>
    <section class="card" id="orch"><h2>Driver</h2><div id="orchbody" class="muted">Loading…</div></section>
    <section class="card" id="work"><h2>Workers</h2><div id="workbody" class="muted">Loading…</div></section>
    <section class="card full" id="act"><h2>Recent activity <span class="muted" id="since"></span></h2><div class="stats" id="stats"></div><div class="spark" id="spark"></div><div class="codes" id="codes"></div><div id="clients" class="sub" style="margin-top:8px"></div><div id="event"></div></section>
    <section class="card full" id="probe"><h2>Backends</h2><div class="probe-controls"><button id="testbtn">Test backends</button><span class="muted">Actively calls each backend (spends a few tokens).</span></div><div id="probebody" class="probe-results"></div></section>
    <section class="card full" id="fleets"><h2>Fleets</h2><div id="fleetsbody" class="muted">Loading…</div></section>
  </div>
</main>
<footer class="footer"><div class="footer-brand">FERRY / SIGNAL STUDIO <span>LOCAL INTELLIGENCE, IN MOTION</span></div><div id="foot"></div></footer>
<dialog id="catalog-detail" aria-labelledby="catalog-title"><div class="dialog-actions"><button type="button" id="catalog-close" autofocus>Close</button></div><h2 id="catalog-title"></h2><div id="catalog-detail-body"></div></dialog>
<dialog id="routepicker" aria-labelledby="routepicker-title"><h2 id="routepicker-title">Add a fallback</h2><p>Primaries stay pinned. Choose a model or route below.</p><div class="picker-options" id="routepicker-options"></div><div class="dialog-actions"><button type="button" id="routepicker-close">Cancel</button></div></dialog>
<script>
const $=s=>document.querySelector(s);
const el=(t,c,x)=>{const e=document.createElement(t);if(c)e.className=c;if(x!=null)e.textContent=x;return e;};
const shortModel=m=>m?String(m).split('/').pop():'—';
let PROBE={};

function dotFor(state){return {up:'green',down:'red',active:'green',standby:'amber',bad:'red'}[state]||'';}

// The driver lane was renamed `orch` -> `heavy`; both still resolve, so prefer
// the current name and fall back rather than hard-coding either.
function driverLane(groups){ return groups.heavy ? 'heavy' : 'orch'; }

function renderOrch(st){
  const t=st.topology||{}, groups=t.groups||{};
  const lane=driverLane(groups), fb=(t.fallbacks||{})[lane]||[];
  const ev=(st.activity||{}).last_event;
  const box=$('#orchbody'); box.innerHTML=''; box.className='';
  // primary
  const pg=groups[lane]||{models:[]};
  let pstate='blue', ptag='primary';
  if(ev && ev.kind==='quota_exhausted'){pstate='red';ptag='quota-exhausted';}
  const pr=PROBE[lane];
  if(pr && pr.served_by){ const served=shortModel(pr.served_by);
    if((pg.models[0]||'').includes(served)){pstate='green';ptag='serving ('+pr.ms+'ms)';}
    else{pstate='red';ptag='down → '+served;} }
  box.appendChild(mkRow('', shortModel(pg.models[0]||lane), lane+' · primary', pstate, ptag));
  // fallback chain
  fb.forEach((g,i)=>{
    const gg=groups[g]||{models:[]}; const served=pr&&pr.served_by?shortModel(pr.served_by):null;
    let state='amber', tag=(i===fb.length-1?'last resort':'standby');
    if(served && (gg.models[0]||'').includes(served)){state='green';tag='ACTIVE · serving ('+pr.ms+'ms)';}
    box.appendChild(mkRow(String(i+1), shortModel(gg.models[0]||g), g, state, tag));
  });
  if(t.error) box.appendChild(el('div','sub',t.error));
}
function mkRow(step,name,sub,state,tag){
  const r=el('div','row');
  r.appendChild(el('span','step',step||''));
  r.appendChild(el('span','dot '+dotFor(state)));
  const c=el('div'); c.appendChild(el('div','name',name)); if(sub)c.appendChild(el('div','sub',sub)); r.appendChild(c);
  r.appendChild(el('span','tag'+(state==='green'?' active':state==='red'?' bad':''),tag||''));
  return r;
}
function renderWork(st){
  const t=st.topology||{}, groups=t.groups||{};
  const box=$('#workbody'); box.innerHTML=''; box.className='';
  // Hide the driver lane and the hops of its own chain — those are the
  // Driver card's job, and the Routes card below shows every lane anyway.
  // Derived from the config rather than matched on an `orch-` name prefix, which
  // stopped covering the driver's hops the moment the lane was renamed.
  const drv=driverLane(groups), hide=new Set([drv, ...((t.fallbacks||{})[drv]||[])]);
  Object.keys(groups).filter(g=>!hide.has(g)).forEach(g=>{
    const gg=groups[g]; const pr=PROBE[g];
    // A pool is several DEPLOYMENTS sharing one model_name — on Gemini, several
    // model ids on ONE key, since limits are per-project-per-model. Never "keys".
    const size=gg.count>1?gg.count+' deployments · pool':'single deployment';
    let state='blue', tag=size;
    if(pr){ state=pr.ok?'green':'red'; tag=(pr.ok?'healthy ('+pr.ms+'ms)':'error '+(pr.status||''))+' · '+size; }
    box.appendChild(mkRow('', g, shortModel(gg.models[0]||''), state, tag));
    if(gg.count>1){ const d=el('div','sub'); d.style.marginLeft='35px';
      d.textContent='● '.repeat(gg.count).trim()+'  ('+t.routing.routing_strategy+', cooldown '+t.routing.cooldown_time+'s)'; box.appendChild(d); }
  });
}
function renderAct(st){
  const a=st.activity||{};
  $('#since').textContent='· since dash start';
  const s=$('#stats'); s.innerHTML='';
  const mk=(b,l)=>{const d=el('div','stat');d.appendChild(el('b',null,b));d.appendChild(el('span',null,l));return d;};
  const codes=a.by_status||{}; const tot=a.total||0;
  const ok=Object.entries(codes).filter(([k])=>k[0]==='2').reduce((n,[,v])=>n+v,0);
  s.appendChild(mk(String(tot),'inference requests'));
  s.appendChild(mk(tot?Math.round(100*ok/tot)+'%':'—','success (2xx)'));
  s.appendChild(mk(String((a.by_client||[]).length),'clients'));
  const spark=$('#spark'); spark.innerHTML='';
  const arr=a.spark||[], mx=Math.max(1,...arr);
  (arr.length?arr:[0]).forEach(v=>{const i=el('i');i.style.height=(6+94*v/mx)+'%';i.title=v+' req/5s';spark.appendChild(i);});
  const cc=$('#codes'); cc.innerHTML='';
  Object.entries(codes).sort().forEach(([k,v])=>{
    const cls=k[0]==='2'?'ok':k[0]==='4'?'warn':'err';
    cc.appendChild(el('span','code '+cls,k+': '+v));
  });
  if(!Object.keys(codes).length) cc.appendChild(el('span','sub','no inference requests since dash start'));
  const cl=$('#clients');
  cl.textContent=(a.by_client||[]).length? 'clients — '+a.by_client.map(([ip,n])=>ip+' ('+n+')').join('   ') : '';
  const eb=$('#event'); eb.innerHTML='';
  if(a.last_event){ const secs=Math.round(Date.now()/1000-a.last_event.t);
    eb.appendChild(el('div','event','⚠ '+a.last_event.text+'  ·  '+secs+'s ago')); }
}
// ── fleets panel ─────────────────────────────────────────────────────────
// One row for the host-wide default, one row per client with a sticky
// selection of its own. Every row is the same control: a <select> of fleet
// names that POSTs the change and re-fetches status, so the row never lies
// about the fleet actually in effect for more than one poll interval.
function renderFleets(st){
  const box=$('#fleetsbody'); box.innerHTML=''; box.className='';
  const fleet=st.fleet||{};
  if(fleet.error){ box.className='muted'; box.textContent=fleet.error; return; }
  const names=Object.keys(fleet.fleets||{});
  const mkFleetRow=(label,current,onChange)=>{
    const r=el('div','row');
    r.appendChild(el('span',null,label));
    const sel=el('select');
    // A sticky selection naming a fleet the yaml no longer has matches no
    // <option>, and a <select> with nothing selected displays its FIRST
    // option — so the row would claim the wrong fleet is in effect. Show the
    // real value instead, disabled because it is not a thing to switch back to.
    if(current && !names.includes(current)){
      const o=el('option',null,current+' (unknown)');
      o.value=current; o.disabled=true; o.selected=true;
      sel.appendChild(o);
    }
    names.forEach(n=>{
      const o=el('option',null,n); o.value=n;
      if(n===current) o.selected=true;
      sel.appendChild(o);
    });
    sel.onchange=()=>onChange(sel.value);
    r.appendChild(sel);
    return r;
  };
  box.appendChild(mkFleetRow('default', fleet.default,
    v=>postFleet({fleet:v, default:true})));
  Object.keys(fleet.clients||{}).forEach(name=>{
    box.appendChild(mkFleetRow(name, fleet.clients[name],
      v=>postFleet({fleet:v, identity:name})));
  });
}
async function postFleet(body){
  await fetch('/api/fleet',{method:'POST',headers:{'Content-Type':'application/json'},
                            body:JSON.stringify(body)});
  tick();
}
// ── route editor ───────────────────────────────────────────────────────────
// Orders always include pinned position zero. Fleet selection filters only the
// view; every preview and apply carries the complete authoritative lane map.
let EDIT=null, SERVER=null, GROUPS={}, DRAG=null, PICKER_OPEN=false;
let HISTORY=[], REVISION=0, REVIEWED=null, PENDING=null, BUSY=false;
let ROUTE_STATUS=null, VIEW=null, VISIBLE=[];
let CATALOG={models:[],fetched_at:null,stale:false,error:null}, CATALOG_LOADING=false;
let LIBRARY_KEY=null;
// ── pure catalog helpers ──────────────────────────────────────────────────
function catalogPrice(value){
  if(!['string','number'].includes(typeof value)||String(value).trim()==='')return 'Unknown';
  const n=Number(value);if(!Number.isFinite(n)||n<0)return 'Unknown';
  return '$'+(n*1000000).toLocaleString('en-US',{maximumFractionDigits:8});
}
function catalogContext(value){return Number.isFinite(value)&&value>0?value.toLocaleString('en-US')+' tokens':'Unknown';}
function catalogGroups(groups,id){return Object.keys(groups).sort().filter(g=>(groups[g].models||[]).includes('openrouter/'+id));}
function libraryMatches(groups,models,query){
  const q=query.toLowerCase().trim();
  return {configured:Object.keys(groups).sort().filter(g=>(g+' '+(groups[g].models||[]).join(' ')).toLowerCase().includes(q)),
    catalog:models.filter(m=>(m.id+' '+m.name).toLowerCase().includes(q))};
}
function catalogResult(previous,next){
  if(!next||!Array.isArray(next.models))return {...previous,stale:true,error:'Invalid catalog response'};
  if(next.error&&!next.models.length&&previous.models.length)return {...previous,stale:true,error:next.error};
  return next;
}
// ── end pure catalog helpers ──────────────────────────────────────────────
const ROUTE_SCROLL=new Map();

// ── pure order helpers — also exercised by lib/ferry-dashui.test.mjs ──────
// moveHop: insert the hop at `from` (>= 1; position 0 is pinned) before the
// index `dropBefore`, clamped into [1..n]. Dropping onto itself or directly
// below itself is a no-op — which is exactly what the drop geometry computes
// for those two pointer positions, so a sloppy drop can't jitter the list.
function moveHop(order,from,dropBefore){
  const n=order.length;
  from=Math.round(from); let to=Math.round(dropBefore);
  if(!(from>=1)||from>=n) return order.slice();
  if(!(to>=1)) to=1; else if(to>n) to=n;
  if(to===from||to===from+1) return order.slice();
  const next=order.slice();
  const [x]=next.splice(from,1);
  next.splice(to>from?to-1:to,0,x);
  return next;
}
// dropIndexFromY: which insert-before slot does a pointer at y land in, given
// the vertical midpoints of the fallback rows? Slot i+1 sits above row i,
// slot len+1 below the last row. There is deliberately no slot 0 — the
// primary row is never a drop target.
function dropIndexFromY(centers,y){
  for(let i=0;i<centers.length;i++) if(y<centers[i]) return i+1;
  return centers.length+1;
}
// Candidates for "+ add hop": anything the catalogue serves that this lane
// neither is nor already falls back through.
function availHops(groups,lane,order){
  return Object.keys(groups).filter(g=>g!==lane&&!order.includes(g)).sort();
}
function sameJSON(a,b){return JSON.stringify(a)===JSON.stringify(b);}
function cloneOrders(orders){return Object.fromEntries(Object.entries(orders||{}).map(([k,v])=>[k,v.slice()]));}
function insertHop(order,group,before){
  const next=order.slice();
  if(!group||next.includes(group)) return next;
  const index=Number.isFinite(before)?Math.max(1,Math.min(next.length,Math.round(before))):next.length;
  next.splice(index,0,group); return next;
}
function eligibleHop(lane,group,fleets){
  const prefix=lane.split('.')[0];
  return !lane.includes('.')||!Object.prototype.hasOwnProperty.call(fleets||{},prefix)||group.startsWith(prefix+'.');
}
function transferHop(orders,source,lane,before,fleets){
  const next=cloneOrders(orders);
  if(!source||!next[lane]) return next;
  const group=source.lane?((orders[source.lane]||[])[source.from]):source.group;
  if(!group||!eligibleHop(lane,group,fleets))return next;
  if(source.lane){
    const origin=orders[source.lane];
    if(!origin||!Number.isInteger(source.from)||source.from<1||source.from>=origin.length) return next;
    if(source.lane===lane){next[lane]=moveHop(next[lane],source.from,before);return next;}
    next[lane]=insertHop(next[lane],origin[source.from],before);
  }else next[lane]=insertHop(next[lane],source.group,before);
  return next;
}
// ── end pure order helpers ─────────────────────────────────────────────────

const laneKind=g=>(GROUPS[g]&&GROUPS[g].count>1)?'pool · '+GROUPS[g].count+' deployments':'ordered chain';
const modelOf=g=>shortModel(((GROUPS[g]||{}).models||[])[0]||'');
function routesDirty(){return !!EDIT&&!!SERVER&&!sameJSON(EDIT,SERVER);}
function invalidateReview(){REVISION++;REVIEWED=null;PENDING=null;showDiff('');}
function controls(){
  const dirty=routesDirty();
  $('#routesact').style.display='';
  $('#previewbtn').disabled=BUSY||!dirty;
  $('#applybtn').disabled=BUSY||!REVIEWED||REVIEWED.key!==JSON.stringify(EDIT);
  $('#resetbtn').disabled=BUSY||!dirty;
  $('#undobtn').disabled=BUSY||!HISTORY.length;
  $('#editstate').textContent=BUSY?'Request in progress':dirty?'Unsaved changes':'All changes saved';
}
function editOrders(next,note){
  if(BUSY||sameJSON(next,EDIT)) return false;
  HISTORY.push(cloneOrders(EDIT)); EDIT=next; invalidateReview();
  redraw();msg(note||'Route updated. Preview before applying.','dirty');return true;
}
function undo(){
  if(BUSY||!HISTORY.length)return;
  EDIT=HISTORY.pop();invalidateReview();redraw();msg('Last edit undone.');
}
function renderRoutes(st){
  if(DRAG||PICKER_OPEN||BUSY)return;
  const t=st.topology||{}, groups=t.groups||{}, fb=t.fallbacks||{};
  const lanes=[...new Set([...Object.keys(fb),...(st.models||[])])].filter(l=>groups[l]).sort();
  const next=Object.fromEntries(lanes.map(l=>[l,[l,...(fb[l]||[])]]));
  const wasDirty=routesDirty();
  const changed=SERVER&&!sameJSON(SERVER,next);
  if(changed)invalidateReview();
  SERVER=next;GROUPS=groups;ROUTE_STATUS=st;
  if(EDIT===null||!wasDirty){EDIT=cloneOrders(SERVER);if(changed)HISTORY=[];}
  drawRoutes();
}
function redraw(){if(EDIT)drawRoutes();}
function drawRoutes(){
  const workspace=$('#routesbody'),workspaceTop=workspace.scrollTop;
  workspace.querySelectorAll('.lane').forEach(section=>{
    const hops=section.querySelector('.hops');
    if(hops)ROUTE_SCROLL.set(section.dataset.lane,hops.scrollLeft);
  });
  const st=ROUTE_STATUS||{}, t=st.topology||{};
  const lanes=Object.keys(EDIT), owned=new Set(Object.values(t.fleets||{}).flat());
  const views=[['*','All lanes',lanes],...Object.entries(t.fleets||{}).map(([f,ls])=>['fleet:'+f,f,ls.filter(l=>lanes.includes(l))])];
  const shared=lanes.filter(l=>!owned.has(l));if(shared.length)views.push(['shared','Shared',shared]);
  if(VIEW===null){
    const known='fleet:'+((st.fleet||{}).fleet||'');
    VIEW=(views.find(([id,,ls])=>id===known&&ls.length)||views.find(([id,,ls])=>id.startsWith('fleet:')&&ls.length)||views[0])[0];
  }
  if(!views.some(([id])=>id===VIEW))VIEW='*';
  VISIBLE=views.find(([id])=>id===VIEW)[2];
  const filters=$('#routefilters');filters.replaceChildren();
  views.forEach(([id,label,ls])=>{const b=el('button','fleet-tab'+(VIEW===id?' selected':''),label+' · '+ls.length);
    b.setAttribute('aria-pressed',String(VIEW===id));b.disabled=BUSY;b.onclick=()=>{VIEW=id;redraw();};filters.appendChild(b);});
  $('#routespath').textContent=st.config_path||'';
  const box=$('#routesbody');box.replaceChildren();box.classList.remove('loading','muted');
  if(!VISIBLE.length)box.appendChild(el('p','muted',t.error||'No lanes in this view.'));
  VISIBLE.forEach(lane=>{
    const order=EDIT[lane],sec=el('div','lane');sec.dataset.lane=lane;
    const h=el('h3',null,lane);h.appendChild(el('span','kind',laneKind(lane)));
    if(!sameJSON(order,SERVER[lane]))h.appendChild(el('span','dirty','modified'));
    sec.appendChild(h);const list=el('div','hops');list.setAttribute('role','list');list.setAttribute('aria-label',lane+' route');
    order.forEach((hop,i)=>{
      if(i)list.appendChild(routeGap(lane,i));
      const r=el('div','hop hoprow'+(i?'':' primary'));r.setAttribute('role','listitem');
      Object.assign(r.dataset,{lane,index:String(i),group:hop});
      const top=el('div','hop-top');top.appendChild(el('span',null,i?'FALLBACK '+i:'PRIMARY'));
      top.appendChild(el('span',i?'grip':'star',i?'⠿':'⌑ PINNED'));r.appendChild(top);
      const who=el('div','who');who.appendChild(el('b',null,modelOf(hop)||hop));who.appendChild(el('span',null,hop));r.appendChild(who);
      if(i){
        const actions=el('div','hop-actions');
        const button=(label,title,fn,disabled)=>{const b=el('button','mini',label);b.title=title;b.setAttribute('aria-label',title);b.disabled=BUSY||!!disabled;b.onclick=fn;actions.appendChild(b);return b;};
        const move=before=>{const next=cloneOrders(EDIT);next[lane]=moveHop(next[lane],i,before);editOrders(next);};
        button('←','Move '+hop+' earlier in '+lane,()=>move(i-1),i===1);
        button('→','Move '+hop+' later in '+lane,()=>move(i+2),i===order.length-1);
        button('×','Remove '+hop+' from '+lane,()=>{const next=cloneOrders(EDIT);next[lane].splice(i,1);editOrders(next,'Fallback removed. Undo is available.');});
        const promote=button('⇈','Promote '+hop+' to primary of '+lane,()=>promoteFlow(lane,hop),routesDirty());
        if(routesDirty())promote.title='Apply or discard route edits before promoting a primary.';
        if(PENDING&&PENDING.lane===lane&&PENDING.hop===hop){promote.textContent='Confirm ⇈';promote.onclick=confirmPromote;}
        r.appendChild(actions);r.draggable=!BUSY;
        r.addEventListener('dragstart',e=>startDrag(e,{lane,from:i,group:hop},r));
        r.addEventListener('dragend',endDrag);
      }
      list.appendChild(r);
    });
    list.appendChild(routeGap(lane,order.length,true));sec.appendChild(list);box.appendChild(sec);
    list.scrollLeft=ROUTE_SCROLL.get(lane)||0;
  });
  $('#routecount').textContent=String(VISIBLE.length);
  $('#hopcount').textContent=String(VISIBLE.reduce((n,l)=>n+EDIT[l].length,0));
  const coverage=$('#coverage');coverage.replaceChildren();
  VISIBLE.forEach(l=>{const row=el('div','coverage-row'),heading=el('div');heading.appendChild(el('span',null,l));heading.appendChild(el('small',null,EDIT[l].length+' HOPS'));row.appendChild(heading);
    const pips=el('div','coverage-pips');EDIT[l].forEach(()=>pips.appendChild(el('i')));row.appendChild(pips);coverage.appendChild(row);});
  workspace.scrollTop=workspaceTop;
  renderLibrary();controls();
}
function renderLibrary(){
  if(DRAG||PICKER_OPEN)return;
  const q=$('#modelsearch').value,box=$('#modellibrary');
  const key=JSON.stringify([q,GROUPS,CATALOG.models,BUSY]);
  const matches=libraryMatches(GROUPS,CATALOG.models,q);
  $('#catalog-count').textContent=matches.configured.length+' / '+Object.keys(GROUPS).length+' configured · '+matches.catalog.length+' / '+CATALOG.models.length+' OpenRouter';
  $('#catalog-refresh').disabled=CATALOG_LOADING;
  $('#catalog-refresh').textContent=CATALOG_LOADING?'Refreshing…':'Refresh';
  const age=CATALOG.fetched_at?' · Updated '+new Date(CATALOG.fetched_at*1000).toLocaleString():'';
  $('#catalog-status').textContent=CATALOG_LOADING?'Loading OpenRouter catalog…':CATALOG.error?(CATALOG.models.length?'Stale catalog · ':'Catalog unavailable · ')+CATALOG.error+age:(CATALOG.stale?'Stale catalog':'Live OpenRouter catalog')+age;
  if(key===LIBRARY_KEY)return;LIBRARY_KEY=key;
  const left=box.scrollLeft,top=box.scrollTop;box.replaceChildren();
  matches.configured.forEach(g=>{
    const b=el('button','model-card');b.dataset.group=g;b.draggable=!BUSY;b.disabled=BUSY;
    b.setAttribute('aria-label','Add '+g+' to a route');b.appendChild(el('span','model-symbol','◈'));
    const copy=el('span','model-copy');copy.appendChild(el('span','source-badge','Configured'));copy.appendChild(el('b',null,modelOf(g)||g));copy.appendChild(el('span',null,g));b.appendChild(copy);
    b.onclick=()=>openPicker(null,null,g);b.addEventListener('dragstart',e=>startDrag(e,{group:g},b));b.addEventListener('dragend',endDrag);box.appendChild(b);
  });
  matches.catalog.forEach(m=>{
    const b=el('button','model-card catalog-card');b.draggable=false;b.dataset.catalogId=m.id;
    const copy=el('span','model-copy'),configured=catalogGroups(GROUPS,m.id);
    copy.appendChild(el('span','source-badge','OpenRouter · '+(configured.length?'Configured match':'Not configured')));
    copy.appendChild(el('b',null,m.name||m.id));copy.appendChild(el('span',null,m.id));
    copy.appendChild(el('span',null,'Context: '+catalogContext(m.context_length)));
    copy.appendChild(el('span',null,'In '+catalogPrice(m.pricing?.prompt)+' · Out '+catalogPrice(m.pricing?.completion)+' / 1M tokens'));
    b.appendChild(copy);b.onclick=()=>openCatalog(m);box.appendChild(b);
  });
  if(!box.childElementCount)box.appendChild(el('p','muted','No models match this search.'));
  box.scrollLeft=left;box.scrollTop=top;
}
async function loadCatalog(refresh=false){
  if(CATALOG_LOADING)return;CATALOG_LOADING=true;renderLibrary();
  try{
    const response=await fetch('/api/models/openrouter'+(refresh?'?refresh=1':''));
    if(!response.ok)throw new Error('HTTP '+response.status);
    CATALOG=catalogResult(CATALOG,await response.json());
  }catch(e){CATALOG={...CATALOG,stale:true,error:e.message};}
  finally{CATALOG_LOADING=false;renderLibrary();}
}
function openCatalog(m){
  const dialog=$('#catalog-detail'),body=$('#catalog-detail-body');body.replaceChildren();
  $('#catalog-title').textContent=m.name||m.id;
  body.appendChild(el('p','catalog-id',m.id));
  const matching=catalogGroups(GROUPS,m.id);
  body.appendChild(el('p',null,matching.length?'Available through the configured groups below.':'Catalog only · Not configured. A configured deployment is required before this model can be added to a route.'));
  body.appendChild(el('p',null,m.description||'No description supplied.'));
  const facts=el('dl','catalog-facts');
  [['Context',catalogContext(m.context_length)],['Input / 1M',catalogPrice(m.pricing?.prompt)],['Output / 1M',catalogPrice(m.pricing?.completion)],['Input types',(m.input_modalities||[]).join(', ')||'Unknown'],['Output types',(m.output_modalities||[]).join(', ')||'Unknown'],['Parameters',(m.supported_parameters||[]).join(', ')||'Not supplied']].forEach(([label,value])=>{facts.appendChild(el('dt',null,label));facts.appendChild(el('dd',null,value));});
  body.appendChild(facts);body.appendChild(el('p','muted','Prices are USD per 1 million tokens as reported by OpenRouter. Other charges and provider differences may apply.'));
  const actions=el('div','catalog-actions'),copy=el('button',null,'Copy model ID');copy.type='button';copy.onclick=async()=>{try{await navigator.clipboard.writeText(m.id);copy.textContent='Copied';}catch{copy.textContent='Select the ID above to copy';}};actions.appendChild(copy);
  const link=el('a',null,'View on OpenRouter ↗');link.href='https://openrouter.ai/'+m.id.split('/').map(encodeURIComponent).join('/');link.target='_blank';link.rel='noopener noreferrer';actions.appendChild(link);
  matching.forEach(g=>{const b=el('button',null,'Add '+g+' to route');b.disabled=BUSY;b.onclick=()=>{closeCatalog();openPicker(null,null,g);};actions.appendChild(b);});body.appendChild(actions);
  PICKER_OPEN=true;dialog.showModal();
}
function syncPickerState(){PICKER_OPEN=$('#catalog-detail').open||$('#routepicker').open;renderLibrary();}
function closeCatalog(){$('#catalog-detail').close();syncPickerState();}
function fleetRules(){return ((ROUTE_STATUS||{}).topology||{}).fleets||{};}
function validDrop(lane){return DRAG&&eligibleHop(lane,DRAG.group,fleetRules())&&(DRAG.lane===lane||!EDIT[lane].includes(DRAG.group));}
function startDrag(e,source,node){
  if(BUSY){e.preventDefault();return;}DRAG=source;node.classList.add('dragging');
  e.dataTransfer.setData('text/plain',source.group);e.dataTransfer.effectAllowed=source.lane?'copyMove':'copy';
  document.querySelectorAll('.route-gap').forEach(g=>g.classList.add(validDrop(g.dataset.lane)?'valid':'invalid'));
}
function endDrag(){
  DRAG=null;document.querySelectorAll('.dragging,.route-gap').forEach(n=>n.classList.remove('dragging','valid','invalid','over'));
  renderLibrary();
}
function routeGap(lane,before,end){
  const gap=el('div','route-gap'+(end?' end':''));Object.assign(gap.dataset,{lane,before:String(before)});
  const b=el('button',null,'+');b.disabled=BUSY;b.setAttribute('aria-label','Add fallback to '+lane+' at position '+before);b.onclick=()=>openPicker(lane,before);gap.appendChild(b);
  gap.addEventListener('dragover',e=>{if(!validDrop(lane))return;e.preventDefault();e.dataTransfer.dropEffect=DRAG.lane===lane?'move':'copy';gap.classList.add('over');});
  gap.addEventListener('dragleave',e=>{if(!gap.contains(e.relatedTarget))gap.classList.remove('over');});
  gap.addEventListener('drop',e=>{if(!DRAG)return;e.preventDefault();const source=DRAG,valid=validDrop(lane);endDrag();
    if(valid)editOrders(transferHop(EDIT,source,lane,before,fleetRules()),source.lane&&source.lane!==lane?'Fallback copied. Source route unchanged.':'Route updated.');
    else msg(eligibleHop(lane,source.group,fleetRules())?'That model group is already in this route.':'Fleet routes only accept model groups from the same fleet.');});return gap;
}
function closePicker(){$('#routepicker').close();syncPickerState();}
function openPicker(lane,before,group){
  if(BUSY)return;const dialog=$('#routepicker'),options=$('#routepicker-options');options.replaceChildren();
  $('#routepicker-title').textContent=group?'Place '+group:'Add fallback · '+lane;
  const entries=group?VISIBLE:Object.keys(GROUPS).sort();
  entries.forEach(value=>{const target=group?value:lane,g=group||value,duplicate=EDIT[target].includes(g),unavailable=!eligibleHop(target,g,fleetRules());
    const b=el('button','pick',group?target+' · '+(duplicate?'Already in route':'Add at end'):g+' · '+modelOf(g)+(duplicate?' · Already in route':''));
    b.disabled=duplicate||unavailable;if(unavailable){b.textContent+=' · Different fleet';b.title='Fleet routes only accept groups in the same fleet.';}b.onclick=()=>{closePicker();const next=cloneOrders(EDIT);next[target]=insertHop(next[target],g,group?next[target].length:before);editOrders(next);};options.appendChild(b);});
  PICKER_OPEN=true;dialog.showModal();
}
async function postRoutes(url,order){
  const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order})});return {code:r.status,body:await r.json()};
}
function showDiff(d){
  const pre=$('#routesdiff');pre.replaceChildren();pre.style.display=d?'':'none';
  (d||'').split('\n').forEach(l=>{const c=(l.startsWith('+++')||l.startsWith('---'))?'hunk':l.startsWith('+')?'add':l.startsWith('-')?'del':l.startsWith('@')?'hunk':'';pre.appendChild(el('div',c,l));});
}
function msg(text,cls){const m=$('#routesmsg');m.className=cls||'';m.textContent=text||'';}
async function postPromote(url,lane,hop){
  const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({lane,hop})});return {code:r.status,body:await r.json()};
}
async function promoteFlow(lane,hop){
  if(BUSY||routesDirty()){msg('Apply or discard route edits before promoting a primary.');return;}
  invalidateReview();const revision=REVISION;BUSY=true;redraw();msg('Promote preview…');
  try{
    const {code,body}=await postPromote('/api/routes/promote/preview',lane,hop);
    if(revision!==REVISION)return;
    if(code!==200||(body.errors||[]).length)throw new Error((body.errors||['Preview failed']).join(' · '));
    showDiff(body.diff||'(no file change)');const live=body.live||{};
    if(live.ok===false)throw new Error('Live refuses: '+((live.errors||[]).join(' · ')||live.error||'Unknown error'));
    PENDING={lane,hop,revision};msg('Swap '+lane+' ⇄ '+hop+': '+shortModel(live.old_lane_model||'?')+' ⇄ '+shortModel(live.old_hop_model||'?')+'. Confirm to swap backends (file + live, no restart).','dirty');
  }catch(e){msg('Promote preview failed: '+e.message,'err');}
  finally{BUSY=false;redraw();}
}
async function confirmPromote(){
  const p=PENDING;if(BUSY||!p||p.revision!==REVISION||routesDirty())return;
  BUSY=true;PENDING=null;redraw();msg('Swapping backends…');
  try{const {code,body}=await postPromote('/api/routes/promote/apply',p.lane,p.hop);
    if(code!==200||(body.errors||[]).length)throw new Error((body.errors||['Swap failed']).join(' · '));
    EDIT=null;HISTORY=[];invalidateReview();msg('Swapped. Snapshot: '+body.snapshot+' · '+body.note,'ok');
  }catch(e){msg('Promote failed: '+e.message,'err');}
  finally{BUSY=false;redraw();tick();}
}

// -- live view --------------------------------------------------------------
// Fed by /api/events (per-request, sub-second) plus /status (topology and
// per-deployment state). The server folds each event into the exhaustion state
// before framing it, so the stream and the poll cannot disagree.
const FEED_CAP=200;          // a long session must not grow the DOM without bound
const FLASH_MS=6000;         // how long an edge stays lit after the request
const STICKY=['quota_exhausted','auth_dead'];
const LIVE={events:[], flash:{}, es:null, fails:0, showAll:false};

const hhmm=sec=>{const d=new Date((sec||0)*1000);return isNaN(d)?'—':d.toTimeString().slice(0,5);};
const clock=t=>{const d=new Date(t);return isNaN(d)?'--:--:--':d.toLocaleTimeString();};
// Everything the dash says about throughput is BYTES per second — a proxy, never
// a tokens/s claim. Null/negative in, null out: the caller shows nothing rather
// than a 0 that reads as "no traffic".
const fmtBps=n=>!isFinite(n)||n<=0?null
  :n<1024?Math.round(n)+' B/s'
  :n<1048576?(n/1024).toFixed(1)+' kB/s'
  :(n/1048576).toFixed(1)+' MB/s';

// ── request metric helpers ──
const validMetric=n=>typeof n==='number'&&Number.isFinite(n)&&n>=0;
const fmtMs=n=>validMetric(n)?(n<1000?Math.round(n)+' ms':(n/1000).toFixed(2)+' s'):'—';
const fmtTokens=n=>validMetric(n)?String(n):'—';
function requestMetrics(e){
  return [
    ['Mode',e.stream===true?'streaming':e.stream===false?'nonstreaming':'unknown','Response mode requested by the client.'],
    ['First text',fmtMs(e.first_text_ms),e.stream===false
      ?'Time until the completed text response was observed at the gateway; not internal generation latency.'
      :'Time until the first output text was observed at the gateway. Reasoning and tool calls do not count as text.'],
    ['Total',fmtMs(e.total_duration_ms),'Time through the final response body, or until an interrupted response stopped.'],
    ['In',fmtTokens(e.input_tokens),'Provider-reported input tokens: OpenAI includes cached input; Anthropic excludes it.'],
    ['Out',fmtTokens(e.output_tokens),'Provider-reported output tokens, including reasoning when reported.'],
    ['Reasoning',fmtTokens(e.reasoning_tokens),'Subset of output tokens; never added to Out. — means not reported, not zero.'],
    ...(e.response_complete===false?[['Response','incomplete','The response did not complete; timing and usage may be partial.']]:[])
  ];
}
// ── end request metric helpers ──

// Stats come from the feed buffer, so they describe what THIS page has seen
// since it loaded - never a claim about history the dash does not have.
function depStats(id){
  if(!id) return {rpm:0,p50:null,bps:null};
  const now=Date.now(), rows=LIVE.events.filter(e=>e.deployment===id);
  const durs=rows.slice(0,20).map(e=>e.total_duration_ms)
                 .filter(v=>typeof v==='number'&&Number.isFinite(v)&&v>=0).sort((a,b)=>a-b);
  // bytes/s over the same ~20-event window: total bytes over total time, so
  // each request is weighted by how long it actually ran. Records without a
  // usable resp_bytes/duration pair (old files, untapped proxy) drop out, and
  // a window with none of them yields null — never a 0 B/s that looks idle.
  let bytes=0, ms=0;
  rows.slice(0,20).forEach(e=>{
    if(validMetric(e.total_duration_ms)&&e.total_duration_ms>0&&validMetric(e.resp_bytes)&&e.resp_bytes>0){
      bytes+=e.resp_bytes; ms+=e.total_duration_ms;
    }});
  return {rpm:rows.filter(e=>now-e._ms<60000).length,
          p50:durs.length?durs[Math.floor(durs.length/2)]:null,
          bps:ms>0?bytes*1000/ms:null};
}

// An event names the deployment that SUCCEEDED and, in hop_errors, the ones
// tried before it. `fallbacks` is the count, and it can be non-zero while
// hop_errors is empty (the header carrying the detail is not guaranteed), so
// the count decides how many edges light and the codes fill in where they exist.
function noteFlash(e){
  const n=Math.max((e.hop_errors||[]).length, e.fallbacks||0), failed={};
  for(let i=0;i<n;i++){
    const h=(e.hop_errors||[])[i]||{};
    failed[i]=h.code?String(h.code):'failed';
  }
  LIVE.flash[e.lane]={ts:Date.now(), served:e.deployment, failed:failed};
}

function depChip(d,st){
  const info=(st.deployments||{})[d.id];
  const state=info?info.state:'';
  const c=el('span','chip'+(state?' '+state:''));
  const s=depStats(d.id), bits=[shortModel(d.model||d.id||'?')];
  if(state) bits.push(state.replace(/_/g,' '));
  if(s.rpm) bits.push(s.rpm+'/min');
  if(s.p50!=null) bits.push('median total '+fmtMs(s.p50));
  const bps=fmtBps(s.bps); if(bps) bits.push(bps);
  if(info && info.code) bits.push(info.code);
  if(info && STICKY.indexOf(state)>=0) bits.push('since '+hhmm(info.since));
  c.textContent=bits.join(' · ');
  c.title=[d.id||'', d.model||'', d.provider||'', (info&&info.detail)||'']
            .filter(Boolean).join('\n');
  return c;
}

function hopBox(lane,hop,i,st,fresh){
  const b=el('div','hopb'), f=LIVE.flash[lane.name];
  const ids=(hop.deployments||[]).map(d=>d.id).filter(Boolean);
  if(hop.missing) b.classList.add('missing');
  if(fresh){
    if(f.served && ids.indexOf(f.served)>=0) b.classList.add('served');
    else if(f.failed[i]!==undefined) b.classList.add('failed');
  }
  const head=el('div');
  head.appendChild(el('b',null,hop.name));
  if(hop.missing) head.appendChild(el('span','pool','not defined in the config'));
  else if(hop.is_pool) head.appendChild(el('span','pool','pool ×'+hop.pool_size));
  b.appendChild(head);
  if((hop.deployments||[]).length){
    const chips=el('div','chips');
    hop.deployments.forEach(d=>chips.appendChild(depChip(d,st)));
    b.appendChild(chips);
  }
  return b;
}

function renderLive(st){
  const tap=st.tap||{}, lanes=st.lanes||[], off=$('#tapoff');
  // Degrade honestly: an empty graph looks exactly like no traffic, so say
  // which of the two this is.
  if(!tap.path){
    off.style.display='';
    off.textContent='event tap is off — restart ferry with FERRY_EVENTS=on, and point the dash at the file with --events';
  }else if(LIVE.fails>2){
    off.style.display='';
    off.textContent='event stream disconnected — the dash cannot read '+tap.path;
  }else if(!tap.writing){
    off.style.display='';
    off.textContent='tap armed, nothing written yet — '+tap.path;
  }else{ off.style.display='none'; }
  // Same honest-degrade idea as #tapoff, one rung softer: the stream works but
  // carries no byte counts (old event file, proxy predating the counter), which
  // must read as "cannot know", not as a relay moving zero bytes.
  const tps=$('#tpsnote');
  if(tap.path && tap.writing && LIVE.fails<=2 && LIVE.events.length
     && !LIVE.events.some(e=>validMetric(e.total_duration_ms)&&e.total_duration_ms>0&&validMetric(e.resp_bytes)&&e.resp_bytes>0)){
    tps.style.display='';
    tps.textContent='throughput unavailable — requires response bytes and total duration';
  }else{ tps.style.display='none'; }
  $('#livemeta').textContent=tap.rules?'':'(no classifier table — every failure reads as "unknown")';

  const box=$('#livelanes'); box.innerHTML=''; box.className='';
  if(!lanes.length){ box.className='muted'; box.textContent='no lanes parsed from the config'; }
  const shown=LIVE.showAll?lanes:lanes.filter(l=>l.public);
  shown.forEach(l=>{
    const f=LIVE.flash[l.name], fresh=!!f && Date.now()-f.ts<FLASH_MS;
    const hdr=el('div','lanehdr');
    hdr.appendChild(el('b',null,l.name));
    hdr.appendChild(el('span','sub',l.public?'public':'internal'));
    if(fresh) hdr.appendChild(el('span','tag active','live'));
    box.appendChild(hdr);
    const chain=el('div','chain');
    l.hops.forEach((h,i)=>{
      if(i){
        const failed=fresh && f.failed[i-1]!==undefined;
        chain.appendChild(el('span','edge'+(failed?' failed':''),
                             failed?('→ '+f.failed[i-1]+' →'):'→'));
      }
      chain.appendChild(hopBox(l,h,i,st,fresh));
    });
    box.appendChild(chain);
  });
  const hidden=lanes.length-shown.length;
  $('#showall').textContent=LIVE.showAll
    ? 'public lanes only'
    : (hidden?('show all lanes (+'+hidden+')'):'show all lanes');
}

function renderFeed(){
  const box=$('#feed'); box.innerHTML='';
  LIVE.events.forEach(e=>{
    const r=el('div','fr'), ok=e.status>=200&&e.status<300;
    r.appendChild(el('span','t',clock(e.t)));
    r.appendChild(el('span','st '+(ok?'ok':'bad'),String(e.status||'—')));
    const w=el('span','w');
    w.textContent=(e.lane||'?')+' → '+shortModel(e.deployment||e.model)
      +(e.provider?' ('+e.provider+')':'')
      +(e.fallbacks?'  ↳ after '+e.fallbacks+' fallback'+(e.fallbacks>1?'s':''):'')
      +(e.retries?'  ↻'+e.retries:'');
    r.appendChild(w);
    const metrics=el('div','metrics');
    requestMetrics(e).forEach(([label,value,title])=>{
      const field=el('span',label==='Response'?'err':'',label+' '+value);
      field.title=title; metrics.appendChild(field);
    });
    const rate=fmtBps(e.bps);
    if(rate) metrics.appendChild(el('span','',rate));
    metrics.title='Response start: '+fmtMs(e.response_start_ms)+' · Cached input: '+fmtTokens(e.cached_input_tokens)
      +' · Completion: '+(e.response_complete===true?'complete':e.response_complete===false?'incomplete':'unknown');
    r.appendChild(metrics);
    box.appendChild(r);
  });
  const n=LIVE.events.length;
  $('#feedmeta').textContent=n
    ? (n+' request'+(n===1?'':'s')+' since this page loaded'+(n>=FEED_CAP?' (capped)':''))
    : 'no requests seen since this page loaded';
}

function openStream(st){
  if(LIVE.es || !((st.tap||{}).path)) return;
  let es;
  try{ es=new EventSource('/api/events'); }catch(err){ return; }
  LIVE.es=es;
  es.onopen=()=>{ LIVE.fails=0; };
  es.onmessage=m=>{
    let e; try{ e=JSON.parse(m.data); }catch(err){ return; }
    e._ms=Date.parse(e.t)||Date.now();
    LIVE.events.unshift(e);
    if(LIVE.events.length>FEED_CAP) LIVE.events.length=FEED_CAP;
    noteFlash(e); renderFeed();
    if(LAST) renderLive(LAST);
  };
  es.onerror=()=>{
    LIVE.fails++;
    // EventSource reconnects on its own, but a tap that is off answers 503 to
    // every attempt forever. Close after a few and let the 5s poll retry, so
    // the failure shows up in the banner instead of hiding in a retry loop.
    if(LIVE.fails>3){ try{es.close();}catch(err){} LIVE.es=null; }
    if(LAST) renderLive(LAST);
  };
}
$('#showall').onclick=()=>{ LIVE.showAll=!LIVE.showAll; if(LAST) renderLive(LAST); };

let LAST=null;

async function tick(){
  try{
    const st=await (await fetch('/status')).json();
    LAST=st;
    const f=st.ferry;
    const host=$('#host');host.replaceChildren(el('span','muted',f.host+':'+f.port+' · groups: '));
    (st.models||[]).forEach(m=>{host.appendChild(el('code',null,m));host.appendChild(document.createTextNode(' '));});
    const pill=$('#uppill'); pill.className='pill '+(f.up?'up':'down'); pill.textContent=f.up?'● UP':'● DOWN';
    $('#updated').textContent='updated '+new Date(st.ts*1000).toLocaleTimeString();
    const foot=$('#foot');foot.replaceChildren(document.createTextNode('config '),el('code',null,st.config_path||''),document.createTextNode(' · log '),el('code',null,(st.activity||{}).log||'—'),document.createTextNode(' · auto-refresh 5s (no tokens)'));
    renderOrch(st); renderWork(st); renderAct(st); renderLive(st); renderFleets(st);
    openStream(st);
    // Only reseed the editor when the user has no unsaved edit. A 5s poll that
    // re-rendered mid-edit would drop a half-built chain and reset the Apply
    // button under the user's cursor.
    if(!routesDirty()) renderRoutes(st);
  }catch(e){ $('#uppill').className='pill down'; $('#uppill').textContent='● dash error'; }
}
$('#testbtn').onclick=async()=>{
  const b=$('#testbtn'); b.disabled=true; b.textContent='probing…';
  const pb=$('#probebody'); pb.innerHTML='';
  try{
    PROBE=await (await fetch('/probe')).json();
    Object.entries(PROBE).forEach(([k,v])=>{
      const line=el('div','row');
      line.appendChild(el('span','dot '+(v.ok?'green':'red')));
      const c=el('div'); c.appendChild(el('div','name',k));
      c.appendChild(el('div','sub', v.ok? ('→ '+shortModel(v.served_by)+' · '+v.ms+'ms · HTTP '+v.status)
                                        : ('HTTP '+v.status+' · '+(v.error||'failed')) ));
      line.appendChild(c); pb.appendChild(line);
    });
    tick();  // re-render topology with the fresh per-hop truth
  }catch(e){ pb.textContent='probe failed: '+e; }
  b.disabled=false; b.textContent='Test backends';
};
$('#modelsearch').addEventListener('input',renderLibrary);
$('#catalog-refresh').onclick=()=>loadCatalog(true);
$('#catalog-close').onclick=closeCatalog;
$('#catalog-detail').addEventListener('close',syncPickerState);
$('#routepicker-close').onclick=closePicker;
$('#routepicker').addEventListener('close',syncPickerState);
// Native dialog Escape closes the active modal; close then synchronizes both states.
$('#undobtn').onclick=undo;
$('#previewbtn').onclick=async()=>{
  if(BUSY||!routesDirty())return;
  invalidateReview();const revision=REVISION,order=cloneOrders(EDIT),key=JSON.stringify(order);
  // Preview remains editable; a later edit makes this response obsolete.
  msg('Preview…');redraw();
  try{const {code,body}=await postRoutes('/api/routes/order/preview',order);
    if(revision!==REVISION||key!==JSON.stringify(EDIT))return;
    if(code!==200||(body.errors||[]).length)throw new Error((body.errors||['Preview failed']).join(' · '));
    showDiff(body.diff||'(no change)');REVIEWED=body.diff?{key,order,revision}:null;
    msg(body.diff?'Review the diff. Apply writes this exact order after a snapshot.':'No change to apply.');
  }catch(e){if(revision===REVISION)msg('Preview failed: '+e.message,'err');}
  finally{controls();}
};
$('#applybtn').onclick=async()=>{
  const reviewed=REVIEWED;
  if(BUSY||!reviewed||reviewed.revision!==REVISION||reviewed.key!==JSON.stringify(EDIT))return;
  BUSY=true;PENDING=null;redraw();msg('Writing…');
  try{const {code,body}=await postRoutes('/api/routes/order/apply',reviewed.order);
    if(code!==200||(body.errors||[]).length)throw new Error((body.errors||['Apply failed']).join(' · '));
    EDIT=null;HISTORY=[];invalidateReview();msg('Written. Snapshot: '+body.snapshot+' · '+body.note,'ok');
  }catch(e){invalidateReview();msg('Apply failed: '+e.message+'. Preview again before retrying.','err');}
  finally{BUSY=false;redraw();tick();}
};
$('#resetbtn').onclick=()=>{
  if(BUSY)return;closePicker();endDrag();
  if(routesDirty()){HISTORY.push(cloneOrders(EDIT));EDIT=cloneOrders(SERVER);}
  invalidateReview();redraw();msg('Draft discarded. Undo is available.');tick();
};
document.addEventListener('keydown',e=>{
  if(e.key==='Escape'){endDrag();}
  if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='z'&&!e.target.closest('input,textarea,select,[contenteditable]')&&!PICKER_OPEN){e.preventDefault();undo();}
});
renderFeed(); tick(); loadCatalog(); setInterval(tick, 5000);
</script>
</body></html>
"""


def find_log(port):
    tmp = tempfile.gettempdir()
    cands = [os.path.join(tmp, "ferry-logs", "cloud-proxy-%s.log" % port)]
    # Linux: TMPDIR is usually unset, so ferry writes to /tmp/ferry-logs.
    cands.append(os.path.join("/tmp", "ferry-logs", "cloud-proxy-%s.log" % port))
    # macOS: ferry's $TMPDIR lives under /var/folders/.../T.
    cands += glob.glob("/var/folders/*/*/T/ferry-logs/cloud-proxy-%s.log" % port)
    cands += glob.glob(os.path.join(tmp, "ferry-logs", "cloud-proxy-*.log"))
    cands += glob.glob("/tmp/ferry-logs/cloud-proxy-*.log")
    existing = [c for c in cands if os.path.exists(c)]
    return max(existing, key=os.path.getmtime) if existing else None


def lan_ip():
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return "127.0.0.1"


def main(argv=None, serve=True):
    ap = argparse.ArgumentParser(description="Live local dashboard for the ferry route proxy.")
    ap.add_argument("--port", type=int, default=8091, help="dashboard port (default 8091)")
    ap.add_argument("--ferry", default="http://127.0.0.1:8090", help="ferry/litellm base URL")
    ap.add_argument("--config", default=os.path.expanduser("~/.config/ferry/litellm.yaml"))
    ap.add_argument("--log", default=None, help="proxy log path (auto-discovered if omitted)")
    ap.add_argument("--key", default="local", help="proxy auth bearer (default 'local')")
    ap.add_argument("--open", action="store_true", help="open the dashboard in a browser")
    ap.add_argument("--events", default=os.path.join(
        (os.environ.get("TMPDIR") or "/tmp").rstrip("/"),
        "ferry-logs", "ferry-events.ndjson"),
        help="per-request event stream written by the front-door tap")
    ap.add_argument("--rules", default=os.path.expanduser(
        "~/.config/ferry/event-rules.json"),
        help="exhaustion classifier table (see event-rules.example.json)")
    args = ap.parse_args(argv)

    ferry_port = args.ferry.rsplit(":", 1)[-1].split("/")[0]
    logpath = args.log or find_log(ferry_port)

    # ── the live half ──────────────────────────────────────────────────────
    # All optional. If lib/ferry_live.py is missing, or the tap has never run,
    # or there is no rules file, the dashboard still serves everything it did
    # before — it just says the tap is off instead of drawing an empty graph.
    # Loaded before Activity so the tailer's classifier has the real table
    # from its first poll, not just from whatever the next reload picks up.
    live = _live()
    rules = live.load_rules(args.rules) if live else {"rules": [], "ttl": {}}

    CFG.update(ferry=args.ferry.rstrip("/"), key=args.key, config_path=args.config,
               host=lan_ip(), ferry_port=ferry_port, activity=Activity(logpath, rules))
    CFG["streams"] = 0
    CFG["stream_lock"] = threading.Lock()
    CFG["events_path"] = args.events
    CFG["rules"] = rules
    CFG["exhaustion"] = live.ExhaustionState(rules) if live else None

    try:
        srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
    except OSError as e:
        raise SystemExit("ferry-dash: cannot bind 127.0.0.1:%d (%s). Try --port." % (args.port, e))
    url = "http://localhost:%d" % srv.server_address[1]
    print("ferry-dash → %s   (ferry %s, log %s)" % (url, CFG["ferry"], logpath or "not found"))
    print("Ctrl-C to stop.")
    if args.open:
        try:
            import webbrowser
            webbrowser.open(url)
        except Exception:
            pass
    if not serve:
        return srv
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\nferry-dash stopped.")
    finally:
        srv.server_close()
    return 0


if __name__ == "__main__":
    main()
