diff --git a/internal/gui/api.go b/internal/gui/api.go
index 4f0bfb1b..d4701a7b 100644
--- a/internal/gui/api.go
+++ b/internal/gui/api.go
@@ -556,7 +556,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler {
case "hide":
w.HidePanel()
case "main":
- w.ShowMain(r.URL.Query().Get("view"))
+ w.ShowMain(mainView(r.URL.Query()))
case "quit":
w.Quit()
case "fit":
diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css
index a1d33e37..39a3c84b 100644
--- a/internal/gui/assets/app.css
+++ b/internal/gui/assets/app.css
@@ -566,9 +566,10 @@ body.panel .view { padding-top: 2px; }
.ptabs button.on { color: var(--fg); font-weight: 550; }
.ptabs button[hidden] { display: none; }
.ptabs em { font-style: normal; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; }
-body.panel[data-ptab="usage"] :is(#agents, .profiles),
-body.panel[data-ptab="profiles"] :is(#agents, #panelQuota),
-body.panel[data-ptab="agents"] :is(.profiles, #panelQuota) { display: none; }
+body.panel[data-ptab="usage"] :is(#agents, .profiles, #panelRouting),
+body.panel[data-ptab="routing"] :is(#agents, .profiles, #panelQuota),
+body.panel[data-ptab="profiles"] :is(#agents, #panelQuota, #panelRouting),
+body.panel[data-ptab="agents"] :is(.profiles, #panelQuota, #panelRouting) { display: none; }
/* ---- the panel's agents: one quiet line each, opened in place ------------ */
/* icon · name · model · effort · chevron, the last three in columns of their
diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js
index 2b7f324a..57576942 100644
--- a/internal/gui/assets/app.js
+++ b/internal/gui/assets/app.js
@@ -952,7 +952,7 @@ function fit(extra = 0, glide) {
const body = document.body, tab = body.dataset.ptab;
delete body.dataset.ptab;
// extra is only ever the agents' (a row opening, the scroll unrolling)
- const tallest = Math.max($("#agents").offsetHeight + extra, $(".profiles").offsetHeight, $("#panelQuota").offsetHeight);
+ const tallest = Math.max($("#agents").offsetHeight + extra, $(".profiles").offsetHeight, $("#panelQuota").offsetHeight, $("#panelRouting").offsetHeight);
if (tab) body.dataset.ptab = tab;
const h = $(".top").offsetHeight + $("#ptabs").offsetHeight + tallest + $(".foot").offsetHeight + 4;
if (h === fit.last) return;
@@ -5048,8 +5048,10 @@ function planSpan(q) {
return s;
}
-// The tray panel is three tabs over the one page: the agents, the usage of
-// every subscription and key, and the saved profiles. The tab is remembered.
+// The tray panel is four tabs over the one page: the agents, the allowances
+// of every subscription and key (the "usage" tab, as it was named before),
+// the gateway's latest requests (routing.js draws those) and the saved
+// profiles. The tab is remembered.
let panelTab = "agents";
try { panelTab = localStorage.getItem("magpie.panelTab") || "agents"; } catch {}
function setPanelTab(tab) {
@@ -5066,6 +5068,7 @@ function setPanelTab(tab) {
// the card under the tab picked glides to it, as on every other pill
slide(tabs, "ptabs");
panelAge();
+ window.panelRoutingShown?.();
fit();
}
if (mode === "panel") {
diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js
index 3b1e5947..9e4e7d9e 100644
--- a/internal/gui/assets/i18n.js
+++ b/internal/gui/assets/i18n.js
@@ -1174,6 +1174,7 @@ const I18N = {
"{n} tok/s": "{n} token/秒",
"requested {sent} · served {served}": "请求 {sent} · 实际 {served}",
"served {served}": "实际 {served}",
+ "Open Routing": "打开路由",
"The vendor was asked for {sent}, and its reply says {served} answered it: another model, not just {sent} under a dated name.": "向服务商请求的是 {sent},但它的回复写明由 {served} 作答:这是另一个模型,而不只是 {sent} 带日期的版本名。",
"{n} min": "{n} 分钟",
"{h} h {m} min": "{h} 小时 {m} 分钟",
diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html
index e5ea0055..f35899c0 100644
--- a/internal/gui/assets/index.html
+++ b/internal/gui/assets/index.html
@@ -45,7 +45,8 @@
@@ -57,6 +58,7 @@
+
diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css
index b25653e8..29ea9c86 100644
--- a/internal/gui/assets/routing.css
+++ b/internal/gui/assets/routing.css
@@ -330,3 +330,37 @@
.rt-day small { font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; }
.rt-day:hover { color: var(--fg); }
.rt-day.on { color: var(--fg); border-color: var(--fg); }
+
+/* the tray panel's Routing tab: the gateway's latest requests, two lines each */
+.panel-routing { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 2px 2px 6px; }
+.pr-head { display: flex; align-items: center; gap: 8px; min-height: 22px; padding: 0 2px 0 4px; font-size: 12px; color: var(--muted); }
+.pr-head .rt-livedot { margin-left: 0; flex: none; }
+.pr-today { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.pr-today b { color: var(--fg); font-weight: 600; }
+.pr-head .text { flex: none; font-size: 12px; }
+.skeleton.pr-sk { display: inline-block; width: 150px; height: 9px; border-radius: 4px; vertical-align: middle; }
+.skeleton.pr-sk-row { height: 40px; border-radius: 11px; }
+.pr-none { margin: 0; padding: 12px; border-radius: 11px; background: var(--card); box-shadow: var(--card-edge); font-size: 12px; line-height: 1.55; color: var(--muted); }
+.pr-none b { display: block; color: var(--fg); font-weight: 550; }
+.pr-list { display: flex; flex-direction: column; padding: 4px; border-radius: 11px; background: var(--card); box-shadow: var(--card-edge); }
+.pr-req { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 2px 10px; padding: 6px 8px; border-radius: 8px; font: inherit; font-size: 12px; text-align: left; color: var(--fg-2); background: none; border: 0; cursor: pointer; }
+.pr-req:hover { background: var(--card-2); }
+.pr-a, .pr-to { display: flex; align-items: center; gap: 6px; min-width: 0; }
+.pr-a i.ag { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--agent); }
+.pr-a .ic, .pr-a .ic img, .pr-a .ic svg { width: 14px; height: 14px; flex: none; }
+.pr-who { flex: none; color: var(--fg); font-weight: 550; }
+.pr-a .m { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; }
+.pr-a .kind { flex: 0 100 auto; min-width: 3.2em; max-width: 8em; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--card-2); border: 1px solid var(--line); white-space: nowrap; }
+.pr-req .at { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 11px; }
+.pr-to { padding-left: 2px; color: var(--fg); }
+.pr-to > i { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
+.pr-req.moved .pr-to > i { background: var(--accent); }
+.pr-req.bad .pr-to { color: var(--red); }
+.pr-req.bad .pr-to > i { background: var(--red); }
+.pr-req.wait .pr-to > i { background: var(--muted); animation: rtlive 1.8s infinite; }
+.pr-where { min-width: 0; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.pr-m { flex: 0 2 auto; min-width: 2em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
+.pr-m::before { content: "· "; }
+.pr-to .swap { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; }
+.pr-req .meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
+@media (prefers-reduced-motion: reduce) { .pr-req.wait .pr-to > i { animation: none; } }
diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js
index c9823f40..75ecfd8d 100644
--- a/internal/gui/assets/routing.js
+++ b/internal/gui/assets/routing.js
@@ -470,6 +470,9 @@
const routes = new Map(); // id → the latest of each route
let seq = 0, mine = true, loaded = false, daysAt = 0;
+ let offMsg = ""; // why the trace can't be watched here, when it can't
+ // the request the window was opened on (?req=), from the tray panel
+ let wanted = document.body.classList.contains("window") && Number(params.get("req")) || 0;
let cur = null; // the route the header and the log tell of: the newest played
let pinned = null; // a past route picked from the strip
let rows = new Map(); // id → { li, wire, st, bi, tg, w, rid, up }
@@ -1588,11 +1591,13 @@
statB[2].textContent = d.totals.errors;
if (!mine) {
const gw = providers?.gateway;
- offline(t(!gw?.running ? "The gateway isn't running, so nothing is routed."
+ offMsg = !gw?.running ? "The gateway isn't running, so nothing is routed."
: gw.window ? "Another magpie serves the gateway; its routing plays live in that magpie's window."
// magpie serve: its routing isn't shown anywhere
- : "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live."));
+ : "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.";
+ offline(t(offMsg));
loaded = false;
+ renderPanel();
await new Promise((r) => setTimeout(r, 5000));
continue;
}
@@ -1606,13 +1611,19 @@
}
for (const id of [...routes.keys()].sort((a, b) => a - b).slice(0, -60)) routes.delete(id);
loaded = true;
+ offMsg = "";
+ renderPanel(first || d.routes.some((r) => r.done));
// a request done is a day's count grown: heard of now and then
if (first || (d.routes.some((r) => r.done) && performance.now() - daysAt > 15e3)) { daysAt = performance.now(); loadDays(); }
if (first) {
offline("");
// the agents' names come with the app's state, which may not be here yet
for (let i = 0; i < 30 && !state.agents.length; i++) await new Promise((res) => setTimeout(res, 100));
- const r = newest();
+ renderPanel();
+ // the window opened from the tray panel's Routing tab on a request
+ const asked = wanted && routes.get(wanted), r = asked || newest();
+ if (wanted) { wanted = 0; params.delete("req"); history.replaceState(null, "", params.size ? "?" + params : location.pathname); }
+ if (asked && asked.id !== newest().id) pinned = asked;
if (r) { cur = r; sync(true); say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r)); renderAll(); } else empty();
} else {
if (cur && routes.has(cur.id) && !rp) cur = routes.get(cur.id);
@@ -1636,6 +1647,7 @@
// the caption said before, said again in these words: it was set as text
if (loaded) { if (cur) { sync(true); renderAll(); capQ = []; say(affWhy(cur, true) || ruleWhy(cur, true) || firstWhy(cur)); } else empty(); }
renderGroups();
+ renderPanel();
}
new MutationObserver(words).observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] });
@@ -2085,6 +2097,95 @@
window.addEventListener("focus", () => { if (shown()) loadGroups(); });
loadGroups();
+ // ---------- the tray panel's Routing tab ----------
+ // The gateway's latest requests, as they come, from the same trace the
+ // page above plays: who sent each, the model asked for, the provider and
+ // account it went to, the model that answered (marked when the reply
+ // names another), how it ended and when; today's calls and tokens over
+ // them, from the Usage page's count. A click opens the window's Routing
+ // page on that request; nothing here moves the panel's scroll.
+ const pBox = document.body.classList.contains("panel") ? $("#panelRouting") : null;
+ if (pBox) pBox.hidden = false;
+ const P_ROWS = 8;
+ let today = null, todayAt = 0;
+ const pShown = () => pBox && document.body.dataset.ptab === "routing";
+ async function loadToday() {
+ todayAt = performance.now();
+ try { today = await api("usage?period=today"); } catch { return; }
+ renderPanel();
+ }
+ window.panelRoutingShown = () => { if (pShown() && performance.now() - todayAt > 5e3) loadToday(); };
+ // renderPanel draws the tab; counted is a request just done, for today's
+ // totals to be read again (now and then)
+ function renderPanel(counted) {
+ if (!pBox) return;
+ if (counted && (!todayAt || performance.now() - todayAt > 5e3)) loadToday();
+ const v = $("#view-agents"), keep = v.scrollTop;
+ const head = el("div", "pr-head");
+ const sum = el("span", "pr-today");
+ if (today) {
+ sum.append(el("span", "", t("today")), " ",
+ el("b", "", String(today.calls || 0)), " ", t(today.calls === 1 ? "call" : "calls"), " · ",
+ el("b", "", fmtN(tokensOf(today) || 0)), " ", t("tokens"));
+ } else sum.append(el("span", "skeleton pr-sk"));
+ const open = el("button", "text", t("Open Routing"));
+ open.type = "button";
+ open.onclick = (e) => { api("window/main?view=routing", {}); e.currentTarget.blur(); };
+ head.append(el("span", "rt-livedot"), sum, el("span", "grow"), open);
+ const out = [head];
+ const rs = [...routes.values()].sort((a, b) => b.id - a.id).slice(0, P_ROWS);
+ if (offMsg) out.push(el("p", "pr-none", t(offMsg)));
+ else if (!loaded) for (let i = 0; i < 3; i++) out.push(el("span", "skeleton pr-sk-row"));
+ else if (!rs.length) {
+ const p = el("div", "pr-none");
+ p.append(el("b", "", t("No request yet")), t("Every request an agent sends to magpie shows up here, routed for real."));
+ out.push(p);
+ } else {
+ const list = el("div", "pr-list");
+ for (const r of rs) list.append(panelRow(r));
+ out.push(list);
+ }
+ pBox.replaceChildren(...out);
+ if (v.scrollTop !== keep) v.scrollTop = keep;
+ fit();
+ }
+ function panelRow(r) {
+ const [, how, tr] = outcome(r);
+ const w = tr && tried(r, tr);
+ const b = el("button", "pr-req " + how);
+ b.type = "button";
+ const ag = agentOf(r.agent);
+ const asked = el("span", "pr-a");
+ const sw = el("i", "ag");
+ sw.style.setProperty("--agent", hueOf(r.agent));
+ asked.append(sw, icon(ag?.icon || "generic"), el("span", "pr-who", agentName(r.agent)), el("code", "m", r.model));
+ if (r.kind) asked.append(kindTag(r));
+ const when = el("span", "at", new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
+ // where it went: the provider, the account or key, and the model that
+ // answered — marked when the reply names another than the one sent
+ const to = el("span", "pr-to");
+ to.append(el("i"));
+ const where = w ? (w.kind === "provider" || !w.who ? w.name || w.provider : `${w.name || w.provider} · ${w.who}`) : r.provider;
+ if (!r.done) to.append(el("span", "pr-where", w ? t("{who} is answering…", { who: where }) : t("routing…")));
+ else if (r.status >= 400) {
+ const last = r.tries[r.tries.length - 1];
+ to.append(el("span", "pr-where", last ? `${r.status} · ${failWord(last.fail)}` : `${r.status || ""} ${r.error || ""}`.trim()));
+ } else {
+ to.append(el("span", "pr-where", where));
+ const model = tr?.model || w?.model;
+ if (model) to.append(el("span", "pr-m", model));
+ if (tr?.swapped && tr.done) to.append(swapTag(tr, true));
+ }
+ const meta = [];
+ if (r.tries.length > 1) meta.push(t("{n} tries", { n: r.tries.length }));
+ if (r.tokens) meta.push(t("{n} tokens", { n: tokens(r.tokens) }));
+ if (r.done && r.ms) meta.push(took(r.ms));
+ b.append(asked, when, to, el("span", "meta", meta.join(" · ")));
+ b.title = `${agentName(r.agent)} · ${r.model} → ${r.provider}`;
+ b.onclick = (e) => { api("window/main?view=routing&req=" + r.id, {}); e.currentTarget.blur(); };
+ return b;
+ }
+
new ResizeObserver(() => layout()).observe(stage);
// the list is as tall as leaves the stage in sight above it: the reader
// scrolls the list, not the page, and a request picked plays in view.
diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md
index fb2a7cce..4ac7032c 100644
--- a/internal/gui/tests/README.md
+++ b/internal/gui/tests/README.md
@@ -55,6 +55,16 @@ list not moving, field and button in sight, one focus ring; the button then
reads Save and a click on it saves, as Enter does; an empty name keeps the
field; Escape closes it; in English and Chinese.
+`panel-routing.test.cjs` opens the tray panel's Routing tab: the gateway's
+latest requests from a faked trace, newest first, each with its agent, the
+model asked for, the provider and account it went to, the model that
+answered ("served gpt-6-luna" on the one a vendor answered with another, not
+on a dated name), a failure's status, and the time; today's calls and tokens
+over them; the allowances' tab named Allowances. A click, with the panel
+scrolled, asks for the window's Routing page on that request and moves
+nothing; the window opened so has that request picked. In English and
+Chinese.
+
`login-import.test.cjs` brings ChatGPT accounts in from CLIProxyAPI's auth
files: offered from the accounts list and from a browser sign-in under way,
the box says the refresh spends the file's sign-in, what is pasted is posted
@@ -82,7 +92,7 @@ the WorkBuddy group only with an account signed in; in English and Chinese.
With Node.js and Playwright available:
```sh
-node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs
+node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs
```
If Playwright is installed outside the repository, set `NODE_PATH` to the
diff --git a/internal/gui/tests/panel-routing.test.cjs b/internal/gui/tests/panel-routing.test.cjs
new file mode 100644
index 00000000..67d4c11f
--- /dev/null
+++ b/internal/gui/tests/panel-routing.test.cjs
@@ -0,0 +1,168 @@
+// Run with Node's test runner and Playwright on the module path; see README.md.
+// The tray panel's Routing tab (v, #feedback: the panel's 用量 showed the
+// accounts' allowances while the window's 用量 is per-request usage and 路由
+// the gateway's routing): the gateway's latest requests, newest first, from
+// the trace the Routing page plays — the agent, the model asked for, the
+// provider and account it went to, the model that answered with the amber
+// "served …" mark on one a vendor answered with another, how it ended and
+// when — under today's calls and tokens. The allowances' tab is named so.
+// A click opens the window's Routing page on that request and leaves the
+// panel where it is; the window opened so has that request picked. No
+// backend: the API is faked here.
+const assert = require("node:assert/strict");
+const fs = require("node:fs/promises");
+const path = require("node:path");
+const { test } = require("node:test");
+const { chromium, webkit } = require("playwright");
+
+const assets = path.resolve(__dirname, "../assets");
+const now = new Date();
+const at = (min) => new Date(now.getTime() - min * 60e3).toISOString();
+const relay = { id: "relay:team", provider: "relay", name: "Relay", kind: "key", who: "team", model: "gpt-6-sol" };
+const claude = { id: "claude:a", provider: "claude", name: "Claude", kind: "account", who: "ann@example.com", model: "claude-opus-5" };
+const ok = (w, min, extra = {}) => ({ id: w.id, model: w.model, start: at(min), done: true, status: 200, ms: 1500, ...extra });
+// not in order: the tab sorts them newest first
+const routes = [
+ { id: 101, seq: 101, time: at(6), agent: "codex", model: "relay/gpt-6-sol", provider: "relay", order: [relay],
+ tries: [{ id: relay.id, model: "gpt-6-sol", start: at(6), done: true, status: 429, ms: 300, fail: "rate" }], done: true, status: 429, ms: 300 },
+ { id: 103, seq: 103, time: at(1), agent: "codex", model: "relay/gpt-6-sol", provider: "relay", order: [relay],
+ tries: [ok(relay, 1, { served: "gpt-6-luna", swapped: true })], done: true, status: 200, ms: 1500, tokens: 12400, served: "gpt-6-luna", swapped: true },
+ { id: 100, seq: 100, time: at(9), agent: "claude", model: "claude/claude-opus-5", provider: "claude", order: [claude],
+ tries: [ok(claude, 9, { served: "claude-opus-5-20260901" })], done: true, status: 200, ms: 1500, tokens: 800 },
+ { id: 102, seq: 102, time: at(3), agent: "claude", model: "claude/claude-opus-5", provider: "claude", order: [claude],
+ tries: [ok(claude, 3)], done: true, status: 200, ms: 1500, tokens: 3000 },
+];
+
+function serve(lang, opened) {
+ const state = { agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }, { id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
+ return async (route) => {
+ const req = route.request(), url = new URL(req.url());
+ const json = (data) => route.fulfill({ json: data });
+ if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
+ if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
+ if (url.pathname === "/api/state") return json(state);
+ if (url.pathname === "/api/gateway/trace") {
+ if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3));
+ return json({ mine: true, now: now.toISOString(), seq: 103, totals: { requests: 4, rerouted: 0, errors: 1 }, routes });
+ }
+ if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] });
+ if (url.pathname === "/api/usage" && url.searchParams.get("period") === "today") {
+ return json({ calls: 42, input: 1000000, output: 234000, cache_read: 0, cache_write: 0, reasoning: 0, cost: 0, series: [] });
+ }
+ if (url.pathname === "/api/usage/quotas") return json([]);
+ if (url.pathname === "/api/groups") return json({ groups: [] });
+ if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } });
+ if (url.pathname === "/api/window/main") { opened.push(url.search); return route.fulfill({ status: 204 }); }
+ if (url.pathname === "/api/window/fit") return route.fulfill({ status: 204 });
+ if (url.pathname.startsWith("/api/")) return json({});
+ const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
+ const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
+ await route.fulfill({ body: await fs.readFile(file), contentType });
+ };
+}
+
+const want = {
+ en: { tab: "Routing", quota: "Allowances", swap: "served gpt-6-luna", today: /today\s*42\s*calls\s*·\s*1\.2M\s*tokens/, fail: /429 · rate limited/, open: "Open Routing", story: /How the request at/ },
+ zh: { tab: "路由", quota: "额度", swap: "实际 gpt-6-luna", today: /今天\s*42\s*次调用\s*·\s*1\.2M\s*token/, fail: /429 · 限流/, open: "打开路由", story: /的请求是怎么路由的/ },
+};
+
+for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
+ for (const lang of ["en", "zh"]) {
+ test(`${engine} ${lang}: the tray panel's Routing tab`, async (t) => {
+ const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
+ const errors = [], pages = [];
+ t.after(async () => {
+ if (process.env.ARTIFACT_DIR) {
+ await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+ for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-panel-routing-${i}.png`) });
+ }
+ await browser.close();
+ });
+ const open = async (url, viewport, opened) => {
+ const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage();
+ pages.push(page);
+ page.setDefaultTimeout(5000);
+ page.on("pageerror", (e) => errors.push(e.message));
+ await page.route("**/*", serve(lang, opened));
+ await page.goto(url);
+ return page;
+ };
+
+ // a short panel, so its page scrolls
+ const opened = [];
+ const page = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, opened);
+ const tab = page.locator('[data-ptab="routing"]');
+ assert.equal((await tab.textContent()).trim(), want[lang].tab);
+ assert.equal((await page.locator('[data-ptab="usage"]').textContent()).trim(), want[lang].quota, "the allowances' tab says what it shows");
+ await tab.click();
+ await page.locator(".pr-req").nth(routes.length - 1).waitFor();
+ assert(await page.locator("#panelRouting").isVisible());
+ assert(!(await page.locator("#agents").isVisible()), "the agents are another tab");
+
+ // newest first: the agent, the model asked, where it went, the mark
+ const rows = await page.locator(".pr-req").evaluateAll((rs) => rs.map((r) => ({
+ who: r.querySelector(".pr-who").textContent, model: r.querySelector(".pr-a .m").textContent,
+ where: r.querySelector(".pr-where").textContent, served: r.querySelector(".pr-m")?.textContent || "",
+ swap: r.querySelector(".swap")?.textContent || "", cls: r.className, at: r.querySelector(".at").textContent,
+ })));
+ assert.deepEqual(rows.map((r) => r.model), ["relay/gpt-6-sol", "claude/claude-opus-5", "relay/gpt-6-sol", "claude/claude-opus-5"]);
+ assert.deepEqual(rows.map((r) => r.who), ["Codex", "Claude Code", "Codex", "Claude Code"]);
+ assert.equal(rows[0].where, "Relay · team");
+ assert.equal(rows[0].served, "gpt-6-sol");
+ assert.equal(rows[1].where, "Claude · ann@example.com");
+ assert.deepEqual(rows.map((r) => r.swap), [want[lang].swap, "", "", ""], "only the request answered by another model is marked, not a dated name");
+ assert.match(rows[2].where, want[lang].fail);
+ assert.match(rows[2].cls, /\bbad\b/);
+ for (const r of rows) assert.match(r.at, /\d{1,2}:\d{2}:\d{2}/);
+ const mark = await page.locator(".pr-req .swap").evaluate((e) => {
+ const row = e.closest(".pr-req").getBoundingClientRect(), b = e.getBoundingClientRect(), cs = getComputedStyle(e);
+ return { inRow: b.left >= row.left && b.right <= row.right + 0.5 && b.width > 0, full: e.clientWidth >= e.scrollWidth, border: cs.borderLeftWidth, colour: cs.color, bg: cs.backgroundColor };
+ });
+ assert(mark.inRow && mark.full, JSON.stringify(mark));
+ assert.equal(mark.border, "0px", "no border stripe");
+ assert.notEqual(mark.bg, "rgba(0, 0, 0, 0)");
+ // no row runs out of the panel
+ const over = await page.locator(".pr-req").evaluateAll((rs) => rs.filter((r) => r.scrollWidth > r.clientWidth + 1).length);
+ assert.equal(over, 0);
+
+ // today's calls and tokens, over the list
+ await page.waitForFunction((re) => new RegExp(re).test(document.querySelector(".pr-today").textContent), want[lang].today.source);
+ assert.equal((await page.locator(".pr-head .text").textContent()).trim(), want[lang].open);
+
+ // a click opens the window on that request; the panel stays put
+ const v = page.locator("#view-agents");
+ const box = await v.boundingBox();
+ await page.mouse.move(box.x + box.width / 2, box.y + 60);
+ for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); }
+ await page.waitForTimeout(400);
+ const before = await v.evaluate((e) => e.scrollTop);
+ assert(before > 0, "the panel must be short enough to scroll");
+ const row = page.locator(".pr-req").nth(3);
+ const top = await row.evaluate((e) => e.getBoundingClientRect().top);
+ await row.click();
+ await page.waitForTimeout(400);
+ assert.equal(await v.evaluate((e) => e.scrollTop), before, "the click scrolled the panel");
+ assert(Math.abs((await row.evaluate((e) => e.getBoundingClientRect().top)) - top) <= 1);
+ assert.deepEqual(await page.evaluate(() => [scrollX, scrollY]), [0, 0]);
+ assert.deepEqual(opened, ["?view=routing&req=100"]);
+ // and Open Routing, over the list, opens the page as it is
+ for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, -40); await page.waitForTimeout(20); }
+ await page.waitForTimeout(400);
+ await page.locator(".pr-head .text").click();
+ await page.waitForTimeout(200);
+ assert.equal(await v.evaluate((e) => e.scrollTop), 0);
+ assert.deepEqual(opened, ["?view=routing&req=100", "?view=routing"]);
+
+ // the window opened so: that request picked, its story told, and the
+ // address no longer asking for it
+ const win = await open("http://magpie.test/?view=routing&req=101", { width: 1100, height: 760 }, []);
+ await win.locator(".rt-req").nth(routes.length - 1).waitFor();
+ await win.waitForFunction(() => !location.search.includes("req="));
+ const picked = await win.locator('.rt-req[aria-pressed="true"]').evaluateAll((rs) => rs.map((r) => r.querySelector(".to").textContent));
+ assert.equal(picked.length, 1);
+ assert.match(picked[0], /429/);
+ assert.match(await win.locator(".rt-log-head").textContent(), want[lang].story);
+ assert.deepEqual(errors, []);
+ });
+ }
+}
diff --git a/internal/gui/trace.go b/internal/gui/trace.go
index d4337946..a5b2012e 100644
--- a/internal/gui/trace.go
+++ b/internal/gui/trace.go
@@ -3,6 +3,7 @@ package gui
import (
"encoding/json"
"net/http"
+ "net/url"
"strconv"
"time"
@@ -16,6 +17,19 @@ type traceJSON struct {
Now time.Time `json:"now"`
}
+// mainView is the tab the window is asked to open on, as the page's view
+// parameter: the Routing page on one request (req, its id) when the tray
+// panel's Routing tab asks for it. Anything but an id is dropped.
+func mainView(q url.Values) string {
+ view := q.Get("view")
+ if req := q.Get("req"); view == "routing" && req != "" {
+ if id, err := strconv.ParseInt(req, 10, 64); err == nil && id > 0 {
+ view += "&req=" + strconv.FormatInt(id, 10)
+ }
+ }
+ return view
+}
+
// traceRoutes serves the routing trace for the Gateway view to play: it
// waits up to 25 s for something to change after the seq it is given, so
// the page hears of a request as it happens.
diff --git a/internal/gui/trace_test.go b/internal/gui/trace_test.go
new file mode 100644
index 00000000..a421afd3
--- /dev/null
+++ b/internal/gui/trace_test.go
@@ -0,0 +1,27 @@
+package gui
+
+import (
+ "net/url"
+ "testing"
+)
+
+// The tray panel's Routing tab opens the window's Routing page on the
+// request clicked: the id goes along, and nothing else does.
+func TestMainView(t *testing.T) {
+ for _, c := range []struct {
+ q url.Values
+ want string
+ }{
+ {url.Values{}, ""},
+ {url.Values{"view": {"settings"}}, "settings"},
+ {url.Values{"view": {"routing"}}, "routing"},
+ {url.Values{"view": {"routing"}, "req": {"42"}}, "routing&req=42"},
+ {url.Values{"view": {"settings"}, "req": {"42"}}, "settings"},
+ {url.Values{"view": {"routing"}, "req": {"42&view=x"}}, "routing"},
+ {url.Values{"view": {"routing"}, "req": {"-1"}}, "routing"},
+ } {
+ if got := mainView(c.q); got != c.want {
+ t.Errorf("%v: %q, want %q", c.q, got, c.want)
+ }
+ }
+}