@timo972/cc-router 0.12.0 → 0.12.1-rc.1
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 +56 -13
- package/README.md +19 -0
- package/dist/providers/openai/account-state.js +1 -0
- package/dist/providers/openai/codex-transport.js +26 -11
- package/dist/providers/openai/device-oauth.js +1 -1
- package/dist/providers/openai/token-pool.js +37 -2
- package/dist/providers/openai/token-refresher.js +120 -23
- package/dist/providers/openai/usage-fetch.js +14 -1
- package/dist/providers/openai/usage-reset.js +30 -0
- package/dist/proxy/account-usage-reset.js +64 -0
- package/dist/proxy/messages-cross-route.js +31 -9
- package/dist/proxy/openai-ingress.js +75 -22
- package/dist/proxy/pool-refresh.js +80 -0
- package/dist/proxy/responses-server.js +23 -4
- package/dist/proxy/server.js +71 -3
- package/dist/proxy/transport-diagnostics.js +32 -0
- package/dist/proxy/transport-timing.js +153 -0
- package/dist/ui/Dashboard.js +142 -23
- package/dist/ui/accountsApi.js +47 -1
- package/package.json +1 -1
package/dist/proxy/server.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { consumeCodexResetCredit } from "../providers/openai/usage-reset.js";
|
|
2
|
+
import { createUsageResetHandler } from "./account-usage-reset.js";
|
|
1
3
|
import express from "express";
|
|
2
4
|
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
5
|
import { ServerResponse } from "http";
|
|
4
6
|
import { timingSafeEqual } from "crypto";
|
|
5
7
|
import { TokenPool } from "./token-pool.js";
|
|
6
|
-
import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
8
|
+
import { needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
7
9
|
import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
|
|
10
|
+
import { createRefreshAllRunner, describeRefreshAll, refreshAllAccounts } from "./pool-refresh.js";
|
|
8
11
|
import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
|
|
9
12
|
import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
|
|
10
13
|
import { loadTelemetryState } from "../config/telemetry.js";
|
|
@@ -17,7 +20,7 @@ import { loadGrokHealthSnapshots } from "../providers/xai/overview.js";
|
|
|
17
20
|
import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
|
|
18
21
|
import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
|
|
19
22
|
import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
|
|
20
|
-
import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
|
|
23
|
+
import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshOpenAIAccountsOnce, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
|
|
21
24
|
import { createOpenAIAccount } from "../providers/openai/account-state.js";
|
|
22
25
|
import { OpenAITokenPool } from "../providers/openai/token-pool.js";
|
|
23
26
|
import { DEFAULT_CODEX_LIMIT_ID } from "../providers/openai/usage.js";
|
|
@@ -61,6 +64,7 @@ export function createOperationalStatus(opts) {
|
|
|
61
64
|
health: "/cc-router/health",
|
|
62
65
|
accounts: "/cc-router/accounts",
|
|
63
66
|
allowance: "/cc-router/allowance",
|
|
67
|
+
refresh: "/cc-router/refresh",
|
|
64
68
|
messages: "/v1/messages",
|
|
65
69
|
responses: "/v1/responses",
|
|
66
70
|
models: "/v1/models",
|
|
@@ -222,7 +226,7 @@ function publicOpenAIAccountView(a, routing) {
|
|
|
222
226
|
enabled: a.enabled !== false,
|
|
223
227
|
sessionLimitPercent: a.sessionLimitPercent,
|
|
224
228
|
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
225
|
-
healthy: a.enabled !== false && a.healthy && expiresInMs > 0,
|
|
229
|
+
healthy: a.enabled !== false && a.healthy && a.authState !== "quarantined" && expiresInMs > 0,
|
|
226
230
|
busy: routing.metrics.coolingDown,
|
|
227
231
|
cooldownUntilMs: routing.metrics.cooldownUntilMs ?? 0,
|
|
228
232
|
globalCooldownUntilMs: routing.cooldowns.globalUntilMs,
|
|
@@ -235,6 +239,8 @@ function publicOpenAIAccountView(a, routing) {
|
|
|
235
239
|
lastRefreshMs: a.lastRefresh,
|
|
236
240
|
codexRateLimits: publicCodexRateLimits(a, routing.cooldowns),
|
|
237
241
|
...(hasPendingCredentialWrite(a) ? { credentialsPendingWrite: true } : {}),
|
|
242
|
+
...(a.authState === "quarantined" ? { authState: "quarantined" } : {}),
|
|
243
|
+
...(a.authFailure ? { authFailure: a.authFailure } : {}),
|
|
238
244
|
};
|
|
239
245
|
}
|
|
240
246
|
function publicCodexRateLimits(a, cooldowns) {
|
|
@@ -532,12 +538,72 @@ export async function startServer(opts = {}) {
|
|
|
532
538
|
const views = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots());
|
|
533
539
|
res.json(createAllowanceView(views, Date.now()));
|
|
534
540
|
});
|
|
541
|
+
// ─── Manual reload (authenticated) ────────────────────────────────────────
|
|
542
|
+
// The dashboard's "reload everything" key. Gives the operator what a
|
|
543
|
+
// restart would — swept cooldowns, due/quarantined tokens re-tried, every
|
|
544
|
+
// account's usage re-fetched — without dropping in-flight requests or
|
|
545
|
+
// sticky sessions. Each provider contributes its own hooks; the route
|
|
546
|
+
// itself knows nothing about OAuth or usage formats.
|
|
547
|
+
const runRefreshAll = createRefreshAllRunner(() => {
|
|
548
|
+
const onError = (provider, error) => {
|
|
549
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
550
|
+
logError(provider, 0, `manual refresh: ${message}`);
|
|
551
|
+
};
|
|
552
|
+
return refreshAllAccounts([
|
|
553
|
+
{
|
|
554
|
+
provider: "anthropic",
|
|
555
|
+
getAll: () => pool.getAll(),
|
|
556
|
+
refreshTokens: async () => {
|
|
557
|
+
let failed = 0;
|
|
558
|
+
await refreshAccountsOnce(pool.getAll(), { onError: error => { failed++; onError("anthropic", error); } });
|
|
559
|
+
return { failed };
|
|
560
|
+
},
|
|
561
|
+
refreshUsage: account => usageRefresher.refreshNow(account),
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
provider: "openai",
|
|
565
|
+
getAll: () => openAIAccounts,
|
|
566
|
+
refreshTokens: () => refreshOpenAIAccountsOnce(openAIAccounts, persistOpenAIAccounts, {
|
|
567
|
+
onError: error => onError("openai", error),
|
|
568
|
+
}),
|
|
569
|
+
refreshUsage: account => openAIUsageRefresher.refreshNow(account),
|
|
570
|
+
},
|
|
571
|
+
], {
|
|
572
|
+
sweepCooldowns: () => {
|
|
573
|
+
pool.sweepExpiredCooldowns();
|
|
574
|
+
openAIPool.sweepExpiredCooldowns();
|
|
575
|
+
},
|
|
576
|
+
onError,
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
app.post("/cc-router/refresh", async (_req, res) => {
|
|
580
|
+
let summary;
|
|
581
|
+
try {
|
|
582
|
+
summary = await runRefreshAll();
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
586
|
+
logError("refresh", 0, `manual refresh failed: ${message}`);
|
|
587
|
+
res.status(500).json({ error: `Refresh failed: ${message}` });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const details = `manual refresh — ${describeRefreshAll(summary)}`;
|
|
591
|
+
stats.addLog({ ts: Date.now(), accountId: "proxy", model: "-", type: "refresh", details });
|
|
592
|
+
res.json({ refresh: summary });
|
|
593
|
+
});
|
|
535
594
|
// ─── Account management endpoints (authenticated) ─────────────────────────
|
|
536
595
|
// These are mounted BEFORE the /v1/* proxy middleware so they don't get
|
|
537
596
|
// forwarded to Anthropic. express.json() is scoped to this sub-router so
|
|
538
597
|
// the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
|
|
539
598
|
const accountsRouter = express.Router();
|
|
540
599
|
accountsRouter.use(express.json({ limit: "32kb" }));
|
|
600
|
+
accountsRouter.post("/:id/reset-usage", createUsageResetHandler({
|
|
601
|
+
findAccount: id => openAIAccounts.find(account => account.id === id),
|
|
602
|
+
prepare: account => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
|
|
603
|
+
consume: consumeCodexResetCredit,
|
|
604
|
+
captureReset: account => openAIPool.captureUsageReset(account),
|
|
605
|
+
refresh: account => openAIUsageRefresher.refreshAfterCurrent(account),
|
|
606
|
+
}));
|
|
541
607
|
// Shape returned to clients — NEVER includes access/refresh tokens.
|
|
542
608
|
accountsRouter.get("/", (_req, res) => {
|
|
543
609
|
const resolveRoutingMetrics = createRoutingMetricsResolver();
|
|
@@ -989,6 +1055,7 @@ export async function startServer(opts = {}) {
|
|
|
989
1055
|
prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
|
|
990
1056
|
modelRouting,
|
|
991
1057
|
onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
|
|
1058
|
+
timeoutMs: proxyRequestTimeoutMs,
|
|
992
1059
|
...upstreamAttempts,
|
|
993
1060
|
});
|
|
994
1061
|
mountMessagesCrossProviderRoute(app, {
|
|
@@ -997,6 +1064,7 @@ export async function startServer(opts = {}) {
|
|
|
997
1064
|
prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
|
|
998
1065
|
modelRouting,
|
|
999
1066
|
onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
|
|
1067
|
+
timeoutMs: proxyRequestTimeoutMs,
|
|
1000
1068
|
...upstreamAttempts,
|
|
1001
1069
|
});
|
|
1002
1070
|
// Shared between the retrying /v1/messages route and the generic /v1 chain
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const SAFE_CODE = /^[A-Za-z0-9_.:-]{1,48}$/;
|
|
3
|
+
const SAFE_CORRELATION = /^[A-Za-z0-9-]{1,48}$/;
|
|
4
|
+
export function createCorrelationId() {
|
|
5
|
+
return `oai-${randomUUID().slice(0, 8)}`;
|
|
6
|
+
}
|
|
7
|
+
export function safeCauseCode(error) {
|
|
8
|
+
let cause = error instanceof Error ? error.cause : undefined;
|
|
9
|
+
for (let depth = 0; depth < 3 && typeof cause === "object" && cause !== null; depth++) {
|
|
10
|
+
const code = cause.code;
|
|
11
|
+
if (typeof code === "string" && SAFE_CODE.test(code))
|
|
12
|
+
return code;
|
|
13
|
+
cause = cause.cause;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function formatTransportDiagnostic(diagnostic) {
|
|
18
|
+
const correlationId = SAFE_CORRELATION.test(diagnostic.correlationId)
|
|
19
|
+
? diagnostic.correlationId
|
|
20
|
+
: "invalid";
|
|
21
|
+
const fields = [
|
|
22
|
+
`correlation=${correlationId}`,
|
|
23
|
+
`operation=${diagnostic.operation}`,
|
|
24
|
+
];
|
|
25
|
+
if (diagnostic.status !== undefined && Number.isInteger(diagnostic.status)) {
|
|
26
|
+
fields.push(`status=${diagnostic.status}`);
|
|
27
|
+
}
|
|
28
|
+
if (diagnostic.causeCode !== undefined && SAFE_CODE.test(diagnostic.causeCode)) {
|
|
29
|
+
fields.push(`cause=${diagnostic.causeCode}`);
|
|
30
|
+
}
|
|
31
|
+
return fields.join(" ");
|
|
32
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export function createHeaderDeadline(timeoutMs, parent) {
|
|
2
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
3
|
+
return { signal: parent, dispose() { } };
|
|
4
|
+
}
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
let timer = setTimeout(() => {
|
|
7
|
+
controller.abort(new DOMException("upstream headers timed out", "TimeoutError"));
|
|
8
|
+
}, timeoutMs);
|
|
9
|
+
const onParentAbort = () => controller.abort(parent?.reason);
|
|
10
|
+
if (parent?.aborted) {
|
|
11
|
+
onParentAbort();
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
parent?.addEventListener("abort", onParentAbort, { once: true });
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
signal: controller.signal,
|
|
18
|
+
dispose() {
|
|
19
|
+
if (timer)
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
timer = undefined;
|
|
22
|
+
parent?.removeEventListener("abort", onParentAbort);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a body with a timeout for each upstream read.
|
|
28
|
+
*
|
|
29
|
+
* The zero high-water mark is deliberate. A positive high-water mark lets the
|
|
30
|
+
* Web Streams implementation prefetch the next chunk as soon as the previous
|
|
31
|
+
* one is delivered. When the HTTP response is waiting for downstream drain,
|
|
32
|
+
* that prefetched read would run the upstream idle timer and could time out an
|
|
33
|
+
* otherwise healthy stream solely because its client was slow.
|
|
34
|
+
*/
|
|
35
|
+
export function withStreamIdleTimeout(body, idleMs, parent) {
|
|
36
|
+
if (!body)
|
|
37
|
+
return null;
|
|
38
|
+
const timeoutEnabled = idleMs !== undefined && idleMs > 0;
|
|
39
|
+
if (!timeoutEnabled && !parent)
|
|
40
|
+
return body;
|
|
41
|
+
const reader = body.getReader();
|
|
42
|
+
let timer;
|
|
43
|
+
let closed = false;
|
|
44
|
+
let controllerRef;
|
|
45
|
+
const clearIdleTimer = () => {
|
|
46
|
+
if (timer)
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = undefined;
|
|
49
|
+
};
|
|
50
|
+
const releaseReader = () => {
|
|
51
|
+
try {
|
|
52
|
+
reader.releaseLock();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A cancellation can briefly leave read() pending. reader.cancel()
|
|
56
|
+
// settles it before trying this again in its finally callback.
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const finish = (release = true) => {
|
|
60
|
+
if (closed)
|
|
61
|
+
return;
|
|
62
|
+
closed = true;
|
|
63
|
+
clearIdleTimer();
|
|
64
|
+
parent?.removeEventListener("abort", onAbort);
|
|
65
|
+
if (release)
|
|
66
|
+
releaseReader();
|
|
67
|
+
};
|
|
68
|
+
const fail = (error) => {
|
|
69
|
+
if (closed)
|
|
70
|
+
return;
|
|
71
|
+
finish(false);
|
|
72
|
+
controllerRef?.error(error);
|
|
73
|
+
void reader.cancel(error).catch(() => { }).finally(releaseReader);
|
|
74
|
+
};
|
|
75
|
+
const onAbort = () => {
|
|
76
|
+
fail(parent?.reason ?? new DOMException("aborted", "AbortError"));
|
|
77
|
+
};
|
|
78
|
+
const startIdleTimer = () => {
|
|
79
|
+
if (!timeoutEnabled)
|
|
80
|
+
return;
|
|
81
|
+
clearIdleTimer();
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
fail(new DOMException("upstream stream idle timeout", "TimeoutError"));
|
|
84
|
+
}, idleMs);
|
|
85
|
+
};
|
|
86
|
+
return new ReadableStream({
|
|
87
|
+
start(controller) {
|
|
88
|
+
controllerRef = controller;
|
|
89
|
+
if (parent?.aborted)
|
|
90
|
+
onAbort();
|
|
91
|
+
else
|
|
92
|
+
parent?.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
},
|
|
94
|
+
async pull(controller) {
|
|
95
|
+
if (closed)
|
|
96
|
+
return;
|
|
97
|
+
startIdleTimer();
|
|
98
|
+
try {
|
|
99
|
+
const next = await reader.read();
|
|
100
|
+
clearIdleTimer();
|
|
101
|
+
if (closed)
|
|
102
|
+
return;
|
|
103
|
+
if (next.done) {
|
|
104
|
+
finish();
|
|
105
|
+
controller.close();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
controller.enqueue(next.value);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (closed)
|
|
112
|
+
return;
|
|
113
|
+
finish();
|
|
114
|
+
controller.error(error);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
async cancel(reason) {
|
|
118
|
+
if (closed)
|
|
119
|
+
return;
|
|
120
|
+
finish(false);
|
|
121
|
+
try {
|
|
122
|
+
await reader.cancel(reason);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
releaseReader();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}, { highWaterMark: 0 });
|
|
129
|
+
}
|
|
130
|
+
/** Resolve only when an HTTP response can accept another write. Every losing
|
|
131
|
+
* listener is removed, including the error/close path. */
|
|
132
|
+
export function waitForWritable(res) {
|
|
133
|
+
if (res.destroyed)
|
|
134
|
+
return Promise.resolve(false);
|
|
135
|
+
return new Promise(resolve => {
|
|
136
|
+
let done = false;
|
|
137
|
+
const finish = (value) => {
|
|
138
|
+
if (done)
|
|
139
|
+
return;
|
|
140
|
+
done = true;
|
|
141
|
+
res.removeListener("drain", drain);
|
|
142
|
+
res.removeListener("close", closed);
|
|
143
|
+
res.removeListener("error", errored);
|
|
144
|
+
resolve(value);
|
|
145
|
+
};
|
|
146
|
+
const drain = () => finish(true);
|
|
147
|
+
const closed = () => finish(false);
|
|
148
|
+
const errored = () => finish(false);
|
|
149
|
+
res.once("drain", drain);
|
|
150
|
+
res.once("close", closed);
|
|
151
|
+
res.once("error", errored);
|
|
152
|
+
});
|
|
153
|
+
}
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
3
4
|
import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
|
|
4
5
|
import { createAccountsApi } from "./accountsApi.js";
|
|
@@ -7,6 +8,9 @@ import { getCurrentVersion } from "../utils/self-update.js";
|
|
|
7
8
|
import { readClaudeRouting, readCodexRouting, setClaudeRouting, setCodexRouting, } from "../utils/cli-routing.js";
|
|
8
9
|
import { mergeGrokIntoHealth, loadGrokHealthSnapshotsWithSubscription } from "../providers/xai/overview.js";
|
|
9
10
|
const POLL_INTERVAL_MS = 2_000;
|
|
11
|
+
/** Progress banner for the manual reload; replaced by the result banner, so
|
|
12
|
+
* only the failure case (client gave up) ever lets it expire. */
|
|
13
|
+
const REFRESH_ALL_BANNER_MS = 120_000;
|
|
10
14
|
/** Grok's plan/code-access rarely change; refresh far slower than the 2s poll. */
|
|
11
15
|
const GROK_SUBSCRIPTION_INTERVAL_MS = 60_000;
|
|
12
16
|
/** Most activity rows the dashboard will show — the list shrinks below this
|
|
@@ -528,6 +532,9 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
|
528
532
|
const [connectError, setConnectError] = useState(null);
|
|
529
533
|
const [lastUpdate, setLastUpdate] = useState(0);
|
|
530
534
|
const [retryCount, setRetryCount] = useState(0);
|
|
535
|
+
// LiveDashboard unmounts on a health-poll failure. Redemption ownership and
|
|
536
|
+
// retry IDs must survive that transition, not just a change in focus.
|
|
537
|
+
const resetSession = useRef({ inFlight: false, pendingIds: new Map() });
|
|
531
538
|
const resolvedBase = baseUrl
|
|
532
539
|
? baseUrl.replace(/\/+$/, "")
|
|
533
540
|
: `http://localhost:${port}`;
|
|
@@ -543,22 +550,22 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
|
543
550
|
// /v1/user round-trip every tick. Undefined until the first fetch lands, at
|
|
544
551
|
// which point `mergeGrokIntoHealth` falls back to its access-token tier.
|
|
545
552
|
const grokSnapshotsRef = useRef(undefined);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
catch {
|
|
555
|
-
// Keep the last known snapshots; the poll degrades to the tier fallback.
|
|
556
|
-
}
|
|
557
|
-
};
|
|
558
|
-
refresh();
|
|
559
|
-
const timer = setInterval(refresh, GROK_SUBSCRIPTION_INTERVAL_MS);
|
|
560
|
-
return () => { cancelled = true; clearInterval(timer); };
|
|
553
|
+
const refreshGrokSnapshots = useCallback(async () => {
|
|
554
|
+
try {
|
|
555
|
+
grokSnapshotsRef.current = await loadGrokHealthSnapshotsWithSubscription();
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Keep the last known snapshots; the poll degrades to the tier fallback.
|
|
559
|
+
}
|
|
561
560
|
}, []);
|
|
561
|
+
useEffect(() => {
|
|
562
|
+
refreshGrokSnapshots();
|
|
563
|
+
const timer = setInterval(refreshGrokSnapshots, GROK_SUBSCRIPTION_INTERVAL_MS);
|
|
564
|
+
return () => { clearInterval(timer); };
|
|
565
|
+
}, [refreshGrokSnapshots]);
|
|
566
|
+
// The manual reload key re-runs the same poll immediately so the freshly
|
|
567
|
+
// fetched usage shows up without waiting for the next 2s tick.
|
|
568
|
+
const pollRef = useRef(async () => { });
|
|
562
569
|
useEffect(() => {
|
|
563
570
|
let cancelled = false;
|
|
564
571
|
const healthUrl = `${resolvedBase}/cc-router/health`;
|
|
@@ -590,24 +597,29 @@ export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
|
590
597
|
setRetryCount(n => n + 1);
|
|
591
598
|
}
|
|
592
599
|
};
|
|
600
|
+
pollRef.current = poll;
|
|
593
601
|
poll();
|
|
594
602
|
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
|
595
603
|
return () => { cancelled = true; clearInterval(timer); };
|
|
596
604
|
}, [resolvedBase, authToken]);
|
|
605
|
+
const refreshLocalViews = useCallback(async () => {
|
|
606
|
+
await refreshGrokSnapshots();
|
|
607
|
+
await pollRef.current();
|
|
608
|
+
}, [refreshGrokSnapshots]);
|
|
597
609
|
if (connectError) {
|
|
598
610
|
return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
|
|
599
611
|
}
|
|
600
612
|
if (!data) {
|
|
601
613
|
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to ", resolvedBase, "..."] }) }));
|
|
602
614
|
}
|
|
603
|
-
return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent }));
|
|
615
|
+
return (_jsx(LiveDashboard, { data: data, port: port, baseUrl: resolvedBase, lastUpdate: lastUpdate, api: api, modelsApi: modelsApi, onIntent: onIntent, onRefreshAll: refreshLocalViews, resetSession: resetSession.current }));
|
|
604
616
|
}
|
|
605
617
|
// ─── Error screen ─────────────────────────────────────────────────────────────
|
|
606
618
|
function ErrorScreen({ error, port, retries }) {
|
|
607
619
|
return (_jsxs(Box, { flexDirection: "column", marginY: 1, marginX: 2, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Is the proxy running? Start it with:" }), _jsx(Text, { color: "cyan", children: " cc-router start" })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", children: ["Retrying every ", POLL_INTERVAL_MS / 1000, "s"] }), retries > 0 && _jsxs(Text, { color: "gray", children: [" (attempt ", retries, ")"] }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] })] }));
|
|
608
620
|
}
|
|
609
621
|
// ─── Live dashboard ───────────────────────────────────────────────────────────
|
|
610
|
-
function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, }) {
|
|
622
|
+
function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onIntent, onRefreshAll, resetSession, }) {
|
|
611
623
|
const [cliRouting, setCliRouting] = useState(() => ({
|
|
612
624
|
claude: readClaudeRouting(),
|
|
613
625
|
codex: readCodexRouting(),
|
|
@@ -633,6 +645,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
633
645
|
// ── Focus / mode ──────────────────────────────────────────────────────────
|
|
634
646
|
const [focus, setFocus] = useState("logs");
|
|
635
647
|
const [mode, setMode] = useState("view");
|
|
648
|
+
const [resetTarget, setResetTarget] = useState(null);
|
|
636
649
|
// Compact ("zen") view: hides TOTALS + RECENT ACTIVITY so the account list
|
|
637
650
|
// gets the whole vertical budget — the fix for a short terminal starving a
|
|
638
651
|
// long fleet (e.g. showing 1 of 11 accounts). Toggled with [z], view-only,
|
|
@@ -770,14 +783,14 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
770
783
|
// setBanner can fire on an unmounted component after `n` exits Ink.
|
|
771
784
|
const [banner, setBanner] = useState(null);
|
|
772
785
|
const bannerTimerRef = useRef(null);
|
|
773
|
-
const showBanner = useCallback((text, color) => {
|
|
786
|
+
const showBanner = useCallback((text, color, durationMs = 4_000) => {
|
|
774
787
|
if (bannerTimerRef.current)
|
|
775
788
|
clearTimeout(bannerTimerRef.current);
|
|
776
789
|
setBanner({ text, color });
|
|
777
790
|
bannerTimerRef.current = setTimeout(() => {
|
|
778
791
|
setBanner(null);
|
|
779
792
|
bannerTimerRef.current = null;
|
|
780
|
-
},
|
|
793
|
+
}, durationMs);
|
|
781
794
|
}, []);
|
|
782
795
|
useEffect(() => () => {
|
|
783
796
|
if (bannerTimerRef.current)
|
|
@@ -872,6 +885,47 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
872
885
|
showBanner(`Models error: ${errMsg(err)}`, "red");
|
|
873
886
|
}
|
|
874
887
|
}, [modelsApi, showBanner]);
|
|
888
|
+
// ── Reload everything ────────────────────────────────────────────────────
|
|
889
|
+
// One key for "make it look like I just restarted the router": the server
|
|
890
|
+
// sweeps cooldowns, re-tries due/quarantined tokens and re-fetches every
|
|
891
|
+
// account's usage; the dashboard then re-reads Grok, CLI routing, the
|
|
892
|
+
// model list (if already loaded) and polls health right away.
|
|
893
|
+
const refreshAllInFlightRef = useRef(false);
|
|
894
|
+
const doRefreshAll = useCallback(async () => {
|
|
895
|
+
if (refreshAllInFlightRef.current) {
|
|
896
|
+
showBanner("Reload already running…", "gray");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
refreshAllInFlightRef.current = true;
|
|
900
|
+
// Stays up until the result banner replaces it: a reload with several
|
|
901
|
+
// slow providers can outlive the default banner lifetime.
|
|
902
|
+
showBanner("Reloading accounts, usage and models…", "cyan", REFRESH_ALL_BANNER_MS);
|
|
903
|
+
try {
|
|
904
|
+
const result = await api.refreshAll();
|
|
905
|
+
refreshCliRouting();
|
|
906
|
+
if (modelsStatus) {
|
|
907
|
+
try {
|
|
908
|
+
const status = await modelsApi.list();
|
|
909
|
+
setModelsStatus(status);
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
// The account reload succeeded; a stale model list is not worth a red banner.
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
await onRefreshAll?.();
|
|
916
|
+
const problems = [
|
|
917
|
+
result.usageFailed > 0 ? `${result.usageFailed} usage fetch failed` : "",
|
|
918
|
+
result.tokenRefreshFailed > 0 ? `${result.tokenRefreshFailed} token refresh failed` : "",
|
|
919
|
+
].filter(Boolean);
|
|
920
|
+
showBanner(`Reloaded ${result.accounts} accounts — usage fresh for ${result.usageRefreshed}${problems.length ? `, ${problems.join(", ")}` : ""}`, problems.length ? "yellow" : "green");
|
|
921
|
+
}
|
|
922
|
+
catch (err) {
|
|
923
|
+
showBanner(`Reload error: ${errMsg(err)}`, "red");
|
|
924
|
+
}
|
|
925
|
+
finally {
|
|
926
|
+
refreshAllInFlightRef.current = false;
|
|
927
|
+
}
|
|
928
|
+
}, [api, modelsApi, modelsStatus, onRefreshAll, refreshCliRouting, showBanner]);
|
|
875
929
|
const doToggleCli = useCallback((target) => {
|
|
876
930
|
const current = target === "claude" ? cliRouting.claude.enabled : cliRouting.codex.enabled;
|
|
877
931
|
const result = target === "claude"
|
|
@@ -910,6 +964,37 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
910
964
|
showBanner(`Models error: ${errMsg(err)}`, "red");
|
|
911
965
|
}
|
|
912
966
|
}, [modelsApi, selectedModel, showBanner]);
|
|
967
|
+
const doResetUsage = useCallback(async (id) => {
|
|
968
|
+
if (resetSession.inFlight)
|
|
969
|
+
return;
|
|
970
|
+
resetSession.inFlight = true;
|
|
971
|
+
const requestId = resetSession.pendingIds.get(id) ?? randomUUID();
|
|
972
|
+
resetSession.pendingIds.set(id, requestId);
|
|
973
|
+
showBanner(`Redeeming usage reset for ${id}…`, "yellow", REFRESH_ALL_BANNER_MS);
|
|
974
|
+
try {
|
|
975
|
+
const result = await api.resetUsage(id, requestId);
|
|
976
|
+
resetSession.pendingIds.delete(id);
|
|
977
|
+
const messages = {
|
|
978
|
+
reset: `Usage reset redeemed for ${id}`,
|
|
979
|
+
already_redeemed: `Usage reset already redeemed for ${id}`,
|
|
980
|
+
nothing_to_reset: `Nothing to reset for ${id}`,
|
|
981
|
+
no_credit: `No reset credits available for ${id}`,
|
|
982
|
+
};
|
|
983
|
+
showBanner(messages[result.code] + (result.usageRefreshed ? "" : " — usage refresh failed; reload with R"), result.usageRefreshed && (result.code === "reset" || result.code === "already_redeemed") ? "green" : "yellow");
|
|
984
|
+
// Failure to poll the dashboard must not turn a confirmed spend into an
|
|
985
|
+
// unknown outcome or encourage another redemption.
|
|
986
|
+
try {
|
|
987
|
+
await onRefreshAll?.();
|
|
988
|
+
}
|
|
989
|
+
catch { /* the regular poll will retry */ }
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
showBanner(`Reset outcome unknown for ${id}; Ctrl+R retries the same redemption (keep dashboard open)`, "red");
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
resetSession.inFlight = false;
|
|
996
|
+
}
|
|
997
|
+
}, [api, onRefreshAll, resetSession, showBanner]);
|
|
913
998
|
// ── Keyboard handler ──────────────────────────────────────────────────────
|
|
914
999
|
useInput((input, key) => {
|
|
915
1000
|
// ── Text editing mode (w / s) ───────────────────────────────────────
|
|
@@ -941,6 +1026,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
941
1026
|
}
|
|
942
1027
|
return;
|
|
943
1028
|
}
|
|
1029
|
+
if (mode === "confirmReset") {
|
|
1030
|
+
if ((input === "y" || input === "Y") && resetTarget)
|
|
1031
|
+
void doResetUsage(resetTarget);
|
|
1032
|
+
else
|
|
1033
|
+
showBanner("Reset cancelled", "gray");
|
|
1034
|
+
setResetTarget(null);
|
|
1035
|
+
setMode("view");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
944
1038
|
// ── Confirm delete (y/n) ────────────────────────────────────────────
|
|
945
1039
|
if (mode === "confirmDelete") {
|
|
946
1040
|
if (input === "y" || input === "Y") {
|
|
@@ -970,6 +1064,31 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
970
1064
|
exit();
|
|
971
1065
|
return;
|
|
972
1066
|
}
|
|
1067
|
+
// Intercept Ctrl+R before model refresh so it only acts on account focus.
|
|
1068
|
+
// Ink exposes the standard DC2 control character as input r + key.ctrl.
|
|
1069
|
+
if (input === "r" && key.ctrl) {
|
|
1070
|
+
if (focus !== "accounts" || !selectedAccount)
|
|
1071
|
+
return;
|
|
1072
|
+
if (resetSession.inFlight) {
|
|
1073
|
+
showBanner("Reset already running", "yellow");
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
if (selectedAccount.provider !== "openai_subscription") {
|
|
1077
|
+
showBanner("Usage resets are only available for ChatGPT accounts", "yellow");
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
if (!resetSession.pendingIds.has(selectedAccount.id) && (selectedAccount.codexRateLimits?.resetCredits?.available ?? 0) <= 0) {
|
|
1081
|
+
showBanner("No reset credits available", "yellow");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
setResetTarget(selectedAccount.id);
|
|
1085
|
+
setMode("confirmReset");
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (input === "R") {
|
|
1089
|
+
void doRefreshAll();
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
973
1092
|
if (key.tab) {
|
|
974
1093
|
// Compact view hides the activity list, so skip "logs" in the cycle.
|
|
975
1094
|
if (compact) {
|
|
@@ -1111,7 +1230,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
1111
1230
|
});
|
|
1112
1231
|
const selectedLog = logs[selectedLogIndex] ?? null;
|
|
1113
1232
|
const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
|
|
1114
|
-
return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
|
|
1233
|
+
return (_jsx(Box, { flexDirection: "column", height: frameBound, overflowY: "hidden", children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, ref: contentRef, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " err" }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 7d cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "editSession" && selectedAccount && (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "cyan", children: "Set 5h cap for " }), _jsx(Text, { color: "white", bold: true, children: selectedAccount.id }), _jsx(Text, { color: "cyan", children: " (0\u2013100%): " }), _jsx(Text, { color: "white", bold: true, children: editBuffer }), _jsx(Text, { color: "gray", children: "\u2588 [Enter] save [Esc] cancel" })] })), mode === "confirmReset" && resetTarget && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "yellow", bold: true, children: ["Redeem 1 reset for \"", resetTarget, "\"? [y] yes [n/Esc] cancel"] }) })), mode === "confirmDelete" && selectedAccount && (_jsx(Box, { paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), data.version !== DASHBOARD_VERSION && (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "yellow", children: " \u26A0 VERSION MISMATCH " }), _jsxs(Text, { color: "yellow", children: [data.version !== undefined
|
|
1115
1234
|
? `daemon v${data.version}`
|
|
1116
1235
|
: "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
|
|
1117
1236
|
// A remote router can only be restarted where it runs; printing a
|
|
@@ -1119,10 +1238,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
1119
1238
|
_jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) })] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
|
|
1120
1239
|
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
1121
1240
|
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })), !compact && focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: focus === "accounts"
|
|
1122
|
-
? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [z] compact [q]"
|
|
1241
|
+
? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
|
|
1123
1242
|
: focus === "models"
|
|
1124
|
-
? " [Tab] [m/r] refresh [c]/[o] default [Esc] logs [z] compact [q]"
|
|
1125
|
-
: " [Tab] [m] models [z] compact [q] quit" }) })] }) }));
|
|
1243
|
+
? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
|
|
1244
|
+
: " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
|
|
1126
1245
|
}
|
|
1127
1246
|
function OperationsPanel({ operational, baseUrl, focus, cliRouting }) {
|
|
1128
1247
|
const authLabel = operational.auth.required ? "protected" : "open";
|