gui(routing): Routing is a view of its own, beside Gateway — the Gateway view keeps to connecting and the model list; Routing plays each request with room to spare, every request the gateway keeps is listed (when, the agent and the model it asked for, who answered and with what model, the tries, how long, the tokens) and picking one shows how it was routed, and each account or key those requests weighed says how often it was tried, answered and failed, what it answered with, when last, and whether it rests; the model each account or key is asked for shows on its row and in the story
This commit is contained in:
5 files changed
+210
-40
No files matched your search
@@ -3195,10 +3195,10 @@ function show(v) {
|
||||
view = v;
|
||||
if (mode === "window") { for (const b of $("#nav").querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === v); slide($("#nav"), "nav"); }
|
||||
$("#prefs").classList.toggle("on", v === "settings");
|
||||
for (const id of ["agents", "providers", "gateway", "usage", "settings"]) $("#view-" + id).hidden = v !== id;
|
||||
for (const id of ["agents", "providers", "gateway", "routing", "usage", "settings"]) $("#view-" + id).hidden = v !== id;
|
||||
closePicker();
|
||||
if (v !== "providers" && editing !== null) cancelEdit();
|
||||
if (v === "providers" || v === "gateway") loadProviders().catch((e) => status(e.message, "err"));
|
||||
if (v === "providers" || v === "gateway" || v === "routing") loadProviders().catch((e) => status(e.message, "err"));
|
||||
if (v === "usage") loadUsage().catch((e) => status(e.message, "err"));
|
||||
if (v === "settings") loadSettings().catch((e) => status(e.message, "err"));
|
||||
syncURL();
|
||||
@@ -3291,6 +3291,6 @@ if (mode === "window" && params.get("import")) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (mode === "window" && params.get("view") === "providers" && params.get("edit")) editing = params.get("edit");
|
||||
if (mode === "window" && ["providers", "gateway", "usage", "settings"].includes(params.get("view"))) show(params.get("view"));
|
||||
if (mode === "window" && ["providers", "gateway", "routing", "usage", "settings"].includes(params.get("view"))) show(params.get("view"));
|
||||
else if (mode === "window") slide($("#nav"), "nav");
|
||||
load();
|
||||
@@ -463,7 +463,6 @@ const I18N = {
|
||||
"fallback": "备用",
|
||||
"gateway": "网关",
|
||||
"your agent": "你的 Agent",
|
||||
"Recent": "最近",
|
||||
"Back to live": "回到实时",
|
||||
"How the last request was routed": "最近一个请求是怎么路由的",
|
||||
"How the request at {time} was routed": "{time} 的请求是怎么路由的",
|
||||
@@ -526,7 +525,18 @@ const I18N = {
|
||||
"In order: with {rested} resting after a failure, {who} is the first that can answer.": "按顺序:{rested} 失败后正在休息,{who} 是第一个能答的。",
|
||||
"{agent} asked for {model}": "{agent} 请求 {model}",
|
||||
"Nothing was tried: {error}": "没有尝试任何账号:{error}",
|
||||
"rerouted {n}×": "改道 {n} 次",
|
||||
"Requests": "请求",
|
||||
"{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}": "{agent} 请求 {model}:由 {name} 提供,向厂商请求的模型是 {sent}",
|
||||
"the last {n} the gateway keeps": "网关保留的最近 {n} 个",
|
||||
"Accounts and keys": "账号与密钥",
|
||||
"over those requests": "统计自这些请求",
|
||||
"tried {n}": "尝试 {n}",
|
||||
"answered {n}": "应答 {n}",
|
||||
"last answered {time}": "最近应答 {time}",
|
||||
"as of {time}": "截至 {time}",
|
||||
"{n} used at {time}; it has renewed since": "{time} 时已用 {n},之后已重置",
|
||||
"{n} tries": "{n} 次尝试",
|
||||
"routing…": "路由中…",
|
||||
"back in {d}": "{d}后恢复",
|
||||
"back at {time}": "{time} 恢复",
|
||||
"its plan doesn't list {model}": "套餐不包含 {model}",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<button data-view="agents" class="on" data-t>Agents</button>
|
||||
<button data-view="providers" data-t>Providers</button>
|
||||
<button data-view="gateway" data-t>Gateway</button>
|
||||
<button data-view="routing" data-t>Routing</button>
|
||||
<button data-view="usage" data-t>Usage</button>
|
||||
</nav>
|
||||
<div class="actions" style="--wails-draggable:no-drag">
|
||||
@@ -58,8 +59,6 @@
|
||||
<div class="list gwcard">
|
||||
<div class="row gw" id="gateway"></div>
|
||||
</div>
|
||||
<div class="row-head"><span class="label" data-t>Routing</span><span class="rt-livedot" aria-hidden="true"></span><span class="grow"></span><span class="note" data-t>live, as the gateway decides</span></div>
|
||||
<section class="rt" id="rt"></section>
|
||||
<div class="row-head"><span class="label" data-t>Connect</span><span class="grow"></span><span class="note" id="connectNote"></span></div>
|
||||
<section class="list connect" id="connect"></section>
|
||||
<div class="row-head"><span class="label" data-t>Models</span><span class="grow"></span><button class="text" id="copyModels" data-t>Copy all ids</button></div>
|
||||
@@ -68,6 +67,12 @@
|
||||
<div class="list activity" id="activity"></div>
|
||||
</main>
|
||||
|
||||
<main class="view" id="view-routing" hidden>
|
||||
<div class="row-head"><span class="label" data-t>Routing</span><span class="rt-livedot" aria-hidden="true"></span><span class="note" data-t>live, as the gateway decides</span></div>
|
||||
<section class="rt" id="rt"></section>
|
||||
<div class="rt-more" id="rtMore"></div>
|
||||
</main>
|
||||
|
||||
<main class="view" id="view-usage" hidden>
|
||||
<div class="usage-head">
|
||||
<div class="segs" id="period"></div>
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
.rt-accts b .ic { display: inline-flex; width: 16px; height: 16px; margin-right: 7px; vertical-align: -3px; }
|
||||
.rt-accts b .ic img, .rt-accts b .ic svg { width: 16px; height: 16px; }
|
||||
.rt-accts b .fb { margin-left: 6px; font-size: 10.5px; font-weight: 560; padding: 0 6px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
|
||||
.rt-accts b code.mdl, .rt-req code, .rt-act code.mdl { 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; }
|
||||
.rt-accts b code.mdl { margin-left: 7px; font-weight: 450; }
|
||||
.rt-accts em { font-style: normal; font-size: 12px; color: var(--muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.rt-accts .bar { grid-column: 2 / 4; height: 3px; border-radius: 2px; background: var(--card-2); overflow: hidden; }
|
||||
.rt-accts .bar i { display: block; height: 100%; width: 0; border-radius: 2px; background: var(--muted); transition: width .4s; }
|
||||
@@ -60,15 +62,6 @@
|
||||
.rt-stats { display: flex; gap: 18px; font-size: 11px; color: var(--muted); }
|
||||
.rt-stats > span { display: flex; flex-direction: column; }
|
||||
.rt-stats b { font-size: 19px; line-height: 1.2; font-weight: 650; letter-spacing: -.02em; color: var(--fg); font-variant-numeric: tabular-nums; }
|
||||
.rt-hist { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; padding-top: 10px; margin-top: 10px; border-top: 1px solid var(--line); }
|
||||
.rt-hist .label { font-size: 11px; color: var(--muted); margin-right: 4px; }
|
||||
.rt-h { display: inline-flex; align-items: center; gap: 5px; font: inherit; font-size: 11px; font-variant-numeric: tabular-nums; color: var(--fg-2); background: none; border: 1px solid transparent; border-radius: 6px; padding: 2px 6px; cursor: pointer; }
|
||||
.rt-h:hover { background: var(--card-2); }
|
||||
.rt-h[aria-pressed="true"] { border-color: var(--line); background: var(--card-2); color: var(--fg); }
|
||||
.rt-h i { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||
.rt-h.moved i { background: var(--accent); }
|
||||
.rt-h.bad i { background: var(--red); }
|
||||
.rt-h.wait i { background: var(--muted); }
|
||||
.rt-log { padding-top: 10px; margin-top: 10px; border-top: 1px solid var(--line); }
|
||||
.rt-log-head { display: flex; align-items: center; font-size: 12px; font-weight: 600; color: var(--fg); }
|
||||
.rt-log-head .grow { flex: 1; }
|
||||
@@ -103,5 +96,47 @@
|
||||
.rt-accts .bar { grid-column: 2; }
|
||||
.rt-stats { gap: 14px; }
|
||||
}
|
||||
/* the Routing view */
|
||||
#view-routing .row-head { margin: 14px 0 6px; padding: 0 2px; gap: 0; }
|
||||
#view-routing .row-head .note { font-size: 11px; color: var(--faint); margin-left: 8px; }
|
||||
#view-routing .row-head .rt-livedot + .note { margin-left: 8px; }
|
||||
#view-routing .rt { margin: 0; }
|
||||
.rt-more { container-type: inline-size; padding-bottom: 12px; }
|
||||
.rt-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 16px; }
|
||||
@container (min-width: 860px) { .rt-cols { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }
|
||||
.rt-col { min-width: 0; }
|
||||
.rt-reqs { padding: 4px; max-height: 420px; overflow-y: auto; display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto; }
|
||||
.rt-req { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; column-gap: 12px; padding: 6px 8px; font: inherit; font-size: 12px; text-align: left; color: var(--fg-2); background: none; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
|
||||
.rt-req:hover { background: var(--card-2); }
|
||||
.rt-req[aria-pressed="true"] { background: var(--card-2); border-color: var(--line); color: var(--fg); }
|
||||
.rt-req .at { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.rt-req .asked { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.rt-req .asked .ic, .rt-req .asked .ic img, .rt-req .asked .ic svg { width: 14px; height: 14px; flex: none; }
|
||||
.rt-req .m, .rt-req .to span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rt-req .to { display: flex; align-items: center; gap: 6px; min-width: 0; color: var(--fg); }
|
||||
.rt-req .to i { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||
.rt-req.moved .to i { background: var(--accent); }
|
||||
.rt-req.bad .to { color: var(--red); }
|
||||
.rt-req.bad .to i { background: var(--red); }
|
||||
.rt-req.wait .to i { background: var(--muted); animation: rtlive 1.8s infinite; }
|
||||
.rt-req .meta { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
|
||||
@container (max-width: 520px) { .rt-reqs { grid-template-columns: auto minmax(0, 1fr); } .rt-req { row-gap: 2px; } .rt-req .to { grid-column: 2; } .rt-req .meta { grid-column: 2; text-align: left; } }
|
||||
.rt-acts { padding: 4px 0; }
|
||||
.rt-prov { display: flex; align-items: center; gap: 7px; padding: 8px 12px 4px; font-size: 12px; }
|
||||
.rt-prov .ic, .rt-prov .ic img, .rt-prov .ic svg { width: 14px; height: 14px; }
|
||||
.rt-prov > span:last-child { margin-left: auto; font-size: 10.5px; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
|
||||
.rt-prov:not(:first-child) { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 10px; }
|
||||
.rt-act { display: grid; gap: 3px; padding: 6px 12px 6px 33px; font-size: 12px; }
|
||||
.rt-act .nm { display: flex; gap: 6px; min-width: 0; }
|
||||
.rt-act .nm b { font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rt-act .nm span { color: var(--muted); white-space: nowrap; }
|
||||
.rt-act .st { color: var(--fg-2); }
|
||||
.rt-act .st:empty { display: none; }
|
||||
.rt-act .st.rest { color: var(--red); }
|
||||
.rt-act .st.left { color: var(--muted); }
|
||||
.rt-act .tally { display: flex; flex-wrap: wrap; gap: 2px 12px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
.rt-act .tally .bad { color: var(--red); }
|
||||
.rt-act .bar { height: 3px; border-radius: 2px; background: var(--card-2); overflow: hidden; }
|
||||
.rt-act .bar i { display: block; height: 100%; border-radius: 2px; background: var(--muted); }
|
||||
.rt-livedot { width: 6px; height: 6px; margin-left: 7px; border-radius: 50%; background: var(--green); animation: rtlive 1.8s infinite; --fg: var(--green); }
|
||||
@media (prefers-reduced-motion: reduce) { .rt-livedot { animation: none; } }
|
||||
+144
-24
@@ -10,7 +10,7 @@
|
||||
if (!box) return;
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const still = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const shown = () => !$("#view-gateway").hidden && !document.hidden;
|
||||
const shown = () => !$("#view-routing").hidden && !document.hidden;
|
||||
|
||||
// ---------- the stage ----------
|
||||
|
||||
@@ -50,10 +50,23 @@
|
||||
const log = el("div", "rt-log");
|
||||
const logHead = el("div", "rt-log-head");
|
||||
const steps = el("ol", "rt-steps");
|
||||
const hist = el("div", "rt-hist");
|
||||
log.append(logHead, steps);
|
||||
const off = el("div", "none rt-off");
|
||||
box.append(top, stage, foot, hist, log, off);
|
||||
box.append(top, stage, foot, log, off);
|
||||
|
||||
// under the stage: every request the gateway keeps, and each account or
|
||||
// key as those requests found it
|
||||
const more = $("#rtMore");
|
||||
const reqHead = el("div", "row-head"), reqNote = el("span", "note");
|
||||
const reqs = el("div", "list rt-reqs");
|
||||
const actHead = el("div", "row-head"), actNote = el("span", "note");
|
||||
const acts = el("div", "list rt-acts");
|
||||
const hist = el("div", "rt-cols");
|
||||
const colA = el("div", "rt-col"), colB = el("div", "rt-col");
|
||||
colA.append(reqHead, reqs);
|
||||
colB.append(actHead, acts);
|
||||
hist.append(colA, colB);
|
||||
more.append(hist);
|
||||
|
||||
const path = () => { const p = document.createElementNS(NS, "path"); wires.appendChild(p); return p; };
|
||||
const wSrc = path();
|
||||
@@ -201,7 +214,8 @@
|
||||
}
|
||||
|
||||
function tryWhy(r, i) {
|
||||
const tr = r.tries[i], w = r.order.find((x) => x.id === tr.id), name = w ? who(w) : tr.id, agent = agentName(r.agent);
|
||||
const tr = r.tries[i], w = r.order.find((x) => x.id === tr.id), agent = agentName(r.agent);
|
||||
const name = w ? `${who(w)} (${w.model})` : tr.id;
|
||||
if (!tr.done) return t("{who} is answering…", { who: name });
|
||||
if (tr.status < 400) {
|
||||
const tk = r.tokens ? " · " + t("{n} tokens", { n: tokens(r.tokens) }) : "";
|
||||
@@ -279,8 +293,8 @@
|
||||
b.append(icon(w.icon || (w.preset ? w.preset : "generic")));
|
||||
const name = el("span", "who", who(w));
|
||||
// the provider's name heads the card; a row names what differs
|
||||
const sub = el("span", "", w.fallback ? `${w.name} · ${w.provider}/${w.model}` : w.kind === "provider" ? w.model : w.plan || "");
|
||||
b.append(name, " ", sub);
|
||||
const sub = el("span", "", w.fallback ? w.name : w.kind === "provider" ? "" : w.plan || "");
|
||||
b.append(name, " ", sub, el("code", "mdl", w.model));
|
||||
if (w.fallback) b.append(el("small", "fb", t("fallback")));
|
||||
const st = el("em"), bar = el("div", "bar"), bi = el("i"), tg = el("span", "tag");
|
||||
bar.append(bi);
|
||||
@@ -384,7 +398,10 @@
|
||||
logHead.append(live);
|
||||
}
|
||||
const items = [];
|
||||
items.push([t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (r.order.find((x) => !x.fallback)?.name || r.provider), ""]);
|
||||
const main = r.order.find((x) => !x.fallback);
|
||||
items.push([main && main.model !== r.model
|
||||
? t("{agent} asked for {model}: {name} serves it, and the vendor is asked for {sent}", { agent: agentName(r.agent), model: r.model, name: main.name, sent: main.model })
|
||||
: t("{agent} asked for {model}", { agent: agentName(r.agent), model: r.model }) + " → " + (main?.name || r.provider), ""]);
|
||||
items.push([firstWhy(r), "why"]);
|
||||
for (const a of asides(r)) items.push([a, "aside"]);
|
||||
r.tries.forEach((_, i) => items.push([tryWhy(r, i), r.tries[i].done ? (r.tries[i].status < 400 ? "ok" : "bad") : "wait"]));
|
||||
@@ -392,25 +409,127 @@
|
||||
steps.replaceChildren(...items.map(([s, c]) => el("li", c, s)));
|
||||
}
|
||||
|
||||
// the strip of recent requests: pick one to see how it was routed
|
||||
// pick sets the stage to a past request, or back to live with the newest
|
||||
function pick(r) {
|
||||
pinned = r.id === newest()?.id ? null : r;
|
||||
gen++; trips = []; flying.clear(); for (const p of wires.querySelectorAll(".pkt")) p.remove();
|
||||
stageFor(pinned || r); renderAll();
|
||||
say(firstWhy(r));
|
||||
if (pinned) box.scrollIntoView({ block: "nearest", behavior: still() ? "auto" : "smooth" });
|
||||
}
|
||||
|
||||
// who answered a request, or what its agent got
|
||||
function outcome(r) {
|
||||
if (!r.done) {
|
||||
const tr = r.tries[r.tries.length - 1], w = tr && r.order.find((x) => x.id === tr.id);
|
||||
return [w ? t("{who} is answering…", { who: `${who(w)} · ${w.model}` }) : t("routing…"), "wait"];
|
||||
}
|
||||
const ok = r.tries.find((tr) => tr.done && tr.status < 400), w = ok && r.order.find((x) => x.id === ok.id);
|
||||
if (r.status < 400) return [w ? `${who(w)} · ${w.model}` : r.provider, r.tries.length > 1 ? "moved" : "ok"];
|
||||
const last = r.tries[r.tries.length - 1];
|
||||
return [last ? `${r.status} · ${failWord(last.fail)}` : `${r.status || ""} ${r.error || ""}`.trim(), "bad"];
|
||||
}
|
||||
|
||||
// the requests the gateway keeps, newest first: pick one to see how it was routed
|
||||
function renderHist() {
|
||||
const rs = [...routes.values()].sort((a, b) => b.id - a.id).slice(0, 14);
|
||||
hist.hidden = rs.length < 2;
|
||||
hist.replaceChildren(el("span", "label", t("Recent")));
|
||||
for (const r of rs) {
|
||||
const b = el("button", "rt-h" + (r.done ? r.status >= 400 ? " bad" : r.tries.length > 1 ? " moved" : "" : " wait"));
|
||||
const rs = [...routes.values()].sort((a, b) => b.id - a.id);
|
||||
hist.hidden = !rs.length;
|
||||
reqHead.replaceChildren(el("span", "label", t("Requests")), el("span", "grow"), reqNote);
|
||||
reqNote.textContent = t("the last {n} the gateway keeps", { n: rs.length });
|
||||
reqs.replaceChildren(...rs.map((r) => {
|
||||
const [said, how] = outcome(r);
|
||||
const b = el("button", "rt-req " + how);
|
||||
const sel = pinned ? pinned.id === r.id : cur?.id === r.id;
|
||||
b.setAttribute("aria-pressed", String(sel));
|
||||
b.title = `${clock(r.time)} · ${r.model}` + (r.tries.length > 1 ? " · " + t("rerouted {n}×", { n: r.tries.length - 1 }) : "") + (r.done ? ` · ${r.status}` : "");
|
||||
b.append(el("i"), el("span", "", new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })));
|
||||
b.onclick = () => {
|
||||
pinned = r.id === newest()?.id ? null : r;
|
||||
gen++; trips = []; flying.clear(); for (const p of wires.querySelectorAll(".pkt")) p.remove();
|
||||
stageFor(r); renderAll();
|
||||
say(firstWhy(r));
|
||||
};
|
||||
hist.append(b);
|
||||
const ag = agentOf(r.agent);
|
||||
const when = el("span", "at", new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
|
||||
const asked = el("span", "asked");
|
||||
asked.append(icon(ag?.icon || "generic"), el("span", "m", r.model));
|
||||
const to = el("span", "to");
|
||||
to.append(el("i"), el("span", "", said));
|
||||
const meta = [];
|
||||
if (r.tries.length > 1) meta.push(t("{n} tries", { n: r.tries.length }));
|
||||
if (r.done && r.ms) meta.push(took(r.ms));
|
||||
if (r.tokens) meta.push(t("{n} tokens", { n: tokens(r.tokens) }));
|
||||
b.append(when, asked, to, el("span", "meta", meta.join(" · ")));
|
||||
b.title = `${agentName(r.agent)} · ${r.model} → ${r.provider}`;
|
||||
b.onclick = () => pick(r);
|
||||
return b;
|
||||
}));
|
||||
renderActs(rs);
|
||||
}
|
||||
|
||||
// each account or key the kept requests weighed: how often it was
|
||||
// tried, answered and failed in them, and how the latest found it
|
||||
function renderActs(rs) {
|
||||
const by = new Map();
|
||||
for (const r of [...rs].reverse()) { // oldest first, so the latest wins
|
||||
const all = [...r.order, ...(r.left || [])];
|
||||
all.forEach((w, i) => {
|
||||
const a = by.get(w.id) || { w, tried: 0, ok: 0, fails: {}, last: 0, rest: null, restAt: 0, seen: 0, pos: 0, models: new Set() };
|
||||
a.w = w; a.seen++; a.pos = i; a.at = r.time;
|
||||
// a later request found it resting, or not
|
||||
if (at(r.time) >= a.restAt) { a.rest = w.rest || null; a.restAt = at(r.time); }
|
||||
by.set(w.id, a);
|
||||
});
|
||||
for (const tr of r.tries) {
|
||||
const a = by.get(tr.id);
|
||||
if (!a || !tr.done) continue;
|
||||
a.tried++;
|
||||
const end = at(tr.start) + (tr.ms || 0);
|
||||
if (tr.status < 400) { a.ok++; a.last = Math.max(a.last, end); const m = r.order.find((x) => x.id === tr.id)?.model; if (m) a.models.add(m); }
|
||||
else a.fails[tr.fail || "other"] = (a.fails[tr.fail || "other"] || 0) + 1;
|
||||
if (tr.rest && end >= a.restAt) { a.rest = tr.rest; a.restAt = end; }
|
||||
}
|
||||
}
|
||||
const list = [...by.values()].sort((x, y) => (x.w.name || "").localeCompare(y.w.name || "") || x.w.provider.localeCompare(y.w.provider) || x.pos - y.pos);
|
||||
actHead.replaceChildren(el("span", "label", t("Accounts and keys")), el("span", "grow"), actNote);
|
||||
actNote.textContent = t("over those requests");
|
||||
const n = now();
|
||||
let prov = "";
|
||||
const out = [];
|
||||
for (const a of list) {
|
||||
const w = a.w;
|
||||
if (w.provider !== prov) {
|
||||
prov = w.provider;
|
||||
const h = el("div", "rt-prov");
|
||||
h.append(icon(w.icon || w.preset || "generic"), el("b", "", w.name || w.provider));
|
||||
const m = MODES[list.find((x) => x.w.provider === prov && !x.w.fallback)?.w.routing || ""] || MODES[""];
|
||||
h.append(el("span", "", t(w.kind === "key" && !w.routing ? "Smart" : m[0])));
|
||||
out.push(h);
|
||||
}
|
||||
const row = el("div", "rt-act");
|
||||
const name = el("div", "nm");
|
||||
name.append(el("b", "", who(w)), el("span", "", w.kind === "provider" ? w.model : w.plan || (w.kind === "key" ? t("API key") : "")));
|
||||
let st, cls = "";
|
||||
const resting = a.rest && at(a.rest.until) > n;
|
||||
if (resting) { st = `${failWord(a.rest.why)} · ${restWhen(a.rest)}`; cls = "rest"; }
|
||||
else if (w.unlisted) { st = t("its plan doesn't list {model}", { model: w.model }); cls = "left"; }
|
||||
else if (w.kind === "account" && w.known) {
|
||||
const soon = renews(w)[0];
|
||||
st = soon && soon <= n ? t("{n} used at {time}; it has renewed since", { n: pct(w.used), time: clock(a.at) })
|
||||
: (soon ? t("{n} used · renews in {d}", { n: pct(w.used), d: dur(soon - n) }) : t("{n} used", { n: pct(w.used) })) + " · " + t("as of {time}", { time: clock(a.at) });
|
||||
} else if (w.kind === "account") st = t("what's left not known yet");
|
||||
else st = "";
|
||||
const tally = el("div", "tally");
|
||||
const fails = Object.entries(a.fails).map(([k, v]) => `${v} ${failWord(k)}`);
|
||||
tally.append(
|
||||
el("span", "", t("tried {n}", { n: a.tried })),
|
||||
el("span", "ok", t("answered {n}", { n: a.ok })),
|
||||
...(fails.length ? [el("span", "bad", fails.join(", "))] : []),
|
||||
...(a.last ? [el("span", "", t("last answered {time}", { time: clock(a.last) }))] : []),
|
||||
...[...a.models].map((m) => el("code", "mdl", m)));
|
||||
row.append(name, el("div", "st " + cls, st), tally);
|
||||
if (w.kind === "account" && w.known) {
|
||||
const bar = el("div", "bar"), bi = el("i");
|
||||
bi.style.width = Math.min(100, w.used) + "%";
|
||||
bar.append(bi);
|
||||
row.append(bar);
|
||||
}
|
||||
row.title = w.id;
|
||||
out.push(row);
|
||||
}
|
||||
acts.replaceChildren(...out);
|
||||
}
|
||||
|
||||
function renderAll() { render(); renderLog(); renderHist(); }
|
||||
@@ -520,12 +639,13 @@
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
// countdowns tick once a second
|
||||
setInterval(() => { if (shown()) render(); }, 1000);
|
||||
setInterval(() => { if (shown()) { render(); renderActs([...routes.values()].sort((a, b) => b.id - a.id)); } }, 1000);
|
||||
|
||||
function offline(msg) {
|
||||
off.textContent = msg;
|
||||
off.hidden = !msg;
|
||||
for (const e of [top, stage, foot, hist, log]) e.hidden = !!msg;
|
||||
for (const e of [top, stage, foot, log]) e.hidden = !!msg;
|
||||
more.hidden = !!msg;
|
||||
}
|
||||
|
||||
function empty() {
|
||||
|
||||
Reference in new issue
Block a user