codex-multi-auth 2.21.0 → 2.23.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/.codex-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/config/schema/config.schema.json +8 -0
- package/dist/lib/accounts.d.ts +5 -0
- package/dist/lib/accounts.js +33 -4
- package/dist/lib/api-route-store.d.ts +7 -0
- package/dist/lib/api-route-store.js +100 -31
- package/dist/lib/auth/account-access.js +2 -1
- package/dist/lib/auth/auth.js +3 -2
- package/dist/lib/auth/device-auth.js +2 -1
- package/dist/lib/auth/http.d.ts +1 -0
- package/dist/lib/auth/http.js +1 -0
- package/dist/lib/codex-manager/account-manager-commands.js +1 -0
- package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
- package/dist/lib/codex-manager/api-login-menu.js +4 -6
- package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
- package/dist/lib/codex-manager/commands/locale.js +25 -0
- package/dist/lib/codex-manager/commands/status.d.ts +5 -1
- package/dist/lib/codex-manager/commands/status.js +501 -13
- package/dist/lib/codex-manager/help.js +3 -1
- package/dist/lib/codex-manager.js +98 -23
- package/dist/lib/config.js +3 -0
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.js +1 -0
- package/dist/lib/locale.d.ts +7 -0
- package/dist/lib/locale.js +35 -0
- package/dist/lib/model-route-policy.d.ts +1 -0
- package/dist/lib/model-route-policy.js +2 -1
- package/dist/lib/quota-probe.js +2 -1
- package/dist/lib/request/proxy-fetch.d.ts +5 -0
- package/dist/lib/request/proxy-fetch.js +37 -0
- package/dist/lib/request/url-rewriting.js +17 -0
- package/dist/lib/runtime/account-model-catalog.d.ts +3 -0
- package/dist/lib/runtime/account-model-catalog.js +7 -2
- package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
- package/dist/lib/runtime/api-model-capabilities.js +155 -24
- package/dist/lib/runtime/app-bind.d.ts +7 -0
- package/dist/lib/runtime/app-bind.js +92 -1
- package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
- package/dist/lib/runtime/automatic-api-checks.js +188 -0
- package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
- package/dist/lib/runtime/model-discovery-status.js +64 -49
- package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
- package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
- package/dist/lib/runtime/responses-websocket.d.ts +48 -5
- package/dist/lib/runtime/responses-websocket.js +275 -39
- package/dist/lib/runtime/rotation-account-selection.d.ts +25 -5
- package/dist/lib/runtime/rotation-account-selection.js +94 -4
- package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
- package/dist/lib/runtime/rotation-proxy-state.js +376 -4
- package/dist/lib/runtime/rotation-server-types.d.ts +6 -0
- package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
- package/dist/lib/runtime/rotation-token-refresh.js +218 -27
- package/dist/lib/runtime-rotation-proxy.js +601 -105
- package/dist/lib/schemas.d.ts +5 -0
- package/dist/lib/schemas.js +1 -0
- package/dist/lib/storage/credential-sidecars.js +34 -2
- package/dist/lib/storage/file-lock.d.ts +2 -0
- package/dist/lib/storage/file-lock.js +44 -30
- package/dist/lib/usage/ledger.js +1 -0
- package/dist/lib/usage/redaction.js +1 -0
- package/dist/lib/usage/types.d.ts +1 -1
- package/dist/lib/web-dashboard-html.d.ts +1 -0
- package/dist/lib/web-dashboard-html.js +66 -0
- package/dist/lib/web-dashboard.d.ts +36 -0
- package/dist/lib/web-dashboard.js +322 -0
- package/package.json +3 -1
- package/scripts/codex-app-router.js +86 -14
- package/scripts/codex-routing.js +1 -0
- package/scripts/codex.js +150 -13
package/dist/lib/schemas.d.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
export declare const PluginConfigSchema: z.ZodObject<{
|
|
8
|
+
locale: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
auto: "auto";
|
|
10
|
+
en: "en";
|
|
11
|
+
zh: "zh";
|
|
12
|
+
}>>;
|
|
8
13
|
codexMode: z.ZodOptional<z.ZodBoolean>;
|
|
9
14
|
codexRuntimeRotationProxy: z.ZodOptional<z.ZodBoolean>;
|
|
10
15
|
codexTuiV2: z.ZodOptional<z.ZodBoolean>;
|
package/dist/lib/schemas.js
CHANGED
|
@@ -22,6 +22,7 @@ function schemaLog() {
|
|
|
22
22
|
// Plugin Configuration Schema
|
|
23
23
|
// ============================================================================
|
|
24
24
|
export const PluginConfigSchema = z.object({
|
|
25
|
+
locale: z.enum(["auto", "en", "zh"]).optional(),
|
|
25
26
|
codexMode: z.boolean().optional(),
|
|
26
27
|
codexRuntimeRotationProxy: z.boolean().optional(),
|
|
27
28
|
codexTuiV2: z.boolean().optional(),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { getApiRoutesPath } from "../api-route-store.js";
|
|
4
5
|
import { withRetry } from "../fs-retry.js";
|
|
@@ -13,18 +14,49 @@ const retry = { maxAttempts: 6, backoffMs: 25 };
|
|
|
13
14
|
*/
|
|
14
15
|
export async function clearCredentialSidecars() {
|
|
15
16
|
const routes = getApiRoutesPath();
|
|
16
|
-
// Same locks as saveApiRoutes, so a concurrent menu save cannot resurrect keys.
|
|
17
|
-
await withStorageLock(() => withFileTransactionLock(routes, () => withRetry(() => fs.rm(routes, { force: true }), retry)));
|
|
18
17
|
const dir = getCodexMultiAuthDir();
|
|
18
|
+
// Same locks as saveApiRoutes, so a concurrent menu save cannot resurrect keys.
|
|
19
|
+
await withStorageLock(() => withFileTransactionLock(routes, async () => {
|
|
20
|
+
await withRetry(() => fs.rm(routes, { force: true }), retry);
|
|
21
|
+
const leases = join(dir, "api-probe-locks");
|
|
22
|
+
try {
|
|
23
|
+
const stat = await fs.lstat(leases);
|
|
24
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
25
|
+
throw Error("Invalid API probe lease directory; manual repair required");
|
|
26
|
+
// Detach while publication is excluded; never recursively delete a
|
|
27
|
+
// canonical directory that a replacement owner can populate.
|
|
28
|
+
await withRetry(() => fs.rename(leases, join(dir, `api-probe-locks.wiped-${randomUUID()}`)), retry);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code !== "ENOENT")
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}));
|
|
19
35
|
// Each writer read-modify-writes under its file lock; deleting under the same
|
|
20
36
|
// lock stops an in-flight refresh or redemption renaming its old state (and a
|
|
21
37
|
// last-resort policy) back after the reset.
|
|
22
38
|
for (const path of [
|
|
23
39
|
join(dir, "reset-credits.json"),
|
|
24
40
|
join(dir, "api-capability-probes.json"),
|
|
41
|
+
join(dir, "automatic-api-checks.json"),
|
|
25
42
|
]) {
|
|
26
43
|
await withFileTransactionLock(path, () => withRetry(() => fs.rm(path, { force: true }), retry));
|
|
27
44
|
}
|
|
45
|
+
// Also finish deletion after an interrupted earlier wipe. These unique paths
|
|
46
|
+
// can contain old holders/candidates, but never a new canonical lease.
|
|
47
|
+
let leftovers;
|
|
48
|
+
try {
|
|
49
|
+
leftovers = await fs.readdir(dir);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (error.code !== "ENOENT")
|
|
53
|
+
throw error;
|
|
54
|
+
leftovers = [];
|
|
55
|
+
}
|
|
56
|
+
for (const entry of leftovers) {
|
|
57
|
+
if (/^api-probe-locks\.wiped-[a-f0-9-]{36}$/.test(entry))
|
|
58
|
+
await withRetry(() => fs.rm(join(dir, entry), { recursive: true, force: true }), retry);
|
|
59
|
+
}
|
|
28
60
|
// Per-account timestamps only; their writers take no lock and hold no secrets.
|
|
29
61
|
const activity = join(dir, "inference-activity");
|
|
30
62
|
await withRetry(() => fs.rm(activity, { recursive: true, force: true }), retry);
|
|
@@ -145,21 +145,32 @@ async function recoverDeadCandidates(lock) {
|
|
|
145
145
|
* A reused PID fails closed (bounded contention) rather than risking two writers.
|
|
146
146
|
*/
|
|
147
147
|
export async function withFileTransactionLock(path, action, options = {}) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const key = join(await fs.realpath(dirname(absolute)), absolute.slice(dirname(absolute).length + 1));
|
|
152
|
-
if (leases.getStore()?.get(key)?.active)
|
|
153
|
-
return action();
|
|
154
|
-
const lock = `${key}.write-lock`;
|
|
155
|
-
await recoverDeadCandidates(lock);
|
|
156
|
-
const candidate = await fs.mkdtemp(`${lock}.candidate-`);
|
|
148
|
+
options.signal?.throwIfAborted();
|
|
149
|
+
const guard = options.guardAcquisition ?? (async (run) => run());
|
|
150
|
+
let key = "", lock = "", candidate;
|
|
157
151
|
const owner = `${host}.${process.pid}.${randomUUID()}`;
|
|
158
|
-
let published = false;
|
|
152
|
+
let published = false, reentrant = false;
|
|
159
153
|
try {
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
154
|
+
await guard(async () => {
|
|
155
|
+
options.signal?.throwIfAborted();
|
|
156
|
+
const absolute = resolve(path);
|
|
157
|
+
await fs.mkdir(dirname(absolute), { recursive: true, mode: 0o700 });
|
|
158
|
+
key = join(await fs.realpath(dirname(absolute)), absolute.slice(dirname(absolute).length + 1));
|
|
159
|
+
options.signal?.throwIfAborted();
|
|
160
|
+
if (leases.getStore()?.get(key)?.active) {
|
|
161
|
+
reentrant = true;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
lock = `${key}.write-lock`;
|
|
165
|
+
await recoverDeadCandidates(lock);
|
|
166
|
+
candidate = await fs.mkdtemp(`${lock}.candidate-`);
|
|
167
|
+
await fs.chmod(candidate, 0o700);
|
|
168
|
+
await fs.writeFile(join(candidate, owner), "", { flag: "wx", mode: 0o600 });
|
|
169
|
+
});
|
|
170
|
+
if (reentrant)
|
|
171
|
+
return action();
|
|
172
|
+
await withRetry(() => guard(async () => {
|
|
173
|
+
options.signal?.throwIfAborted();
|
|
163
174
|
try {
|
|
164
175
|
await fs.rename(candidate, lock);
|
|
165
176
|
published = true;
|
|
@@ -179,12 +190,13 @@ export async function withFileTransactionLock(path, action, options = {}) {
|
|
|
179
190
|
await recoverDeadOwner(lock);
|
|
180
191
|
throw Object.assign(Error("Storage is busy with another live writer; retry shortly."), { code: "ELOCKED" });
|
|
181
192
|
}
|
|
182
|
-
}, {
|
|
193
|
+
}), {
|
|
183
194
|
maxAttempts: lockMaxAttemptsForWaitMs(options.waitMs ?? 10000),
|
|
184
195
|
backoffMs: lockRetryBaseDelayMs,
|
|
185
196
|
jitterMs: LOCK_RETRY_JITTER_MS,
|
|
186
197
|
retryableCodes: ["ELOCKED", "EBUSY", "EPERM", "EACCES", "EAGAIN"],
|
|
187
198
|
});
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
188
200
|
const lease = { active: true };
|
|
189
201
|
const context = new Map(leases.getStore());
|
|
190
202
|
context.set(key, lease);
|
|
@@ -197,22 +209,24 @@ export async function withFileTransactionLock(path, action, options = {}) {
|
|
|
197
209
|
}
|
|
198
210
|
finally {
|
|
199
211
|
const directory = published ? lock : candidate;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
212
|
+
if (directory) {
|
|
213
|
+
try {
|
|
214
|
+
await withRetry(async () => {
|
|
215
|
+
try {
|
|
216
|
+
await fs.unlink(join(directory, owner));
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (code(error) !== "ENOENT")
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
await removeEmpty(directory);
|
|
223
|
+
}, { maxAttempts: 6, backoffMs: 25 });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
abandonedOwners.add(owner);
|
|
227
|
+
retryAbandonedRelease(directory, owner);
|
|
228
|
+
logWarn("Storage lock cleanup deferred", { code: code(error) ?? "unknown" });
|
|
229
|
+
}
|
|
216
230
|
}
|
|
217
231
|
}
|
|
218
232
|
}
|
package/dist/lib/usage/ledger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type UsageLedgerSource = "runtime-proxy" | "plugin-host" | "local-bridge" | "cli" | "unknown";
|
|
2
|
-
export type UsageLedgerOperation = "responses" | "images" | "models" | "thread-goal" | "auth-refresh" | "diagnostic" | "unknown";
|
|
2
|
+
export type UsageLedgerOperation = "responses" | "images" | "models" | "thread-goal" | "transcribe" | "auth-refresh" | "diagnostic" | "unknown";
|
|
3
3
|
export type UsageLedgerOutcome = "success" | "failure" | "blocked" | "cancelled";
|
|
4
4
|
/**
|
|
5
5
|
* The service tier a response was actually billed at.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderDashboardHtml(token: string | null, locale?: "en" | "zh"): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const DASHBOARD_HTML = `
|
|
2
|
+
<!doctype html>
|
|
3
|
+
<html lang="zh-CN">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
7
|
+
<title>Codex Multi-Auth · 号池监控</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root{color-scheme:dark;--bg:#09111f;--panel:#101c2f;--panel2:#14243b;--line:#223653;--text:#edf5ff;--muted:#8ea5c2;--green:#36d399;--amber:#f7c95c;--red:#fb7185;--blue:#65a8ff;--shadow:0 14px 40px rgba(0,0,0,.25)}
|
|
10
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#17345a 0,transparent 38%),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
11
|
+
.shell{max-width:1440px;margin:0 auto;padding:28px 26px 48px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:26px}.brand{display:flex;gap:14px;align-items:center}.mark{width:44px;height:44px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(135deg,#4b8dff,#55d6ae);color:#07111f;font-weight:900;font-size:18px;box-shadow:0 8px 24px #3b82f655}.eyebrow{color:var(--blue);font-size:12px;letter-spacing:.12em;text-transform:uppercase}.title{font-size:28px;font-weight:800;letter-spacing:-.03em}.subtitle{color:var(--muted);margin-top:2px}.actions{display:flex;gap:10px;align-items:center}.button{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:9px;padding:9px 13px;cursor:pointer}.button:hover{border-color:var(--blue);background:var(--panel2)}.live{display:flex;gap:7px;align-items:center;color:var(--muted);font-size:12px}.dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green)}.dot.off{background:var(--red);box-shadow:0 0 10px var(--red)}
|
|
12
|
+
.summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:22px}.card,.panel{background:linear-gradient(145deg,#122139dd,#0e192add);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:17px}.label{color:var(--muted);font-size:12px}.value{font-size:26px;font-weight:800;margin-top:7px}.value.green{color:var(--green)}.value.amber{color:var(--amber)}.value.red{color:var(--red)}.value.blue{color:var(--blue)}
|
|
13
|
+
.grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.7fr);gap:18px;align-items:start}.panel{padding:18px}.panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.panel-title{font-size:16px;font-weight:750}.panel-meta{color:var(--muted);font-size:12px}.accounts{display:grid;gap:10px}.account{padding:15px;border:1px solid var(--line);border-radius:11px;background:#0e1a2caa}.account-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.account-name{font-weight:700;overflow-wrap:anywhere}.account-meta{color:var(--muted);font-size:12px;margin-top:3px}.badge{border-radius:99px;padding:3px 9px;font-size:11px;white-space:nowrap;border:1px solid #ffffff18}.badge.ready{color:var(--green);background:#36d39918}.badge.login{color:var(--red);background:#fb718518}.badge.disabled{color:var(--muted);background:#8ea5c218}.badge.current{color:var(--blue);background:#65a8ff18}.account-stats{display:grid;grid-template-columns:1fr 1fr 120px;gap:15px;margin-top:14px}.quota{min-width:0}.quota-row{display:flex;justify-content:space-between;gap:7px;color:var(--muted);font-size:11px;margin-top:8px}.bar{height:8px;border-radius:99px;background:#243752;overflow:hidden;margin-top:5px}.fill{height:100%;border-radius:99px;background:linear-gradient(90deg,var(--green),#7de2ab)}.fill.warn{background:linear-gradient(90deg,var(--amber),#ff9e5c)}.fill.danger{background:linear-gradient(90deg,var(--red),#ffadad)}.risk{text-align:right}.risk-num{font-size:23px;font-weight:800}.risk-low{color:var(--green)}.risk-medium{color:var(--amber)}.risk-high{color:var(--red)}.reason{color:var(--muted);font-size:12px;margin-top:8px;overflow-wrap:anywhere}.empty{padding:35px;text-align:center;color:var(--muted)}.subhead{margin:14px 0 4px;color:var(--muted);font-size:11px;letter-spacing:.1em;text-transform:uppercase}.side{display:grid;gap:18px}.kv{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #ffffff0d}.kv:last-child{border-bottom:0}.kv b{font-weight:650;text-align:right;overflow-wrap:anywhere}.notice{padding:12px;border-radius:10px;background:#f7c95c13;border:1px solid #f7c95c3d;color:#fbe6a2;font-size:12px}.error{padding:12px;border-radius:10px;background:#fb718513;border:1px solid #fb71853d;color:#ffc2cb;font-size:12px}.foot{color:var(--muted);font-size:12px;margin-top:20px;text-align:center}
|
|
14
|
+
@media(max-width:1050px){.summary{grid-template-columns:repeat(3,minmax(0,1fr))}.grid{grid-template-columns:1fr}.side{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:680px){.shell{padding:18px 13px 36px}.top{display:block}.actions{margin-top:15px;justify-content:space-between}.summary{grid-template-columns:repeat(2,minmax(0,1fr))}.account-stats{grid-template-columns:1fr 1fr}.risk{text-align:left}.side{grid-template-columns:1fr}.title{font-size:23px}}
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<main class="shell">
|
|
19
|
+
<header class="top"><div class="brand"><div class="mark">MA</div><div><div class="eyebrow">Codex Multi-Auth</div><div class="title" data-i18n="title">号池实时监控</div><div class="subtitle" data-i18n="subtitle">账号、配额与轮换状态</div></div></div><div class="actions"><div class="live"><span id="dot" class="dot"></span><span id="connection" data-i18n="connecting">连接中…</span></div><button id="lang" class="button">EN</button><button id="refresh" class="button" data-i18n="refresh">刷新</button></div></header>
|
|
20
|
+
<section id="summary" class="summary"></section>
|
|
21
|
+
<div id="error" class="error" hidden></div>
|
|
22
|
+
<section class="grid"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="accounts">账号池</div><div id="updated" class="panel-meta">—</div></div><div id="accounts" class="accounts"></div></section><aside class="side"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="routing">路由</div></div><div id="routing"></div></section><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="runtime">运行时</div></div><div id="runtime"></div></section></aside></section>
|
|
23
|
+
<div class="foot" data-i18n="foot">数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。</div>
|
|
24
|
+
</main>
|
|
25
|
+
<script>
|
|
26
|
+
(function(){
|
|
27
|
+
var token="__CMA_TOKEN__";
|
|
28
|
+
var lang="__CMA_LANG__";
|
|
29
|
+
var text={zh:{title:"号池实时监控",subtitle:"账号、配额与轮换状态",connecting:"连接中…",connected:"实时连接",offline:"连接中断",refresh:"刷新",accounts:"账号池",apiAccounts:"API/ZDR 账号",routing:"路由",runtime:"运行时",total:"总账号",ready:"可用",login:"需重新登录",disabled:"已禁用",enabled:"已启用",recommend:"推荐账号",active:"当前账号",requests:"请求",success:"成功",failed:"失败",last:"最近请求",quota:"配额",risk:"风险",unknown:"未知",reason:"原因",primary:"主窗口",secondary:"次窗口",priority:"优先级",models:"可见模型",foot:"数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。"},en:{title:"Pool live monitor",subtitle:"Accounts, quota and routing",connecting:"Connecting…",connected:"Live",offline:"Disconnected",refresh:"Refresh",accounts:"Account pool",apiAccounts:"API/ZDR accounts",routing:"Routing",runtime:"Runtime",total:"Accounts",ready:"Ready",login:"Re-login needed",disabled:"Disabled",enabled:"Enabled",recommend:"Recommended",active:"Current",requests:"Requests",success:"Success",failed:"Failed",last:"Last request",quota:"Quota",risk:"Risk",unknown:"Unknown",reason:"Reason",primary:"primary",secondary:"secondary",priority:"priority",models:"visible models",foot:"Data comes from the local pool; the server listens on loopback by default. Ctrl-C stops it."}};
|
|
30
|
+
function t(k){return text[lang][k]||k}
|
|
31
|
+
function label(v){var s=String(v==null?"":v);return lang==="zh"?s.replace(/^Account /,"账号 ").replace(/Token account/g,"令牌账号").replace(/, id:/g,",ID:").replace(/, /g,","):s}
|
|
32
|
+
function reasonText(v){var s=String(v==null?"—":v);if(lang!=="zh")return s;return s.replace("token-invalid — re-login needed","令牌无效——需要重新登录").replace("primary quota ","主窗口配额 ").replace(" secondary quota "," 次窗口配额 ").replace(" used","已使用").replace("account is disabled","账号已禁用").replace("quota cache exhausted","配额缓存已用尽")}
|
|
33
|
+
function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return({"&":"&","<":"<",">":">",'"':""","'":"'"})[c]})}
|
|
34
|
+
function api(path){return path+(path.indexOf("?")>=0?"&":"?")+"token="+encodeURIComponent(token)}
|
|
35
|
+
function ago(ms){if(typeof ms!=="number"||!isFinite(ms))return "—";var s=Math.max(0,Math.round(ms/1000));if(s<60)return s+"s";if(s<3600)return Math.floor(s/60)+"m";if(s<86400)return Math.floor(s/3600)+"h";return Math.floor(s/86400)+"d"}
|
|
36
|
+
function statusClass(a){var m=(a.markers||[]).join(" ");if(a.enabled===false)return "disabled";if(/invalid|sign-in|re-login/i.test(m))return "login";return "ready"}
|
|
37
|
+
function winLabel(w,key){var m=w&&w.windowMinutes;if(typeof m==="number"&&isFinite(m)&&m>0){var zh=lang==="zh";if(m%1440===0)return (m/1440)+(zh?"天":"d");if(m%60===0)return (m/60)+(zh?"小时":"h");return m+(zh?"分钟":"m")}return t(key)}
|
|
38
|
+
function bar(window,key){if(!window||typeof window.usedPercent!=="number")return '<div class="label">'+t("unknown")+"</div>";var p=Math.max(0,Math.min(100,Math.round(window.usedPercent)));var cls=p>=90?" danger":p>=75?" warn":"";return '<div class="quota-row"><span>'+esc(winLabel(window,key))+" "+t("quota")+'</span><span>'+p+"% / "+(100-p)+"%</span></div><div class="bar"><div class="fill'+cls+'" style="width:'+p+'%"></div></div>'}
|
|
39
|
+
function draw(data){window.__data=data;var all=data.accounts||[];var ready=all.filter(function(a){return a.enabled!==false&&statusClass(a)==="ready"}).length;var login=all.filter(function(a){return statusClass(a)==="login"}).length;var disabled=all.filter(function(a){return a.enabled===false}).length;var rec=data.recommendedIndex==null?"—":"#"+(data.recommendedIndex+1);var rt=data.runtime||{};document.getElementById("summary").innerHTML='<div class="card"><div class="label">'+t("total")+'</div><div class="value blue">'+(data.totalAccountCount||all.length)+'</div></div><div class="card"><div class="label">'+t("ready")+'</div><div class="value green">'+ready+'</div></div><div class="card"><div class="label">'+t("login")+'</div><div class="value red">'+login+'</div></div><div class="card"><div class="label">'+t("disabled")+'</div><div class="value">'+disabled+'</div></div><div class="card"><div class="label">'+t("recommend")+'</div><div class="value amber">'+rec+'</div></div>';
|
|
40
|
+
document.getElementById("accounts").innerHTML=all.length?all.map(function(a){var sub=a.subscription||{};var q=a.quota||{};var risk=a.forecastRiskScore==null?"—":a.forecastRiskScore+"/100";var rl=a.forecastRiskLevel||"low";var badges=(a.markers||[]).map(function(m){return '<span class="badge '+(/current/.test(m)?"current":statusClass(a))+'">'+esc(label(m))+'</span>'}).join(" ");return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||("Account "+(a.index+1))))+'</div><div class="account-meta">'+esc(sub.planLabel||t("unknown"))+" · "+esc(label(sub.selectedWorkspace||""))+'</div></div><div>'+badges+' <span class="badge '+statusClass(a)+'">'+(statusClass(a)==="ready"?t("ready"):statusClass(a)==="login"?t("login"):t("disabled"))+'</span></div></div><div class="account-stats"><div class="quota">'+bar(q.primary,"primary")+bar(q.secondary,"secondary")+'</div><div><div class="label">'+t("last")+'</div><div class="value" style="font-size:18px">'+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div><div class="reason">'+t("reason")+": "+esc(reasonText(a.reason||"—"))+'</div></div><div class="risk"><div class="label">'+t("risk")+'</div><div class="risk-num risk-'+esc(rl)+'">'+esc(risk)+"</div></div></div></article>"}).join(""):'<div class="empty">'+t("unknown")+"</div>";
|
|
41
|
+
var apis=(data.apiAccounts||[]).map(function(a){return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||"API"))+'</div><div class="account-meta">'+esc(a.kind||"api")+" · "+t("priority")+" "+esc(a.priority==null?"—":a.priority)+'</div></div><div><span class="badge '+(a.enabled===false?"disabled":"ready")+'">'+(a.enabled===false?t("disabled"):t("enabled"))+'</span></div></div><div class="account-meta">'+t("models")+": "+esc((a.visibleModels||[]).length||"—")+" · "+t("last")+": "+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div></article>'}).join("");
|
|
42
|
+
if(apis)document.getElementById("accounts").innerHTML+='<div class="subhead">'+t("apiAccounts")+"</div>"+apis;
|
|
43
|
+
var recReason=data.recommendationReason||"—";document.getElementById("routing").innerHTML='<div class="kv"><span>'+t("active")+'</span><b>'+esc(data.activeIndex==null?"—":"#"+(data.activeIndex+1))+'</b></div><div class="kv"><span>'+t("recommend")+'</span><b>'+esc(rec)+"</b></div><div class="kv"><span>'+t("reason")+'</span><b>'+esc(recReason)+'</b></div>';
|
|
44
|
+
document.getElementById("runtime").innerHTML='<div class="kv"><span>'+t("requests")+'</span><b>'+esc(rt.responsesRequests==null?"—":rt.responsesRequests)+'</b></div><div class="kv"><span>'+t("success")+'</span><b>'+esc(rt.successfulRequests==null?"—":rt.successfulRequests)+'</b></div><div class="kv"><span>'+t("failed")+'</span><b>'+esc(rt.failedRequests==null?"—":rt.failedRequests)+'</b></div><div class="kv"><span>'+t("last")+'</span><b>'+esc(data.runtimeInUseIndex==null?"—":"#"+(data.runtimeInUseIndex+1))+'</b></div>';
|
|
45
|
+
document.getElementById("updated").textContent=new Date().toLocaleTimeString();document.getElementById("error").hidden=true;document.getElementById("dot").className="dot";document.getElementById("connection").textContent=t("connected")
|
|
46
|
+
}
|
|
47
|
+
function setLang(){document.documentElement.lang=lang==="zh"?"zh-CN":"en";document.querySelectorAll("[data-i18n]").forEach(function(e){e.textContent=t(e.getAttribute("data-i18n"))});document.getElementById("lang").textContent=lang==="zh"?"EN":"中文";if(window.__data)draw(window.__data)}
|
|
48
|
+
function failed(e){document.getElementById("error").hidden=false;document.getElementById("error").textContent=e&&e.message?e.message:"Request failed";document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}
|
|
49
|
+
function load(){fetch(api("/api/status"),{cache:"no-store"}).then(function(r){if(!r.ok)throw Error("HTTP "+r.status);return r.json()}).then(draw).catch(failed)}
|
|
50
|
+
document.getElementById("refresh").onclick=load;document.getElementById("lang").onclick=function(){lang=lang==="zh"?"en":"zh";setLang()};setLang();load();try{var es=new EventSource(api("/events"));es.onmessage=function(e){try{draw(JSON.parse(e.data))}catch(_){}};es.onerror=function(){document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}}catch(_){setInterval(load,5000)}
|
|
51
|
+
})();
|
|
52
|
+
</script>
|
|
53
|
+
</body>
|
|
54
|
+
</html>`;
|
|
55
|
+
export function renderDashboardHtml(token, locale = "zh") {
|
|
56
|
+
const safeToken = JSON.stringify(token ?? "")
|
|
57
|
+
.slice(1, -1)
|
|
58
|
+
.replace(/</g, "\\u003c")
|
|
59
|
+
.replace(/>/g, "\\u003e")
|
|
60
|
+
.replace(/&/g, "\\u0026")
|
|
61
|
+
.replace(/\u2028/g, "\\u2028")
|
|
62
|
+
.replace(/\u2029/g, "\\u2029");
|
|
63
|
+
return DASHBOARD_HTML
|
|
64
|
+
.replace("__CMA_TOKEN__", safeToken)
|
|
65
|
+
.replace("__CMA_LANG__", locale === "zh" ? "zh" : "en");
|
|
66
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { type OutputLocale } from "./locale.js";
|
|
3
|
+
export interface WebStatusSnapshot {
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface WebServerOptions {
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
token?: string | null;
|
|
10
|
+
refreshMs?: number;
|
|
11
|
+
getStatus: () => Promise<WebStatusSnapshot>;
|
|
12
|
+
openBrowser?: (url: string) => void;
|
|
13
|
+
locale?: OutputLocale;
|
|
14
|
+
/** Called when an SSE client is dropped for falling behind on broadcasts. */
|
|
15
|
+
onSseClientDrop?: (client: ServerResponse) => void;
|
|
16
|
+
}
|
|
17
|
+
export interface WebServerHandle {
|
|
18
|
+
server: Server;
|
|
19
|
+
url: string;
|
|
20
|
+
token: string | null;
|
|
21
|
+
close: () => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function startWebServer(options: WebServerOptions): Promise<WebServerHandle>;
|
|
24
|
+
export declare function runWebCommand(args: string[], deps: {
|
|
25
|
+
getStatus: () => Promise<WebStatusSnapshot>;
|
|
26
|
+
logInfo?: (message: string) => void;
|
|
27
|
+
openBrowser?: (url: string) => void;
|
|
28
|
+
locale?: OutputLocale;
|
|
29
|
+
}): Promise<number>;
|
|
30
|
+
export declare function parseWebCommandArgs(args: string[]): {
|
|
31
|
+
host: string;
|
|
32
|
+
port: number;
|
|
33
|
+
token: string | null;
|
|
34
|
+
refreshMs: number;
|
|
35
|
+
openBrowser: boolean;
|
|
36
|
+
};
|