panel: a Routing tab shows the gateway's latest requests, and the allowances' tab is named Allowances (v on Discord #feedback: the panel's Usage tab and the window's Usage page mean different things, and the panel had no routing) — the tray panel had Agents, Usage and Profiles, its Usage being each subscription's allowance while the window's Usage counts requests and Routing shows where they went. The panel's tab now reads Allowances (额度; the window keeps Usage/用量, and the tab's id and remembered choice are unchanged), and a new Routing tab (路由) lists the gateway's last 8 requests as they come, from the trace the Routing page already long-polls (/api/gateway/trace, which routing.js polled in the panel too; no new endpoint): each a two-line row with the agent's dot, icon and name and the model it asked for, the time, then where it went (provider · account or key), the model that try sent and, when the reply named another model (the new served/swapped fields), the amber "served gpt-6-luna" (实际 …) tag of the Requests list, beside the tokens and time taken; a failed one says its status and why (429 · rate limited), one under way who is answering. Over them, today's calls and tokens (the Usage page's /api/usage?period=today, read when the tab is shown and after a request ends, at most every 5 s) and Open Routing. A click on a request opens the window's Routing page on it: POST /api/window/main takes req beside view=routing (mainView lets only a positive id through), and the page, loaded so, picks that request as a click in its list would (its story told) and drops req from the address. The panel's height counts the tab as its tallest, as the others; the tab redraws keeping the panel's scroll, the click moves nothing, no border stripe. Tested with a new internal/gui/tests/panel-routing.test.cjs in Chromium and WebKit, en and zh: the Routing and Allowances labels, four fixture requests listed newest first with agent, model, "Relay · team" and "Claude · ann@example.com", the served tag on the swapped one only (not on a dated name), the 429 row, times, no row overflowing, today's 42 calls · 1.2M tokens, a click with the panel scrolled asking for view=routing&req=100 and leaving the scroll as it was, Open Routing, and the window opened on ?view=routing&req=101 with that request picked and its story shown; and TestMainView. go build -tags nogui (and GOOS=linux, GOOS=windows), go test -tags nogui ./... and the whole GUI suite (102 tests) pass. Not tried in the real tray panel or with a live gateway: the window opening on the request is tested in the page, not through Wails.
This commit is contained in:
11 files changed
+373
-12
No files matched your search
+1
-1
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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} 分钟",
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
|
||||
<nav class="ptabs" id="ptabs" hidden>
|
||||
<button type="button" data-ptab="agents"><span data-t>Agents</span><em id="ptabN"></em></button>
|
||||
<button type="button" data-ptab="usage"><span data-t>Usage</span></button>
|
||||
<button type="button" data-ptab="usage"><span data-t>Allowances</span></button>
|
||||
<button type="button" data-ptab="routing"><span data-t>Routing</span></button>
|
||||
<button type="button" data-ptab="profiles"><span data-t>Profiles</span></button>
|
||||
</nav>
|
||||
|
||||
@@ -57,6 +58,7 @@
|
||||
<button class="text" id="save" data-t>+ Save current</button>
|
||||
</section>
|
||||
<section class="panel-quota" id="panelQuota" hidden></section>
|
||||
<section class="panel-routing" id="panelRouting" hidden></section>
|
||||
</main>
|
||||
|
||||
<main class="view" id="view-providers" hidden>
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user