clauderipple 0.9.5 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +118 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +70 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +133 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +227 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +50 -40
- package/dist/cli/src/settings.js +26 -1
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +473 -227
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +237 -32
- package/dist/mod/hooks/session.ts +102 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +903 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +120 -4
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +408 -27
- package/dist/mod/types/index.d.ts +66 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +336 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +331 -5
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +94 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +108 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +191 -14
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +302 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/translate.js +13 -2
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +371 -261
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +1083 -59
- package/dist/ui/i18n.js +670 -69
- package/dist/ui/index.html +33 -6
- package/dist/ui/style.css +177 -0
- package/docs/ARCHITECTURE.md +402 -14
- package/package.json +8 -2
package/dist/ui/app.js
CHANGED
|
@@ -21,6 +21,20 @@ function el(tag, attrs, children) {
|
|
|
21
21
|
return node;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Inside the ClaudeRipple app the page is the app's own copy, so it is here even with the router
|
|
25
|
+
// down; window.rippleApp is how it asks the app for what only the app can do (repair, the direct
|
|
26
|
+
// switch). In a browser the object is absent and none of the app-only parts are drawn.
|
|
27
|
+
const APP = window.rippleApp || null;
|
|
28
|
+
if (APP) {
|
|
29
|
+
document.documentElement.classList.add("in-app", `platform-${APP.platform}`);
|
|
30
|
+
// Korean wraps between words in the app's own sentences (style.css :lang(ko)).
|
|
31
|
+
document.documentElement.lang = CURRENT_LANG;
|
|
32
|
+
}
|
|
33
|
+
/** The router's admin API answered 503 "routerDown" (the app's way of saying it is not running). */
|
|
34
|
+
let routerDown = false;
|
|
35
|
+
/** What the app last said about the connection (doctor, a running repair, …); see renderConnection. */
|
|
36
|
+
let shellState = null;
|
|
37
|
+
|
|
24
38
|
let toastTimer = null;
|
|
25
39
|
function toast(message, isError, raw) {
|
|
26
40
|
const box = $("#toast");
|
|
@@ -71,9 +85,13 @@ function modelsOf(provider) {
|
|
|
71
85
|
}
|
|
72
86
|
function labelOf(model) { return model.name || model.id; }
|
|
73
87
|
function clone(value) { return JSON.parse(JSON.stringify(value)); }
|
|
74
|
-
function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "")
|
|
75
|
-
|
|
76
|
-
|
|
88
|
+
function safeName(value) { return value.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""); }
|
|
89
|
+
// A name with no Latin letters ("하이브") slugs to nothing: fall back to the URL's domain ("thehive").
|
|
90
|
+
function domainWord(url) {
|
|
91
|
+
try { const parts = new URL(url).hostname.split("."); return safeName(parts.length > 1 ? parts[parts.length - 2] : parts[0]); } catch { return ""; }
|
|
92
|
+
}
|
|
93
|
+
function uniqueName(name, providers, url) {
|
|
94
|
+
const base = safeName(name) || (url ? domainWord(url) : "") || "provider";
|
|
77
95
|
let value = base;
|
|
78
96
|
let number = 2;
|
|
79
97
|
while (providers[value]) value = `${base}-${number++}`;
|
|
@@ -153,6 +171,10 @@ let pickerBusy = false;
|
|
|
153
171
|
let probeStates = new Map();
|
|
154
172
|
let chatgptLoginBusy = false;
|
|
155
173
|
let chatgptLoginMessage = "";
|
|
174
|
+
let phoneGeneration = 0;
|
|
175
|
+
let phoneSnapshot = null;
|
|
176
|
+
let phoneQrGeneration = 0;
|
|
177
|
+
let phoneBusy = false;
|
|
156
178
|
|
|
157
179
|
function selectOption(value, text) { return el("option", { value }, [text]); }
|
|
158
180
|
function badge(kind, text) { return el("span", { class: `badge ${kind} dot`, text }, []); }
|
|
@@ -195,9 +217,16 @@ function svgIcon(viewBox, paths, cls) {
|
|
|
195
217
|
function chevronIcon() { return svgIcon("0 0 8 13", ["M1.5 1.5l5 5-5 5"], "chevron"); }
|
|
196
218
|
|
|
197
219
|
function showView(name) {
|
|
220
|
+
if (name !== "phone") { phoneGeneration++; hidePhoneQr(); }
|
|
198
221
|
for (const button of $all(".nav-btn")) button.classList.toggle("active", button.dataset.view === name);
|
|
199
222
|
for (const view of $all(".view")) view.classList.toggle("active", view.id === `view-${name}`);
|
|
200
223
|
if (location.hash !== `#${name}`) history.replaceState(null, "", `#${name}`);
|
|
224
|
+
applyOffline();
|
|
225
|
+
// With the router down (inside the app) a page has nothing to load; it says so instead of
|
|
226
|
+
// failing, and loads when the router is back (setRouterDown).
|
|
227
|
+
if (offline()) return;
|
|
228
|
+
if (name === "phone") queueMicrotask(() => void loadPhone());
|
|
229
|
+
if (name === "mac") queueMicrotask(() => void loadMac());
|
|
201
230
|
if (name === "slots" && !slotsLoaded) void loadSlots();
|
|
202
231
|
if (name === "providers" && !providersLoaded) void loadProviders();
|
|
203
232
|
if (name === "clients" && !clientsLoaded) void loadClients();
|
|
@@ -241,8 +270,11 @@ async function refreshHealth() {
|
|
|
241
270
|
status = await api("/api/status");
|
|
242
271
|
} catch (error) {
|
|
243
272
|
$("#health-desktop").replaceChildren(cell(t("health.connection"), badge("bad", t("health.disconnected"))));
|
|
273
|
+
// Inside the app a router that does not answer is a state with a way out, not an error.
|
|
274
|
+
if (APP && error.body && error.body.routerDown) setRouterDown(true);
|
|
244
275
|
return;
|
|
245
276
|
}
|
|
277
|
+
if (APP) setRouterDown(false);
|
|
246
278
|
// Served files changed underneath an open window (e.g. after an update): reload, unless a form is open.
|
|
247
279
|
if (status.uiRevision) {
|
|
248
280
|
if (loadedUiRevision === null) loadedUiRevision = status.uiRevision;
|
|
@@ -322,6 +354,257 @@ async function startUpdate() {
|
|
|
322
354
|
renderVersionRow();
|
|
323
355
|
}
|
|
324
356
|
|
|
357
|
+
// ---- Inside the app: the connection card, and pages while the router is down -------------
|
|
358
|
+
// The app can do what this page cannot once the router is down: ask the CLI what is wrong
|
|
359
|
+
// (doctor), repair it, switch Claude to its default connection and back, reopen Claude. The card at
|
|
360
|
+
// the top of Status draws that, calm while all is well and plain about what to press when not. The
|
|
361
|
+
// other pages say where to go instead of failing. None of it exists in a browser.
|
|
362
|
+
|
|
363
|
+
function offline() { return Boolean(APP) && routerDown; }
|
|
364
|
+
function inDirectMode() { return Boolean(shellState && shellState.doctor && shellState.doctor.mode === "direct"); }
|
|
365
|
+
|
|
366
|
+
function setRouterDown(down) {
|
|
367
|
+
if (routerDown === down) return;
|
|
368
|
+
routerDown = down;
|
|
369
|
+
if (!down) {
|
|
370
|
+
// Back: what failed or was skipped while it was down loads now.
|
|
371
|
+
slotsLoaded = false;
|
|
372
|
+
providersLoaded = false;
|
|
373
|
+
clientsLoaded = false;
|
|
374
|
+
showView((location.hash || "#health").slice(1) || "health");
|
|
375
|
+
} else applyOffline();
|
|
376
|
+
renderConnection();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Every page but Status shows a short note (and the way to Status) instead of its contents. */
|
|
380
|
+
function applyOffline() {
|
|
381
|
+
const off = offline();
|
|
382
|
+
const direct = inDirectMode();
|
|
383
|
+
for (const view of $all(".view")) {
|
|
384
|
+
if (view.id === "view-health") {
|
|
385
|
+
view.classList.toggle("router-off", off);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
view.classList.toggle("offline", off);
|
|
389
|
+
let box = $(".offline-state", view);
|
|
390
|
+
if (!off) {
|
|
391
|
+
if (box) box.remove();
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (!box) {
|
|
395
|
+
box = el("div", { class: "offline-state", role: "status" });
|
|
396
|
+
const header = $(".page-header", view);
|
|
397
|
+
view.insertBefore(box, header ? header.nextSibling : view.firstChild);
|
|
398
|
+
}
|
|
399
|
+
box.replaceChildren(
|
|
400
|
+
el("div", { class: `offline-icon${direct ? " direct" : ""}` }, [svgIcon("0 0 20 20", direct ? CONN_ICONS.direct : ["M3 3l14 14", "M6.2 15.5h8.1c.4 0 .8-.1 1.2-.2", "M17.4 12.6a3.3 3.3 0 0 0-2.7-3.7 4.8 4.8 0 0 0-6.8-3.5", "M5.6 8.3a3.7 3.7 0 0 0 .6 7.2"])]),
|
|
401
|
+
el("h2", { text: t(direct ? "conn.offlineDirectTitle" : "conn.offlineTitle") }),
|
|
402
|
+
el("p", { text: t(direct ? "conn.offlineDirectBody" : "conn.offlineBody") }),
|
|
403
|
+
el("button", { class: "btn", type: "button", text: t("conn.goStatus"), onclick: () => showView("health") }),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const CONN_ICONS = {
|
|
409
|
+
ok: ["M5.5 10.4l3 3 6-6.6"],
|
|
410
|
+
bad: ["M10 5.6v5.2", "M10 14.3v.1"],
|
|
411
|
+
warn: ["M10 5.6v5.2", "M10 14.3v.1"],
|
|
412
|
+
direct: ["M3.8 10h12", "M12.2 6.2L16 10l-3.8 3.8"],
|
|
413
|
+
checking: ["M10 3.8a6.2 6.2 0 1 1-6.2 6.2"],
|
|
414
|
+
};
|
|
415
|
+
// What doctor's codes mean to someone who only wants Claude to answer. The cause comes before the
|
|
416
|
+
// symptom: a startup entry gone missing is why the router is down.
|
|
417
|
+
const CONN_REASONS = [
|
|
418
|
+
["not-installed", "conn.why.notInstalled"],
|
|
419
|
+
["supervisor-missing", "conn.why.supervisorMissing"],
|
|
420
|
+
["supervisor-program-missing", "conn.why.programMissing"],
|
|
421
|
+
["supervisor-other-install", "conn.why.otherInstall"],
|
|
422
|
+
["supervisor-not-running", "conn.why.stopped"],
|
|
423
|
+
["router-down", "conn.why.stopped"],
|
|
424
|
+
["router-version-mismatch", "conn.why.version"],
|
|
425
|
+
["cli-port-mismatch", "conn.why.oldSetting"],
|
|
426
|
+
["desktop-port-mismatch", "conn.why.oldSetting"],
|
|
427
|
+
["cli-foreign-proxy", "conn.why.cliOther"],
|
|
428
|
+
["desktop-foreign-proxy", "conn.why.desktopOther"],
|
|
429
|
+
];
|
|
430
|
+
function connReason(doctor) {
|
|
431
|
+
const problems = (doctor && doctor.problems) || [];
|
|
432
|
+
for (const [code, key] of CONN_REASONS) if (problems.some((problem) => problem.code === code)) return t(key);
|
|
433
|
+
const other = problems.find((problem) => problem.code !== "mixed-mode" && problem.message);
|
|
434
|
+
return other ? other.message : null;
|
|
435
|
+
}
|
|
436
|
+
function connRoute(proxy) { return t(proxy === "ripple" ? "conn.routeRipple" : proxy === "none" ? "conn.routeDirect" : "conn.routeOther"); }
|
|
437
|
+
|
|
438
|
+
/** Whether the router answers, by this page's own 5-second check first, then the app's. */
|
|
439
|
+
function connAnswering(s) {
|
|
440
|
+
if (routerDown) return false;
|
|
441
|
+
if (status) return true;
|
|
442
|
+
if (s && typeof s.routerAnswering === "boolean") return s.routerAnswering;
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Which state the card is in, what it says, and which button is the obvious one. */
|
|
447
|
+
function connectionModel(s) {
|
|
448
|
+
const d = s && s.doctor;
|
|
449
|
+
const answering = connAnswering(s);
|
|
450
|
+
const lastResult = s && s.result;
|
|
451
|
+
if (d && d.mode === "direct") return { kind: "direct", title: t("conn.directTitle"), lead: t("conn.directBody"), primary: "direct-off", secondary: null };
|
|
452
|
+
if (answering === false) {
|
|
453
|
+
// A repair that did not work leaves the switch as the way to a working Claude now.
|
|
454
|
+
const repairFailed = Boolean(lastResult && lastResult.action === "repair" && !lastResult.ok);
|
|
455
|
+
const reason = d ? connReason(d) || t("conn.why.stopped") : s && s.doctorFailed ? t("conn.why.checkFailed") : null;
|
|
456
|
+
return { kind: "bad", title: t("conn.downTitle"), lead: t("conn.downLead"), reason, primary: repairFailed ? "direct-on" : "repair", secondary: repairFailed ? "repair" : "direct-on" };
|
|
457
|
+
}
|
|
458
|
+
if (!d && !(s && s.doctorFailed) && answering === null) return { kind: "checking", title: t("conn.checking"), lead: "", primary: null, secondary: null };
|
|
459
|
+
if (d && d.mode === "mixed") return { kind: "warn", title: t("conn.mixedTitle"), lead: t("conn.mixedBody", { desktop: connRoute(d.desktop && d.desktop.proxy), cli: connRoute(d.cli && d.cli.proxy) }), primary: "direct-off", secondary: "direct-on" };
|
|
460
|
+
const reason = d && connReason(d);
|
|
461
|
+
if (reason) return { kind: "warn", title: t("conn.warnTitle"), lead: reason, primary: "repair", secondary: "direct-on" };
|
|
462
|
+
return { kind: "ok", title: t("conn.okTitle"), lead: s && s.doctorFailed ? t("conn.why.checkFailed") : t("conn.okBody"), primary: null, secondary: "direct-on" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const CONN_ACTIONS = {
|
|
466
|
+
"repair": { label: "conn.repair", hint: "conn.repairHint", run: () => APP.repair() },
|
|
467
|
+
"direct-on": { label: "conn.directOn", hint: "conn.directOnHint", run: () => APP.directOn() },
|
|
468
|
+
"direct-off": { label: "conn.directOff", hint: "conn.directOffHint", run: () => APP.directOff() },
|
|
469
|
+
"restart-claude": { label: "conn.restart", hint: null, run: () => APP.restartClaude() },
|
|
470
|
+
};
|
|
471
|
+
const CONN_BUSY = { "repair": "conn.busy.repair", "direct-on": "conn.busy.directOn", "direct-off": "conn.busy.directOff", "restart-claude": "conn.busy.restart", "checking": "conn.busy.checking" };
|
|
472
|
+
/** A result older than this is history, not news. */
|
|
473
|
+
const CONN_RESULT_MS = 10 * 60 * 1000;
|
|
474
|
+
|
|
475
|
+
async function connRun(run) {
|
|
476
|
+
try {
|
|
477
|
+
const next = await run();
|
|
478
|
+
if (next) shellState = next;
|
|
479
|
+
} catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
480
|
+
renderConnection();
|
|
481
|
+
void refreshHealth();
|
|
482
|
+
}
|
|
483
|
+
function connButton(id, cls) {
|
|
484
|
+
const action = CONN_ACTIONS[id];
|
|
485
|
+
const button = el("button", { class: cls, type: "button", text: t(action.label), onclick: () => void connRun(action.run) });
|
|
486
|
+
button.disabled = Boolean(shellState && shellState.busy);
|
|
487
|
+
return button;
|
|
488
|
+
}
|
|
489
|
+
function connResultText(r) {
|
|
490
|
+
if (r.timedOut) return t("conn.res.timeout");
|
|
491
|
+
const keys = {
|
|
492
|
+
"repair": r.ok ? "conn.res.repairOk" : "conn.res.repairFail",
|
|
493
|
+
"direct-on": r.ok ? "conn.res.directOnOk" : "conn.res.fail",
|
|
494
|
+
"direct-off": r.ok ? "conn.res.directOffOk" : "conn.res.fail",
|
|
495
|
+
"restart-claude": r.ok ? "conn.res.restartOk" : "conn.res.restartFail",
|
|
496
|
+
};
|
|
497
|
+
return t(keys[r.action] || "conn.res.fail");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** The running action, the last result and the "reopen Claude" step: the same in both card sizes. */
|
|
501
|
+
function connNotes(s) {
|
|
502
|
+
const notes = [];
|
|
503
|
+
if (s && s.busy) notes.push(el("div", { class: "conn-note busy", role: "status" }, [el("span", { class: "spinner", "aria-hidden": "true" }), el("span", { text: t(CONN_BUSY[s.busy] || "conn.busy.checking") })]));
|
|
504
|
+
const r = s && s.result;
|
|
505
|
+
if (r && !s.busy && (!r.at || Date.now() - r.at < CONN_RESULT_MS)) {
|
|
506
|
+
const ok = r.ok && !r.timedOut;
|
|
507
|
+
notes.push(el("div", { class: `conn-note ${ok ? "good" : "bad"}`, role: "status" }, [
|
|
508
|
+
el("div", { class: "conn-note-line" }, [svgIcon("0 0 20 20", ok ? CONN_ICONS.ok : CONN_ICONS.bad, "conn-note-icon"), el("span", { text: connResultText(r) })]),
|
|
509
|
+
r.details && r.details.length ? el("details", { class: "conn-fold" }, [el("summary", { text: t("conn.details") }), el("pre", { text: r.details.join("\n") })]) : null,
|
|
510
|
+
]));
|
|
511
|
+
}
|
|
512
|
+
if (s && s.restartClaude) {
|
|
513
|
+
notes.push(el("div", { class: "conn-note restart" }, [
|
|
514
|
+
el("div", { class: "conn-note-text" }, [el("strong", { text: t("conn.restartTitle") }), el("span", { text: t("conn.restartBody") })]),
|
|
515
|
+
connButton("restart-claude", "btn"),
|
|
516
|
+
]));
|
|
517
|
+
}
|
|
518
|
+
return notes;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let connDetailsOpen = false;
|
|
522
|
+
let connLogLines = null;
|
|
523
|
+
function connDetails(s) {
|
|
524
|
+
const d = (s && s.doctor) || {};
|
|
525
|
+
const router = d.router || {};
|
|
526
|
+
const supervisor = d.supervisor || {};
|
|
527
|
+
const row = (k, v) => el("div", { class: "row" }, [el("span", { class: "k", text: k }), el("span", { class: "v mono", text: v })]);
|
|
528
|
+
const rows = [
|
|
529
|
+
row(t("conn.d.router"), `${router.responding ? "responding" : "not responding"}${router.version ? ` · ${router.version}` : ""}${router.url ? ` · ${router.url}` : ""}`),
|
|
530
|
+
row(t("conn.d.supervisor"), `${supervisor.registered ? "registered" : "not registered"}${supervisor.state ? ` · ${supervisor.state}` : ""}${supervisor.program ? ` · ${supervisor.program}` : ""}`),
|
|
531
|
+
row(t("conn.d.desktop"), `${(d.desktop && d.desktop.proxy) || "?"}${d.desktop && d.desktop.url ? ` · ${d.desktop.url}` : ""}`),
|
|
532
|
+
row(t("conn.d.cli"), `${(d.cli && d.cli.proxy) || "?"}${d.cli && d.cli.url ? ` · ${d.cli.url}` : ""}`),
|
|
533
|
+
...((d.problems || []).map((problem) => row(problem.code || "?", problem.message || ""))),
|
|
534
|
+
];
|
|
535
|
+
const log = el("pre", { class: "conn-log", text: connLogLines === null ? "…" : connLogLines.length ? connLogLines.join("\n") : t("conn.noLog") });
|
|
536
|
+
const fold = el("details", { class: "conn-details" }, [
|
|
537
|
+
el("summary", { text: t("conn.details") }),
|
|
538
|
+
el("div", { class: "group conn-detail-rows" }, rows),
|
|
539
|
+
el("div", { class: "conn-log-head" }, [el("span", { class: "section-title", text: t("conn.recentLog") }), el("button", { class: "btn secondary compact", type: "button", text: t("conn.openLogs"), onclick: () => void APP.openLogs() })]),
|
|
540
|
+
log,
|
|
541
|
+
]);
|
|
542
|
+
fold.open = connDetailsOpen;
|
|
543
|
+
fold.addEventListener("toggle", () => {
|
|
544
|
+
connDetailsOpen = fold.open;
|
|
545
|
+
if (fold.open) void APP.recentLog().then((lines) => { connLogLines = lines || []; log.textContent = connLogLines.length ? connLogLines.join("\n") : t("conn.noLog"); log.scrollTop = log.scrollHeight; });
|
|
546
|
+
});
|
|
547
|
+
return fold;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function connLoginOffer(s) {
|
|
551
|
+
if (!s || s.loginItem !== false) return null;
|
|
552
|
+
return el("div", { class: "conn-login" }, [
|
|
553
|
+
el("span", { text: t("conn.loginOffer") }),
|
|
554
|
+
el("button", { class: "btn secondary compact", type: "button", text: t("conn.loginOn"), onclick: () => void connRun(() => APP.enableLoginItem()) }),
|
|
555
|
+
]);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function renderConnection() {
|
|
559
|
+
const host = $("#health-connection");
|
|
560
|
+
if (!APP || !host) return;
|
|
561
|
+
host.hidden = false;
|
|
562
|
+
$("#health-desktop-section").hidden = true;
|
|
563
|
+
const s = shellState;
|
|
564
|
+
const m = connectionModel(s);
|
|
565
|
+
const calm = m.kind === "ok" || m.kind === "checking";
|
|
566
|
+
// After a switch, reopening Claude is the one thing left to do; the rest steps back.
|
|
567
|
+
const demote = Boolean(s && s.restartClaude);
|
|
568
|
+
host.className = `conn conn-${m.kind}${calm ? " calm" : " loud"}`;
|
|
569
|
+
const refresh = el("button", { class: "link-btn", type: "button", text: t("conn.refresh"), onclick: () => void connRun(() => APP.refresh()) });
|
|
570
|
+
refresh.disabled = Boolean(s && s.busy);
|
|
571
|
+
if (calm) {
|
|
572
|
+
host.replaceChildren(el("div", { class: "group" }, [
|
|
573
|
+
el("div", { class: "cell conn-calm-row" }, [
|
|
574
|
+
el("span", { class: "conn-dot", "aria-hidden": "true" }),
|
|
575
|
+
el("div", { class: "cell-text" }, [el("span", { class: "cell-label strong", text: m.title }), m.lead ? el("span", { class: "cell-sub", text: m.lead }) : null]),
|
|
576
|
+
m.secondary ? el("div", { class: "cell-value" }, [connButton(m.secondary, "btn secondary compact")]) : null,
|
|
577
|
+
]),
|
|
578
|
+
...connNotes(s).map((note) => el("div", { class: "cell-note conn-calm-note" }, [note])),
|
|
579
|
+
connLoginOffer(s) ? el("div", { class: "cell-note conn-calm-note" }, [connLoginOffer(s)]) : null,
|
|
580
|
+
]));
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const actions = [m.primary, m.secondary].filter(Boolean).map((id, index) => el("div", { class: "conn-action" }, [
|
|
584
|
+
connButton(id, `btn large${index === 0 && !demote ? "" : " secondary"}`),
|
|
585
|
+
CONN_ACTIONS[id].hint ? el("p", { class: "hint", text: t(CONN_ACTIONS[id].hint) }) : null,
|
|
586
|
+
]));
|
|
587
|
+
host.replaceChildren(el("div", { class: "conn-card" }, [
|
|
588
|
+
el("div", { class: "conn-hero" }, [
|
|
589
|
+
el("div", { class: "conn-icon" }, [svgIcon("0 0 20 20", CONN_ICONS[m.kind] || CONN_ICONS.bad)]),
|
|
590
|
+
el("div", { class: "conn-hero-text" }, [
|
|
591
|
+
el("h2", { class: "conn-title", text: m.title }),
|
|
592
|
+
m.lead ? el("p", { class: "conn-lead", text: m.lead }) : null,
|
|
593
|
+
m.reason ? el("p", { class: "conn-reason", text: m.reason }) : null,
|
|
594
|
+
]),
|
|
595
|
+
]),
|
|
596
|
+
...connNotes(s),
|
|
597
|
+
actions.length ? el("div", { class: "conn-actions" }, actions) : null,
|
|
598
|
+
connLoginOffer(s),
|
|
599
|
+
el("div", { class: "conn-foot" }, [connDetails(s), refresh]),
|
|
600
|
+
]));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (APP) {
|
|
604
|
+
APP.onState((next) => { shellState = next; renderConnection(); applyOffline(); });
|
|
605
|
+
void APP.state().then((next) => { shellState = next; renderConnection(); applyOffline(); });
|
|
606
|
+
}
|
|
607
|
+
|
|
325
608
|
function quotaLine(name) {
|
|
326
609
|
const quota = status && status.chatgpt && status.chatgpt.quota && status.chatgpt.quota[name];
|
|
327
610
|
const primary = quota && quota.rate_limits && quota.rate_limits.primary;
|
|
@@ -727,6 +1010,499 @@ async function toggleCodex(enabled) {
|
|
|
727
1010
|
void Promise.all([loadCatalogs(), refreshHealth()]);
|
|
728
1011
|
setInterval(refreshHealth, 5000);
|
|
729
1012
|
|
|
1013
|
+
// ---- Phone --------------------------------------------------------------------------
|
|
1014
|
+
|
|
1015
|
+
function hidePhoneQr() {
|
|
1016
|
+
phoneQrGeneration++;
|
|
1017
|
+
const box = $("#phone-qr");
|
|
1018
|
+
if (box) box.replaceChildren();
|
|
1019
|
+
}
|
|
1020
|
+
function phoneActive() { return $("#view-phone").classList.contains("active"); }
|
|
1021
|
+
function phonePost(route, body) {
|
|
1022
|
+
return api(`/api/phone/${route}`, { method: "POST", headers: { "content-type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}) });
|
|
1023
|
+
}
|
|
1024
|
+
async function showPhoneQr(button) {
|
|
1025
|
+
if (phoneBusy) return;
|
|
1026
|
+
const generation = ++phoneQrGeneration;
|
|
1027
|
+
button.disabled = true;
|
|
1028
|
+
try {
|
|
1029
|
+
const pairing = await api("/api/phone/pairing");
|
|
1030
|
+
if (generation !== phoneQrGeneration || !phoneActive()) return;
|
|
1031
|
+
const copy = el("button", { class: "btn secondary", type: "button", text: t("phone.copyLink") });
|
|
1032
|
+
copy.addEventListener("click", async () => {
|
|
1033
|
+
try { await navigator.clipboard.writeText(pairing.url); toast(t("phone.copied")); }
|
|
1034
|
+
catch { toast(t("phone.copyFailed"), true); }
|
|
1035
|
+
});
|
|
1036
|
+
// An image, never trusted SVG markup in the dashboard's DOM.
|
|
1037
|
+
$("#phone-qr").replaceChildren(el("img", { class: "phone-qr-image", alt: t("phone.qrAlt"), src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(pairing.svg)}`, width: "240", height: "240" }), copy,
|
|
1038
|
+
el("button", { class: "btn secondary", type: "button", text: t("common.hide"), onclick: hidePhoneQr }));
|
|
1039
|
+
} catch { if (generation === phoneQrGeneration) toast(t("common.loadFailed"), true); }
|
|
1040
|
+
finally { button.disabled = false; }
|
|
1041
|
+
}
|
|
1042
|
+
function renderPhoneStatus(data) {
|
|
1043
|
+
const rows = [cell(t("phone.host"), badge(data.hostRunning ? "ok" : "warn", t(data.hostRunning ? "phone.running" : "phone.stopped")))];
|
|
1044
|
+
if (data.hostRunning) rows.push(cell(t("phone.mode"), el("span", { text: t(data.mode === "relay" ? "phone.relayStatus" : data.mode === "direct" ? "phone.directStatus" : "phone.localStatus") }), `${data.bind}:${data.port} · ${data.version}`),
|
|
1045
|
+
cell(t("phone.notifications"), badge(data.pushReady ? "ok" : "warn", t(data.pushReady ? "phone.ready" : "phone.needsRelay"))));
|
|
1046
|
+
else rows.push(el("div", { class: "cell-note" }, [hint(t("phone.install")), el("code", { text: data.hint })]));
|
|
1047
|
+
$("#phone-status").replaceChildren(...rows);
|
|
1048
|
+
if (!data.hostRunning) return;
|
|
1049
|
+
$("#phone-phones").replaceChildren(...(data.phones.length ? data.phones.map(phone => cell(
|
|
1050
|
+
el("span", { class: "cell-label mono", text: phone.clientId.length > 12 ? `${phone.clientId.slice(0, 12)}…` : phone.clientId, title: phone.clientId }),
|
|
1051
|
+
badge(phone.connected ? "ok" : "", t(phone.connected ? "phone.connected" : "phone.offline")),
|
|
1052
|
+
[phone.lastSeen ? t("phone.lastSeen", { time: new Date(phone.lastSeen).toLocaleString(CURRENT_LANG) }) : t("phone.unknownSeen"), t(phone.push ? "phone.pushOn" : "phone.pushOff")],
|
|
1053
|
+
)) : [el("div", { class: "cell-note", text: t("phone.empty") })]));
|
|
1054
|
+
$("#phone-restart").hidden = !data.restartRequired;
|
|
1055
|
+
}
|
|
1056
|
+
function phoneSettings(data) {
|
|
1057
|
+
// The phone app pairs through the Relay only, so phone access is on (Relay) or off. A custom
|
|
1058
|
+
// relay address stays in the host's config file; the dashboard keeps whichever one is saved.
|
|
1059
|
+
const saved = data.settings;
|
|
1060
|
+
const selected = saved.relay ? "relay" : "local";
|
|
1061
|
+
const choices = ["relay", "local"].map(value => {
|
|
1062
|
+
const radio = el("input", { type: "radio", name: "phone-mode", value, checked: value === selected });
|
|
1063
|
+
return el("label", { class: "cell phone-choice" }, [radio, el("span", { class: "cell-text" }, [el("span", { class: "cell-label", text: t(`phone.choice.${value}`) }), el("span", { class: "cell-sub", text: t(`phone.help.${value}`) })])]);
|
|
1064
|
+
});
|
|
1065
|
+
const save = el("button", { class: "btn", type: "button", text: t("common.save") });
|
|
1066
|
+
save.addEventListener("click", async () => {
|
|
1067
|
+
if (phoneBusy) return;
|
|
1068
|
+
const mode = $('#phone-content input[name="phone-mode"]:checked').value;
|
|
1069
|
+
phoneBusy = true; save.disabled = true;
|
|
1070
|
+
hidePhoneQr();
|
|
1071
|
+
try {
|
|
1072
|
+
const result = await phonePost("settings", mode === "relay" ? { relay: saved.relay || data.defaultRelay, bind: "local" } : { relay: "off", bind: "local" });
|
|
1073
|
+
toast(t(result.restartRequired ? "phone.restartRequired" : "common.saved"));
|
|
1074
|
+
await refreshPhone();
|
|
1075
|
+
} catch (error) { toast(t("common.saveFailed"), true, error.message); }
|
|
1076
|
+
finally { phoneBusy = false; save.disabled = false; }
|
|
1077
|
+
});
|
|
1078
|
+
return section(t("phone.settings"), [...choices, cell(t("phone.apply"), save)]);
|
|
1079
|
+
}
|
|
1080
|
+
function phoneRestart() {
|
|
1081
|
+
const button = el("button", { class: "btn secondary", type: "button", text: t("phone.restart") });
|
|
1082
|
+
const message = hint("");
|
|
1083
|
+
button.addEventListener("click", async () => {
|
|
1084
|
+
if (!confirm(t("phone.restartConfirm"))) return;
|
|
1085
|
+
button.disabled = true;
|
|
1086
|
+
hidePhoneQr();
|
|
1087
|
+
try {
|
|
1088
|
+
const result = await phonePost("restart");
|
|
1089
|
+
if (result.manual) message.textContent = t("phone.manualRestart", { command: result.command });
|
|
1090
|
+
else { toast(t("phone.restarting")); setTimeout(() => void loadPhone(), 2000); }
|
|
1091
|
+
} catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1092
|
+
finally { button.disabled = false; }
|
|
1093
|
+
});
|
|
1094
|
+
return el("div", { id: "phone-restart", class: "group stacked-group", hidden: "" }, [cell(t("phone.restartRequired"), button, message, "wide")]);
|
|
1095
|
+
}
|
|
1096
|
+
function phoneControls(data) {
|
|
1097
|
+
const qr = el("button", { class: "btn secondary", type: "button", text: t("phone.showQr") });
|
|
1098
|
+
qr.addEventListener("click", () => void showPhoneQr(qr));
|
|
1099
|
+
const rotate = el("button", { class: "cell-button destructive", type: "button", text: t("phone.rotate") });
|
|
1100
|
+
rotate.addEventListener("click", async () => {
|
|
1101
|
+
if (phoneBusy || !confirm(t("phone.rotateConfirm"))) return;
|
|
1102
|
+
phoneBusy = true; rotate.disabled = true; hidePhoneQr();
|
|
1103
|
+
try { await phonePost("rotate"); toast(t("phone.rotated")); await refreshPhone(); }
|
|
1104
|
+
catch (error) { toast(t("common.actionFailed"), true, error.message); }
|
|
1105
|
+
finally { phoneBusy = false; rotate.disabled = false; }
|
|
1106
|
+
});
|
|
1107
|
+
const phones = section(t("phone.phones"), []);
|
|
1108
|
+
$(".group", phones).id = "phone-phones";
|
|
1109
|
+
return [section(t("phone.pairing"), [cell(t("phone.scan"), qr), el("div", { id: "phone-qr", class: "phone-qr" })], [t("phone.qrWarning"), !data.reachableFromPhone ? t("phone.unreachable") : null]),
|
|
1110
|
+
phoneSettings(data), phoneRestart(), phones, section(null, [rotate], t("phone.sharedKey"))];
|
|
1111
|
+
}
|
|
1112
|
+
async function refreshPhone(rebuild = false) {
|
|
1113
|
+
if (!phoneActive() || offline()) return;
|
|
1114
|
+
const generation = phoneGeneration;
|
|
1115
|
+
try {
|
|
1116
|
+
const data = await api("/api/phone/status");
|
|
1117
|
+
if (generation !== phoneGeneration || !phoneActive()) return;
|
|
1118
|
+
if (rebuild || !phoneSnapshot || phoneSnapshot.hostRunning !== data.hostRunning) {
|
|
1119
|
+
hidePhoneQr();
|
|
1120
|
+
const state = section(t("phone.status"), []);
|
|
1121
|
+
$(".group", state).id = "phone-status";
|
|
1122
|
+
$("#phone-content").replaceChildren(state, ...(data.hostRunning ? phoneControls(data) : []));
|
|
1123
|
+
}
|
|
1124
|
+
phoneSnapshot = data;
|
|
1125
|
+
renderPhoneStatus(data);
|
|
1126
|
+
} catch (error) { toast(t("common.loadFailed"), true, error.message); }
|
|
1127
|
+
}
|
|
1128
|
+
async function loadPhone() { phoneGeneration++; hidePhoneQr(); await refreshPhone(true); }
|
|
1129
|
+
$("#phone-refresh").addEventListener("click", () => void loadPhone());
|
|
1130
|
+
setInterval(() => { if (!phoneBusy) void refreshPhone(); }, 5000);
|
|
1131
|
+
|
|
1132
|
+
// ---- Laptop: awake with the lid closed, and the phone hotspot when Wi-Fi fails -------------
|
|
1133
|
+
// GET|PUT /api/power (router power.ts) and POST|GET /api/power/setup, which runs the CLI's
|
|
1134
|
+
// `awake setup` — macOS shows its own password dialog, Windows needs none. Written for someone who
|
|
1135
|
+
// has never heard of pmset: the router's reasons and states are English codes, so each is turned
|
|
1136
|
+
// into a sentence here. The power status names its platform, so wording and setup follow it rather
|
|
1137
|
+
// than assuming a Mac and an iPhone (Windows: awake-win.ts, tether-win.ts).
|
|
1138
|
+
|
|
1139
|
+
let macPower = null;
|
|
1140
|
+
/** null, "unavailable" (a router without the feature) or a load error. */
|
|
1141
|
+
let macError = null;
|
|
1142
|
+
let macSetup = { running: false };
|
|
1143
|
+
/** A setup run this page started or saw running: its progress and result are shown. */
|
|
1144
|
+
let macSetupWatched = false;
|
|
1145
|
+
let macSetupPoll = null;
|
|
1146
|
+
let macBusy = false;
|
|
1147
|
+
let macGeneration = 0;
|
|
1148
|
+
let macSsidDraft = null;
|
|
1149
|
+
const macOpen = { awake: false, tether: false, setup: false };
|
|
1150
|
+
|
|
1151
|
+
/** The OS the router reports. A router too old to name it had macOS-only power features. */
|
|
1152
|
+
function macPlatform() { return (macPower && macPower.platform) || "darwin"; }
|
|
1153
|
+
function macIsMac() { return macPlatform() === "darwin"; }
|
|
1154
|
+
function macActive() { return $("#view-mac").classList.contains("active"); }
|
|
1155
|
+
async function loadMac() { macGeneration++; macDrawn = ""; await refreshMac(true); }
|
|
1156
|
+
async function refreshMac(force) {
|
|
1157
|
+
if (!macActive() || offline()) return;
|
|
1158
|
+
const generation = macGeneration;
|
|
1159
|
+
const [power, setup] = await Promise.allSettled([api("/api/power"), api("/api/power/setup")]);
|
|
1160
|
+
if (generation !== macGeneration || !macActive()) return;
|
|
1161
|
+
if (power.status === "fulfilled") { macPower = power.value; macError = null; }
|
|
1162
|
+
else macError = power.reason && power.reason.status === 501 ? "unavailable" : (power.reason && power.reason.message) || "error";
|
|
1163
|
+
if (setup.status === "fulfilled") macAdoptSetup(setup.value);
|
|
1164
|
+
// A field being typed in is not redrawn under the cursor; the next refresh catches up.
|
|
1165
|
+
const focused = document.activeElement;
|
|
1166
|
+
if (!force && focused && $("#mac-content").contains(focused) && focused.matches("input:not([type=checkbox])")) return;
|
|
1167
|
+
renderMac();
|
|
1168
|
+
}
|
|
1169
|
+
function macAdoptSetup(next) {
|
|
1170
|
+
const was = macSetup;
|
|
1171
|
+
macSetup = next || { running: false };
|
|
1172
|
+
if (macSetup.running) {
|
|
1173
|
+
macSetupWatched = true;
|
|
1174
|
+
if (!macSetupPoll) macSetupPoll = setInterval(() => void macPollSetup(), 1500);
|
|
1175
|
+
} else if (macSetupPoll) {
|
|
1176
|
+
clearInterval(macSetupPoll);
|
|
1177
|
+
macSetupPoll = null;
|
|
1178
|
+
}
|
|
1179
|
+
if (was.running && !macSetup.running && macSetupWatched) {
|
|
1180
|
+
toast(t(macSetup.ok ? "mac.setup.done" : /cancel/i.test(macSetup.output || "") ? "mac.setup.cancelled" : "mac.setup.failed"), !macSetup.ok);
|
|
1181
|
+
// Success shows as the controls coming alive; only a failure stays on screen to be read.
|
|
1182
|
+
if (macSetup.ok) macSetupWatched = false;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
async function macPollSetup() {
|
|
1186
|
+
try { macAdoptSetup(await api("/api/power/setup")); } catch { return; }
|
|
1187
|
+
if (!macSetup.running) {
|
|
1188
|
+
// The router looks for the rule again when the run ends; read what it found.
|
|
1189
|
+
try { macPower = await api("/api/power"); } catch { /* the 5s refresh retries */ }
|
|
1190
|
+
}
|
|
1191
|
+
if (macActive()) renderMac();
|
|
1192
|
+
}
|
|
1193
|
+
async function startMacSetup(ssid) {
|
|
1194
|
+
if (macSetup.running) return;
|
|
1195
|
+
try {
|
|
1196
|
+
await api("/api/power/setup", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ssid ? { ssid } : {}) });
|
|
1197
|
+
macSetupWatched = true;
|
|
1198
|
+
macOpen.setup = false;
|
|
1199
|
+
macAdoptSetup({ running: true, startedAt: new Date().toISOString() });
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
if (error.status === 409) { macSetupWatched = true; macAdoptSetup({ running: true }); }
|
|
1202
|
+
else toast(t("mac.setup.failed"), true, error.message);
|
|
1203
|
+
}
|
|
1204
|
+
renderMac();
|
|
1205
|
+
}
|
|
1206
|
+
async function macPut(change) {
|
|
1207
|
+
if (macBusy) return;
|
|
1208
|
+
macBusy = true;
|
|
1209
|
+
try {
|
|
1210
|
+
macPower = await api("/api/power", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(change) });
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
toast(t("common.saveFailed"), true, error.message);
|
|
1213
|
+
// Nothing changed, but the switch or field that was touched shows the change: draw it back.
|
|
1214
|
+
macDrawn = "";
|
|
1215
|
+
}
|
|
1216
|
+
finally { macBusy = false; }
|
|
1217
|
+
renderMac();
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** The same rule the router holds a hotspot name to (config.ts ssidProblem), said in words. */
|
|
1221
|
+
function macSsidProblem(name) {
|
|
1222
|
+
if (!name) return null;
|
|
1223
|
+
const bytes = new TextEncoder().encode(name).length;
|
|
1224
|
+
if (bytes > 32 || !/^[A-Za-z0-9 ._'’-]+$/u.test(name) || /^ | $| {2}/.test(name) || name.startsWith("-")) return t("mac.ssid.invalid");
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
function macTime(iso) {
|
|
1228
|
+
const d = new Date(iso);
|
|
1229
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
1230
|
+
const time = d.toLocaleTimeString(CURRENT_LANG, { hour: "numeric", minute: "2-digit" });
|
|
1231
|
+
const today = new Date();
|
|
1232
|
+
if (d.toDateString() === today.toDateString()) return time;
|
|
1233
|
+
const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
|
1234
|
+
if (d.toDateString() === tomorrow.toDateString()) return t("mac.tomorrowAt", { time });
|
|
1235
|
+
return `${d.toLocaleDateString(CURRENT_LANG, { month: "short", day: "numeric" })} ${time}`;
|
|
1236
|
+
}
|
|
1237
|
+
/** "HH:MM" → the next time the clock shows it, as an ISO instant. */
|
|
1238
|
+
function macNextAt(hhmm) {
|
|
1239
|
+
const [h, m] = hhmm.split(":").map(Number);
|
|
1240
|
+
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
|
|
1241
|
+
const now = new Date();
|
|
1242
|
+
const at = new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m, 0, 0);
|
|
1243
|
+
if (at.getTime() <= now.getTime()) at.setDate(at.getDate() + 1);
|
|
1244
|
+
return at.toISOString();
|
|
1245
|
+
}
|
|
1246
|
+
function macHHMM(iso) {
|
|
1247
|
+
const d = new Date(iso);
|
|
1248
|
+
return Number.isNaN(d.getTime()) ? "" : `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** The router's one-line reason (awake.ts decideAwake) as a sentence, with whether it is a warning. */
|
|
1252
|
+
function macAwakeWhy(a) {
|
|
1253
|
+
const r = String(a.reason || "");
|
|
1254
|
+
let m;
|
|
1255
|
+
if (r === "off") return { text: t("mac.why.off") };
|
|
1256
|
+
if (r === "on") return { text: a.until ? t("mac.why.onUntil", { time: macTime(a.until) }) : t("mac.why.on") };
|
|
1257
|
+
if (/^working: \d+ model call/.test(r)) return { text: t("mac.why.working") };
|
|
1258
|
+
if ((m = /^working: last model call (\d+)s ago/.exec(r))) return { text: t("mac.why.recent", { min: Math.max(1, Math.round(Number(m[1]) / 60)) }) };
|
|
1259
|
+
if (r.startsWith("idle")) return { text: t("mac.why.idle") };
|
|
1260
|
+
if (/^until .* has passed$/.test(r)) return { text: t("mac.why.until") };
|
|
1261
|
+
if ((m = /^battery (\d+)% is below (\d+)%/.exec(r))) return { text: t("mac.why.battery", { floor: m[2], percent: m[1] }), warn: true };
|
|
1262
|
+
if (r === "thermal warning") return { text: t("mac.why.thermal"), warn: true };
|
|
1263
|
+
if (r.startsWith("error:")) return { text: t("mac.why.error"), warn: true };
|
|
1264
|
+
if (r.startsWith("needs setup")) return { text: t("mac.why.setup"), warn: true };
|
|
1265
|
+
if (r.startsWith("pmset failed")) return { text: t("mac.why.failed"), warn: true };
|
|
1266
|
+
// Windows (awake-win.ts): the helper's own failures and a refused power-plan write.
|
|
1267
|
+
if (/^(SetThreadExecutionState failed|Windows refused|could not restore the lid-close action)/.test(r)) return { text: t("mac.why.failed"), warn: true };
|
|
1268
|
+
if (r === "not started") return { text: t("mac.why.starting") };
|
|
1269
|
+
return { text: r };
|
|
1270
|
+
}
|
|
1271
|
+
function macFacts(a) {
|
|
1272
|
+
const facts = [];
|
|
1273
|
+
if (a.battery && typeof a.battery.percent === "number") facts.push(t(a.battery.onBattery ? "mac.fact.battery" : "mac.fact.charging", { percent: a.battery.percent }));
|
|
1274
|
+
if (a.lidClosed === true) facts.push(t("mac.fact.lidClosed"));
|
|
1275
|
+
else if (a.lidClosed === false) facts.push(t("mac.fact.lidOpen"));
|
|
1276
|
+
if (a.thermal === "warning") facts.push(t("mac.fact.hot"));
|
|
1277
|
+
return facts.join(" · ");
|
|
1278
|
+
}
|
|
1279
|
+
/** A status line: a coloured dot, what is true now in a word or two, and why underneath. */
|
|
1280
|
+
function macStateRow(tone, title, sub, side) {
|
|
1281
|
+
return el("div", { class: "cell mac-state" }, [
|
|
1282
|
+
el("span", { class: `mac-dot ${tone}`, "aria-hidden": "true" }),
|
|
1283
|
+
el("div", { class: "cell-text" }, [el("span", { class: "cell-label strong", text: title }), sub ? el("span", { class: "cell-sub", text: sub }) : null]),
|
|
1284
|
+
side ? el("span", { class: "mac-side", text: side }) : null,
|
|
1285
|
+
]);
|
|
1286
|
+
}
|
|
1287
|
+
function macDetails(key, rows) {
|
|
1288
|
+
const box = el("details", { class: "details mac-details" }, [
|
|
1289
|
+
el("summary", { text: t("mac.details") }),
|
|
1290
|
+
el("div", { class: "group conn-detail-rows" }, rows.filter(Boolean).map(([k, v]) => el("div", { class: "row" }, [el("span", { class: "k", text: k }), el("span", { class: "v mono", text: v })]))),
|
|
1291
|
+
]);
|
|
1292
|
+
box.open = macOpen[key];
|
|
1293
|
+
box.addEventListener("toggle", () => { macOpen[key] = box.open; });
|
|
1294
|
+
return box;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function macSetupNote() {
|
|
1298
|
+
if (macSetup.running) {
|
|
1299
|
+
// macOS opens its own password window; Windows only checks (no password, no elevation).
|
|
1300
|
+
const title = t(macIsMac() ? "mac.setup.waiting" : "mac.setup.waitingWin");
|
|
1301
|
+
const sub = t(macIsMac() ? "mac.setup.waitingSub" : "mac.setup.waitingSubWin");
|
|
1302
|
+
return el("div", { class: "conn-note busy", role: "status" }, [el("span", { class: "spinner", "aria-hidden": "true" }),
|
|
1303
|
+
el("div", { class: "conn-note-text" }, [el("strong", { text: title }), el("span", { text: sub })])]);
|
|
1304
|
+
}
|
|
1305
|
+
if (!macSetupWatched || macSetup.ok === undefined) return null;
|
|
1306
|
+
const cancelled = /cancel/i.test(macSetup.output || "");
|
|
1307
|
+
const good = macSetup.ok === true;
|
|
1308
|
+
const output = el("details", { class: "conn-fold" }, [el("summary", { text: t("mac.details") }), el("pre", { text: macSetup.output || "" })]);
|
|
1309
|
+
output.open = macOpen.setup;
|
|
1310
|
+
output.addEventListener("toggle", () => { macOpen.setup = output.open; });
|
|
1311
|
+
return el("div", { class: `conn-note ${good ? "good" : "bad"}`, role: "status" }, [
|
|
1312
|
+
el("div", { class: "conn-note-line" }, [
|
|
1313
|
+
svgIcon("0 0 20 20", good ? ["M4.5 10.5l3.5 3.5 7.5-8"] : ["M5.5 5.5l9 9M14.5 5.5l-9 9"], "conn-note-icon"),
|
|
1314
|
+
el("span", { text: t(good ? "mac.setup.done" : cancelled ? "mac.setup.cancelled" : "mac.setup.failed") }),
|
|
1315
|
+
]),
|
|
1316
|
+
macSetup.output ? output : null,
|
|
1317
|
+
]);
|
|
1318
|
+
}
|
|
1319
|
+
function macSetupCard(p) {
|
|
1320
|
+
const saved = (p.tether && p.tether.hotspotSsid) || "";
|
|
1321
|
+
if (macSsidDraft === null) macSsidDraft = saved;
|
|
1322
|
+
const input = el("input", { type: "text", id: "mac-ssid", value: macSsidDraft, placeholder: t("mac.ssid.placeholder"), autocomplete: "off", spellcheck: "false", maxlength: "32" });
|
|
1323
|
+
const problem = el("small", { class: "bad-text", role: "alert" });
|
|
1324
|
+
const button = el("button", { class: "btn large", type: "button", "data-key": "mac-setup", text: t(macSetup.running ? "mac.setup.running" : "mac.setup.button") });
|
|
1325
|
+
const check = () => {
|
|
1326
|
+
const issue = macSsidProblem(input.value.trim());
|
|
1327
|
+
problem.textContent = issue || "";
|
|
1328
|
+
problem.hidden = !issue;
|
|
1329
|
+
button.disabled = Boolean(issue) || macSetup.running;
|
|
1330
|
+
};
|
|
1331
|
+
input.addEventListener("input", () => { macSsidDraft = input.value; check(); });
|
|
1332
|
+
button.addEventListener("click", () => void startMacSetup(input.value.trim()));
|
|
1333
|
+
check();
|
|
1334
|
+
input.disabled = macSetup.running;
|
|
1335
|
+
const allow = ["mac.setup.allowSleep", "mac.setup.allowLid", "mac.setup.allowHotspot"];
|
|
1336
|
+
return el("section", { class: "conn-card mac-setup", "aria-labelledby": "mac-setup-title" }, [
|
|
1337
|
+
el("div", { class: "conn-hero" }, [
|
|
1338
|
+
el("div", { class: "conn-icon" }, [svgIcon("0 0 20 20", ["M6 9V6.5a4 4 0 0 1 8 0V9", "M5 9h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1z"])]),
|
|
1339
|
+
el("div", { class: "conn-hero-text" }, [
|
|
1340
|
+
el("h2", { class: "conn-title", id: "mac-setup-title", text: t("mac.setup.title") }),
|
|
1341
|
+
el("p", { class: "conn-lead", text: t("mac.setup.lead") }),
|
|
1342
|
+
]),
|
|
1343
|
+
]),
|
|
1344
|
+
el("ul", { class: "mac-allow" }, allow.map((key) => el("li", { text: t(key) }))),
|
|
1345
|
+
el("p", { class: "hint mac-setup-note", text: t("mac.setup.private") }),
|
|
1346
|
+
el("p", { class: "hint mac-setup-note", text: t("mac.setup.insomnia") }),
|
|
1347
|
+
el("label", { class: "form-field" }, [el("span", { text: t("mac.ssid.label") }), input, problem, el("small", { text: t("mac.ssid.help") })]),
|
|
1348
|
+
el("div", { class: "mac-setup-actions" }, [button]),
|
|
1349
|
+
macSetupNote(),
|
|
1350
|
+
]);
|
|
1351
|
+
}
|
|
1352
|
+
function openMacRename(current) {
|
|
1353
|
+
const input = el("input", { type: "text", value: current, placeholder: t("mac.ssid.placeholder"), autocomplete: "off", spellcheck: "false", maxlength: "32" });
|
|
1354
|
+
const problem = el("small", { class: "bad-text", role: "alert", hidden: "" });
|
|
1355
|
+
const save = el("button", { class: "btn", type: "button", "data-default-action": "", text: t("mac.rename.save") });
|
|
1356
|
+
const check = () => {
|
|
1357
|
+
const name = input.value.trim();
|
|
1358
|
+
const issue = macSsidProblem(name);
|
|
1359
|
+
problem.textContent = issue || "";
|
|
1360
|
+
problem.hidden = !issue;
|
|
1361
|
+
save.disabled = !name || Boolean(issue) || name === current;
|
|
1362
|
+
};
|
|
1363
|
+
input.addEventListener("input", check);
|
|
1364
|
+
save.addEventListener("click", () => { const name = input.value.trim(); closeModal(); void startMacSetup(name); });
|
|
1365
|
+
check();
|
|
1366
|
+
showModal(el("div", {}, [
|
|
1367
|
+
el("h1", { text: t(current ? "mac.rename.title" : "mac.rename.titleNew") }),
|
|
1368
|
+
hint(t("mac.rename.help")),
|
|
1369
|
+
el("label", { class: "form-field" }, [el("span", { text: t("mac.ssid.label") }), input, problem, el("small", { text: t("mac.ssid.help") })]),
|
|
1370
|
+
el("div", { class: "actions end" }, [el("button", { class: "btn secondary", type: "button", text: t("common.cancel"), onclick: closeModal }), save]),
|
|
1371
|
+
]));
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function macAwakeCard(a) {
|
|
1375
|
+
const why = macAwakeWhy(a);
|
|
1376
|
+
const state = a.applied
|
|
1377
|
+
? macStateRow("on", t("mac.awake.on"), why.text, macFacts(a))
|
|
1378
|
+
: macStateRow(why.warn ? "warn" : "off", t("mac.awake.off"), why.text, macFacts(a));
|
|
1379
|
+
const modes = [{ value: "off", label: t("mac.mode.off") }, { value: "working", label: t("mac.mode.working") }, { value: "on", label: t("mac.mode.on") }];
|
|
1380
|
+
const rows = [
|
|
1381
|
+
state,
|
|
1382
|
+
cell(el("span", { class: "cell-label", text: t("mac.mode.label") }),
|
|
1383
|
+
modSegmented("mac-mode", modes, a.mode, t("mac.mode.label"), (mode) => void macPut({ awake: { mode, until: null } })),
|
|
1384
|
+
t(`mac.mode.${a.mode}Help`), "wide"),
|
|
1385
|
+
];
|
|
1386
|
+
if (a.mode === "on") {
|
|
1387
|
+
const time = el("input", { type: "time", class: "mac-time", "data-key": "mac-until", value: a.until ? macHHMM(a.until) : "", "aria-label": t("mac.until.label") });
|
|
1388
|
+
// Saved when the field is left, not on each keystroke: a redraw mid-typing would eat the minutes.
|
|
1389
|
+
const commit = () => {
|
|
1390
|
+
const until = time.value ? macNextAt(time.value) : null;
|
|
1391
|
+
if (time.value && !until) return;
|
|
1392
|
+
if ((until ? macHHMM(until) : "") === (a.until ? macHHMM(a.until) : "")) return;
|
|
1393
|
+
void macPut({ awake: { until } });
|
|
1394
|
+
};
|
|
1395
|
+
time.addEventListener("blur", commit);
|
|
1396
|
+
time.addEventListener("keydown", (event) => { if (event.key === "Enter") time.blur(); });
|
|
1397
|
+
const clear = a.until ? el("button", { class: "link-btn", type: "button", "data-key": "mac-until-clear", text: t("mac.until.clear"), onclick: () => void macPut({ awake: { until: null } }) }) : null;
|
|
1398
|
+
rows.push(cell(t("mac.until.label"), [time, clear], a.until ? t("mac.until.set", { time: macTime(a.until) }) : t("mac.until.none")));
|
|
1399
|
+
}
|
|
1400
|
+
return el("section", { class: "mod-card mac-card", "aria-labelledby": "mac-awake-title" }, [
|
|
1401
|
+
el("div", { class: "mod-card-head" }, [el("h2", { id: "mac-awake-title", text: t("mac.awake.title") }), hint(t("mac.awake.blurb"))]),
|
|
1402
|
+
el("div", { class: "group" }, rows),
|
|
1403
|
+
el("p", { class: "group-footer", text: t("mac.awake.safety", { floor: macBatteryFloor(a) }) }),
|
|
1404
|
+
macDetails("awake", [
|
|
1405
|
+
[t("mac.d.reason"), a.reason || "—"],
|
|
1406
|
+
[t("mac.d.applied"), a.applied ? (macIsMac() ? "disablesleep 1" : "ES_SYSTEM_REQUIRED") : macIsMac() ? "disablesleep 0" : "—"],
|
|
1407
|
+
[t("mac.d.battery"), a.battery ? `${a.battery.percent ?? "?"}% · ${a.battery.onBattery ? "battery" : "AC"}` : "—"],
|
|
1408
|
+
[t("mac.d.thermal"), a.thermal || "—"],
|
|
1409
|
+
[t("mac.d.lid"), a.lidClosed === null || a.lidClosed === undefined ? "—" : a.lidClosed ? "closed" : "open"],
|
|
1410
|
+
[t("mac.d.until"), a.until || "—"],
|
|
1411
|
+
]),
|
|
1412
|
+
]);
|
|
1413
|
+
}
|
|
1414
|
+
/** The battery floor is in config, not in the status; the reason names it when it bites. */
|
|
1415
|
+
function macBatteryFloor(a) {
|
|
1416
|
+
const m = /below (\d+)%/.exec(String(a.reason || ""));
|
|
1417
|
+
return m ? m[1] : "20";
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const MAC_HINT_STATES = new Set(["hotspot-not-visible", "join-failed", "no-wifi", "no-hotspot-ssid", "needs-setup", "no-hotspot-profile", "needs-location"]);
|
|
1421
|
+
function macTetherCard(tt, setupOk) {
|
|
1422
|
+
const network = tt.network === "hotspot" ? "hotspot" : tt.network === "wifi" ? "wifi" : "none";
|
|
1423
|
+
const tone = network === "wifi" ? "ok" : network === "hotspot" ? "on" : "bad";
|
|
1424
|
+
let side = "";
|
|
1425
|
+
if (tt.lastSwitch) {
|
|
1426
|
+
const time = macTime(tt.lastSwitch.at);
|
|
1427
|
+
side = !tt.lastSwitch.ok ? t("mac.switch.failed", { time }) : t(tt.lastSwitch.to === "hotspot" ? "mac.switch.toHotspot" : "mac.switch.toWifi", { time });
|
|
1428
|
+
}
|
|
1429
|
+
const enabled = modSwitch("mac-tether", tt.enabled, t("mac.tether.switch"), (on) => void macPut({ tether: { enabled: on } }));
|
|
1430
|
+
const name = tt.hotspotSsid || "";
|
|
1431
|
+
const rename = el("button", { class: "btn secondary compact", type: "button", "data-key": "mac-rename", text: t(name ? "mac.ssid.change" : "mac.ssid.set") });
|
|
1432
|
+
rename.disabled = macSetup.running || !setupOk;
|
|
1433
|
+
rename.addEventListener("click", () => openMacRename(name));
|
|
1434
|
+
const rows = [
|
|
1435
|
+
cell(el("label", { class: "cell-label", text: t("mac.tether.switch") }), enabled, t("mac.tether.switchHelp")),
|
|
1436
|
+
macStateRow(tone, t(`mac.net.${network}`), I18N.en[`mac.tstate.${tt.state}`] ? t(`mac.tstate.${tt.state}`) : tt.state, side),
|
|
1437
|
+
// No "keep this network" pin here: beside the switch above it read as the same thing twice
|
|
1438
|
+
// (2026-10-20). The API keeps `pin` for the CLI.
|
|
1439
|
+
cell(t("mac.ssid.label"), [el("span", { class: name ? "mac-ssid" : "mac-ssid empty", text: name || t("mac.ssid.none") }), rename]),
|
|
1440
|
+
];
|
|
1441
|
+
let notice = null;
|
|
1442
|
+
// Before setup the card above says what to do; the router's own setup hint would only repeat it.
|
|
1443
|
+
if (tt.hint && tt.state !== "pinned" && setupOk) {
|
|
1444
|
+
const known = MAC_HINT_STATES.has(tt.state);
|
|
1445
|
+
notice = el("div", { class: "notice warn mac-hint", role: "status" }, [
|
|
1446
|
+
el("strong", { text: t(known ? `mac.tstate.${tt.state}` : "mac.hint.title") }),
|
|
1447
|
+
el("span", { text: known ? t(`mac.hint.${tt.state}`) : tt.hint }),
|
|
1448
|
+
]);
|
|
1449
|
+
}
|
|
1450
|
+
return el("section", { class: "mod-card mac-card", "aria-labelledby": "mac-tether-title" }, [
|
|
1451
|
+
el("div", { class: "mod-card-head" }, [el("h2", { id: "mac-tether-title", text: t("mac.tether.title") }), hint(t("mac.tether.blurb"))]),
|
|
1452
|
+
notice,
|
|
1453
|
+
el("div", { class: "group" }, rows),
|
|
1454
|
+
el("p", { class: "group-footer", text: t(macIsMac() ? "mac.tether.help" : "mac.tether.helpWin") }),
|
|
1455
|
+
macDetails("tether", [
|
|
1456
|
+
[t("mac.d.state"), tt.state],
|
|
1457
|
+
[t("mac.d.network"), tt.network],
|
|
1458
|
+
[t("mac.d.probe"), tt.lastProbe ? `${tt.lastProbe.ok ? "ok" : "failed"} · ${tt.lastProbe.network} · ${macTime(tt.lastProbe.at)}` : "—"],
|
|
1459
|
+
[t("mac.d.switch"), tt.lastSwitch ? `${tt.lastSwitch.to} · ${tt.lastSwitch.ok ? "ok" : "failed"} · ${tt.lastSwitch.reason}` : "—"],
|
|
1460
|
+
tt.hint ? [t("mac.d.hint"), tt.hint] : null,
|
|
1461
|
+
]),
|
|
1462
|
+
]);
|
|
1463
|
+
}
|
|
1464
|
+
function macEmpty(title, body) {
|
|
1465
|
+
return el("div", { class: "offline-state", role: "status" }, [
|
|
1466
|
+
el("div", { class: "offline-icon direct" }, [svgIcon("0 0 20 20", ["M3.5 13V5.6c0-.9.7-1.6 1.6-1.6h9.8c.9 0 1.6.7 1.6 1.6V13", "M1.8 15.8h16.4"])]),
|
|
1467
|
+
el("h2", { text: title }),
|
|
1468
|
+
el("p", { text: body }),
|
|
1469
|
+
]);
|
|
1470
|
+
}
|
|
1471
|
+
/** What the page was last drawn from: an unchanged refresh leaves the controls alone, so a click
|
|
1472
|
+
* that lands while it arrives is not lost to a button replaced under the pointer. */
|
|
1473
|
+
let macDrawn = "";
|
|
1474
|
+
function renderMac() {
|
|
1475
|
+
const root = $("#mac-content");
|
|
1476
|
+
const drawn = JSON.stringify([macPower, macError, macSetup, macSetupWatched, CURRENT_LANG]);
|
|
1477
|
+
if (drawn === macDrawn && root.childElementCount) return;
|
|
1478
|
+
macDrawn = drawn;
|
|
1479
|
+
if (!macPower) {
|
|
1480
|
+
if (macError === "unavailable") root.replaceChildren(macEmpty(t("mac.unavailable.title"), t("mac.unavailable.body")));
|
|
1481
|
+
else if (macError) root.replaceChildren(section(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${macError}` })]));
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
const a = macPower.awake;
|
|
1485
|
+
if (a.setup === "unsupported") {
|
|
1486
|
+
root.replaceChildren(macEmpty(t("mac.unsupported.title"), t("mac.unsupported.body")));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const setupOk = a.setup === "ok";
|
|
1490
|
+
// The password/setup card is macOS's alone: Windows holds sleep and edits the power plan as the
|
|
1491
|
+
// signed-in user, so its setup() always answers "ok" and this card never appears there (a Linux
|
|
1492
|
+
// power status has no backend at all and shows the unsupported state above).
|
|
1493
|
+
const needsSetup = !setupOk && macIsMac();
|
|
1494
|
+
const cards = el("div", { class: `mac-cards${needsSetup ? " mac-locked" : ""}` }, [macAwakeCard(a), macTetherCard(macPower.tether, setupOk)]);
|
|
1495
|
+
if (needsSetup) cards.setAttribute("inert", "");
|
|
1496
|
+
// Once set up, a later run (a new hotspot name) reports here, above the cards.
|
|
1497
|
+
const note = setupOk && macSetupWatched ? macSetupNote() : null;
|
|
1498
|
+
modRedraw(root, [
|
|
1499
|
+
needsSetup ? macSetupCard(macPower) : null,
|
|
1500
|
+
note ? el("div", { class: "mac-run" }, [note]) : null,
|
|
1501
|
+
cards,
|
|
1502
|
+
]);
|
|
1503
|
+
}
|
|
1504
|
+
setInterval(() => { if (!macBusy && !macSetup.running) void refreshMac(); }, 5000);
|
|
1505
|
+
|
|
730
1506
|
// ---- Model links --------------------------------------------------------------------
|
|
731
1507
|
|
|
732
1508
|
function claudeSelect(selected) {
|
|
@@ -1928,10 +2704,12 @@ function openAnthropicProviderForm(options) {
|
|
|
1928
2704
|
function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
|
|
1929
2705
|
auth.addEventListener("change", syncModeHint);
|
|
1930
2706
|
syncModeHint();
|
|
2707
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2708
|
+
const limitsField = el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]);
|
|
1931
2709
|
const form = el("div", { class: "provider-form" }, [
|
|
1932
2710
|
el("h1", { id: "modal-title", text: existing ? t("providers.edit") : t("providers.addTitle") }),
|
|
1933
2711
|
inputRow(t("providers.name"), nameInput, t("providers.nameHelp")), authField, keyField, probeButton, sourceLine, result, modelArea,
|
|
1934
|
-
modeHint,
|
|
2712
|
+
modeHint, limitsField,
|
|
1935
2713
|
]);
|
|
1936
2714
|
const saveButton = el("button", { class: "btn", type: "button", "data-default-action": "", text: existing ? t("common.save") : t("providers.add") });
|
|
1937
2715
|
saveButton.addEventListener("click", async () => {
|
|
@@ -1940,7 +2718,7 @@ function openAnthropicProviderForm(options) {
|
|
|
1940
2718
|
const next = clone(await latestConfig());
|
|
1941
2719
|
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
1942
2720
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), existing && next.providers[options.name]);
|
|
1943
|
-
const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), models: checkedModels };
|
|
2721
|
+
const provider = { type: "anthropic", auth: auth.value, ...(auth.value === "claude-code" && accountPool ? { accountPool: true } : {}), ...(auth.value === "api-key" && (keyInput.value || (existing && existing.apiKey)) ? { apiKey: keyInput.value || existing.apiKey } : {}), limits: limitsInput.checked, models: checkedModels };
|
|
1944
2722
|
next.providers[providerName] = provider;
|
|
1945
2723
|
// accountPool makes these models native Claude routing targets. Without it this remains the
|
|
1946
2724
|
// historical OpenAI ingress provider, and routing deliberately ignores it.
|
|
@@ -2007,9 +2785,11 @@ function openGrokProviderForm(options) {
|
|
|
2007
2785
|
probeButton.addEventListener("click", () => void runProbe());
|
|
2008
2786
|
const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
|
|
2009
2787
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2788
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2010
2789
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2011
2790
|
advanced.appendChild(el("div", { class: "advanced-content" }, [
|
|
2012
2791
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2792
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2013
2793
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2014
2794
|
]));
|
|
2015
2795
|
const pickerInput = el("input", { type: "checkbox", checked: existing ? modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id)) : true });
|
|
@@ -2030,12 +2810,13 @@ function openGrokProviderForm(options) {
|
|
|
2030
2810
|
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
2031
2811
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2032
2812
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
2033
|
-
const formKeys = new Set(["type", "identity", "instructionsAppend", "models"]);
|
|
2813
|
+
const formKeys = new Set(["type", "identity", "limits", "instructionsAppend", "models"]);
|
|
2034
2814
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2035
2815
|
next.providers[providerName] = {
|
|
2036
2816
|
...kept,
|
|
2037
2817
|
type: "grok",
|
|
2038
2818
|
identity: identity.checked,
|
|
2819
|
+
limits: limitsInput.checked,
|
|
2039
2820
|
...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
|
|
2040
2821
|
models: checkedModels,
|
|
2041
2822
|
};
|
|
@@ -2110,9 +2891,11 @@ function openGoogleProviderForm(options) {
|
|
|
2110
2891
|
probeButton.addEventListener("click", () => void runProbe());
|
|
2111
2892
|
const advanced = el("details", { class: "details" }, [el("summary", { text: t("common.advanced") })]);
|
|
2112
2893
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
2894
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2113
2895
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2114
2896
|
advanced.appendChild(el("div", { class: "advanced-content" }, [
|
|
2115
2897
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
2898
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2116
2899
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2117
2900
|
]));
|
|
2118
2901
|
const pickerInput = el("input", { type: "checkbox", checked: Boolean(existing && modelsOf(existing).some((model) => ((currentConfig.cli && currentConfig.cli.extraModels) || []).some((extra) => extra.model === model.id))) });
|
|
@@ -2133,7 +2916,7 @@ function openGoogleProviderForm(options) {
|
|
|
2133
2916
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2134
2917
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
2135
2918
|
// Fields this form has no control for (defaultEffort, a hand-written setting) survive the save.
|
|
2136
|
-
const formKeys = new Set(["type", "auth", "apiKey", "identity", "instructionsAppend", "models"]);
|
|
2919
|
+
const formKeys = new Set(["type", "auth", "apiKey", "identity", "limits", "instructionsAppend", "models"]);
|
|
2137
2920
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2138
2921
|
next.providers[providerName] = {
|
|
2139
2922
|
...kept,
|
|
@@ -2142,6 +2925,7 @@ function openGoogleProviderForm(options) {
|
|
|
2142
2925
|
...(apiKey ? { apiKey } : {}),
|
|
2143
2926
|
...(existing && existing.url ? { url: existing.url } : {}),
|
|
2144
2927
|
identity: identity.checked,
|
|
2928
|
+
limits: limitsInput.checked,
|
|
2145
2929
|
...(append.value.trim() ? { instructionsAppend: append.value.trim() } : {}),
|
|
2146
2930
|
models: checkedModels,
|
|
2147
2931
|
};
|
|
@@ -2242,12 +3026,14 @@ function openProviderForm(options) {
|
|
|
2242
3026
|
// Without a line of its own the model reads Claude Code's system prompt and answers that it is
|
|
2243
3027
|
// Claude, which is what DeepSeek did before this was offered here too.
|
|
2244
3028
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
3029
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2245
3030
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2246
3031
|
advancedContent.append(
|
|
2247
3032
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
3033
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2248
3034
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2249
3035
|
);
|
|
2250
|
-
advancedContent._prompt = { identity, append };
|
|
3036
|
+
advancedContent._prompt = { identity, limits: limitsInput, append };
|
|
2251
3037
|
} else {
|
|
2252
3038
|
const auth = el("select", {}, [selectOption("auto", t("providers.authAuto")), selectOption("own", t("providers.authOwn")), selectOption("borrow-codex", t("providers.authBorrow"))]);
|
|
2253
3039
|
auth.value = (existing && existing.auth) || "auto";
|
|
@@ -2259,6 +3045,7 @@ function openProviderForm(options) {
|
|
|
2259
3045
|
const loginHelp = el("small", { text: t("providers.loginHelp") });
|
|
2260
3046
|
const effort = effortSelect((existing && existing.defaultEffort) || "high", ["low", "medium", "high", "xhigh", "max"]);
|
|
2261
3047
|
const identity = el("input", { type: "checkbox", checked: !(existing && existing.identity === false) });
|
|
3048
|
+
const limitsInput = el("input", { type: "checkbox", checked: !(existing && existing.limits === false) });
|
|
2262
3049
|
const append = el("textarea", { rows: "2", value: (existing && existing.instructionsAppend) || "" });
|
|
2263
3050
|
chatgptFields = [
|
|
2264
3051
|
inputRow(t("providers.credentials"), auth, t("providers.credentialsHelp")),
|
|
@@ -2267,9 +3054,10 @@ function openProviderForm(options) {
|
|
|
2267
3054
|
];
|
|
2268
3055
|
advancedContent.append(
|
|
2269
3056
|
el("div", { class: "form-field" }, [el("label", { class: "check" }, [identity, el("span", { text: t("providers.identity") })]), el("small", { text: t("providers.identityHelp") })]),
|
|
3057
|
+
el("div", { class: "form-field" }, [el("label", { class: "check" }, [limitsInput, el("span", { text: t("providers.limits") })]), el("small", { text: t("providers.limitsHelp") })]),
|
|
2270
3058
|
inputRow(t("providers.append"), append, t("providers.appendHelp")),
|
|
2271
3059
|
);
|
|
2272
|
-
advancedContent._chatgpt = { auth, effort, identity, append };
|
|
3060
|
+
advancedContent._chatgpt = { auth, effort, identity, limits: limitsInput, append };
|
|
2273
3061
|
}
|
|
2274
3062
|
advanced.appendChild(advancedContent);
|
|
2275
3063
|
const customUrlRow = isCustom ? inputRow(t("providers.url"), urlInput, t("providers.urlHelp")) : null;
|
|
@@ -2311,13 +3099,13 @@ function openProviderForm(options) {
|
|
|
2311
3099
|
function draftProvider() {
|
|
2312
3100
|
if (isChatgpt) {
|
|
2313
3101
|
const chat = advancedContent._chatgpt;
|
|
2314
|
-
return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
|
|
3102
|
+
return { type: "chatgpt", auth: chat.auth.value, defaultEffort: chat.effort.value, identity: chat.identity.checked, limits: chat.limits.checked, ...(chat.append.value.trim() ? { instructionsAppend: chat.append.value.trim() } : {}) };
|
|
2315
3103
|
}
|
|
2316
3104
|
const prompt = advancedContent._prompt;
|
|
2317
3105
|
return {
|
|
2318
3106
|
type: openAiNow() ? "openai-compatible" : "anthropic-compatible",
|
|
2319
3107
|
url: urlInput.value.trim(),
|
|
2320
|
-
...(prompt ? { identity: prompt.identity.checked } : {}),
|
|
3108
|
+
...(prompt ? { identity: prompt.identity.checked, limits: prompt.limits.checked } : {}),
|
|
2321
3109
|
...(prompt && prompt.append.value.trim() ? { instructionsAppend: prompt.append.value.trim() } : {}),
|
|
2322
3110
|
...(preset ? { preset: preset.id } : {}),
|
|
2323
3111
|
// A vendor that asks for a session header keys its prompt cache on it, so carry it from the
|
|
@@ -2410,7 +3198,7 @@ function openProviderForm(options) {
|
|
|
2410
3198
|
const provider = draftProvider();
|
|
2411
3199
|
if (!isChatgpt && (!provider.url || !/^https?:\/\//.test(provider.url))) { toast(t("providers.urlRequired"), true); return; }
|
|
2412
3200
|
const next = clone(await latestConfig());
|
|
2413
|
-
const providerName = existing ? options.name : uniqueName(typedName, next.providers);
|
|
3201
|
+
const providerName = existing ? options.name : uniqueName(typedName, next.providers, provider.url);
|
|
2414
3202
|
const saved = existing && (next.providers[options.name] || existing);
|
|
2415
3203
|
if (existing && providerName !== options.name) delete next.providers[options.name];
|
|
2416
3204
|
const checkedModels = withSavedModelFields(form.querySelector(".model-picker").selected(), saved);
|
|
@@ -2419,8 +3207,8 @@ function openProviderForm(options) {
|
|
|
2419
3207
|
// (debugDump, a hand-written setting) is carried over from the saved one rather than dropped.
|
|
2420
3208
|
// A field the form owns is left to the draft, so clearing it in the form still clears it.
|
|
2421
3209
|
const formKeys = new Set(isChatgpt
|
|
2422
|
-
? ["type", "auth", "defaultEffort", "identity", "instructionsAppend", "models"]
|
|
2423
|
-
: ["type", "url", "identity", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
|
|
3210
|
+
? ["type", "auth", "defaultEffort", "identity", "limits", "instructionsAppend", "models"]
|
|
3211
|
+
: ["type", "url", "identity", "limits", "instructionsAppend", "preset", "sessionHeader", "wire", "caps", "headers", "models"]);
|
|
2424
3212
|
const kept = saved ? Object.fromEntries(Object.entries(saved).filter(([key]) => !formKeys.has(key))) : {};
|
|
2425
3213
|
next.providers[providerName] = { ...kept, ...provider };
|
|
2426
3214
|
// Read after `next.providers` has been updated, so an unticked model is already undeclared here
|
|
@@ -2459,8 +3247,8 @@ function openProviderForm(options) {
|
|
|
2459
3247
|
//
|
|
2460
3248
|
// The mod's settings live in the router (GET/PUT /api/mod-settings); the ⚙ panel in the bar edits
|
|
2461
3249
|
// the same object. As in System Settings a change applies itself: it is saved after a short pause
|
|
2462
|
-
// with the revision it was made on. A 409 means another surface saved first
|
|
2463
|
-
//
|
|
3250
|
+
// with the revision it was made on. A 409 means another surface saved first: the fields changed
|
|
3251
|
+
// here are laid over its settings and sent again, so neither side's change is lost.
|
|
2464
3252
|
|
|
2465
3253
|
const MOD_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
2466
3254
|
// The band's parts the person can turn off (`hide`), then the ones it shows only when turned on
|
|
@@ -2490,13 +3278,16 @@ const MOD_DEFAULTS = {
|
|
|
2490
3278
|
fallback: { agent: "general-purpose", model: "sonnet" },
|
|
2491
3279
|
keepWarm: false, keepWarmHours: 2, keepWarmMinTokens: 30000,
|
|
2492
3280
|
compactWith: "haiku", handoffSkill: "", handoffAfter: "continue", handoffButton: "advised",
|
|
2493
|
-
swampAt: 50, layout: "dashboard", appearance: "auto", language: "auto", hide: ["reason"], extras: [], setupDone: false,
|
|
3281
|
+
swampAt: 50, layout: "dashboard", appearance: "auto", skin: "deep", language: "auto", hide: ["reason"], extras: [], setupDone: false,
|
|
2494
3282
|
};
|
|
2495
3283
|
|
|
2496
|
-
|
|
3284
|
+
// `base` is the router's copy `settings` was edited from: what differs between them is this page's own change.
|
|
3285
|
+
let modState = { settings: null, base: null, revision: 0, options: null, retiredNote: false };
|
|
2497
3286
|
let modSaveTimer = null;
|
|
2498
3287
|
let modSaving = false;
|
|
2499
3288
|
let modSaveAgain = false;
|
|
3289
|
+
// Counts the router's answers to saves, so a read that went out before one does not undo it.
|
|
3290
|
+
let modWrites = 0;
|
|
2500
3291
|
|
|
2501
3292
|
/**
|
|
2502
3293
|
* The router hands back the mod's full normalized settings; this only fills what an older router
|
|
@@ -2517,6 +3308,42 @@ function modNormalize(raw) {
|
|
|
2517
3308
|
};
|
|
2518
3309
|
}
|
|
2519
3310
|
|
|
3311
|
+
/**
|
|
3312
|
+
* The fields changed from `before` to `after`, found as the mod's settingsChanges finds them: values
|
|
3313
|
+
* and arrays whole, objects field by field, and a rule whose mode changed replaced whole.
|
|
3314
|
+
*/
|
|
3315
|
+
function modChanges(before, after, path = []) {
|
|
3316
|
+
if (JSON.stringify(before) === JSON.stringify(after)) return [];
|
|
3317
|
+
if (after === undefined) return [{ path, remove: true }];
|
|
3318
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3319
|
+
if (isObject(before) && isObject(after) && before.mode === after.mode) {
|
|
3320
|
+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => modChanges(before[key], after[key], [...path, key]));
|
|
3321
|
+
}
|
|
3322
|
+
return [{ path, value: after }];
|
|
3323
|
+
}
|
|
3324
|
+
function modApply(base, changes) {
|
|
3325
|
+
const result = clone(base);
|
|
3326
|
+
for (const change of changes) {
|
|
3327
|
+
const key = change.path.at(-1);
|
|
3328
|
+
if (key === undefined) continue;
|
|
3329
|
+
let parent = result;
|
|
3330
|
+
for (const step of change.path.slice(0, -1)) {
|
|
3331
|
+
if (!parent[step] || typeof parent[step] !== "object" || Array.isArray(parent[step])) parent[step] = {};
|
|
3332
|
+
parent = parent[step];
|
|
3333
|
+
}
|
|
3334
|
+
if (change.remove) delete parent[key];
|
|
3335
|
+
else parent[key] = clone(change.value);
|
|
3336
|
+
}
|
|
3337
|
+
return result;
|
|
3338
|
+
}
|
|
3339
|
+
/** Take the router's copy, keeping on it what was changed here and is not saved yet (unless `drop`). */
|
|
3340
|
+
function modAdopt(snapshot, drop) {
|
|
3341
|
+
const changes = !drop && modState.base && modState.settings ? modChanges(modState.base, modState.settings) : [];
|
|
3342
|
+
modState.base = modNormalize(snapshot.settings);
|
|
3343
|
+
modState.settings = modApply(modState.base, changes);
|
|
3344
|
+
modState.revision = Number(snapshot.revision) || 0;
|
|
3345
|
+
}
|
|
3346
|
+
|
|
2520
3347
|
function modCap(text) { return text ? text.charAt(0).toLocaleUpperCase() + text.slice(1) : text; }
|
|
2521
3348
|
function modEffortName(effort) { return t(`mod.effort.${effort}`); }
|
|
2522
3349
|
function modSigned(n) { return n > 0 ? `+${n}` : n < 0 ? `−${-n}` : "0"; }
|
|
@@ -2604,42 +3431,57 @@ async function saveMod() {
|
|
|
2604
3431
|
if (modSaving) { modSaveAgain = true; return; }
|
|
2605
3432
|
modSaving = true;
|
|
2606
3433
|
try {
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
//
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
3434
|
+
// Another surface saving first (the bar's ⚙ panel, another tab of this page) costs this change
|
|
3435
|
+
// nothing: it is laid over their settings and sent again. Only a router that keeps moving under
|
|
3436
|
+
// it, three times running, gets the change dropped.
|
|
3437
|
+
for (let attempt = 1; ; attempt++) {
|
|
3438
|
+
const sent = clone(modState.settings);
|
|
3439
|
+
let out;
|
|
3440
|
+
try {
|
|
3441
|
+
out = await api("/api/mod-settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ schemaVersion: 1, settings: sent, revision: modState.revision }) });
|
|
3442
|
+
} catch (error) {
|
|
3443
|
+
if (error.status !== 409 || !error.body || !("settings" in error.body)) throw error;
|
|
3444
|
+
modWrites++;
|
|
3445
|
+
if (attempt < 3) { modAdopt(error.body); modRefresh(); continue; }
|
|
3446
|
+
clearTimeout(modSaveTimer);
|
|
3447
|
+
modSaveTimer = null;
|
|
3448
|
+
modSaveAgain = false;
|
|
3449
|
+
modAdopt(error.body, true);
|
|
3450
|
+
modRefresh();
|
|
3451
|
+
modStatus(t("mod.conflict"), true);
|
|
3452
|
+
toast(t("mod.conflict"), true);
|
|
3453
|
+
return;
|
|
3454
|
+
}
|
|
3455
|
+
modWrites++;
|
|
3456
|
+
modState.revision = Number(out.revision) || modState.revision + 1;
|
|
3457
|
+
// The router's copy is the normalized one; take it unless a newer change is already waiting.
|
|
3458
|
+
if (out.settings && !modSaveTimer && !modSaveAgain) { modAdopt(out, true); modRefresh(); }
|
|
3459
|
+
else modState.base = sent;
|
|
3460
|
+
modStatus(t("mod.saved"));
|
|
3461
|
+
return;
|
|
2624
3462
|
}
|
|
3463
|
+
} catch (error) {
|
|
3464
|
+
modStatus(`${t("common.saveFailed")} ${error.message}`, true);
|
|
2625
3465
|
} finally {
|
|
2626
3466
|
modSaving = false;
|
|
2627
3467
|
if (modSaveAgain) { modSaveAgain = false; void saveMod(); }
|
|
2628
3468
|
}
|
|
2629
3469
|
}
|
|
2630
3470
|
async function loadMod() {
|
|
2631
|
-
// A
|
|
3471
|
+
// A save on its way brings back the router's copy itself.
|
|
2632
3472
|
if (modSaveTimer || modSaving) return;
|
|
2633
3473
|
let retired = false;
|
|
3474
|
+
const writes = modWrites;
|
|
2634
3475
|
const [settingsResult, optionsResult] = await Promise.allSettled([api("/api/mod-settings"), api("/api/mod-options")]);
|
|
2635
3476
|
if (settingsResult.status === "rejected") {
|
|
2636
3477
|
if (!modState.settings) {
|
|
2637
3478
|
$("#mod-content").replaceChildren(modSection(null, [el("div", { class: "cell-note bad-text", text: `${t("common.loadFailed")} ${settingsResult.reason.message}` })]));
|
|
2638
3479
|
return;
|
|
2639
3480
|
}
|
|
2640
|
-
} else {
|
|
2641
|
-
|
|
2642
|
-
|
|
3481
|
+
} else if (!modSaving && modWrites === writes) {
|
|
3482
|
+
// A change made while this read was out stays on top of what it brought. One already saved, or
|
|
3483
|
+
// being saved, has the router's newer answer from that save instead.
|
|
3484
|
+
modAdopt(settingsResult.value);
|
|
2643
3485
|
retired = settingsResult.value.legacy === true;
|
|
2644
3486
|
}
|
|
2645
3487
|
modState.options = optionsResult.status === "fulfilled" ? optionsResult.value : null;
|
|
@@ -2649,6 +3491,10 @@ async function loadMod() {
|
|
|
2649
3491
|
// The settings came back without Auto and the field rules; saving them as they are keeps the note to this once.
|
|
2650
3492
|
if (retired) modPatch({});
|
|
2651
3493
|
}
|
|
3494
|
+
// Back on this page after a while: the bar's ⚙ panel or another tab may have saved meanwhile.
|
|
3495
|
+
document.addEventListener("visibilitychange", () => {
|
|
3496
|
+
if (document.visibilityState === "visible" && $("#view-mod").classList.contains("active")) void loadMod();
|
|
3497
|
+
});
|
|
2652
3498
|
function modRefresh() {
|
|
2653
3499
|
if (!modState.settings) return;
|
|
2654
3500
|
renderModNotice();
|
|
@@ -3056,6 +3902,7 @@ function modLookCard() {
|
|
|
3056
3902
|
modSection(null, [
|
|
3057
3903
|
cell(t("mod.layout"), modSegmented("layout", [{ value: "dashboard", label: t("mod.dashboard") }, { value: "minimal", label: t("mod.minimal") }], s.layout, t("mod.layout"), (layout) => modPatch({ layout })), s.layout === "minimal" ? modCap(t("mod.footerHint")) : null, "wide"),
|
|
3058
3904
|
cell(t("mod.appearance"), modSegmented("appearance", [{ value: "auto", label: t("mod.auto") }, { value: "dark", label: t("mod.dark") }, { value: "light", label: t("mod.lightAppearance") }], s.appearance, t("mod.appearance"), (appearance) => modPatch({ appearance })), null, "wide"),
|
|
3905
|
+
cell(t("mod.skin"), modSegmented("skin", [{ value: "deep", label: t("mod.skinDeep") }, { value: "flat", label: t("mod.skinFlat") }], s.skin || "deep", t("mod.skin"), (skin) => modPatch({ skin })), null, "wide"),
|
|
3059
3906
|
cell(t("mod.language"), modSelect("language", languages, s.language, t("mod.language"), (language) => modPatch({ language }), "narrow")),
|
|
3060
3907
|
]),
|
|
3061
3908
|
modSection(t("mod.show"), [
|
|
@@ -3080,6 +3927,29 @@ let knownRequestProviders = new Set();
|
|
|
3080
3927
|
let requestProvider = "";
|
|
3081
3928
|
let showCountTokens = false;
|
|
3082
3929
|
let expandedRequestId = null;
|
|
3930
|
+
// The usage period. "hour" is the exact rolling hour; every other period reads the hourly history.
|
|
3931
|
+
const USAGE_PERIODS = ["hour", "today", "7d", "30d", "month", "all", "custom"];
|
|
3932
|
+
const USAGE_STORE = "clauderipple_logs_usage";
|
|
3933
|
+
let usagePeriod = "hour";
|
|
3934
|
+
let usageFrom = ""; // custom start, a local "YYYY-MM-DD"
|
|
3935
|
+
let usageTo = ""; // custom end (inclusive), a local "YYYY-MM-DD"
|
|
3936
|
+
let lastSummary = null;
|
|
3937
|
+
let configuredProviders = null; // provider names from /api/config, fetched once for this view
|
|
3938
|
+
let renderedProviderNames = "";
|
|
3939
|
+
let requestsGeneration = 0;
|
|
3940
|
+
(function restoreUsage() {
|
|
3941
|
+
try {
|
|
3942
|
+
const saved = JSON.parse(localStorage.getItem(USAGE_STORE) || "null");
|
|
3943
|
+
if (!saved || typeof saved !== "object") return;
|
|
3944
|
+
if (USAGE_PERIODS.includes(saved.period)) usagePeriod = saved.period;
|
|
3945
|
+
if (isDay(saved.from)) usageFrom = saved.from;
|
|
3946
|
+
if (isDay(saved.to)) usageTo = saved.to;
|
|
3947
|
+
if (typeof saved.provider === "string") requestProvider = saved.provider;
|
|
3948
|
+
} catch { /* storage unavailable or unreadable: start from the defaults */ }
|
|
3949
|
+
})();
|
|
3950
|
+
function saveUsage() {
|
|
3951
|
+
try { localStorage.setItem(USAGE_STORE, JSON.stringify({ period: usagePeriod, from: usageFrom, to: usageTo, provider: requestProvider })); } catch { /* unavailable */ }
|
|
3952
|
+
}
|
|
3083
3953
|
|
|
3084
3954
|
function colorizeLogLine(line) {
|
|
3085
3955
|
const cls = /\bERROR\b|\berror\b/.test(line) ? "tag-ERR" : /\bWARN\b/.test(line) ? "tag-WARN" : "tag-PASS";
|
|
@@ -3110,10 +3980,97 @@ function showLogsPanel(name) {
|
|
|
3110
3980
|
}
|
|
3111
3981
|
$("#logs-requests-tab").addEventListener("click", () => showLogsPanel("requests"));
|
|
3112
3982
|
$("#logs-raw-tab").addEventListener("click", () => showLogsPanel("raw"));
|
|
3113
|
-
$("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; void refreshRequests(); });
|
|
3983
|
+
$("#logs-provider").addEventListener("change", (event) => { requestProvider = event.target.value; saveUsage(); void refreshRequests(); });
|
|
3114
3984
|
|
|
3985
|
+
// ---- Usage period: local calendar days, so "today" starts at this machine's midnight.
|
|
3986
|
+
function isDay(value) { return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(dayStart(value).valueOf()); }
|
|
3987
|
+
function dayStart(value) { const [y, m, d] = value.split("-").map(Number); return new Date(y, m - 1, d); }
|
|
3988
|
+
function dayKey(date) { const two = (n) => String(n).padStart(2, "0"); return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}`; }
|
|
3989
|
+
function daysAgo(n) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate() - n); }
|
|
3990
|
+
/** The period as `{from, to}` in epoch ms (`to` exclusive), or null for the rolling hour. */
|
|
3991
|
+
function usageRange() {
|
|
3992
|
+
const now = Date.now();
|
|
3993
|
+
if (usagePeriod === "today") return { from: daysAgo(0).valueOf(), to: now };
|
|
3994
|
+
if (usagePeriod === "7d") return { from: daysAgo(6).valueOf(), to: now };
|
|
3995
|
+
if (usagePeriod === "30d") return { from: daysAgo(29).valueOf(), to: now };
|
|
3996
|
+
if (usagePeriod === "month") { const today = new Date(); return { from: new Date(today.getFullYear(), today.getMonth(), 1).valueOf(), to: now }; }
|
|
3997
|
+
if (usagePeriod === "all") return { from: 0, to: now };
|
|
3998
|
+
if (usagePeriod === "custom") {
|
|
3999
|
+
if (!isDay(usageFrom) || !isDay(usageTo)) return null;
|
|
4000
|
+
const [first, last] = usageFrom <= usageTo ? [usageFrom, usageTo] : [usageTo, usageFrom];
|
|
4001
|
+
const end = dayStart(last);
|
|
4002
|
+
return { from: dayStart(first).valueOf(), to: new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1).valueOf() };
|
|
4003
|
+
}
|
|
4004
|
+
return null;
|
|
4005
|
+
}
|
|
4006
|
+
function formatDay(ms, withYear) {
|
|
4007
|
+
const options = withYear ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" };
|
|
4008
|
+
try { return new Intl.DateTimeFormat(CURRENT_LANG, options).format(new Date(ms)); } catch { return dayKey(new Date(ms)); }
|
|
4009
|
+
}
|
|
4010
|
+
/** What the usage numbers cover, in words: "Last hour · …" or "Oct 14 – Oct 20 · Records start Oct 12". */
|
|
4011
|
+
function usageCaption(range, earliest) {
|
|
4012
|
+
if (!range) return `${t("logs.lastHour")} · ${t("logs.polling")}`;
|
|
4013
|
+
const firstRecord = earliest ? new Date(earliest).valueOf() : NaN;
|
|
4014
|
+
// "All" starts where the records do; any other period that starts before them says so.
|
|
4015
|
+
const start = usagePeriod === "all" && Number.isFinite(firstRecord) ? firstRecord : range.from;
|
|
4016
|
+
const end = Math.max(start, Math.min(range.to, Date.now()) - 1);
|
|
4017
|
+
const year = new Date().getFullYear();
|
|
4018
|
+
const withYear = new Date(start).getFullYear() !== year || new Date(end).getFullYear() !== year;
|
|
4019
|
+
const first = formatDay(start, withYear);
|
|
4020
|
+
const last = formatDay(end, withYear);
|
|
4021
|
+
const parts = [first === last ? first : `${first} – ${last}`];
|
|
4022
|
+
if (usagePeriod !== "all" && Number.isFinite(firstRecord) && firstRecord > range.from) parts.push(t("logs.since", { date: formatDay(firstRecord, withYear) }));
|
|
4023
|
+
return parts.join(" · ");
|
|
4024
|
+
}
|
|
4025
|
+
function renderPeriodControl() {
|
|
4026
|
+
const control = $("#logs-period");
|
|
4027
|
+
control.replaceChildren(...USAGE_PERIODS.map((period) => {
|
|
4028
|
+
const on = period === usagePeriod;
|
|
4029
|
+
return el("button", { type: "button", class: on ? "active" : "", role: "radio", "aria-checked": String(on), text: t(`logs.period.${period}`), onclick: () => pickPeriod(period) });
|
|
4030
|
+
}));
|
|
4031
|
+
$("#logs-custom").hidden = usagePeriod !== "custom";
|
|
4032
|
+
}
|
|
4033
|
+
function pickPeriod(period) {
|
|
4034
|
+
if (period === usagePeriod) return;
|
|
4035
|
+
usagePeriod = period;
|
|
4036
|
+
if (period === "custom") {
|
|
4037
|
+
// A first custom range starts as the last seven days, so both fields hold something to adjust.
|
|
4038
|
+
if (!isDay(usageFrom)) usageFrom = dayKey(daysAgo(6));
|
|
4039
|
+
if (!isDay(usageTo)) usageTo = dayKey(daysAgo(0));
|
|
4040
|
+
fillCustomDates();
|
|
4041
|
+
}
|
|
4042
|
+
saveUsage();
|
|
4043
|
+
renderPeriodControl();
|
|
4044
|
+
void refreshRequests();
|
|
4045
|
+
}
|
|
4046
|
+
// Written only on load and when the period becomes custom: the 3-second refresh never touches these
|
|
4047
|
+
// fields, so a date being typed is not reset under the cursor.
|
|
4048
|
+
function fillCustomDates() {
|
|
4049
|
+
const today = dayKey(daysAgo(0));
|
|
4050
|
+
for (const [selector, value] of [["#logs-from", usageFrom], ["#logs-to", usageTo]]) {
|
|
4051
|
+
const input = $(selector);
|
|
4052
|
+
input.value = value;
|
|
4053
|
+
input.max = today;
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
for (const selector of ["#logs-from", "#logs-to"]) {
|
|
4057
|
+
$(selector).addEventListener("change", () => {
|
|
4058
|
+
const from = $("#logs-from").value;
|
|
4059
|
+
const to = $("#logs-to").value;
|
|
4060
|
+
if (!isDay(from) || !isDay(to)) return;
|
|
4061
|
+
usageFrom = from;
|
|
4062
|
+
usageTo = to;
|
|
4063
|
+
saveUsage();
|
|
4064
|
+
void refreshRequests();
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
renderPeriodControl();
|
|
4068
|
+
fillCustomDates();
|
|
4069
|
+
|
|
4070
|
+
const EMPTY_BUCKET = { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, avgMs: 0, cacheHitPercent: 0 };
|
|
3115
4071
|
function renderSummary(summary) {
|
|
3116
|
-
const
|
|
4072
|
+
const providers = (summary && summary.providers) || {};
|
|
4073
|
+
const total = (requestProvider ? providers[requestProvider] : summary && summary.total) || EMPTY_BUCKET;
|
|
3117
4074
|
$("#logs-summary").replaceChildren(
|
|
3118
4075
|
summaryChip(t("logs.summary.requests"), formatNumber(total.count)),
|
|
3119
4076
|
summaryChip(t("logs.summary.success"), `${formatNumber(total.ok)} / ${formatNumber(total.failed)}`),
|
|
@@ -3123,17 +4080,70 @@ function renderSummary(summary) {
|
|
|
3123
4080
|
summaryChip(t("logs.summary.output"), formatNumber(total.output)),
|
|
3124
4081
|
summaryChip(t("logs.summary.duration"), formatSeconds(total.avgMs)),
|
|
3125
4082
|
);
|
|
4083
|
+
fitSummaryValues();
|
|
4084
|
+
}
|
|
4085
|
+
// A long period runs the input to ten digits and the requests to "52,939 / 310", wider than a chip
|
|
4086
|
+
// in a 1,024px window (2026-10-20): a value that overflows steps its type down before it ellipsizes.
|
|
4087
|
+
function fitSummaryValues() {
|
|
4088
|
+
for (const value of document.querySelectorAll("#logs-summary .logs-chip .value")) {
|
|
4089
|
+
value.style.fontSize = "";
|
|
4090
|
+
for (let size = parseFloat(getComputedStyle(value).fontSize); value.scrollWidth > value.clientWidth && size > 14; size--) value.style.fontSize = `${size - 1}px`;
|
|
4091
|
+
// A pane about 300px wide fits no ten digits even then: a plain number goes compact ("78.9억"),
|
|
4092
|
+
// the whole of it on hover. The next refresh draws it whole again.
|
|
4093
|
+
const whole = value.dataset.whole || value.textContent;
|
|
4094
|
+
const number = Number(whole.replace(/,/g, ""));
|
|
4095
|
+
if (value.scrollWidth > value.clientWidth && whole.includes(",") && Number.isFinite(number)) {
|
|
4096
|
+
value.dataset.whole = whole;
|
|
4097
|
+
value.title = whole;
|
|
4098
|
+
value.textContent = new Intl.NumberFormat(CURRENT_LANG, { notation: "compact", maximumFractionDigits: 1 }).format(number);
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
3126
4101
|
}
|
|
3127
|
-
|
|
3128
|
-
|
|
4102
|
+
window.addEventListener("resize", fitSummaryValues);
|
|
4103
|
+
// Every provider that is configured or shows up in the usage or the requests, not only the ones in
|
|
4104
|
+
// the last 200 requests: those were all one provider for a heavy user, so the menu offered nothing
|
|
4105
|
+
// else (2026-10-20). The chosen provider stays listed even before anything names it.
|
|
4106
|
+
function renderProviderFilter(summary, records) {
|
|
4107
|
+
const configured = (currentConfig && currentConfig.providers) || configuredProviders || (status && status.providers) || {};
|
|
4108
|
+
for (const name of Object.keys(configured)) knownRequestProviders.add(name);
|
|
4109
|
+
for (const name of Object.keys((summary && summary.providers) || {})) knownRequestProviders.add(name);
|
|
4110
|
+
for (const model of (summary && Array.isArray(summary.models) ? summary.models : [])) if (model.provider) knownRequestProviders.add(model.provider);
|
|
3129
4111
|
for (const record of records) if (record.provider) knownRequestProviders.add(record.provider);
|
|
4112
|
+
if (requestProvider) knownRequestProviders.add(requestProvider);
|
|
3130
4113
|
const names = [...knownRequestProviders].sort();
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
4114
|
+
// Rebuilt only when the list changes, so the 3-second refresh does not close an open menu.
|
|
4115
|
+
const key = names.join("\n");
|
|
4116
|
+
const select = $("#logs-provider");
|
|
4117
|
+
if (key !== renderedProviderNames) {
|
|
4118
|
+
renderedProviderNames = key;
|
|
4119
|
+
select.replaceChildren(el("option", { value: "", text: t("logs.all") }), ...names.map((name) => el("option", { value: name, text: name })));
|
|
4120
|
+
}
|
|
4121
|
+
if (select.value !== requestProvider) select.value = requestProvider;
|
|
4122
|
+
}
|
|
4123
|
+
async function loadConfiguredProviders() {
|
|
4124
|
+
if (configuredProviders || currentConfig) return;
|
|
4125
|
+
try { configuredProviders = (await api("/api/config")).providers || {}; } catch { /* the usage and requests still name their providers */ }
|
|
4126
|
+
}
|
|
4127
|
+
function modelUsageRow(entry) {
|
|
4128
|
+
const total = totalInputOf(entry);
|
|
4129
|
+
const model = el("div", { class: "request-models" }, [
|
|
4130
|
+
el("span", { class: "model", text: entry.model, title: entry.model }),
|
|
4131
|
+
el("span", { class: `provider-badge ${providerClass(entry.provider)}`, text: entry.provider }),
|
|
4132
|
+
]);
|
|
4133
|
+
// The labels show only where a narrow window drops the header row.
|
|
4134
|
+
return el("tr", {}, [
|
|
4135
|
+
el("td", { class: "c-model" }, [model]),
|
|
4136
|
+
el("td", { class: "c-count", "data-label": t("logs.th.count") }, [formatNumber(entry.count)]),
|
|
4137
|
+
el("td", { class: "c-in", "data-label": t("logs.th.input") }, [el("span", { class: "token-cell", text: formatNumber(total) }, [el("span", { class: "cache-pill", text: t("logs.cacheHit", { percent: entry.cacheHitPercent || 0 }) })])]),
|
|
4138
|
+
el("td", { class: "c-out", "data-label": t("logs.th.output") }, [formatNumber(entry.output)]),
|
|
4139
|
+
el("td", { class: "c-dur", "data-label": t("logs.th.avg") }, [formatSeconds(entry.avgMs)]),
|
|
4140
|
+
]);
|
|
4141
|
+
}
|
|
4142
|
+
function renderModelUsage(summary) {
|
|
4143
|
+
const models = (summary && Array.isArray(summary.models) ? summary.models : []).filter((entry) => !requestProvider || entry.provider === requestProvider);
|
|
4144
|
+
$("#models-table tbody").replaceChildren(...models.map(modelUsageRow));
|
|
4145
|
+
$("#models-table").closest(".logs-table-card").hidden = models.length === 0;
|
|
4146
|
+
$("#models-empty").hidden = models.length !== 0;
|
|
3137
4147
|
}
|
|
3138
4148
|
/** Everything the model read: uncached, read from the cache, and written to it. */
|
|
3139
4149
|
function totalInputOf(usage) {
|
|
@@ -3184,25 +4194,39 @@ function requestRow(record) {
|
|
|
3184
4194
|
return row;
|
|
3185
4195
|
}
|
|
3186
4196
|
function renderRequests(records) {
|
|
3187
|
-
const scroll = $(".logs-table-card");
|
|
4197
|
+
const scroll = $("#requests-table").closest(".logs-table-card");
|
|
3188
4198
|
const left = scroll.scrollLeft;
|
|
3189
4199
|
const tbody = $("#requests-table tbody");
|
|
3190
4200
|
tbody.replaceChildren(...records.flatMap((record) => expandedRequestId === record.id ? [requestRow(record), requestDetail(record)] : [requestRow(record)]));
|
|
3191
|
-
|
|
4201
|
+
scroll.hidden = records.length === 0;
|
|
3192
4202
|
$("#requests-empty").hidden = records.length !== 0;
|
|
3193
4203
|
scroll.scrollLeft = left;
|
|
3194
4204
|
}
|
|
3195
4205
|
async function refreshRequests() {
|
|
3196
4206
|
if (logsPanel !== "requests") return;
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
4207
|
+
// A period or provider picked while an older refresh is in flight must not be painted over by it.
|
|
4208
|
+
const generation = ++requestsGeneration;
|
|
4209
|
+
const provider = requestProvider;
|
|
4210
|
+
const range = usageRange();
|
|
4211
|
+
// A custom range with an unusable date waits for the fields instead of falling back to the hour.
|
|
4212
|
+
if (usagePeriod === "custom" && !range) return;
|
|
4213
|
+
$("#logs-range-caption").textContent = usageCaption(range, lastSummary && lastSummary.earliest);
|
|
4214
|
+
const summaryPath = range ? `/api/requests/summary?from=${range.from}&to=${range.to}` : "/api/requests/summary?since=3600";
|
|
4215
|
+
const suffix = provider ? `&provider=${encodeURIComponent(provider)}` : "";
|
|
4216
|
+
const [records, summary] = await Promise.allSettled([api(`/api/requests?n=200${suffix}&kind=messages`), api(summaryPath), loadConfiguredProviders()]);
|
|
4217
|
+
if (generation !== requestsGeneration) return;
|
|
4218
|
+
// Each half keeps its last good contents when its own call fails.
|
|
4219
|
+
if (summary.status === "fulfilled") {
|
|
4220
|
+
lastSummary = summary.value;
|
|
4221
|
+
$("#logs-range-caption").textContent = usageCaption(range, lastSummary.earliest);
|
|
4222
|
+
renderSummary(lastSummary);
|
|
4223
|
+
renderModelUsage(lastSummary);
|
|
4224
|
+
}
|
|
4225
|
+
if (records.status === "fulfilled") {
|
|
4226
|
+
requestRows = Array.isArray(records.value.requests) ? records.value.requests : [];
|
|
3204
4227
|
renderRequests(requestRows);
|
|
3205
|
-
}
|
|
4228
|
+
}
|
|
4229
|
+
renderProviderFilter(lastSummary, requestRows);
|
|
3206
4230
|
}
|
|
3207
4231
|
async function refreshLogs() {
|
|
3208
4232
|
if (logsPanel !== "raw") return;
|