@askalf/dario 3.38.6 → 4.0.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/README.md +23 -1
- package/dist/analytics.d.ts +32 -3
- package/dist/analytics.js +48 -3
- package/dist/cli.js +111 -25
- package/dist/config-file.d.ts +157 -0
- package/dist/config-file.js +326 -0
- package/dist/proxy.js +93 -23
- package/dist/tui/app.d.ts +96 -0
- package/dist/tui/app.js +178 -0
- package/dist/tui/input.d.ts +57 -0
- package/dist/tui/input.js +206 -0
- package/dist/tui/layout.d.ts +66 -0
- package/dist/tui/layout.js +152 -0
- package/dist/tui/proxy-client.d.ts +60 -0
- package/dist/tui/proxy-client.js +166 -0
- package/dist/tui/render.d.ts +178 -0
- package/dist/tui/render.js +246 -0
- package/dist/tui/tab.d.ts +89 -0
- package/dist/tui/tab.js +19 -0
- package/dist/tui/tabs/accounts.d.ts +32 -0
- package/dist/tui/tabs/accounts.js +110 -0
- package/dist/tui/tabs/analytics.d.ts +53 -0
- package/dist/tui/tabs/analytics.js +161 -0
- package/dist/tui/tabs/backends.d.ts +19 -0
- package/dist/tui/tabs/backends.js +77 -0
- package/dist/tui/tabs/config.d.ts +35 -0
- package/dist/tui/tabs/config.js +267 -0
- package/dist/tui/tabs/hits.d.ts +34 -0
- package/dist/tui/tabs/hits.js +223 -0
- package/dist/tui/tabs/status.d.ts +45 -0
- package/dist/tui/tabs/status.js +132 -0
- package/dist/tui/tui-app.d.ts +41 -0
- package/dist/tui/tui-app.js +217 -0
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config tab — view + edit the persistent ~/.dario/config.json.
|
|
3
|
+
*
|
|
4
|
+
* Renders a flat list of editable fields. Each field has a typed
|
|
5
|
+
* editor: bool toggles inline, numbers / strings open an input prompt
|
|
6
|
+
* at the bottom of the panel.
|
|
7
|
+
*
|
|
8
|
+
* Keys:
|
|
9
|
+
* ↑↓ navigate
|
|
10
|
+
* Enter edit (bool: toggle; number/string: open input)
|
|
11
|
+
* Esc cancel input
|
|
12
|
+
* s save → write ~/.dario/config.json
|
|
13
|
+
* d discard local changes
|
|
14
|
+
* r reload from disk
|
|
15
|
+
*
|
|
16
|
+
* Coverage in v4.0: port, host, stealth, pacing/thinkTime/sessionStart
|
|
17
|
+
* sub-knobs, drainOnClose. Additional fields (preserveTools, mergeTools,
|
|
18
|
+
* etc.) can follow the same FieldDef pattern in v4.x without API
|
|
19
|
+
* change. The DarioConfig schema is the source of truth — adding a
|
|
20
|
+
* field there + a row here lights it up.
|
|
21
|
+
*/
|
|
22
|
+
import { fg, dim, brand, inverse, pad } from '../render.js';
|
|
23
|
+
import { CONFIG_SCHEMA_VERSION, defaultConfig, loadConfig, saveConfig, } from '../../config-file.js';
|
|
24
|
+
/**
|
|
25
|
+
* The visible field registry. Order = display order. New fields just
|
|
26
|
+
* append; the tab grows automatically. Path → DarioConfig is dotted.
|
|
27
|
+
*/
|
|
28
|
+
const FIELDS = [
|
|
29
|
+
{ path: 'port', label: 'Port', type: 'number', hint: 'default 3456' },
|
|
30
|
+
{ path: 'host', label: 'Host', type: 'string', hint: '127.0.0.1 (loopback only)' },
|
|
31
|
+
{ path: 'stealth', label: 'Stealth preset', type: 'bool', hint: 'enables behavioural pacing + jitter' },
|
|
32
|
+
{ path: 'drainOnClose', label: 'Drain on close', type: 'bool', hint: 'finish upstream SSE after client disconnects' },
|
|
33
|
+
{ path: 'pacing.minMs', label: 'Pacing min (ms)', type: 'number', hint: 'min inter-request distance' },
|
|
34
|
+
{ path: 'pacing.jitterMs', label: 'Pacing jitter (ms)', type: 'number', hint: 'uniform-random extra delay' },
|
|
35
|
+
{ path: 'thinkTime.baseMs', label: 'Think-time base (ms)', type: 'number' },
|
|
36
|
+
{ path: 'thinkTime.perTokenMs', label: 'Think-time per-token', type: 'number', hint: 'ms per output token of last response' },
|
|
37
|
+
{ path: 'thinkTime.jitterMs', label: 'Think-time jitter', type: 'number' },
|
|
38
|
+
{ path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
|
|
39
|
+
{ path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
|
|
40
|
+
{ path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
|
|
41
|
+
];
|
|
42
|
+
export const ConfigTab = {
|
|
43
|
+
id: 'config',
|
|
44
|
+
label: 'Config',
|
|
45
|
+
hotkey: 'c',
|
|
46
|
+
initialState() {
|
|
47
|
+
const loaded = loadConfig();
|
|
48
|
+
return {
|
|
49
|
+
config: loaded.config,
|
|
50
|
+
snapshot: structuredClone(loaded.config),
|
|
51
|
+
selectedIdx: 0,
|
|
52
|
+
editBuffer: null,
|
|
53
|
+
statusMessage: null,
|
|
54
|
+
statusKind: null,
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
onKey(state, key) {
|
|
58
|
+
// ── Edit mode key handling ─────────────────────────────────
|
|
59
|
+
if (state.editBuffer !== null) {
|
|
60
|
+
if (key.name === 'escape') {
|
|
61
|
+
return { ...state, editBuffer: null, statusMessage: 'Edit cancelled.', statusKind: 'info' };
|
|
62
|
+
}
|
|
63
|
+
if (key.name === 'enter') {
|
|
64
|
+
return commitEdit(state);
|
|
65
|
+
}
|
|
66
|
+
if (key.name === 'backspace') {
|
|
67
|
+
return { ...state, editBuffer: state.editBuffer.slice(0, -1) };
|
|
68
|
+
}
|
|
69
|
+
if (key.name === 'printable' && !key.ctrl) {
|
|
70
|
+
return { ...state, editBuffer: state.editBuffer + key.ch };
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
// ── Normal mode ────────────────────────────────────────────
|
|
75
|
+
if (key.name === 'up') {
|
|
76
|
+
return { ...state, selectedIdx: Math.max(0, state.selectedIdx - 1), statusMessage: null, statusKind: null };
|
|
77
|
+
}
|
|
78
|
+
if (key.name === 'down') {
|
|
79
|
+
return { ...state, selectedIdx: Math.min(FIELDS.length - 1, state.selectedIdx + 1), statusMessage: null, statusKind: null };
|
|
80
|
+
}
|
|
81
|
+
if (key.name === 'enter') {
|
|
82
|
+
return startEdit(state);
|
|
83
|
+
}
|
|
84
|
+
if (key.name === 'printable' && !key.ctrl) {
|
|
85
|
+
if (key.ch === 's')
|
|
86
|
+
return doSave(state);
|
|
87
|
+
if (key.ch === 'd')
|
|
88
|
+
return doDiscard(state);
|
|
89
|
+
if (key.ch === 'r')
|
|
90
|
+
return doReload();
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
},
|
|
94
|
+
render(state, dimv) {
|
|
95
|
+
const lines = [];
|
|
96
|
+
const w = dimv.cols;
|
|
97
|
+
const labelW = 26;
|
|
98
|
+
const valueW = w - labelW - 6;
|
|
99
|
+
const dirty = isDirty(state);
|
|
100
|
+
const title = dirty
|
|
101
|
+
? brand('Config') + dim(' — ') + fg('yellow', '● unsaved changes')
|
|
102
|
+
: brand('Config');
|
|
103
|
+
lines.push(' ' + title);
|
|
104
|
+
lines.push('');
|
|
105
|
+
for (let i = 0; i < FIELDS.length; i++) {
|
|
106
|
+
const field = FIELDS[i];
|
|
107
|
+
const value = getByPath(state.config, field.path);
|
|
108
|
+
const orig = getByPath(state.snapshot, field.path);
|
|
109
|
+
const changed = !Object.is(value, orig);
|
|
110
|
+
const valueRender = renderValue(field, value, changed);
|
|
111
|
+
const hint = field.hint ? ' ' + dim('— ' + field.hint) : '';
|
|
112
|
+
const row = ' ' + pad(field.label + ':', labelW) + pad(valueRender, valueW) + hint;
|
|
113
|
+
lines.push(i === state.selectedIdx ? inverse(row) : row);
|
|
114
|
+
}
|
|
115
|
+
// ── Edit prompt or status line ─────────────────────────────
|
|
116
|
+
lines.push('');
|
|
117
|
+
if (state.editBuffer !== null) {
|
|
118
|
+
const f = FIELDS[state.selectedIdx];
|
|
119
|
+
lines.push(' ' + fg('cyan', `Edit ${f.label}:`) + ' ' + state.editBuffer + fg('cyan', '_'));
|
|
120
|
+
lines.push(' ' + dim('Enter to confirm · Esc to cancel'));
|
|
121
|
+
}
|
|
122
|
+
else if (state.statusMessage) {
|
|
123
|
+
const color = state.statusKind === 'error' ? 'red'
|
|
124
|
+
: state.statusKind === 'success' ? 'green'
|
|
125
|
+
: 'cyan';
|
|
126
|
+
lines.push(' ' + fg(color, state.statusMessage));
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
lines.push(' ' + dim('↑↓ navigate · Enter edit · s save · d discard · r reload'));
|
|
130
|
+
}
|
|
131
|
+
return lines.join('\n');
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
135
|
+
function getByPath(obj, path) {
|
|
136
|
+
return path.split('.').reduce((acc, part) => {
|
|
137
|
+
if (acc && typeof acc === 'object' && part in acc) {
|
|
138
|
+
return acc[part];
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}, obj);
|
|
142
|
+
}
|
|
143
|
+
function setByPath(obj, path, value) {
|
|
144
|
+
// Guard against prototype-pollution paths. `path` is always sourced
|
|
145
|
+
// from FIELDS (the static registry at the top of this file), but
|
|
146
|
+
// CodeQL flags the recursive descent as risky because it can't
|
|
147
|
+
// prove that statically — and rightly so: if a future caller ever
|
|
148
|
+
// passes a user-controlled path, walking `__proto__` or
|
|
149
|
+
// `constructor` would mutate Object.prototype. Reject those
|
|
150
|
+
// segments explicitly so the seam is safe by construction.
|
|
151
|
+
const parts = path.split('.');
|
|
152
|
+
for (const part of parts) {
|
|
153
|
+
if (part === '__proto__' || part === 'constructor' || part === 'prototype') {
|
|
154
|
+
throw new Error(`refusing to set forbidden path segment: ${part}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const next = structuredClone(obj);
|
|
158
|
+
let cursor = next;
|
|
159
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
160
|
+
const part = parts[i];
|
|
161
|
+
// Object.prototype.hasOwnProperty.call so we don't accidentally
|
|
162
|
+
// pick up inherited keys when probing for existing nested groups.
|
|
163
|
+
if (!Object.prototype.hasOwnProperty.call(cursor, part)
|
|
164
|
+
|| typeof cursor[part] !== 'object'
|
|
165
|
+
|| cursor[part] === null) {
|
|
166
|
+
cursor[part] = {};
|
|
167
|
+
}
|
|
168
|
+
cursor = cursor[part];
|
|
169
|
+
}
|
|
170
|
+
cursor[parts[parts.length - 1]] = value;
|
|
171
|
+
return next;
|
|
172
|
+
}
|
|
173
|
+
function renderValue(field, value, changed) {
|
|
174
|
+
let text;
|
|
175
|
+
if (field.type === 'bool')
|
|
176
|
+
text = value === true ? 'on' : 'off';
|
|
177
|
+
else if (value === null || value === undefined)
|
|
178
|
+
text = '—';
|
|
179
|
+
else
|
|
180
|
+
text = String(value);
|
|
181
|
+
// Yellow if changed-from-snapshot; green for bool-on; default otherwise
|
|
182
|
+
if (changed)
|
|
183
|
+
return fg('yellow', text);
|
|
184
|
+
if (field.type === 'bool' && value === true)
|
|
185
|
+
return fg('green', text);
|
|
186
|
+
return text;
|
|
187
|
+
}
|
|
188
|
+
function startEdit(state) {
|
|
189
|
+
const f = FIELDS[state.selectedIdx];
|
|
190
|
+
if (f.type === 'bool') {
|
|
191
|
+
// Toggle in place
|
|
192
|
+
const current = getByPath(state.config, f.path);
|
|
193
|
+
const next = setByPath(state.config, f.path, !current);
|
|
194
|
+
return { ...state, config: next, statusMessage: null, statusKind: null };
|
|
195
|
+
}
|
|
196
|
+
// String / number: open the prompt with the current value
|
|
197
|
+
const current = getByPath(state.config, f.path);
|
|
198
|
+
return { ...state, editBuffer: current === null || current === undefined ? '' : String(current) };
|
|
199
|
+
}
|
|
200
|
+
function commitEdit(state) {
|
|
201
|
+
if (state.editBuffer === null)
|
|
202
|
+
return state;
|
|
203
|
+
const f = FIELDS[state.selectedIdx];
|
|
204
|
+
let parsed;
|
|
205
|
+
if (f.type === 'number') {
|
|
206
|
+
if (state.editBuffer === '') {
|
|
207
|
+
// Empty number → null (clears the override)
|
|
208
|
+
parsed = null;
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const n = Number(state.editBuffer);
|
|
212
|
+
if (!Number.isFinite(n)) {
|
|
213
|
+
return { ...state, editBuffer: null, statusMessage: `Not a number: "${state.editBuffer}"`, statusKind: 'error' };
|
|
214
|
+
}
|
|
215
|
+
parsed = n;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
parsed = state.editBuffer;
|
|
220
|
+
}
|
|
221
|
+
const next = setByPath(state.config, f.path, parsed);
|
|
222
|
+
return { ...state, config: next, editBuffer: null, statusMessage: `Updated ${f.label}.`, statusKind: 'success' };
|
|
223
|
+
}
|
|
224
|
+
function doSave(state) {
|
|
225
|
+
try {
|
|
226
|
+
saveConfig(undefined, { ...state.config, version: CONFIG_SCHEMA_VERSION });
|
|
227
|
+
return {
|
|
228
|
+
...state,
|
|
229
|
+
snapshot: structuredClone(state.config),
|
|
230
|
+
statusMessage: 'Saved to ~/.dario/config.json',
|
|
231
|
+
statusKind: 'success',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
...state,
|
|
237
|
+
statusMessage: `Save failed: ${err.message}`,
|
|
238
|
+
statusKind: 'error',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function doDiscard(state) {
|
|
243
|
+
return {
|
|
244
|
+
...state,
|
|
245
|
+
config: structuredClone(state.snapshot),
|
|
246
|
+
statusMessage: 'Local changes discarded.',
|
|
247
|
+
statusKind: 'info',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function doReload() {
|
|
251
|
+
const loaded = loadConfig();
|
|
252
|
+
return {
|
|
253
|
+
config: loaded.config,
|
|
254
|
+
snapshot: structuredClone(loaded.config),
|
|
255
|
+
selectedIdx: 0,
|
|
256
|
+
editBuffer: null,
|
|
257
|
+
statusMessage: loaded.source === 'file' ? 'Reloaded from disk.'
|
|
258
|
+
: loaded.source === 'missing' ? 'No file on disk — showing defaults.'
|
|
259
|
+
: `Invalid file: ${loaded.error}`,
|
|
260
|
+
statusKind: loaded.source === 'invalid' ? 'error' : 'info',
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function isDirty(state) {
|
|
264
|
+
return JSON.stringify(state.config) !== JSON.stringify(state.snapshot);
|
|
265
|
+
}
|
|
266
|
+
// Avoid "unused" lint
|
|
267
|
+
void defaultConfig;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hits tab — live request stream with per-record detail drill-down.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to /analytics/stream on mount. Each incoming RequestRecord
|
|
5
|
+
* is prepended to the buffer (newest at the top of the visible list).
|
|
6
|
+
* Up/Down navigate the selection; the lower pane shows the selected
|
|
7
|
+
* record's full field set.
|
|
8
|
+
*
|
|
9
|
+
* Layout:
|
|
10
|
+
*
|
|
11
|
+
* ┌─ Hits ────────────────────────[ ↑↓ select · r refresh ]
|
|
12
|
+
* │ HH:MM:SS METHOD MODEL IN OUT LAT ST
|
|
13
|
+
* │ 18:42:01 POST opus-4-7 842 216 1.2s 200 ←
|
|
14
|
+
* │ 18:42:03 POST sonnet-4-6 1.2k 480 0.8s 200
|
|
15
|
+
* │ …
|
|
16
|
+
* ├─────────────────────────────────────────────────────────────
|
|
17
|
+
* │ selected: 18:42:01 req_011…NvMn
|
|
18
|
+
* │ account: sprayberryit (single)
|
|
19
|
+
* │ model: claude-opus-4-7
|
|
20
|
+
* │ bucket: subscription
|
|
21
|
+
* │ tokens: in 842 / out 216 / cache-read 6.2k / thinking 84
|
|
22
|
+
* │ latency: 1.18s stream: yes status: 200
|
|
23
|
+
* │ 5h util: 18% 7d util: 8%
|
|
24
|
+
* └─────────────────────────────────────────────────────────────
|
|
25
|
+
*/
|
|
26
|
+
import type { Tab } from '../tab.js';
|
|
27
|
+
import type { RequestRecord } from '../../analytics.js';
|
|
28
|
+
export interface HitsState {
|
|
29
|
+
buffer: RequestRecord[];
|
|
30
|
+
selectedIdx: number;
|
|
31
|
+
subscribed: boolean;
|
|
32
|
+
connectionError: string | null;
|
|
33
|
+
}
|
|
34
|
+
export declare const HitsTab: Tab<HitsState>;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hits tab — live request stream with per-record detail drill-down.
|
|
3
|
+
*
|
|
4
|
+
* Subscribes to /analytics/stream on mount. Each incoming RequestRecord
|
|
5
|
+
* is prepended to the buffer (newest at the top of the visible list).
|
|
6
|
+
* Up/Down navigate the selection; the lower pane shows the selected
|
|
7
|
+
* record's full field set.
|
|
8
|
+
*
|
|
9
|
+
* Layout:
|
|
10
|
+
*
|
|
11
|
+
* ┌─ Hits ────────────────────────[ ↑↓ select · r refresh ]
|
|
12
|
+
* │ HH:MM:SS METHOD MODEL IN OUT LAT ST
|
|
13
|
+
* │ 18:42:01 POST opus-4-7 842 216 1.2s 200 ←
|
|
14
|
+
* │ 18:42:03 POST sonnet-4-6 1.2k 480 0.8s 200
|
|
15
|
+
* │ …
|
|
16
|
+
* ├─────────────────────────────────────────────────────────────
|
|
17
|
+
* │ selected: 18:42:01 req_011…NvMn
|
|
18
|
+
* │ account: sprayberryit (single)
|
|
19
|
+
* │ model: claude-opus-4-7
|
|
20
|
+
* │ bucket: subscription
|
|
21
|
+
* │ tokens: in 842 / out 216 / cache-read 6.2k / thinking 84
|
|
22
|
+
* │ latency: 1.18s stream: yes status: 200
|
|
23
|
+
* │ 5h util: 18% 7d util: 8%
|
|
24
|
+
* └─────────────────────────────────────────────────────────────
|
|
25
|
+
*/
|
|
26
|
+
import { fg, dim, brand, inverse, BOX, pad, truncate } from '../render.js';
|
|
27
|
+
import { renderKvRow } from '../layout.js';
|
|
28
|
+
import { billingBucketFromClaim } from '../../analytics.js';
|
|
29
|
+
const MAX_BUFFER = 5000;
|
|
30
|
+
export const HitsTab = {
|
|
31
|
+
id: 'hits',
|
|
32
|
+
label: 'Hits',
|
|
33
|
+
hotkey: 'h',
|
|
34
|
+
initialState() {
|
|
35
|
+
return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null };
|
|
36
|
+
},
|
|
37
|
+
onMount(_state, ctx) {
|
|
38
|
+
// Subscribe to the live stream. Each record is prepended-conceptually
|
|
39
|
+
// (we push to the array and render in reverse, which keeps the
|
|
40
|
+
// buffer's mutation simple — Array.push is O(1) while unshift is O(n)).
|
|
41
|
+
const close = ctx.client.subscribeAnalyticsStream((record) => {
|
|
42
|
+
ctx.setState((s) => {
|
|
43
|
+
const next = {
|
|
44
|
+
...s,
|
|
45
|
+
buffer: [...s.buffer, record].slice(-MAX_BUFFER),
|
|
46
|
+
subscribed: true,
|
|
47
|
+
connectionError: null,
|
|
48
|
+
};
|
|
49
|
+
// If user was at top (newest), keep them there. -1 means "no
|
|
50
|
+
// selection yet"; auto-select newest on first record.
|
|
51
|
+
if (s.selectedIdx === -1 || s.selectedIdx === 0) {
|
|
52
|
+
next.selectedIdx = 0;
|
|
53
|
+
}
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
}, (err) => {
|
|
57
|
+
ctx.setState({ subscribed: false, connectionError: err.message });
|
|
58
|
+
});
|
|
59
|
+
ctx.registerCleanup(close);
|
|
60
|
+
return undefined;
|
|
61
|
+
},
|
|
62
|
+
onKey(state, key) {
|
|
63
|
+
if (state.buffer.length === 0)
|
|
64
|
+
return undefined;
|
|
65
|
+
// ↑ — go to OLDER (toward higher index in our reversed display)
|
|
66
|
+
if (key.name === 'up') {
|
|
67
|
+
const max = state.buffer.length - 1;
|
|
68
|
+
return { ...state, selectedIdx: Math.min(state.selectedIdx + 1, max) };
|
|
69
|
+
}
|
|
70
|
+
// ↓ — go to NEWER
|
|
71
|
+
if (key.name === 'down') {
|
|
72
|
+
return { ...state, selectedIdx: Math.max(state.selectedIdx - 1, 0) };
|
|
73
|
+
}
|
|
74
|
+
// PgUp / PgDn — step by 10
|
|
75
|
+
if (key.name === 'pageup') {
|
|
76
|
+
const max = state.buffer.length - 1;
|
|
77
|
+
return { ...state, selectedIdx: Math.min(state.selectedIdx + 10, max) };
|
|
78
|
+
}
|
|
79
|
+
if (key.name === 'pagedown') {
|
|
80
|
+
return { ...state, selectedIdx: Math.max(state.selectedIdx - 10, 0) };
|
|
81
|
+
}
|
|
82
|
+
// Home — jump to newest
|
|
83
|
+
if (key.name === 'home') {
|
|
84
|
+
return { ...state, selectedIdx: 0 };
|
|
85
|
+
}
|
|
86
|
+
// End — jump to oldest
|
|
87
|
+
if (key.name === 'end') {
|
|
88
|
+
return { ...state, selectedIdx: state.buffer.length - 1 };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
},
|
|
92
|
+
render(state, dimv) {
|
|
93
|
+
const lines = [];
|
|
94
|
+
const w = dimv.cols;
|
|
95
|
+
const totalRows = dimv.rows;
|
|
96
|
+
// Split the body roughly 60/40 between list and detail.
|
|
97
|
+
const detailRows = 9;
|
|
98
|
+
const listRows = Math.max(3, totalRows - detailRows - 2);
|
|
99
|
+
if (state.buffer.length === 0) {
|
|
100
|
+
lines.push(' ' + brand('Hits') + dim(' — live request stream'));
|
|
101
|
+
lines.push('');
|
|
102
|
+
if (state.connectionError) {
|
|
103
|
+
lines.push(' ' + fg('red', `SSE error: ${state.connectionError}`));
|
|
104
|
+
lines.push(' ' + dim('Is `dario proxy` running? The stream reconnects automatically on the next mount.'));
|
|
105
|
+
}
|
|
106
|
+
else if (!state.subscribed) {
|
|
107
|
+
lines.push(' ' + dim('Connecting to /analytics/stream …'));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
lines.push(' ' + dim('Waiting for requests. Send one through dario to see it land here.'));
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
// Render newest-first: the LAST element of the buffer renders at
|
|
115
|
+
// the TOP of the list.
|
|
116
|
+
const newestFirst = [...state.buffer].reverse();
|
|
117
|
+
const startIdx = clampVisibleStart(state.selectedIdx, listRows, newestFirst.length);
|
|
118
|
+
const endIdx = Math.min(startIdx + listRows, newestFirst.length);
|
|
119
|
+
// Column layout — fixed widths to keep alignment stable across
|
|
120
|
+
// varied content. Fall back to truncation when columns overflow.
|
|
121
|
+
const colTime = 9;
|
|
122
|
+
const colModel = 18;
|
|
123
|
+
const colIn = 8, colOut = 7, colLat = 7, colStatus = 5;
|
|
124
|
+
lines.push(' ' + brand('Hits') +
|
|
125
|
+
dim(` ${state.buffer.length} buffered · ${state.subscribed ? fg('green', 'live') : fg('yellow', 'disconnected')}`));
|
|
126
|
+
lines.push('');
|
|
127
|
+
// Header row (aligned with data rows)
|
|
128
|
+
lines.push(' ' + dim(pad('time', colTime) +
|
|
129
|
+
pad('model', colModel) +
|
|
130
|
+
pad('in', colIn) +
|
|
131
|
+
pad('out', colOut) +
|
|
132
|
+
pad('lat', colLat) +
|
|
133
|
+
pad('st', colStatus)));
|
|
134
|
+
for (let i = startIdx; i < endIdx; i++) {
|
|
135
|
+
const r = newestFirst[i];
|
|
136
|
+
const marker = i === state.selectedIdx ? fg('cyan', '▎') : ' ';
|
|
137
|
+
const row = marker + ' ' +
|
|
138
|
+
pad(formatTime(r.timestamp), colTime) +
|
|
139
|
+
pad(shortenModel(r.model), colModel) +
|
|
140
|
+
pad(formatTokens(r.inputTokens), colIn) +
|
|
141
|
+
pad(formatTokens(r.outputTokens), colOut) +
|
|
142
|
+
pad(formatLatency(r.latencyMs), colLat) +
|
|
143
|
+
pad(formatStatus(r.status), colStatus);
|
|
144
|
+
lines.push(i === state.selectedIdx ? inverse(truncate(row, w - 2)) : truncate(row, w - 2));
|
|
145
|
+
}
|
|
146
|
+
// Scroll hint
|
|
147
|
+
if (newestFirst.length > listRows) {
|
|
148
|
+
lines.push(' ' + dim(`${state.selectedIdx + 1} / ${newestFirst.length} ` +
|
|
149
|
+
(startIdx > 0 ? '↑ more ' : '') +
|
|
150
|
+
(endIdx < newestFirst.length ? '↓ more' : '')));
|
|
151
|
+
}
|
|
152
|
+
// Separator
|
|
153
|
+
lines.push(' ' + dim(BOX.horizontal.repeat(w - 2)));
|
|
154
|
+
// Detail pane
|
|
155
|
+
if (state.selectedIdx >= 0 && state.selectedIdx < newestFirst.length) {
|
|
156
|
+
const r = newestFirst[state.selectedIdx];
|
|
157
|
+
lines.push(' ' + brand('Selected') + dim(` ${formatTime(r.timestamp)}`));
|
|
158
|
+
lines.push(' ' + renderKvRow('Account', r.account, w - 4));
|
|
159
|
+
lines.push(' ' + renderKvRow('Model', r.model, w - 4));
|
|
160
|
+
lines.push(' ' + renderKvRow('Billing bucket', billingBucketFromClaim(r.claim), w - 4));
|
|
161
|
+
lines.push(' ' + renderKvRow('Tokens', tokenBreakdown(r), w - 4));
|
|
162
|
+
lines.push(' ' + renderKvRow('Latency', `${formatLatency(r.latencyMs)} ${dim(r.isStream ? '(streaming)' : '(buffered)')}`, w - 4));
|
|
163
|
+
lines.push(' ' + renderKvRow('Util at request', `5h ${(r.util5h * 100).toFixed(0)}% 7d ${(r.util7d * 100).toFixed(0)}%`, w - 4));
|
|
164
|
+
lines.push(' ' + renderKvRow('Status', formatStatus(r.status), w - 4));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push(' ' + dim('Use ↑↓ to select a request for details.'));
|
|
169
|
+
}
|
|
170
|
+
return lines.join('\n');
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* Decide what range of the (newest-first) buffer to show given the
|
|
175
|
+
* current selection. Keeps the selection visible: if selected drifts
|
|
176
|
+
* off the bottom we scroll down; off the top we scroll up.
|
|
177
|
+
*/
|
|
178
|
+
function clampVisibleStart(selectedIdx, listRows, total) {
|
|
179
|
+
if (selectedIdx < 0)
|
|
180
|
+
return 0;
|
|
181
|
+
// Try to keep selection roughly centered when scrolling
|
|
182
|
+
const desired = selectedIdx - Math.floor(listRows / 3);
|
|
183
|
+
return Math.max(0, Math.min(desired, Math.max(0, total - listRows)));
|
|
184
|
+
}
|
|
185
|
+
function formatTime(ts) {
|
|
186
|
+
const d = new Date(ts);
|
|
187
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
|
|
188
|
+
}
|
|
189
|
+
function pad2(n) { return n < 10 ? '0' + n : String(n); }
|
|
190
|
+
function shortenModel(model) {
|
|
191
|
+
return model.replace(/^claude-/, '');
|
|
192
|
+
}
|
|
193
|
+
function formatTokens(n) {
|
|
194
|
+
if (n >= 1_000_000)
|
|
195
|
+
return (n / 1_000_000).toFixed(1) + 'M';
|
|
196
|
+
if (n >= 1000)
|
|
197
|
+
return (n / 1000).toFixed(1) + 'k';
|
|
198
|
+
return String(n);
|
|
199
|
+
}
|
|
200
|
+
function formatLatency(ms) {
|
|
201
|
+
if (ms >= 1000)
|
|
202
|
+
return (ms / 1000).toFixed(1) + 's';
|
|
203
|
+
return ms + 'ms';
|
|
204
|
+
}
|
|
205
|
+
function formatStatus(code) {
|
|
206
|
+
if (code >= 200 && code < 300)
|
|
207
|
+
return fg('green', String(code));
|
|
208
|
+
if (code >= 400 && code < 500)
|
|
209
|
+
return fg('yellow', String(code));
|
|
210
|
+
if (code >= 500)
|
|
211
|
+
return fg('red', String(code));
|
|
212
|
+
return String(code);
|
|
213
|
+
}
|
|
214
|
+
function tokenBreakdown(r) {
|
|
215
|
+
const parts = [`in ${r.inputTokens}`, `out ${r.outputTokens}`];
|
|
216
|
+
if (r.cacheReadTokens > 0)
|
|
217
|
+
parts.push(`cache-read ${formatTokens(r.cacheReadTokens)}`);
|
|
218
|
+
if (r.cacheCreateTokens > 0)
|
|
219
|
+
parts.push(`cache-create ${formatTokens(r.cacheCreateTokens)}`);
|
|
220
|
+
if (r.thinkingTokens > 0)
|
|
221
|
+
parts.push(`thinking ${r.thinkingTokens}`);
|
|
222
|
+
return parts.join(' / ');
|
|
223
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status tab — at-a-glance proxy + auth + config-source view.
|
|
3
|
+
*
|
|
4
|
+
* Read-mostly. On mount: probe /health for proxy reachability; load
|
|
5
|
+
* config-file metadata locally. On any key, return undefined (no
|
|
6
|
+
* mutations from this tab).
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
*
|
|
10
|
+
* ┌─ Proxy ─────────────────────────────────────────┐
|
|
11
|
+
* │ status: running │
|
|
12
|
+
* │ port: 3456 │
|
|
13
|
+
* │ oauth: healthy (expires in 7h 41m) │
|
|
14
|
+
* │ requests: 247 │
|
|
15
|
+
* └─────────────────────────────────────────────────┘
|
|
16
|
+
* ┌─ Config ────────────────────────────────────────┐
|
|
17
|
+
* │ source: ~/.dario/config.json │
|
|
18
|
+
* │ schema: v1 │
|
|
19
|
+
* │ …per-knob effective values (read-only) │
|
|
20
|
+
* └─────────────────────────────────────────────────┘
|
|
21
|
+
*/
|
|
22
|
+
import type { Tab, TabContext } from '../tab.js';
|
|
23
|
+
export interface StatusState {
|
|
24
|
+
loading: boolean;
|
|
25
|
+
/** Proxy /health response, or null if unreachable. */
|
|
26
|
+
health: {
|
|
27
|
+
status: string;
|
|
28
|
+
oauth: string;
|
|
29
|
+
expiresIn?: string;
|
|
30
|
+
requests?: number;
|
|
31
|
+
} | null;
|
|
32
|
+
/** Config-file load source: file | missing | invalid. */
|
|
33
|
+
configSource: 'file' | 'missing' | 'invalid' | null;
|
|
34
|
+
/** Last refresh timestamp (ms). */
|
|
35
|
+
lastRefreshAt: number;
|
|
36
|
+
/** Error from the last refresh attempt, if any. */
|
|
37
|
+
error: string | null;
|
|
38
|
+
}
|
|
39
|
+
export declare const StatusTab: Tab<StatusState>;
|
|
40
|
+
/**
|
|
41
|
+
* Refresh the Status tab's data — probe /health, load config file
|
|
42
|
+
* metadata. Exported separately so the parent can re-invoke on key
|
|
43
|
+
* 'r' without re-running the full onMount flow.
|
|
44
|
+
*/
|
|
45
|
+
export declare function refreshStatus(ctx: TabContext): Promise<StatusState>;
|