@timo972/cc-router 0.10.0-rc.2 → 0.10.0-rc.3

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 CHANGED
@@ -8,6 +8,38 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ### Added
12
+
13
+ - `cc-router accounts rename <id> <new-id>`. An account's id is the key its
14
+ routing state hangs off — in-flight counters and sticky session bindings are
15
+ both id-keyed — so on a running proxy the rename runs as a transaction
16
+ (`PATCH /cc-router/accounts/:id` with `{"id": ...}`): pool, session router,
17
+ and accounts.json move together, and are rolled back together if persistence
18
+ fails. Sticky sessions keep their prompt-cache affinity through the rename.
19
+ With no proxy running the record is renamed on disk. A proxy from before
20
+ this feature answers the PATCH with 200 having silently ignored the field —
21
+ that is detected and reported as an error rather than falling back to a disk
22
+ write its refresh loop would overwrite. The id namespace is shared across
23
+ both providers, so a rename onto any existing account name is refused (409).
24
+
25
+ - The health endpoint reports the daemon's version, and the dashboard shows a
26
+ version-mismatch banner when the daemon runs a different build than the CLI
27
+ rendering it. A service manager can keep an old build alive long after an
28
+ upgrade — launchd pins the versioned pnpm store path in its plist, so the
29
+ daemon silently stays on the old version across upgrades and even reboots —
30
+ and every log row and account view on the dashboard comes from that build.
31
+ Until now nothing surfaced this: a fix could ship, the package could update,
32
+ and the dashboard would still render the old daemon's output as if the new
33
+ version were broken. A daemon that reports no version at all predates the
34
+ field, which is itself proof it is outdated, and banners the same way.
35
+ - The activity list scrolls to follow its selection. The dashboard renders the
36
+ newest 20 of up to 50 entries, but the arrow keys would walk the selection
37
+ through all 50 — past row 20 the highlight left the screen while the detail
38
+ panel kept updating for rows that were not visible. The window now shifts by
39
+ one row when the selection steps past its bottom or top edge, stays put while
40
+ the selection moves inside it, and re-clamps when new entries push the
41
+ selected row (which is timestamp-anchored) out of the stored window.
42
+
11
43
  ### Fixed
12
44
 
13
45
  - OpenAI activity rows carry the same columns as Claude ones. The OpenAI ingress
@@ -1,10 +1,11 @@
1
1
  import chalk from "chalk";
2
- import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, readConfig, serialize } from "../config/manager.js";
2
+ import { loadAccounts, loadOpenAIAccounts, accountsFileExists, upsertAccountRecord, removeAccountRecordById, renameAccountRecordById, readConfig, serialize } from "../config/manager.js";
3
3
  import { saveAccounts } from "../proxy/token-refresher.js";
4
4
  import { formatExpiry, redactToken } from "../utils/token-extractor.js";
5
5
  import { PROXY_PORT } from "../config/paths.js";
6
6
  import { createOpenAIAccountRecord } from "../providers/openai/account-record.js";
7
7
  import { loginOpenAIWithDeviceCode } from "../providers/openai/device-oauth.js";
8
+ import { isValidAccountId } from "../proxy/account-rename.js";
8
9
  export function registerAccounts(program) {
9
10
  const accounts = program
10
11
  .command("accounts")
@@ -233,6 +234,47 @@ export function registerAccounts(program) {
233
234
  console.log(chalk.yellow(" No accounts left. Run: cc-router setup"));
234
235
  }
235
236
  });
237
+ accounts
238
+ .command("rename <id> <new-id>")
239
+ .description("Rename an account — its routing state and sticky sessions follow the new name")
240
+ .action(async (id, newId) => {
241
+ if (!accountsFileExists()) {
242
+ console.log(chalk.yellow("No accounts configured."));
243
+ return;
244
+ }
245
+ if (!isValidAccountId(newId)) {
246
+ console.log(chalk.red(`✗ "${newId}" is not a valid account name.`));
247
+ console.log(chalk.gray(" 1-64 characters: alphanumeric start, then letters, digits, dots, underscores, or dashes."));
248
+ process.exit(1);
249
+ }
250
+ const { ids: existingIds } = mergeAccountInventory(loadAccounts().map(a => a.id), loadOpenAIAccounts().map(a => a.id), await fetchLiveStats());
251
+ if (!existingIds.includes(id)) {
252
+ console.log(chalk.red(`✗ Account "${id}" not found.`));
253
+ console.log(chalk.gray(` Available: ${existingIds.join(", ")}`));
254
+ process.exit(1);
255
+ }
256
+ if (id === newId) {
257
+ console.log(chalk.gray(`Account is already named "${newId}".`));
258
+ return;
259
+ }
260
+ if (existingIds.includes(newId)) {
261
+ console.log(chalk.red(`✗ An account named "${newId}" already exists.`));
262
+ process.exit(1);
263
+ }
264
+ let result;
265
+ try {
266
+ result = await renameAccountRuntimeAware(id, newId);
267
+ }
268
+ catch (err) {
269
+ const message = err instanceof Error ? err.message : String(err);
270
+ console.log(chalk.red(`✗ Could not rename "${id}": ${message}`));
271
+ process.exit(1);
272
+ }
273
+ console.log(chalk.green(`✓ Renamed "${id}" → "${newId}".`));
274
+ console.log(result.mode === "live"
275
+ ? chalk.gray(" Applied to the running proxy — in-flight requests and sticky sessions follow the new name.")
276
+ : chalk.gray(" Saved to accounts.json — loads on next start: cc-router start"));
277
+ });
236
278
  }
237
279
  // ─── Helpers ──────────────────────────────────────────────────────────────────
238
280
  /** Tell the user whether the new account is already live or needs a restart. */
@@ -326,6 +368,66 @@ export async function tryRemoveAccountFromRunningProxy(id, options = {}) {
326
368
  }
327
369
  return true;
328
370
  }
371
+ /**
372
+ * Rename an account on a running proxy. Returns false only when no proxy can
373
+ * be reached (the caller then renames on disk); HTTP errors are authoritative
374
+ * and thrown. A 200 whose returned account still carries the old id means the
375
+ * proxy predates rename support — its patch validation drops unknown fields
376
+ * and reports success having done nothing — and MUST be an error, not a
377
+ * fallthrough to disk: that proxy's refresh loop persists its own snapshot
378
+ * over accounts.json and would silently undo a disk-side rename.
379
+ */
380
+ export async function tryRenameAccountOnRunningProxy(id, newId, options = {}) {
381
+ const fetchImpl = options.fetch ?? globalThis.fetch;
382
+ const baseUrl = (options.baseUrl ?? `http://localhost:${PROXY_PORT}`).replace(/\/+$/, "");
383
+ const authToken = options.authToken ?? readConfig().proxySecret;
384
+ let response;
385
+ try {
386
+ response = await fetchImpl(`${baseUrl}/cc-router/accounts/${encodeURIComponent(id)}`, {
387
+ method: "PATCH",
388
+ headers: {
389
+ "content-type": "application/json",
390
+ ...(authToken ? { authorization: `Bearer ${authToken}` } : {}),
391
+ },
392
+ body: JSON.stringify({ id: newId }),
393
+ signal: AbortSignal.timeout(3_000),
394
+ });
395
+ }
396
+ catch {
397
+ return false;
398
+ }
399
+ if (!response.ok) {
400
+ let detail = "";
401
+ try {
402
+ const payload = await response.json();
403
+ if (typeof payload.error === "string")
404
+ detail = `: ${payload.error}`;
405
+ }
406
+ catch { /* best effort */ }
407
+ throw new Error(`HTTP ${response.status}${detail}`);
408
+ }
409
+ let renamedId;
410
+ try {
411
+ const payload = await response.json();
412
+ renamedId = payload.account?.id;
413
+ }
414
+ catch { /* fall through to the mismatch error below */ }
415
+ if (renamedId !== newId) {
416
+ throw new Error("the running proxy does not support rename (older version) — update and restart it first: cc-router stop --keep-config && cc-router start");
417
+ }
418
+ return true;
419
+ }
420
+ export async function renameAccountRuntimeAware(id, newId, dependencies = {
421
+ tryRenameLive: tryRenameAccountOnRunningProxy,
422
+ renameStored: renameAccountRecordById,
423
+ }) {
424
+ if (await dependencies.tryRenameLive(id, newId))
425
+ return { mode: "live" };
426
+ const renamed = dependencies.renameStored(id, newId);
427
+ if (!renamed)
428
+ throw new Error(`Account "${id}" disappeared before it could be renamed`);
429
+ return { mode: "stored", renamed };
430
+ }
329
431
  export async function removeAccountRuntimeAware(id, dependencies = {
330
432
  tryRemoveLive: tryRemoveAccountFromRunningProxy,
331
433
  removeStored: removeAccountRecordById,
@@ -90,6 +90,25 @@ export function removeAccountRecordById(id) {
90
90
  writeAccountsAtomicToPath(ACCOUNTS_PATH, existing.filter(a => a.id !== id));
91
91
  return removed;
92
92
  }
93
+ /**
94
+ * Rename a stored account record in place, keeping every other field. The
95
+ * uniqueness check spans ALL providers — both live in one accounts.json and
96
+ * one URL namespace, so two records sharing an id would be unaddressable.
97
+ * Returns the renamed record, or null if no record has `oldId`.
98
+ */
99
+ export function renameAccountRecordById(oldId, newId) {
100
+ ensureConfigDir();
101
+ const existing = readAccountsRaw();
102
+ const target = existing.find(a => a.id === oldId) ?? null;
103
+ if (!target)
104
+ return null;
105
+ if (newId !== oldId && existing.some(a => a.id === newId)) {
106
+ throw new Error(`An account named "${newId}" already exists`);
107
+ }
108
+ target.id = newId;
109
+ writeAccountsAtomicToPath(ACCOUNTS_PATH, existing);
110
+ return target;
111
+ }
93
112
  function normalizeAccountProvider(record) {
94
113
  return record.provider === "openai_subscription"
95
114
  ? "openai_subscription"
@@ -175,6 +175,28 @@ export class OpenAITokenPool {
175
175
  findById(id) {
176
176
  return this.accounts.find(account => account.id === id) ?? null;
177
177
  }
178
+ /**
179
+ * Change an account's id in place. The in-flight counter is keyed by id and
180
+ * must move with it — an open lease's release() re-reads `account.id`, so
181
+ * after a rename it decrements the NEW key, which would otherwise never
182
+ * have been incremented. Cooldowns are keyed by the account object and
183
+ * follow the rename untouched. Returns the renamed account, or null if the
184
+ * id was not found. Callers are responsible for id-uniqueness and for
185
+ * session-binding migration. Mirrors `TokenPool.renameAccount`.
186
+ */
187
+ renameAccount(oldId, newId) {
188
+ const account = this.findById(oldId);
189
+ if (!account)
190
+ return null;
191
+ if (newId !== oldId) {
192
+ const load = this.inFlight.get(oldId);
193
+ this.inFlight.delete(oldId);
194
+ if (load !== undefined)
195
+ this.inFlight.set(newId, load);
196
+ account.id = newId;
197
+ }
198
+ return account;
199
+ }
178
200
  /**
179
201
  * Drop every piece of per-account routing state after the account has been
180
202
  * removed from the shared `accounts` array. The array splice itself is owned
@@ -1,3 +1,4 @@
1
+ import { isValidAccountId } from "./account-rename.js";
1
2
  import { clampPercent } from "./types.js";
2
3
  /**
3
4
  * Validate a `PATCH /cc-router/accounts/:id` request body. Shared between the
@@ -7,6 +8,15 @@ import { clampPercent } from "./types.js";
7
8
  */
8
9
  export function validateAccountPatchBody(body) {
9
10
  const patch = {};
11
+ if (body.id !== undefined) {
12
+ if (!isValidAccountId(body.id)) {
13
+ return { ok: false, error: "id must be 1-64 characters: alphanumeric start, then letters, digits, dots, underscores, or dashes" };
14
+ }
15
+ if (body.enabled !== undefined || body.sessionLimitPercent !== undefined || body.weeklyLimitPercent !== undefined) {
16
+ return { ok: false, error: "id (rename) cannot be combined with other fields" };
17
+ }
18
+ patch.id = body.id;
19
+ }
10
20
  if (body.enabled !== undefined) {
11
21
  if (typeof body.enabled !== "boolean") {
12
22
  return { ok: false, error: "enabled must be boolean" };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Renaming an account changes the key that routing state hangs off: the
3
+ * pool's in-flight counters and the session router's sticky bindings are
4
+ * both id-keyed, so a rename is a transaction over pool + router + disk —
5
+ * not a field write. Mirrors account-deletion.ts in shape: the provider
6
+ * branches in server.ts supply their pool/router/persist as ports, and the
7
+ * CLI shares the id rules from here.
8
+ */
9
+ /**
10
+ * An account id ends up in URL paths (`/cc-router/accounts/:id`), fixed-width
11
+ * dashboard columns, and accounts.json — allow one conservative shape
12
+ * everywhere: alphanumeric start, then dots/underscores/dashes, 64 max.
13
+ */
14
+ const ACCOUNT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
15
+ export function isValidAccountId(value) {
16
+ return typeof value === "string" && ACCOUNT_ID_RE.test(value);
17
+ }
18
+ export class AccountRenameConflictError extends Error {
19
+ constructor(id) {
20
+ super(`An account named "${id}" already exists`);
21
+ this.name = "AccountRenameConflictError";
22
+ }
23
+ }
24
+ /**
25
+ * Rename an account atomically with respect to disk: runtime state is only
26
+ * left renamed if persistence succeeded, otherwise it is renamed back so
27
+ * memory keeps matching accounts.json. `takenIds` must cover every live id
28
+ * across ALL providers — the two pools share one id namespace (one URL
29
+ * space, one accounts.json).
30
+ */
31
+ export function renameAccountTransaction(oldId, newId, takenIds, ports) {
32
+ if (newId === oldId)
33
+ return "renamed";
34
+ if (takenIds.has(newId))
35
+ throw new AccountRenameConflictError(newId);
36
+ if (!ports.rename(oldId, newId))
37
+ return "not_found";
38
+ ports.renameSessions(oldId, newId);
39
+ try {
40
+ ports.persist();
41
+ }
42
+ catch (error) {
43
+ ports.rename(newId, oldId);
44
+ ports.renameSessions(newId, oldId);
45
+ throw error;
46
+ }
47
+ return "renamed";
48
+ }
@@ -5,7 +5,7 @@ import { timingSafeEqual } from "crypto";
5
5
  import { TokenPool } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
7
  import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
8
- import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner } from "../utils/self-update.js";
8
+ import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
9
9
  import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
10
  import { loadTelemetryState } from "../config/telemetry.js";
11
11
  import { logRoute, logError, logStartup } from "./logger.js";
@@ -13,6 +13,7 @@ import { createLocalRoutingErrorLog, stats } from "./stats.js";
13
13
  import { PROXY_PORT, LITELLM_URL, ACCOUNTS_PATH } from "../config/paths.js";
14
14
  import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
15
15
  import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
16
+ import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
16
17
  import { hasPendingCredentialWrite, markOpenAICredentialsPersisted, prepareOpenAIAccountForRequest, refreshAndPersistOpenAIAccount, startOpenAIRefreshLoop, } from "../providers/openai/token-refresher.js";
17
18
  import { createOpenAIAccount } from "../providers/openai/account-state.js";
18
19
  import { OpenAITokenPool } from "../providers/openai/token-pool.js";
@@ -487,6 +488,11 @@ export async function startServer(opts = {}) {
487
488
  }
488
489
  res.json({
489
490
  status,
491
+ // The version of the code this daemon actually runs — not what is
492
+ // installed on disk. A service manager can keep an old build alive
493
+ // long after an upgrade (launchd pins the versioned pnpm store path
494
+ // in its plist), and without this field no client can tell.
495
+ version: getCurrentVersion(),
490
496
  mode,
491
497
  target,
492
498
  operational: createOperationalStatus({
@@ -627,6 +633,54 @@ export async function startServer(opts = {}) {
627
633
  return;
628
634
  }
629
635
  const patch = validation.patch;
636
+ // A rename is a transaction over pool + session router + disk, not a
637
+ // field write (see account-rename.ts) — validation already guarantees it
638
+ // arrives alone. The two pools share one id namespace, so uniqueness is
639
+ // checked across both regardless of which provider owns the account.
640
+ if (patch.id !== undefined) {
641
+ const newId = patch.id;
642
+ const takenIds = new Set([
643
+ ...pool.getAll().map(a => a.id),
644
+ ...openAIAccounts.map(a => a.id),
645
+ ]);
646
+ const inAnthropic = pool.findById(id) !== null;
647
+ if (!inAnthropic && !openAIAccounts.some(a => a.id === id)) {
648
+ res.status(404).json({ error: `Account "${id}" not found` });
649
+ return;
650
+ }
651
+ try {
652
+ renameAccountTransaction(id, newId, takenIds, inAnthropic
653
+ ? {
654
+ rename: (oldId, nextId) => pool.renameAccount(oldId, nextId) !== null,
655
+ renameSessions: (oldId, nextId) => { sessionRouter.renameAccount(oldId, nextId); },
656
+ persist: () => saveAccounts(pool.getAll()),
657
+ }
658
+ : {
659
+ rename: (oldId, nextId) => openAIPool.renameAccount(oldId, nextId) !== null,
660
+ renameSessions: (oldId, nextId) => { openAIRouter.renameAccount(oldId, nextId); },
661
+ persist: () => persistOpenAIAccounts(openAIAccounts),
662
+ });
663
+ }
664
+ catch (err) {
665
+ if (err instanceof AccountRenameConflictError) {
666
+ res.status(409).json({ error: err.message });
667
+ return;
668
+ }
669
+ const message = err instanceof Error ? err.message : String(err);
670
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
671
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
672
+ return;
673
+ }
674
+ if (inAnthropic) {
675
+ const account = pool.findById(newId);
676
+ res.json({ account: publicAnthropicAccountView(account, createRoutingMetricsResolver()(account.id)) });
677
+ }
678
+ else {
679
+ const account = openAIAccounts.find(a => a.id === newId);
680
+ res.json({ account: publicOpenAIAccountView(account, resolveOpenAIRouting(account.id)) });
681
+ }
682
+ return;
683
+ }
630
684
  // Snapshot the previous values so we can roll back on persistence failure
631
685
  const existing = pool.findById(id);
632
686
  if (existing) {
@@ -92,6 +92,29 @@ export class SessionRouter {
92
92
  }
93
93
  return removed;
94
94
  }
95
+ /**
96
+ * Re-point every binding (and the aggregate session count) at an account's
97
+ * new id after a rename. Without this the sticky re-acquire looks the old
98
+ * id up in the pool, finds nothing, and silently fails the session over —
99
+ * a rename must not break prompt-cache affinity. Returns bindings moved.
100
+ */
101
+ renameAccount(oldId, newId) {
102
+ if (newId === oldId)
103
+ return 0;
104
+ let moved = 0;
105
+ for (const binding of this.bindings.values()) {
106
+ if (binding.accountId !== oldId)
107
+ continue;
108
+ binding.accountId = newId;
109
+ moved++;
110
+ }
111
+ const count = this.activeSessionCounts.get(oldId);
112
+ if (count !== undefined) {
113
+ this.activeSessionCounts.delete(oldId);
114
+ this.activeSessionCounts.set(newId, count);
115
+ }
116
+ return moved;
117
+ }
95
118
  getActiveSessionCount(accountId) {
96
119
  this.sweepExpiredBindings(this.now());
97
120
  return this.getRawActiveSessionCount(accountId);
@@ -599,6 +599,28 @@ export class TokenPool {
599
599
  }
600
600
  return a;
601
601
  }
602
+ /**
603
+ * Change an account's id in place. The in-flight counter is keyed by id and
604
+ * must move with it: an open lease's release() re-reads `account.id`, so
605
+ * after a rename it decrements the NEW key — which would never have been
606
+ * incremented, leaving the old key stuck at its count forever. Cooldowns
607
+ * are keyed by the Account object and follow the rename untouched.
608
+ * Returns the renamed account, or null if the id was not found. Callers
609
+ * are responsible for id-uniqueness and for session-binding migration.
610
+ */
611
+ renameAccount(oldId, newId) {
612
+ const account = this.findById(oldId);
613
+ if (!account)
614
+ return null;
615
+ if (newId !== oldId) {
616
+ const load = this.inFlight.get(oldId);
617
+ this.inFlight.delete(oldId);
618
+ if (load !== undefined)
619
+ this.inFlight.set(newId, load);
620
+ account.id = newId;
621
+ }
622
+ return account;
623
+ }
602
624
  /**
603
625
  * Append a new account built from a persisted AccountRecord.
604
626
  * Rejects duplicates by id — callers should pre-check with findById().
@@ -3,9 +3,14 @@ import React, { useState, useEffect, useCallback, useRef } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
4
  import { createAccountsApi } from "./accountsApi.js";
5
5
  import { createModelsApi } from "./modelsApi.js";
6
+ import { getCurrentVersion } from "../utils/self-update.js";
6
7
  const POLL_INTERVAL_MS = 2_000;
7
8
  const LOG_VISIBLE = 20;
8
9
  const MODEL_VISIBLE_ROWS = 16;
10
+ const DASHBOARD_VERSION = getCurrentVersion();
11
+ // Distinguishes "this machine's daemon" (restartable from this shell) from a
12
+ // remote router the dashboard is merely pointed at.
13
+ const LOCAL_TARGET_RE = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
9
14
  const EMPTY_RL = {
10
15
  status: "unknown", fiveHourUtil: 0, fiveHourReset: 0,
11
16
  sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
@@ -208,6 +213,24 @@ export function isCodexLimited(codex) {
208
213
  return false;
209
214
  return (defaultBucket.primary?.utilization ?? 0) >= 1 || (defaultBucket.secondary?.utilization ?? 0) >= 1;
210
215
  }
216
+ /**
217
+ * First visible row of a scrolling list window that follows its selection.
218
+ *
219
+ * The window stays where it is while the selection moves inside it, and
220
+ * shifts just far enough to contain the selection when it crosses an edge —
221
+ * one row per one-row step, but any distance when the selection jumps (it is
222
+ * timestamp-anchored, so a burst of new entries can move it many rows at
223
+ * once). A stale `scrollTop` from a longer list clamps back into range.
224
+ */
225
+ export function followScrollWindow(scrollTop, selectedIndex, total, visible) {
226
+ const maxTop = Math.max(0, total - visible);
227
+ let top = Math.min(Math.max(0, scrollTop), maxTop);
228
+ if (selectedIndex < top)
229
+ top = selectedIndex;
230
+ else if (selectedIndex > top + visible - 1)
231
+ top = selectedIndex - visible + 1;
232
+ return Math.min(Math.max(0, top), maxTop);
233
+ }
211
234
  export function Dashboard({ port, baseUrl, authToken, onIntent }) {
212
235
  const { exit } = useApp();
213
236
  const [data, setData] = useState(null);
@@ -285,6 +308,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
285
308
  const selectedLogIndex = selectedTs !== null
286
309
  ? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
287
310
  : 0;
311
+ // First visible activity row. The stored position only moves on navigation;
312
+ // the derived value re-clamps every render because the selection is
313
+ // timestamp-anchored — new entries arriving between keypresses can push the
314
+ // selected row out of the stored window, and it must stay visible anyway.
315
+ const [logScrollTop, setLogScrollTop] = useState(0);
316
+ const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length, LOG_VISIBLE);
288
317
  // Selected account by id
289
318
  const [selectedAccountId, setSelectedAccountId] = useState(null);
290
319
  const selectedAccountIndex = selectedAccountId !== null
@@ -494,10 +523,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
494
523
  if (key.upArrow) {
495
524
  const next = Math.max(0, selectedLogIndex - 1);
496
525
  setSelectedTs(logs[next]?.ts ?? null);
526
+ setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
497
527
  }
498
528
  if (key.downArrow) {
499
529
  const next = Math.min(logs.length - 1, selectedLogIndex + 1);
500
530
  setSelectedTs(logs[next]?.ts ?? null);
531
+ setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, LOG_VISIBLE));
501
532
  }
502
533
  }
503
534
  if (focus === "accounts") {
@@ -580,10 +611,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
580
611
  }
581
612
  });
582
613
  const selectedLog = logs[selectedLogIndex] ?? null;
583
- const visibleLogs = logs.slice(0, LOG_VISIBLE);
584
- return (_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 }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models" }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [a] Claude all [o] OpenAI all [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, 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, { marginTop: 1, 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, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _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 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
614
+ const visibleLogs = logs.slice(logWindowTop, logWindowTop + LOG_VISIBLE);
615
+ return (_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 }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), 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
616
+ ? `daemon v${data.version}`
617
+ : "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" })] })) : (
618
+ // A remote router can only be restarted where it runs; printing a
619
+ // local restart command here would never clear the banner.
620
+ _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 }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models" }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: focus === "accounts" ? "white" : "gray", children: "[Tab] focus [e] toggle [a] Claude all [o] OpenAI all [w] 7d cap [s] 5h cap [n] add [d] delete" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && i === selectedAccountIndex }, a.id))) })] }), mode === "editWeekly" && selectedAccount && (_jsxs(Box, { marginTop: 1, 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, { marginTop: 1, 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, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: "red", bold: true, children: ["Delete \"", selectedAccount.id, "\"? [y] yes [n/Esc] cancel"] }) })), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), _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 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
585
621
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
586
- : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
622
+ : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })] }), focus === "logs" && selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
587
623
  }
588
624
  function OperationsPanel({ operational, baseUrl, focus }) {
589
625
  const authLabel = operational.auth.required ? "protected" : "open";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.0-rc.2",
3
+ "version": "0.10.0-rc.3",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {