@timo972/cc-router 0.10.0-rc.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/dist/ui/Dashboard.js +287 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -55,6 +55,17 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
55
55
|
|
|
56
56
|
### Fixed
|
|
57
57
|
|
|
58
|
+
- The dashboard fits the terminal. The frame had a fixed shape — 20 activity
|
|
59
|
+
rows, a detail panel, and every account expanded — which with a real fleet
|
|
60
|
+
of accounts rendered ~70 lines. Ink can only erase as many lines as the
|
|
61
|
+
viewport holds, so on any shorter terminal every 2-second poll re-appended
|
|
62
|
+
the frame and scrolled the header and OPERATIONS panel permanently out of
|
|
63
|
+
view (the "jumps back down" effect, especially in split panes). The layout
|
|
64
|
+
is now height-aware: the activity list absorbs the deficit first (down to 3
|
|
65
|
+
rows), and if the chrome alone still exceeds the viewport the frame is
|
|
66
|
+
clipped at the bottom — the header always wins over the detail panel. The
|
|
67
|
+
fit tracks terminal resizes.
|
|
68
|
+
|
|
58
69
|
- Anthropic activity rows show their cache rate and token counts. The proxy
|
|
59
70
|
captures usage by passively parsing the response body, but skipped any
|
|
60
71
|
compressed response — and since the proxy is byte-transparent, the client's
|
package/dist/ui/Dashboard.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
3
|
-
import { Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
import { Box, Text, useInput, useApp, useStdout, measureElement } from "ink";
|
|
4
4
|
import { createAccountsApi } from "./accountsApi.js";
|
|
5
5
|
import { createModelsApi } from "./modelsApi.js";
|
|
6
6
|
import { getCurrentVersion } from "../utils/self-update.js";
|
|
7
7
|
const POLL_INTERVAL_MS = 2_000;
|
|
8
|
+
/** Most activity rows the dashboard will show — the list shrinks below this
|
|
9
|
+
* (down to MIN_LOG_VISIBLE) when the terminal is too short for the full
|
|
10
|
+
* frame. Ink can only erase as many lines as the viewport holds, so a frame
|
|
11
|
+
* taller than the terminal makes every poll re-append it — scrolling the
|
|
12
|
+
* header and OPERATIONS panel permanently out of view. */
|
|
8
13
|
const LOG_VISIBLE = 20;
|
|
14
|
+
const MIN_LOG_VISIBLE = 3;
|
|
15
|
+
/** The model window may shrink to a single row: it follows its selection, so
|
|
16
|
+
* one visible row IS the selected row — while a larger minimum rendered
|
|
17
|
+
* rows below the clip that the selection could land on invisibly. */
|
|
18
|
+
const MIN_MODELS_VISIBLE = 1;
|
|
9
19
|
const MODEL_VISIBLE_ROWS = 16;
|
|
10
20
|
const DASHBOARD_VERSION = getCurrentVersion();
|
|
11
21
|
// Distinguishes "this machine's daemon" (restartable from this shell) from a
|
|
@@ -231,6 +241,166 @@ export function followScrollWindow(scrollTop, selectedIndex, total, visible) {
|
|
|
231
241
|
top = selectedIndex - visible + 1;
|
|
232
242
|
return Math.min(Math.max(0, top), maxTop);
|
|
233
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* How long a denied growth stays denied. A denial is measured against
|
|
246
|
+
* concrete rendered row heights, and those can change through ordinary data
|
|
247
|
+
* updates the reset key cannot enumerate (an account gaining or losing
|
|
248
|
+
* capacity rows, a scrolled window showing different entries). Expiry is the
|
|
249
|
+
* general cure: a stale denial costs at most one clipped grow/shrink pair
|
|
250
|
+
* per TTL — invisible under the frame bound — instead of a list that stays
|
|
251
|
+
* collapsed until an unrelated resize.
|
|
252
|
+
*/
|
|
253
|
+
export const FIT_DENIAL_TTL_MS = 2_500;
|
|
254
|
+
/**
|
|
255
|
+
* One reallocation step for the height-fitting controller. `excess` is the
|
|
256
|
+
* measured content height minus the viewport budget: positive shrinks lists
|
|
257
|
+
* in array order until covered; negative (slack) grows exactly ONE list —
|
|
258
|
+
* the last eligible in the array — so a single mispredicted growth can be
|
|
259
|
+
* attributed, denied, and refined rather than compounding.
|
|
260
|
+
*
|
|
261
|
+
* Denial rule: a growth whose commit overflows is remembered with the slack
|
|
262
|
+
* it actually needs (the slack it had plus the overflow it caused) and is
|
|
263
|
+
* not retried below that; the next attempt steps DOWN from the denied
|
|
264
|
+
* target. Targets only ever decrease under denial, so refinement
|
|
265
|
+
* terminates. Callers reset the memory whenever row heights may have
|
|
266
|
+
* changed (viewport, fleet, or data identity).
|
|
267
|
+
*/
|
|
268
|
+
export function planViewportFit(excess, lists, memory, now = 0) {
|
|
269
|
+
const targets = {};
|
|
270
|
+
// An expired denial stops CLAMPING but is not forgotten: its retry count
|
|
271
|
+
// must survive the lapse, or the escalating backoff restarts at the base
|
|
272
|
+
// TTL on every re-denial and a permanently tall hidden row gets probed
|
|
273
|
+
// every few seconds forever. The record is deleted only when a retried
|
|
274
|
+
// growth finally fits (geometry improved) or the caller resets the memory.
|
|
275
|
+
const activeDenial = (key) => {
|
|
276
|
+
const denied = memory.denials[key];
|
|
277
|
+
return denied && (denied.expiresAt === undefined || denied.expiresAt > now) ? denied : undefined;
|
|
278
|
+
};
|
|
279
|
+
if (excess > 0) {
|
|
280
|
+
for (const list of lists) {
|
|
281
|
+
const attempt = memory.attempts[list.key];
|
|
282
|
+
if (attempt && attempt.to === list.current) {
|
|
283
|
+
// Re-denials escalate the TTL (capped at a minute): a hidden row that
|
|
284
|
+
// stays tall would otherwise be probed every TTL forever, while one
|
|
285
|
+
// that changed shape is picked up on the next expiry. The count
|
|
286
|
+
// continues from ANY prior denial of this list — including a lapsed
|
|
287
|
+
// one, and regardless of the target (the frontier is monotone while
|
|
288
|
+
// active, so a different target means a lapse happened in between).
|
|
289
|
+
const count = (memory.denials[list.key]?.count ?? 0) + 1;
|
|
290
|
+
memory.denials[list.key] = {
|
|
291
|
+
to: attempt.to,
|
|
292
|
+
slack: attempt.slack + excess,
|
|
293
|
+
count,
|
|
294
|
+
expiresAt: now + Math.min(60_000, FIT_DENIAL_TTL_MS * 2 ** (count - 1)),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
delete memory.attempts[list.key];
|
|
298
|
+
}
|
|
299
|
+
let remaining = excess;
|
|
300
|
+
for (const list of lists) {
|
|
301
|
+
if (remaining <= 0)
|
|
302
|
+
break;
|
|
303
|
+
const drop = Math.min(list.current - list.min, Math.ceil(remaining / list.avgRow));
|
|
304
|
+
if (drop > 0) {
|
|
305
|
+
targets[list.key] = list.current - drop;
|
|
306
|
+
remaining = Math.max(0, remaining - drop * list.avgRow);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return targets;
|
|
310
|
+
}
|
|
311
|
+
for (const list of lists) {
|
|
312
|
+
// A standing attempt in the fitting branch means last commit's growth
|
|
313
|
+
// fit. That disproves a denial only when the growth reached the denied
|
|
314
|
+
// target — a smaller growth fitting says nothing about the larger one,
|
|
315
|
+
// and resetting on it would let the denied target be retried in a loop.
|
|
316
|
+
const attempt = memory.attempts[list.key];
|
|
317
|
+
const denied = memory.denials[list.key];
|
|
318
|
+
if (attempt && denied && attempt.to >= denied.to)
|
|
319
|
+
delete memory.denials[list.key];
|
|
320
|
+
delete memory.attempts[list.key];
|
|
321
|
+
}
|
|
322
|
+
const slack = -excess;
|
|
323
|
+
for (let i = lists.length - 1; i >= 0; i--) {
|
|
324
|
+
const list = lists[i];
|
|
325
|
+
if (list.current >= list.max)
|
|
326
|
+
continue;
|
|
327
|
+
// The slack alone may not cover this list's growth gate after a
|
|
328
|
+
// lower-priority list absorbed it (e.g. logs grew while an account
|
|
329
|
+
// growth was denied). A higher-priority growth may RECLAIM budget by
|
|
330
|
+
// shrinking lower-priority lists in the same step — without this, an
|
|
331
|
+
// expired denial finds no slack left and the list stays collapsed.
|
|
332
|
+
const unitCost = Math.max(1, Math.ceil(list.avgRow));
|
|
333
|
+
const gate = list.growOne ? unitCost + 1 : 2;
|
|
334
|
+
let usable = slack;
|
|
335
|
+
const funding = [];
|
|
336
|
+
if (usable < gate) {
|
|
337
|
+
if (i === 0)
|
|
338
|
+
continue; // no lower-priority lists to fund from — the gate stands
|
|
339
|
+
// Fund one row's ACTUAL cost, not the gate: the +1 hysteresis margin
|
|
340
|
+
// applies only to free-slack growth — a funded growth is protected
|
|
341
|
+
// from flapping by the denial memory, and demanding the margin here
|
|
342
|
+
// made a growth permanently unfundable when lower-priority lists
|
|
343
|
+
// could yield exactly the row's height and nothing more.
|
|
344
|
+
let deficit = unitCost - usable;
|
|
345
|
+
for (let j = 0; j < i && deficit > 0; j++) {
|
|
346
|
+
const funder = lists[j];
|
|
347
|
+
const dropMax = funder.current - funder.min;
|
|
348
|
+
if (dropMax <= 0)
|
|
349
|
+
continue;
|
|
350
|
+
const drop = Math.min(dropMax, Math.ceil(deficit / funder.avgRow));
|
|
351
|
+
funding.push({ key: funder.key, to: funder.current - drop });
|
|
352
|
+
deficit -= drop * funder.avgRow;
|
|
353
|
+
}
|
|
354
|
+
if (deficit > 0)
|
|
355
|
+
continue; // cannot fund this growth — try lower priority
|
|
356
|
+
usable = Math.max(usable, unitCost);
|
|
357
|
+
}
|
|
358
|
+
let target = list.growOne
|
|
359
|
+
? list.current + 1
|
|
360
|
+
: Math.min(list.max, list.current + Math.max(1, Math.floor((usable - 1) / list.avgRow)));
|
|
361
|
+
const denied = activeDenial(list.key);
|
|
362
|
+
if (denied && usable < denied.slack)
|
|
363
|
+
target = Math.min(target, denied.to - 1);
|
|
364
|
+
if (target <= list.current)
|
|
365
|
+
continue; // denied or no room — funding is discarded
|
|
366
|
+
for (const fund of funding)
|
|
367
|
+
targets[fund.key] = fund.to;
|
|
368
|
+
memory.attempts[list.key] = { to: target, slack: usable };
|
|
369
|
+
targets[list.key] = target;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
return targets;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Current terminal size, tracking resizes. rows/columns are 0 when unknown.
|
|
376
|
+
*
|
|
377
|
+
* Columns matter even where only rows is consumed: a width-only resize
|
|
378
|
+
* re-wraps text and changes the RENDERED height without changing the row
|
|
379
|
+
* count, and the fitting effect only runs on a React commit. Bailing out
|
|
380
|
+
* when rows is unchanged would leave the freshly wrapped, taller frame
|
|
381
|
+
* unmeasured until the next poll — reintroducing the scroll jump this hook
|
|
382
|
+
* exists to prevent.
|
|
383
|
+
*/
|
|
384
|
+
function useTerminalViewport() {
|
|
385
|
+
const { stdout } = useStdout();
|
|
386
|
+
const [viewport, setViewport] = useState({
|
|
387
|
+
rows: stdout?.rows ?? 0,
|
|
388
|
+
columns: stdout?.columns ?? 0,
|
|
389
|
+
});
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
if (!stdout)
|
|
392
|
+
return;
|
|
393
|
+
const onResize = () => setViewport(prev => {
|
|
394
|
+
const rows = stdout.rows ?? 0;
|
|
395
|
+
const columns = stdout.columns ?? 0;
|
|
396
|
+
return prev.rows === rows && prev.columns === columns ? prev : { rows, columns };
|
|
397
|
+
});
|
|
398
|
+
stdout.on("resize", onResize);
|
|
399
|
+
onResize();
|
|
400
|
+
return () => { stdout.off("resize", onResize); };
|
|
401
|
+
}, [stdout]);
|
|
402
|
+
return viewport;
|
|
403
|
+
}
|
|
234
404
|
export function Dashboard({ port, baseUrl, authToken, onIntent }) {
|
|
235
405
|
const { exit } = useApp();
|
|
236
406
|
const [data, setData] = useState(null);
|
|
@@ -308,18 +478,117 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
308
478
|
const selectedLogIndex = selectedTs !== null
|
|
309
479
|
? Math.max(0, logs.findIndex(l => l.ts === selectedTs))
|
|
310
480
|
: 0;
|
|
481
|
+
// ── Viewport fitting ──────────────────────────────────────────────────────
|
|
482
|
+
// The frame must fit the terminal or Ink cannot erase it between polls (see
|
|
483
|
+
// LOG_VISIBLE above). Two mechanisms cooperate:
|
|
484
|
+
//
|
|
485
|
+
// 1. A HARD bound applied synchronously from the terminal height on the
|
|
486
|
+
// outer box — every commit is clipped at the bottom, so no settling
|
|
487
|
+
// step, resize, or content growth can ever emit an oversized frame.
|
|
488
|
+
// (One row of slack: a frame of exactly `rows` lines still scrolls by
|
|
489
|
+
// one when the cursor advances past the last line.)
|
|
490
|
+
// 2. A post-render controller that measures the natural content height and
|
|
491
|
+
// reallocates the two windowed lists so the clip normally has nothing to
|
|
492
|
+
// cut: the activity list shrinks first (to MIN_LOG_VISIBLE), then the
|
|
493
|
+
// accounts window (to one account). Growth is stepped and hysteretic so
|
|
494
|
+
// variable-height rows cannot oscillate the layout.
|
|
495
|
+
const { rows: terminalRows, columns: terminalColumns } = useTerminalViewport();
|
|
496
|
+
const frameBound = terminalRows > 0 ? terminalRows - 1 : undefined;
|
|
497
|
+
const [logVisible, setLogVisible] = useState(MIN_LOG_VISIBLE);
|
|
498
|
+
const [accountsVisible, setAccountsVisible] = useState(Number.MAX_SAFE_INTEGER);
|
|
499
|
+
const [modelsVisible, setModelsVisible] = useState(MODEL_VISIBLE_ROWS);
|
|
500
|
+
const shownAccounts = Math.max(1, Math.min(accountsVisible, data.accounts.length));
|
|
501
|
+
const contentRef = useRef(null);
|
|
502
|
+
const accountRowsRef = useRef(null);
|
|
503
|
+
const logRowsRef = useRef(null);
|
|
504
|
+
const modelRowsRef = useRef(null);
|
|
505
|
+
// Growing the accounts window estimates the NEXT (hidden, unmeasurable)
|
|
506
|
+
// row's height from the average of the visible ones. When that account is
|
|
507
|
+
// much taller than average, the growth overflows and is removed again —
|
|
508
|
+
// and without memory the same growth is retried every commit, forever
|
|
509
|
+
// (hundreds of repaints per second). A denied growth is remembered with
|
|
510
|
+
// the slack it would actually need (the slack it had plus the overflow it
|
|
511
|
+
// caused) and not retried below that; the memory resets when the
|
|
512
|
+
// viewport or the fleet changes, since either can change row heights.
|
|
513
|
+
const fitMemoryRef = useRef({ attempts: {}, denials: {} });
|
|
514
|
+
const fitKeyRef = useRef("");
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
// Denials are measured against concrete row heights, so the memory
|
|
517
|
+
// resets whenever those visibly change: viewport size, fleet size, model
|
|
518
|
+
// count, or the newest activity entry (new rows wrap differently).
|
|
519
|
+
// Deliberately NOT part of the key: the window offsets. They derive from
|
|
520
|
+
// the visible counts, so with a selection at the end of a list a
|
|
521
|
+
// controller-driven grow/shrink shifts them — keying on them wiped the
|
|
522
|
+
// pending attempt on the very commit that should have recorded the
|
|
523
|
+
// denial, re-enabling the grow/shrink oscillation. Geometry drift from
|
|
524
|
+
// scrolling (like every other content mutation the key cannot see) is
|
|
525
|
+
// covered by the denial TTL instead.
|
|
526
|
+
const fitKey = `${terminalRows}:${terminalColumns}:${data.accounts.length}:${modelsStatus?.models.length ?? 0}:${logs[0]?.ts ?? 0}`;
|
|
527
|
+
if (fitKeyRef.current !== fitKey) {
|
|
528
|
+
fitKeyRef.current = fitKey;
|
|
529
|
+
fitMemoryRef.current = { attempts: {}, denials: {} };
|
|
530
|
+
}
|
|
531
|
+
if (frameBound === undefined) {
|
|
532
|
+
if (logVisible !== LOG_VISIBLE)
|
|
533
|
+
setLogVisible(LOG_VISIBLE);
|
|
534
|
+
if (accountsVisible !== Number.MAX_SAFE_INTEGER)
|
|
535
|
+
setAccountsVisible(Number.MAX_SAFE_INTEGER);
|
|
536
|
+
if (modelsVisible !== MODEL_VISIBLE_ROWS)
|
|
537
|
+
setModelsVisible(MODEL_VISIBLE_ROWS);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const modelsPanelOpen = focus === "models" || modelsStatus !== null;
|
|
541
|
+
const contentH = contentRef.current ? measureElement(contentRef.current).height : 0;
|
|
542
|
+
const accountsH = accountRowsRef.current ? measureElement(accountRowsRef.current).height : 0;
|
|
543
|
+
const logsH = logRowsRef.current ? measureElement(logRowsRef.current).height : 0;
|
|
544
|
+
const modelsH = modelRowsRef.current ? measureElement(modelRowsRef.current).height : 0;
|
|
545
|
+
const shownLogs = Math.min(logVisible, logs.length);
|
|
546
|
+
const shownModels = Math.min(modelsVisible, modelsStatus?.models.length ?? 0);
|
|
547
|
+
// Shrink priority order; growth walks it in reverse, so the models panel
|
|
548
|
+
// (the active surface while open) regrows first and the activity list
|
|
549
|
+
// last. Every list goes through the same measured-average + denial
|
|
550
|
+
// mechanics — each list got its own oscillation bug while the paths were
|
|
551
|
+
// separate (tall accounts, wrapped activity details, wrapped model ids).
|
|
552
|
+
const lists = [
|
|
553
|
+
{
|
|
554
|
+
key: "logs", current: logVisible, min: MIN_LOG_VISIBLE, max: LOG_VISIBLE,
|
|
555
|
+
avgRow: shownLogs > 0 ? Math.max(1, logsH / shownLogs) : 1,
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
key: "accounts", current: shownAccounts, min: 1, max: data.accounts.length, growOne: true,
|
|
559
|
+
avgRow: shownAccounts > 0 ? Math.max(1, accountsH / shownAccounts) : 2,
|
|
560
|
+
},
|
|
561
|
+
...(modelsPanelOpen ? [{
|
|
562
|
+
key: "models", current: modelsVisible, min: MIN_MODELS_VISIBLE, max: MODEL_VISIBLE_ROWS,
|
|
563
|
+
avgRow: shownModels > 0 ? Math.max(1, modelsH / shownModels) : 1,
|
|
564
|
+
}] : []),
|
|
565
|
+
];
|
|
566
|
+
const targets = planViewportFit(contentH - frameBound, lists, fitMemoryRef.current, Date.now());
|
|
567
|
+
if (targets["logs"] !== undefined)
|
|
568
|
+
setLogVisible(targets["logs"]);
|
|
569
|
+
if (targets["accounts"] !== undefined)
|
|
570
|
+
setAccountsVisible(targets["accounts"]);
|
|
571
|
+
if (targets["models"] !== undefined)
|
|
572
|
+
setModelsVisible(targets["models"]);
|
|
573
|
+
});
|
|
311
574
|
// First visible activity row. The stored position only moves on navigation;
|
|
312
575
|
// the derived value re-clamps every render because the selection is
|
|
313
576
|
// timestamp-anchored — new entries arriving between keypresses can push the
|
|
314
577
|
// selected row out of the stored window, and it must stay visible anyway.
|
|
315
578
|
const [logScrollTop, setLogScrollTop] = useState(0);
|
|
316
|
-
const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length,
|
|
579
|
+
const logWindowTop = followScrollWindow(logScrollTop, selectedLogIndex, logs.length, logVisible);
|
|
317
580
|
// Selected account by id
|
|
318
581
|
const [selectedAccountId, setSelectedAccountId] = useState(null);
|
|
319
582
|
const selectedAccountIndex = selectedAccountId !== null
|
|
320
583
|
? Math.max(0, data.accounts.findIndex(a => a.id === selectedAccountId))
|
|
321
584
|
: 0;
|
|
322
585
|
const selectedAccount = data.accounts[selectedAccountIndex] ?? null;
|
|
586
|
+
// Same follow-scroll for the accounts window: when the fitting controller
|
|
587
|
+
// shrinks the list below the fleet size, the selected account must stay on
|
|
588
|
+
// screen — account actions (caps, toggle, delete confirmation) target the
|
|
589
|
+
// selection, and acting on an invisible account is how a wrong one dies.
|
|
590
|
+
const [accountScrollTop, setAccountScrollTop] = useState(0);
|
|
591
|
+
const accountWindowTop = followScrollWindow(accountScrollTop, selectedAccountIndex, data.accounts.length, shownAccounts);
|
|
323
592
|
const [modelsStatus, setModelsStatus] = useState(null);
|
|
324
593
|
const [selectedModelId, setSelectedModelId] = useState(null);
|
|
325
594
|
const modelRows = modelsStatus?.models ?? [];
|
|
@@ -523,22 +792,24 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
523
792
|
if (key.upArrow) {
|
|
524
793
|
const next = Math.max(0, selectedLogIndex - 1);
|
|
525
794
|
setSelectedTs(logs[next]?.ts ?? null);
|
|
526
|
-
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length,
|
|
795
|
+
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, logVisible));
|
|
527
796
|
}
|
|
528
797
|
if (key.downArrow) {
|
|
529
798
|
const next = Math.min(logs.length - 1, selectedLogIndex + 1);
|
|
530
799
|
setSelectedTs(logs[next]?.ts ?? null);
|
|
531
|
-
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length,
|
|
800
|
+
setLogScrollTop(followScrollWindow(logWindowTop, next, logs.length, logVisible));
|
|
532
801
|
}
|
|
533
802
|
}
|
|
534
803
|
if (focus === "accounts") {
|
|
535
804
|
if (key.upArrow) {
|
|
536
805
|
const next = Math.max(0, selectedAccountIndex - 1);
|
|
537
806
|
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
807
|
+
setAccountScrollTop(followScrollWindow(accountWindowTop, next, data.accounts.length, shownAccounts));
|
|
538
808
|
}
|
|
539
809
|
if (key.downArrow) {
|
|
540
810
|
const next = Math.min(data.accounts.length - 1, selectedAccountIndex + 1);
|
|
541
811
|
setSelectedAccountId(data.accounts[next]?.id ?? null);
|
|
812
|
+
setAccountScrollTop(followScrollWindow(accountWindowTop, next, data.accounts.length, shownAccounts));
|
|
542
813
|
}
|
|
543
814
|
// Account actions (only when focus = accounts)
|
|
544
815
|
if (input === "e") {
|
|
@@ -611,15 +882,15 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
|
|
|
611
882
|
}
|
|
612
883
|
});
|
|
613
884
|
const selectedLog = logs[selectedLogIndex] ?? null;
|
|
614
|
-
const visibleLogs = logs.slice(logWindowTop, logWindowTop +
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
885
|
+
const visibleLogs = logs.slice(logWindowTop, logWindowTop + logVisible);
|
|
886
|
+
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 }), _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"] })] }), 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
|
|
887
|
+
? `daemon v${data.version}`
|
|
888
|
+
: "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" })] })) : (
|
|
889
|
+
// A remote router can only be restarted where it runs; printing a
|
|
890
|
+
// local restart command here would never clear the banner.
|
|
891
|
+
_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", 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 === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), shownAccounts < data.accounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), _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", ref: accountRowsRef, children: data.accounts.slice(accountWindowTop, accountWindowTop + shownAccounts).map((a, i) => (_jsx(AccountRow, { account: a, selected: focus === "accounts" && accountWindowTop + i === selectedAccountIndex }, a.id))) })] }), 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 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }), _jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
|
|
892
|
+
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
893
|
+
: visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) }), focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }) }));
|
|
623
894
|
}
|
|
624
895
|
function OperationsPanel({ operational, baseUrl, focus }) {
|
|
625
896
|
const authLabel = operational.auth.required ? "protected" : "open";
|
|
@@ -630,14 +901,14 @@ function OperationsPanel({ operational, baseUrl, focus }) {
|
|
|
630
901
|
const modelsReady = operational.capabilities.dynamicModels;
|
|
631
902
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " OPERATIONS " }), _jsx(Text, { color: "gray", children: "base " }), _jsx(Text, { color: "cyan", children: baseUrl }), _jsx(Text, { color: "gray", children: " \u00B7 auth " }), _jsx(Text, { color: authColor, children: authLabel }), _jsx(Text, { color: "gray", children: " \u00B7 models " }), _jsx(Text, { color: modelsReady ? "green" : "red", children: modelsReady ? "dynamic" : "off" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(ProviderBadge, { label: "Claude", status: operational.providers.anthropic, ready: claudeReady }), _jsx(Text, { color: "gray", children: " " }), _jsx(ProviderBadge, { label: "OpenAI", status: operational.providers.openai, ready: openAIReady }), _jsx(Text, { color: "gray", children: " \u00B7 cross-route " }), _jsx(Text, { color: crossReady ? "green" : "gray", children: crossReady ? "ready" : "needs OpenAI" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "endpoints " }), _jsx(Text, { color: "white", children: operational.endpoints.messages }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.responses }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.models }), _jsx(Text, { color: "gray", children: " " }), _jsx(Text, { color: "white", children: operational.endpoints.accounts })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "routing " }), _jsxs(Text, { color: "white", children: ["claude=", operational.routing.anthropicDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.anthropicAliases.join(",") || "-", "]"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", operational.routing.openAIDefaultModel ?? "default"] }), _jsxs(Text, { color: "gray", children: [" aliases[", operational.routing.openAIAliases.join(",") || "-", "]"] })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "models " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[m] list/select" }), _jsx(Text, { color: "gray", children: " change " }), _jsx(Text, { color: focus === "models" ? "white" : "cyan", children: "[c] Claude [o] OpenAI" })] })] }));
|
|
632
903
|
}
|
|
633
|
-
function ModelsPanel({ status, selectedIndex, focused, }) {
|
|
904
|
+
function ModelsPanel({ status, selectedIndex, focused, visibleRows = MODEL_VISIBLE_ROWS, rowsRef, }) {
|
|
634
905
|
const models = status?.models ?? [];
|
|
635
|
-
const visible = getVisibleModelWindow(models, selectedIndex,
|
|
906
|
+
const visible = getVisibleModelWindow(models, selectedIndex, Math.max(1, visibleRows));
|
|
636
907
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " MODELS " }), _jsx(Text, { color: "gray", children: "[m/r] refresh [\u2191/\u2193] select [c] Claude default [o] OpenAI default [Esc] logs" })] }), _jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "current " }), _jsxs(Text, { color: "white", children: ["claude=", status?.routing.anthropicDefaultModel ?? "default"] }), _jsx(Text, { color: "gray", children: " " }), _jsxs(Text, { color: "white", children: ["openai=", status?.routing.openAIDefaultModel ?? "default"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: status === null
|
|
637
908
|
? _jsx(Text, { color: "gray", children: " Press [m] to load models from provider APIs" })
|
|
638
909
|
: models.length === 0
|
|
639
910
|
? _jsx(Text, { color: "gray", children: " No models discovered" })
|
|
640
|
-
: (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" showing ", visible.start + 1, "-", visible.end, " of ", models.length] }), visible.rows.map((model, i) => (_jsx(ModelRow, { model: model, selected: focused && visible.start + i === selectedIndex, currentClaude: status.routing.anthropicDefaultModel, currentOpenAI: status.routing.openAIDefaultModel }, model.id)))] })) })] }));
|
|
911
|
+
: (_jsxs(_Fragment, { children: [_jsxs(Text, { color: "gray", children: [" showing ", visible.start + 1, "-", visible.end, " of ", models.length] }), _jsx(Box, { flexDirection: "column", ref: rowsRef, children: visible.rows.map((model, i) => (_jsx(ModelRow, { model: model, selected: focused && visible.start + i === selectedIndex, currentClaude: status.routing.anthropicDefaultModel, currentOpenAI: status.routing.openAIDefaultModel }, model.id))) })] })) })] }));
|
|
641
912
|
}
|
|
642
913
|
export function getVisibleModelWindow(models, selectedIndex, maxRows = MODEL_VISIBLE_ROWS) {
|
|
643
914
|
if (models.length <= maxRows) {
|
package/package.json
CHANGED