jev-gateway 0.1.0 → 0.2.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/.env.example +12 -0
- package/CHANGELOG.md +36 -0
- package/README.md +220 -119
- package/bin/jev-claude.mjs +1 -1
- package/bin/jev-codex.mjs +1 -1
- package/bin/launcher.mjs +96 -26
- package/dist/app.js +33 -6
- package/dist/config.js +5 -1
- package/dist/dashboard.html +500 -0
- package/dist/dashboard.js +57 -0
- package/dist/events.js +101 -0
- package/dist/index.js +4 -1
- package/dist/usage.js +86 -0
- package/package.json +14 -5
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>jev-gateway dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light;
|
|
10
|
+
--plane: #f9f9f7; --surface: #fcfcfb; --ink: #0b0b0b; --ink-2: #52514e; --muted: #898781;
|
|
11
|
+
--grid: #e1e0d9; --axis: #c3c2b7; --border: rgba(11, 11, 11, 0.1); --wash: rgba(11, 11, 11, 0.04);
|
|
12
|
+
--direct: #2a78d6; --forced: #eb6834; --hint: #1baf7a; --none: #eda100; --passthrough: #a9a79f;
|
|
13
|
+
--good: #0ca30c; --warning: #fab219; --critical: #d03b3b;
|
|
14
|
+
}
|
|
15
|
+
@media (prefers-color-scheme: dark) {
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: dark;
|
|
18
|
+
--plane: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7; --muted: #898781;
|
|
19
|
+
--grid: #2c2c2a; --axis: #383835; --border: rgba(255, 255, 255, 0.1); --wash: rgba(255, 255, 255, 0.05);
|
|
20
|
+
--direct: #3987e5; --forced: #d95926; --hint: #199e70; --none: #c98500; --passthrough: #5d5c57;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
* { box-sizing: border-box; }
|
|
24
|
+
body { margin: 0; background: var(--plane); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
25
|
+
main { max-width: 1180px; margin: 0 auto; padding: 20px 16px 48px; display: grid; gap: 16px; }
|
|
26
|
+
header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; }
|
|
27
|
+
h1 { font-size: 18px; margin: 0; font-weight: 650; }
|
|
28
|
+
h2 { font-size: 13px; margin: 0 0 12px; font-weight: 600; color: var(--ink-2); }
|
|
29
|
+
.spacer { flex: 1; }
|
|
30
|
+
.live { color: var(--muted); font-size: 12px; }
|
|
31
|
+
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); }
|
|
32
|
+
.seg button { font: inherit; font-size: 12px; color: var(--ink-2); background: none; border: 0; padding: 5px 10px; cursor: pointer; }
|
|
33
|
+
.seg button:hover { background: var(--wash); }
|
|
34
|
+
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--surface); }
|
|
35
|
+
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 16px; min-width: 0; }
|
|
36
|
+
.cols { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
|
37
|
+
|
|
38
|
+
.router { display: grid; gap: 6px; border-left: 4px solid var(--state); }
|
|
39
|
+
.router .who { font-size: 12px; color: var(--ink-2); }
|
|
40
|
+
.router .state { display: flex; align-items: center; gap: 10px; font-size: 28px; font-weight: 650; line-height: 1.15; }
|
|
41
|
+
.router .icon { flex: none; width: 26px; height: 26px; border-radius: 50%; background: var(--state); color: #fff; font-size: 15px; font-weight: 700; display: grid; place-items: center; }
|
|
42
|
+
.router .why { color: var(--ink-2); }
|
|
43
|
+
.router .meta { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
44
|
+
.s-routing { --state: var(--good); } .s-passthrough { --state: var(--warning); } .s-failing { --state: var(--critical); }
|
|
45
|
+
.s-idle, .s-offline, .s-baseline { --state: var(--muted); }
|
|
46
|
+
.router .switch { justify-self: start; font: inherit; font-size: 12px; color: var(--ink); background: none; border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
47
|
+
.router .switch:hover { background: var(--wash); }
|
|
48
|
+
.compare { width: 100%; border-collapse: collapse; }
|
|
49
|
+
.compare th, .compare td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: right; font-variant-numeric: tabular-nums; }
|
|
50
|
+
.compare th:first-child, .compare td:first-child { text-align: left; color: var(--ink-2); font-weight: 400; }
|
|
51
|
+
.compare .delta { color: var(--ink-2); font-size: 12px; }
|
|
52
|
+
.s-passthrough .icon { color: #0b0b0b; }
|
|
53
|
+
|
|
54
|
+
.tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
|
|
55
|
+
.tile { padding: 12px 14px; }
|
|
56
|
+
.tile .label { font-size: 12px; color: var(--ink-2); }
|
|
57
|
+
.tile .value { font-size: 24px; font-weight: 650; }
|
|
58
|
+
.tile .sub { font-size: 12px; color: var(--muted); }
|
|
59
|
+
|
|
60
|
+
.share { display: flex; gap: 2px; height: 16px; border-radius: 4px; overflow: hidden; background: var(--wash); }
|
|
61
|
+
.share span { min-width: 2px; }
|
|
62
|
+
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 12px; font-size: 12px; color: var(--ink-2); }
|
|
63
|
+
.legend b { color: var(--ink); font-weight: 600; }
|
|
64
|
+
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; background: var(--c); }
|
|
65
|
+
.c-direct { --c: var(--direct); } .c-forced { --c: var(--forced); } .c-hint { --c: var(--hint); }
|
|
66
|
+
.c-none { --c: var(--none); } .c-passthrough { --c: var(--passthrough); } .c-failed { --c: var(--critical); }
|
|
67
|
+
|
|
68
|
+
.timeline { display: flex; align-items: flex-end; gap: 2px; height: 96px; border-bottom: 1px solid var(--axis); margin-top: 16px; }
|
|
69
|
+
.timeline .col { flex: 1; height: 100%; display: flex; flex-direction: column-reverse; align-items: center; gap: 2px; outline: none; }
|
|
70
|
+
.timeline .col span { width: 100%; max-width: 24px; background: var(--c); }
|
|
71
|
+
.timeline .col span:last-child { border-radius: 4px 4px 0 0; }
|
|
72
|
+
.timeline .col:hover, .timeline .col:focus-visible { background: var(--wash); }
|
|
73
|
+
.axis { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 4px; }
|
|
74
|
+
#tip { position: fixed; z-index: 2; pointer-events: none; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; font-size: 12px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); display: none; }
|
|
75
|
+
#tip div { display: flex; gap: 12px; justify-content: space-between; color: var(--ink-2); }
|
|
76
|
+
#tip b { color: var(--ink); }
|
|
77
|
+
|
|
78
|
+
.reasons { display: grid; gap: 10px; }
|
|
79
|
+
.reason .top { display: flex; gap: 8px; align-items: baseline; }
|
|
80
|
+
.reason code { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
81
|
+
.reason .count { margin-left: auto; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
82
|
+
.reason .bar { height: 6px; border-radius: 0 4px 4px 0; background: var(--c); margin: 4px 0 2px; min-width: 2px; }
|
|
83
|
+
.reason .text { font-size: 12px; color: var(--ink-2); }
|
|
84
|
+
.tag { font-size: 11px; color: var(--ink-2); border: 1px solid var(--border); border-radius: 999px; padding: 0 7px; white-space: nowrap; }
|
|
85
|
+
.empty { color: var(--muted); font-size: 13px; }
|
|
86
|
+
|
|
87
|
+
.scroll { overflow-x: auto; margin: 0 -16px -16px; }
|
|
88
|
+
table { border-collapse: collapse; width: 100%; font-size: 12.5px; font-variant-numeric: tabular-nums; }
|
|
89
|
+
th, td { text-align: left; padding: 7px 8px; white-space: nowrap; border-top: 1px solid var(--grid); }
|
|
90
|
+
th { color: var(--ink-2); font-weight: 600; border-top: 0; }
|
|
91
|
+
th:first-child, td:first-child { padding-left: 16px; }
|
|
92
|
+
th:last-child, td:last-child { padding-right: 16px; }
|
|
93
|
+
td.num, th.num { text-align: right; }
|
|
94
|
+
td.reason-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; color: var(--ink-2); }
|
|
95
|
+
tr.before td { opacity: 0.55; }
|
|
96
|
+
tbody tr:hover td { background: var(--wash); }
|
|
97
|
+
.note { font-size: 12px; color: var(--muted); margin: 0 0 12px; }
|
|
98
|
+
</style>
|
|
99
|
+
</head>
|
|
100
|
+
<body>
|
|
101
|
+
<main>
|
|
102
|
+
<header>
|
|
103
|
+
<h1>jev-gateway</h1>
|
|
104
|
+
<span class="live" id="live">connecting…</span>
|
|
105
|
+
<span class="spacer"></span>
|
|
106
|
+
<div class="seg" id="clients" role="group" aria-label="Client"></div>
|
|
107
|
+
<div class="seg" id="ranges" role="group" aria-label="Time range"></div>
|
|
108
|
+
</header>
|
|
109
|
+
|
|
110
|
+
<section class="cols" id="routers" aria-label="Router status"></section>
|
|
111
|
+
<section class="tiles" id="tiles" aria-label="Totals"></section>
|
|
112
|
+
|
|
113
|
+
<section class="cols">
|
|
114
|
+
<div class="card">
|
|
115
|
+
<h2>What happened to each request</h2>
|
|
116
|
+
<div class="share" id="share"></div>
|
|
117
|
+
<div class="legend" id="legend"></div>
|
|
118
|
+
<div class="timeline" id="timeline"></div>
|
|
119
|
+
<div class="axis" id="axis"></div>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="card">
|
|
122
|
+
<h2>Why requests were not routed</h2>
|
|
123
|
+
<div class="reasons" id="reasons"></div>
|
|
124
|
+
</div>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section class="card">
|
|
128
|
+
<h2>Token use: with Jev routing vs. baseline</h2>
|
|
129
|
+
<p class="note" id="compare-note"></p>
|
|
130
|
+
<div class="scroll"><table class="compare" id="compare"></table></div>
|
|
131
|
+
</section>
|
|
132
|
+
|
|
133
|
+
<section class="card">
|
|
134
|
+
<h2>Recent requests</h2>
|
|
135
|
+
<p class="note" id="note"></p>
|
|
136
|
+
<div class="scroll"><table>
|
|
137
|
+
<thead><tr>
|
|
138
|
+
<th>Time</th><th>Client</th><th>Path</th><th>Model</th><th class="num">Tools</th><th>Mode</th><th>Tool</th>
|
|
139
|
+
<th class="num">Conf.</th><th>Reason</th><th class="num">Status</th><th class="num">Jev ms</th>
|
|
140
|
+
<th class="num">In</th><th class="num">Out</th><th class="num">Secs</th>
|
|
141
|
+
</tr></thead>
|
|
142
|
+
<tbody id="rows"></tbody>
|
|
143
|
+
</table></div>
|
|
144
|
+
</section>
|
|
145
|
+
</main>
|
|
146
|
+
<div id="tip" role="status"></div>
|
|
147
|
+
|
|
148
|
+
<script>
|
|
149
|
+
"use strict";
|
|
150
|
+
// Everything shown here comes from /dashboard/events, which carries request metadata only. Names
|
|
151
|
+
// in it (models, tools, reasons) are still someone else's strings: they reach the DOM through
|
|
152
|
+
// textContent, never innerHTML.
|
|
153
|
+
|
|
154
|
+
const POLL_MS = 2000;
|
|
155
|
+
const IDLE_MS = 5 * 60 * 1000;
|
|
156
|
+
const MAX_ROWS = 200;
|
|
157
|
+
const KEPT_PER_ROUTER = 1000;
|
|
158
|
+
const JEV_USD_PER_MILLION_INPUT_TOKENS = 0.042;
|
|
159
|
+
const RANGES = [["15 min", 15 * 60e3], ["1 h", 60 * 60e3], ["24 h", 24 * 60 * 60e3], ["All", Infinity]];
|
|
160
|
+
const CATEGORIES = [
|
|
161
|
+
["direct", "direct — answered without the LLM"],
|
|
162
|
+
["forced", "forced — tool_choice set to Jev's pick"],
|
|
163
|
+
["hint", "hint — Jev's pick suggested to the LLM"],
|
|
164
|
+
["none", "none — tool_choice none"],
|
|
165
|
+
["passthrough", "passthrough — the LLM decided"],
|
|
166
|
+
["failed", "passthrough — Jev failed"],
|
|
167
|
+
];
|
|
168
|
+
// [who made the call, what it means]
|
|
169
|
+
const REASONS = {
|
|
170
|
+
no_tools: ["Jev not asked", "The request carried no tools, so there was nothing to select."],
|
|
171
|
+
no_messages: ["Jev not asked", "The request had no conversation to decide on."],
|
|
172
|
+
previous_response_id: ["Jev not asked", "The conversation lives upstream (previous_response_id); the router can't see it."],
|
|
173
|
+
non_function_tools: ["Jev not asked", "The roster has tools that aren't plain functions."],
|
|
174
|
+
too_many_tools: ["Jev not asked", "The tool roster is larger than the router can shortlist."],
|
|
175
|
+
duplicate_tool_names: ["Jev not asked", "Two tools share a name."],
|
|
176
|
+
reserved_tool_name: ["Jev not asked", "A tool is named no_tool_needed, which the router reserves."],
|
|
177
|
+
tool_choice_already_decided: ["Jev not asked", "The client already fixed tool_choice itself."],
|
|
178
|
+
routing_disabled: ["Jev not asked", "Routing is switched off (baseline mode): tokens are metered, Jev is never asked."],
|
|
179
|
+
disabled_by_header: ["Jev not asked", "The client sent x-jev-gateway: off."],
|
|
180
|
+
unparseable_body: ["Jev not asked", "The body wasn't readable JSON; upstream gets to reject it."],
|
|
181
|
+
low_confidence: ["Jev asked", "Jev's confidence was below JEV_MIN_CONFIDENCE."],
|
|
182
|
+
jev_answers_disagree: ["Jev asked", "The tool pick and the needs-a-tool check contradicted each other."],
|
|
183
|
+
no_tool_needed: ["Jev asked", "Jev said no tool is needed; left to the LLM (hint-only request, or JEV_ON_NONE=passthrough)."],
|
|
184
|
+
hosted_tool_selected: ["Jev asked", "Jev picked a provider-run tool, which can't be forced by name."],
|
|
185
|
+
namespaced_tool_selected: ["Jev asked", "Jev picked a namespaced tool, which backends refuse to force."],
|
|
186
|
+
jev_unknown_tool: ["Jev asked", "Jev named a tool that isn't in the roster."],
|
|
187
|
+
upstream_rejected: ["Jev asked", "Upstream refused the rewritten request; the original was replayed."],
|
|
188
|
+
jev_error: ["Jev failed", "The Jev call errored or timed out. Check TYPESAFE_API_KEY and the network."],
|
|
189
|
+
jev_unexpected_answer: ["Jev failed", "Jev's reply didn't contain the expected answers."],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const params = new URLSearchParams(location.search);
|
|
193
|
+
const key = params.get("key");
|
|
194
|
+
const ownPort = location.port || (location.protocol === "https:" ? "443" : "80");
|
|
195
|
+
// The launchers' default ports and the standalone server's; `?peers=8795,8796` adds custom ones.
|
|
196
|
+
const ports = new Set([ownPort, "8787", "8789", "8790", ...(params.get("peers") ?? "").split(",").filter(Boolean)]);
|
|
197
|
+
const sources = [...ports].map((port) => ({
|
|
198
|
+
port,
|
|
199
|
+
own: port === ownPort,
|
|
200
|
+
// Another router has its own key, if any; ours is only ever sent back to where it came from.
|
|
201
|
+
url: port === ownPort ? "/dashboard/events?" + (key ? "key=" + encodeURIComponent(key) + "&" : "") : `${location.protocol}//${location.hostname}:${port}/dashboard/events?`,
|
|
202
|
+
// Same rule for the routing switch: a bodyless POST, so crossing ports needs no preflight.
|
|
203
|
+
routingUrl: port === ownPort ? "/dashboard/routing?" + (key ? "key=" + encodeURIComponent(key) + "&" : "") : `${location.protocol}//${location.hostname}:${port}/dashboard/routing?`,
|
|
204
|
+
events: [], lastSeq: 0, router: undefined, up: false, seen: false, misses: 0,
|
|
205
|
+
}));
|
|
206
|
+
|
|
207
|
+
const view = { client: "all", range: RANGES[1][1] };
|
|
208
|
+
let signature = "";
|
|
209
|
+
|
|
210
|
+
const $ = (id) => document.getElementById(id);
|
|
211
|
+
function h(tag, attrs, ...children) {
|
|
212
|
+
const node = document.createElement(tag);
|
|
213
|
+
for (const [name, value] of Object.entries(attrs ?? {})) if (value !== undefined) node.setAttribute(name, value);
|
|
214
|
+
node.append(...children.filter((child) => child !== undefined && child !== ""));
|
|
215
|
+
return node;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const reasonKey = (reason) => (reason.startsWith("jev_error") ? "jev_error" : reason.startsWith("upstream_rejected") ? "upstream_rejected" : reason);
|
|
219
|
+
const failed = (event) => event.mode === "passthrough" && (REASONS[reasonKey(event.reason ?? "")] ?? [])[0] === "Jev failed";
|
|
220
|
+
const category = (event) => (failed(event) ? "failed" : event.mode);
|
|
221
|
+
const routed = (event) => event.mode !== "passthrough";
|
|
222
|
+
const baseline = (event) => event.reason === "routing_disabled";
|
|
223
|
+
const at = (event) => Date.parse(event.time);
|
|
224
|
+
|
|
225
|
+
const int = (n) => Math.round(n).toLocaleString("en-US");
|
|
226
|
+
const pct = (part, whole) => (whole ? Math.round((100 * part) / whole) + "%" : "–");
|
|
227
|
+
const clock = (ms, withDate) => new Date(ms).toLocaleString([], withDate ? { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" } : { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
228
|
+
function ago(ms) {
|
|
229
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
230
|
+
if (s < 90) return s + " s";
|
|
231
|
+
if (s < 90 * 60) return Math.round(s / 60) + " min";
|
|
232
|
+
if (s < 36 * 3600) return Math.round(s / 3600) + " h";
|
|
233
|
+
return Math.round(s / 86400) + " d";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function poll(source) {
|
|
237
|
+
// A port nobody ever answered on is probably just not in use: look again, but rarely.
|
|
238
|
+
if (!source.own && !source.seen && source.misses++ % 5 !== 0) return;
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(source.url + "since=" + source.lastSeq, { signal: AbortSignal.timeout(1500) });
|
|
241
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
242
|
+
const data = await response.json();
|
|
243
|
+
if (source.router && source.router.startedAt !== data.router.startedAt && source.lastSeq > 0) {
|
|
244
|
+
// The router restarted and its numbering with it: what we asked for makes no sense to it.
|
|
245
|
+
Object.assign(source, { events: [], lastSeq: 0, router: data.router });
|
|
246
|
+
return poll(source);
|
|
247
|
+
}
|
|
248
|
+
source.router = data.router;
|
|
249
|
+
source.events = source.events.concat(data.events.map((event) => ({ ...event, client: data.router.client, port: source.port }))).slice(-KEPT_PER_ROUTER);
|
|
250
|
+
source.lastSeq = data.events.length ? data.events.at(-1).seq : source.lastSeq;
|
|
251
|
+
source.up = source.seen = true;
|
|
252
|
+
} catch {
|
|
253
|
+
source.up = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function status(source, now) {
|
|
258
|
+
if (!source.up) return ["offline", "×", "Offline", "This router isn't answering. Start it again to keep routing."];
|
|
259
|
+
const recent = source.events.filter((event) => now - at(event) < IDLE_MS);
|
|
260
|
+
if (source.router?.routing === false) {
|
|
261
|
+
return ["baseline", "◌", "Baseline — routing off", `Jev is switched off: ${recent.length} request${recent.length === 1 ? "" : "s"} in the last 5 minutes went straight to the LLM, metered for comparison.`];
|
|
262
|
+
}
|
|
263
|
+
if (recent.length === 0) {
|
|
264
|
+
const last = source.events.at(-1);
|
|
265
|
+
return ["idle", "–", "Idle", "No requests in the last 5 minutes" + (last ? `; the last one was ${ago(now - at(last))} ago (${last.mode}).` : ".")];
|
|
266
|
+
}
|
|
267
|
+
const asked = recent.filter((event) => event.jev || failed(event));
|
|
268
|
+
if (asked.length && failed(asked.at(-1))) {
|
|
269
|
+
return ["failing", "!", "Jev is failing", `The latest Jev call failed (${asked.at(-1).reason}). Requests fall back to the LLM.`];
|
|
270
|
+
}
|
|
271
|
+
const decided = recent.filter(routed).length;
|
|
272
|
+
if (decided) return ["routing", "✓", "Routing", `Jev decided ${decided} of the last ${recent.length} requests (5 min).`];
|
|
273
|
+
const reasons = tally(recent.map((event) => reasonKey(event.reason ?? "unknown")));
|
|
274
|
+
return ["passthrough", "→", "Passthrough only", `None of the last ${recent.length} requests were routed — mostly ${reasons[0][0]}.`];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function tally(keys) {
|
|
278
|
+
const counts = new Map();
|
|
279
|
+
for (const name of keys) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
280
|
+
return [...counts].sort((a, b) => b[1] - a[1]);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function setRouting(source, enabled) {
|
|
284
|
+
try {
|
|
285
|
+
const response = await fetch(source.routingUrl + "enabled=" + enabled, { method: "POST", signal: AbortSignal.timeout(1500) });
|
|
286
|
+
if (response.ok) source.router.routing = (await response.json()).routing;
|
|
287
|
+
} catch {
|
|
288
|
+
// The next poll shows the router's real state either way.
|
|
289
|
+
}
|
|
290
|
+
signature = "";
|
|
291
|
+
render();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderRouters(now) {
|
|
295
|
+
$("routers").replaceChildren(...sources.filter((source) => source.seen || source.own).map((source) => {
|
|
296
|
+
const [state, icon, title, why] = status(source, now);
|
|
297
|
+
const router = source.router;
|
|
298
|
+
return h("div", { class: `card router s-${state}` },
|
|
299
|
+
h("div", { class: "who" }, `${router?.client ?? "router"} · :${source.port}`),
|
|
300
|
+
h("div", { class: "state" }, h("span", { class: "icon", "aria-hidden": "true" }, icon), title),
|
|
301
|
+
h("div", { class: "why" }, why),
|
|
302
|
+
router && source.up && routingSwitch(source),
|
|
303
|
+
router && h("div", { class: "meta" }, `→ ${router.upstream} · Jev model ${router.jevModel} · min confidence ${router.minConfidence} · up ${ago(now - Date.parse(router.startedAt))} · ${int(router.recorded)} requests logged`),
|
|
304
|
+
);
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function routingSwitch(source) {
|
|
309
|
+
const on = source.router.routing !== false;
|
|
310
|
+
const node = h("button", { type: "button", class: "switch" }, on ? "Switch routing off (measure a baseline)" : "Switch routing back on");
|
|
311
|
+
node.addEventListener("click", () => setRouting(source, !on));
|
|
312
|
+
return node;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sum = (events, pick) => events.reduce((total, event) => total + (pick(event) ?? 0), 0);
|
|
316
|
+
|
|
317
|
+
// Both columns cover everything that went through in that state — passthroughs included — because
|
|
318
|
+
// that is what each way of working really costs. It only means something if the work is alike.
|
|
319
|
+
function renderCompare(events) {
|
|
320
|
+
const metered = events.filter((event) => event.usage);
|
|
321
|
+
const groups = [["With Jev routing", metered.filter((event) => !baseline(event))], ["Baseline (routing off)", metered.filter(baseline)]];
|
|
322
|
+
const per = (group, pick) => (group.length ? sum(group, pick) / group.length : undefined);
|
|
323
|
+
const rows = [
|
|
324
|
+
["LLM requests metered", (group) => group.length, int],
|
|
325
|
+
["Input tokens / request", (group) => per(group, (event) => event.usage.input), int],
|
|
326
|
+
["…of which cached", (group) => (sum(group, (event) => event.usage.input) ? (100 * sum(group, (event) => event.usage.cached)) / sum(group, (event) => event.usage.input) : undefined), (value) => Math.round(value) + "%"],
|
|
327
|
+
["Output tokens / request", (group) => per(group, (event) => event.usage.output), int],
|
|
328
|
+
["…of which reasoning", (group) => per(group, (event) => event.usage.reasoning), int],
|
|
329
|
+
["Seconds / request", (group) => per(group, (event) => event.durationMs / 1000), (value) => value.toFixed(1)],
|
|
330
|
+
["Total input tokens", (group) => sum(group, (event) => event.usage.input), int],
|
|
331
|
+
["Total output tokens", (group) => sum(group, (event) => event.usage.output), int],
|
|
332
|
+
];
|
|
333
|
+
const cell = (value, format) => (value === undefined || Number.isNaN(value) ? "–" : format(value));
|
|
334
|
+
$("compare").replaceChildren(
|
|
335
|
+
h("thead", {}, h("tr", {}, h("th", {}, ""), ...groups.map(([label]) => h("th", {}, label)), h("th", {}, "Difference"))),
|
|
336
|
+
h("tbody", {}, ...rows.map(([label, measure, format], index) => {
|
|
337
|
+
const [withJev, without] = groups.map(([, group]) => measure(group));
|
|
338
|
+
const comparable = index > 0 && index < 6 && withJev !== undefined && without;
|
|
339
|
+
const delta = comparable ? (100 * (withJev - without)) / without : undefined;
|
|
340
|
+
return h("tr", {}, h("td", {}, label), h("td", {}, cell(withJev, format)), h("td", {}, cell(without, format)),
|
|
341
|
+
h("td", { class: "delta" }, delta === undefined || !Number.isFinite(delta) ? "" : (delta > 0 ? "+" : "") + Math.round(delta) + "%"));
|
|
342
|
+
})),
|
|
343
|
+
);
|
|
344
|
+
const direct = events.filter((event) => event.mode === "direct").length;
|
|
345
|
+
$("compare-note").textContent = groups[1][1].length
|
|
346
|
+
? `Per-request averages in the selected range${direct ? `; ${int(direct)} direct answer${direct === 1 ? "" : "s"} cost no LLM tokens at all and are not in the averages` : ""}. Only meaningful when both columns did similar work.`
|
|
347
|
+
: "No baseline yet. Switch routing off on a router card, do a comparable piece of work, then switch it back on: both columns fill in.";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderTiles(events) {
|
|
351
|
+
const asked = events.filter((event) => event.jev);
|
|
352
|
+
const failures = events.filter(failed).length;
|
|
353
|
+
const latencies = asked.map((event) => event.jev.latencyMs).sort((a, b) => a - b);
|
|
354
|
+
const mean = (values) => (values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : undefined);
|
|
355
|
+
const tokens = asked.reduce((sum, event) => sum + event.jev.inputTokens, 0);
|
|
356
|
+
const decided = events.filter(routed).length;
|
|
357
|
+
const tile = (label, value, sub) => h("div", { class: "card tile" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
|
358
|
+
const avgLatency = mean(latencies);
|
|
359
|
+
const avgConfidence = mean(asked.map((event) => event.jev.confidence));
|
|
360
|
+
$("tiles").replaceChildren(
|
|
361
|
+
tile("Requests", int(events.length), "through the gateway"),
|
|
362
|
+
tile("Routed by Jev", pct(decided, events.length), `${int(decided)} requests`),
|
|
363
|
+
tile("Passed through", pct(events.length - decided, events.length), `${int(events.length - decided)} requests`),
|
|
364
|
+
tile("Jev calls", int(asked.length + failures), failures ? `${int(failures)} failed` : "none failed"),
|
|
365
|
+
tile("Jev latency", avgLatency === undefined ? "–" : int(avgLatency) + " ms", latencies.length ? `p95 ${int(latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * 0.95))])} ms` : "no calls yet"),
|
|
366
|
+
tile("Jev confidence", avgConfidence === undefined ? "–" : avgConfidence.toFixed(2), "average of its tool picks"),
|
|
367
|
+
tile("LLM input tokens", int(sum(events, (event) => event.usage?.input)), `${pct(sum(events, (event) => event.usage?.cached), sum(events, (event) => event.usage?.input))} served from cache`),
|
|
368
|
+
tile("LLM output tokens", int(sum(events, (event) => event.usage?.output)), `${int(sum(events, (event) => event.usage?.reasoning))} of them reasoning`),
|
|
369
|
+
tile("Jev input tokens", int(tokens), `≈ $${((tokens / 1e6) * JEV_USD_PER_MILLION_INPUT_TOKENS).toFixed(4)} at $${JEV_USD_PER_MILLION_INPUT_TOKENS}/M`),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function renderShare(events) {
|
|
374
|
+
const counts = new Map(tally(events.map(category)));
|
|
375
|
+
const present = CATEGORIES.filter(([name]) => counts.get(name));
|
|
376
|
+
$("share").replaceChildren(...present.map(([name, label]) => h("span", { class: `c-${name}`, style: `flex:${counts.get(name)};background:var(--c)`, title: `${label}: ${counts.get(name)}` })));
|
|
377
|
+
$("legend").replaceChildren(...(present.length ? present : CATEGORIES.slice(0, 5)).map(([name, label]) =>
|
|
378
|
+
h("span", {}, h("i", { class: `dot c-${name}` }), label + " ", h("b", {}, `${int(counts.get(name) ?? 0)} · ${pct(counts.get(name) ?? 0, events.length)}`))));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderTimeline(events, now) {
|
|
382
|
+
const BUCKETS = 36;
|
|
383
|
+
const from = view.range === Infinity ? Math.min(now - RANGES[0][1], ...events.map(at)) : now - view.range;
|
|
384
|
+
const width = (now - from) / BUCKETS;
|
|
385
|
+
const buckets = Array.from({ length: BUCKETS }, (_, index) => ({ start: from + index * width, counts: new Map(), total: 0 }));
|
|
386
|
+
for (const event of events) {
|
|
387
|
+
const bucket = buckets[Math.min(BUCKETS - 1, Math.floor((at(event) - from) / width))];
|
|
388
|
+
if (!bucket) continue;
|
|
389
|
+
bucket.counts.set(category(event), (bucket.counts.get(category(event)) ?? 0) + 1);
|
|
390
|
+
bucket.total++;
|
|
391
|
+
}
|
|
392
|
+
const peak = Math.max(1, ...buckets.map((bucket) => bucket.total));
|
|
393
|
+
const long = now - from > 20 * 3600e3;
|
|
394
|
+
$("timeline").replaceChildren(...buckets.map((bucket) => {
|
|
395
|
+
const col = h("div", { class: "col", tabindex: "0", "aria-label": `${clock(bucket.start, long)}: ${bucket.total} requests` },
|
|
396
|
+
...CATEGORIES.filter(([name]) => bucket.counts.get(name)).map(([name]) => h("span", { class: `c-${name}`, style: `height:${(100 * bucket.counts.get(name)) / peak}%` })));
|
|
397
|
+
const show = () => showTip(col, [[`${clock(bucket.start, long)} – ${clock(bucket.start + width, long)}`, int(bucket.total)], ...CATEGORIES.filter(([name]) => bucket.counts.get(name)).map(([name]) => [name === "failed" ? "Jev failed" : name, int(bucket.counts.get(name))])]);
|
|
398
|
+
col.addEventListener("pointerenter", show);
|
|
399
|
+
col.addEventListener("focus", show);
|
|
400
|
+
col.addEventListener("pointerleave", hideTip);
|
|
401
|
+
col.addEventListener("blur", hideTip);
|
|
402
|
+
return col;
|
|
403
|
+
}));
|
|
404
|
+
$("axis").replaceChildren(h("span", {}, clock(from, long)), h("span", {}, `requests per ${ago(width)} · peak ${peak}`), h("span", {}, "now"));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function showTip(anchor, rows) {
|
|
408
|
+
const tip = $("tip");
|
|
409
|
+
tip.replaceChildren(...rows.map(([label, value]) => h("div", {}, h("span", {}, label), h("b", {}, value))));
|
|
410
|
+
tip.style.display = "block";
|
|
411
|
+
const box = anchor.getBoundingClientRect();
|
|
412
|
+
tip.style.left = Math.max(8, Math.min(innerWidth - tip.offsetWidth - 8, box.left + box.width / 2 - tip.offsetWidth / 2)) + "px";
|
|
413
|
+
tip.style.top = Math.max(8, box.top - tip.offsetHeight - 6) + "px";
|
|
414
|
+
}
|
|
415
|
+
const hideTip = () => ($("tip").style.display = "none");
|
|
416
|
+
|
|
417
|
+
function renderReasons(events) {
|
|
418
|
+
const passed = events.filter((event) => !routed(event));
|
|
419
|
+
const counts = tally(passed.map((event) => reasonKey(event.reason ?? "unknown")));
|
|
420
|
+
if (!counts.length) return $("reasons").replaceChildren(h("div", { class: "empty" }, events.length ? "Every request in this range was routed by Jev." : "No requests in this range yet."));
|
|
421
|
+
$("reasons").replaceChildren(...counts.map(([name, count]) => {
|
|
422
|
+
const [who, meaning] = REASONS[name] ?? ["", ""];
|
|
423
|
+
return h("div", { class: `reason c-${who === "Jev failed" ? "failed" : "passthrough"}` },
|
|
424
|
+
h("div", { class: "top" }, h("code", {}, name), who && h("span", { class: "tag" }, who), h("span", { class: "count" }, `${int(count)} · ${pct(count, passed.length)}`)),
|
|
425
|
+
h("div", { class: "bar", style: `width:${(100 * count) / counts[0][1]}%` }),
|
|
426
|
+
h("div", { class: "text" }, meaning));
|
|
427
|
+
}));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function renderRows(events) {
|
|
431
|
+
const started = new Map(sources.map((source) => [source.port, Date.parse(source.router?.startedAt ?? 0)]));
|
|
432
|
+
const today = new Date().toDateString();
|
|
433
|
+
$("rows").replaceChildren(...events.slice(-MAX_ROWS).reverse().map((event) => {
|
|
434
|
+
const name = category(event);
|
|
435
|
+
return h("tr", { class: at(event) < started.get(event.port) ? "before" : undefined },
|
|
436
|
+
h("td", {}, clock(at(event), new Date(at(event)).toDateString() !== today)),
|
|
437
|
+
h("td", {}, event.client),
|
|
438
|
+
h("td", {}, event.path),
|
|
439
|
+
h("td", {}, event.model ?? "–"),
|
|
440
|
+
h("td", { class: "num" }, String(event.tools)),
|
|
441
|
+
h("td", {}, h("i", { class: `dot c-${name}` }), event.mode),
|
|
442
|
+
h("td", {}, event.tool ?? (event.jev && event.mode === "passthrough" ? `(${event.jev.choice})` : "–")),
|
|
443
|
+
h("td", { class: "num" }, (event.confidence ?? event.jev?.confidence)?.toFixed(2) ?? "–"),
|
|
444
|
+
h("td", { class: "reason-cell", title: event.reason }, event.reason ?? ""),
|
|
445
|
+
h("td", { class: "num" }, event.status === undefined ? "–" : String(event.status)),
|
|
446
|
+
h("td", { class: "num" }, event.jev ? int(event.jev.latencyMs) : "–"),
|
|
447
|
+
h("td", { class: "num", title: event.usage ? `${int(event.usage.cached)} cached, ${int(event.usage.cacheWrite)} written to cache` : undefined }, event.usage ? int(event.usage.input) : "–"),
|
|
448
|
+
h("td", { class: "num", title: event.usage ? `${int(event.usage.reasoning)} reasoning` : undefined }, event.usage ? int(event.usage.output) : "–"),
|
|
449
|
+
h("td", { class: "num" }, event.durationMs === undefined ? "–" : (event.durationMs / 1000).toFixed(1)));
|
|
450
|
+
}));
|
|
451
|
+
$("note").textContent = `Newest first, up to ${MAX_ROWS}. Dimmed rows are from before the router's last restart; a tool in brackets is what Jev picked when the LLM still made the call. A request appears once its reply has finished, which is when the provider reports its tokens (hover In/Out for cache and reasoning).`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function renderControls() {
|
|
455
|
+
const clients = [...new Set(sources.filter((source) => source.router).map((source) => source.router.client))];
|
|
456
|
+
const button = (label, pressed, press) => {
|
|
457
|
+
const node = h("button", { type: "button", "aria-pressed": String(pressed) }, label);
|
|
458
|
+
node.addEventListener("click", () => { press(); signature = ""; render(); });
|
|
459
|
+
return node;
|
|
460
|
+
};
|
|
461
|
+
$("clients").hidden = clients.length < 2;
|
|
462
|
+
$("clients").replaceChildren(...["all", ...clients].map((name) => button(name === "all" ? "All clients" : name, view.client === name, () => (view.client = name))));
|
|
463
|
+
$("ranges").replaceChildren(...RANGES.map(([label, ms]) => button(label, view.range === ms, () => (view.range = ms))));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function render() {
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
renderRouters(now);
|
|
469
|
+
const up = sources.filter((source) => source.up).length;
|
|
470
|
+
$("live").textContent = up ? `live · refreshed ${clock(now)}` : "not connected — retrying";
|
|
471
|
+
|
|
472
|
+
const events = sources
|
|
473
|
+
.flatMap((source) => source.events)
|
|
474
|
+
.filter((event) => (view.client === "all" || event.client === view.client) && now - at(event) <= view.range)
|
|
475
|
+
.sort((a, b) => at(a) - at(b));
|
|
476
|
+
// Rebuilding charts and rows under the pointer kills hover and text selection, so only do it
|
|
477
|
+
// when they would actually differ (new data, another filter, or the time axis moving on).
|
|
478
|
+
const next = [view.client, view.range, events.length, events.at(-1)?.time, sources.map((source) => `${source.router?.client}${source.router?.routing}`).join(), Math.floor(now / 30e3)].join("|");
|
|
479
|
+
if (next === signature) return;
|
|
480
|
+
signature = next;
|
|
481
|
+
renderControls();
|
|
482
|
+
renderTiles(events);
|
|
483
|
+
renderCompare(events);
|
|
484
|
+
renderShare(events);
|
|
485
|
+
renderTimeline(events, now);
|
|
486
|
+
renderReasons(events);
|
|
487
|
+
renderRows(events);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function tick() {
|
|
491
|
+
if (!document.hidden) {
|
|
492
|
+
await Promise.all(sources.map(poll));
|
|
493
|
+
render();
|
|
494
|
+
}
|
|
495
|
+
setTimeout(tick, POLL_MS);
|
|
496
|
+
}
|
|
497
|
+
tick();
|
|
498
|
+
</script>
|
|
499
|
+
</body>
|
|
500
|
+
</html>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
// A real .html file rather than a string in a module, so it stays editable as HTML; `pnpm build`
|
|
4
|
+
// copies it next to the compiled output.
|
|
5
|
+
const page = readFileSync(new URL("./dashboard.html", import.meta.url), "utf8");
|
|
6
|
+
/**
|
|
7
|
+
* jev-codex and jev-claude each run their own router, and one page should show both: a dashboard
|
|
8
|
+
* served by one router polls the others. Only a page that itself came from this machine may read
|
|
9
|
+
* across ports — any other origin gets no CORS header, so the browser withholds the response.
|
|
10
|
+
*/
|
|
11
|
+
const LOCAL_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
12
|
+
export function dashboardRoutes(config, events, routing) {
|
|
13
|
+
const startedAt = new Date().toISOString();
|
|
14
|
+
const routes = new Hono();
|
|
15
|
+
routes.get("/", (c) => c.html(page));
|
|
16
|
+
routes.get("/events", (c) => {
|
|
17
|
+
const origin = c.req.header("origin");
|
|
18
|
+
if (origin && LOCAL_ORIGIN.test(origin)) {
|
|
19
|
+
c.header("access-control-allow-origin", origin);
|
|
20
|
+
c.header("vary", "origin");
|
|
21
|
+
}
|
|
22
|
+
c.header("cache-control", "no-store");
|
|
23
|
+
const since = Number(c.req.query("since") ?? 0);
|
|
24
|
+
return c.json({
|
|
25
|
+
router: {
|
|
26
|
+
client: config.client,
|
|
27
|
+
upstream: config.upstreamBaseUrl,
|
|
28
|
+
jevModel: config.jevModel,
|
|
29
|
+
minConfidence: config.minConfidence,
|
|
30
|
+
routing: routing.get(),
|
|
31
|
+
// Sequence numbers restart with the process: a page that sees this change starts over.
|
|
32
|
+
startedAt,
|
|
33
|
+
now: new Date().toISOString(),
|
|
34
|
+
recorded: events.last,
|
|
35
|
+
},
|
|
36
|
+
events: events.since(Number.isFinite(since) ? since : 0),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Turn Jev routing on or off without a restart, to compare token use with and without it. A
|
|
41
|
+
* bodyless POST needs no CORS preflight, so the origin is checked here instead: browsers always
|
|
42
|
+
* name the origin of a cross-site POST, and only this machine's own pages may flip the switch.
|
|
43
|
+
*/
|
|
44
|
+
routes.post("/routing", (c) => {
|
|
45
|
+
const origin = c.req.header("origin");
|
|
46
|
+
if (origin && !LOCAL_ORIGIN.test(origin))
|
|
47
|
+
return c.json({ error: "local pages only" }, 403);
|
|
48
|
+
if (origin)
|
|
49
|
+
c.header("access-control-allow-origin", origin);
|
|
50
|
+
const enabled = c.req.query("enabled");
|
|
51
|
+
if (enabled !== "true" && enabled !== "false")
|
|
52
|
+
return c.json({ error: "enabled must be true or false" }, 400);
|
|
53
|
+
routing.set(enabled === "true");
|
|
54
|
+
return c.json({ routing: routing.get() });
|
|
55
|
+
});
|
|
56
|
+
return routes;
|
|
57
|
+
}
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
const DEFAULT_CAPACITY = 1000;
|
|
3
|
+
/** Enough of a log's tail to fill the buffer; a Claude Code line with a shortlist is ~1 kB. */
|
|
4
|
+
const HISTORY_BYTES = 2 * 1024 * 1024;
|
|
5
|
+
const text = (value, max = 200) => (typeof value === "string" ? value.slice(0, max) : undefined);
|
|
6
|
+
const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
|
7
|
+
function toEvent(entry, seq) {
|
|
8
|
+
const mode = text(entry.mode);
|
|
9
|
+
if (entry.event !== "route" || !mode)
|
|
10
|
+
return undefined;
|
|
11
|
+
const jev = entry.jev && typeof entry.jev === "object" ? entry.jev : undefined;
|
|
12
|
+
const usage = entry.usage && typeof entry.usage === "object" ? entry.usage : undefined;
|
|
13
|
+
return {
|
|
14
|
+
seq,
|
|
15
|
+
time: text(entry.time) ?? new Date().toISOString(),
|
|
16
|
+
path: text(entry.path) ?? "",
|
|
17
|
+
model: text(entry.model),
|
|
18
|
+
tools: number(entry.tools) ?? 0,
|
|
19
|
+
mode,
|
|
20
|
+
reason: text(entry.reason),
|
|
21
|
+
tool: text(entry.tool),
|
|
22
|
+
confidence: number(entry.confidence),
|
|
23
|
+
status: number(entry.status),
|
|
24
|
+
durationMs: number(entry.durationMs),
|
|
25
|
+
usage: usage && {
|
|
26
|
+
input: number(usage.input) ?? 0,
|
|
27
|
+
output: number(usage.output) ?? 0,
|
|
28
|
+
cached: number(usage.cached) ?? 0,
|
|
29
|
+
cacheWrite: number(usage.cacheWrite) ?? 0,
|
|
30
|
+
reasoning: number(usage.reasoning) ?? 0,
|
|
31
|
+
},
|
|
32
|
+
jev: jev && {
|
|
33
|
+
choice: text(jev.choice) ?? "",
|
|
34
|
+
confidence: number(jev.confidence) ?? 0,
|
|
35
|
+
latencyMs: number(jev.latencyMs) ?? 0,
|
|
36
|
+
inputTokens: number(jev.inputTokens) ?? 0,
|
|
37
|
+
shortlist: Array.isArray(jev.shortlist) ? jev.shortlist.flatMap((name) => text(name) ?? []) : undefined,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The last `bytes` of a JSON-lines file, parsed; anything unreadable is skipped. */
|
|
42
|
+
function readTail(file, bytes) {
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
fd = openSync(file, "r");
|
|
46
|
+
const { size } = fstatSync(fd);
|
|
47
|
+
const start = Math.max(0, size - bytes);
|
|
48
|
+
const buffer = Buffer.alloc(size - start);
|
|
49
|
+
readSync(fd, buffer, 0, buffer.length, start);
|
|
50
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
51
|
+
// Starting mid-file means starting mid-line.
|
|
52
|
+
if (start > 0)
|
|
53
|
+
lines.shift();
|
|
54
|
+
return lines.flatMap((line) => {
|
|
55
|
+
if (!line.startsWith("{"))
|
|
56
|
+
return [];
|
|
57
|
+
try {
|
|
58
|
+
return [JSON.parse(line)];
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// No history yet, or not ours to read: the dashboard just starts empty.
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (fd !== undefined)
|
|
71
|
+
closeSync(fd);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The newest route events, in memory. `historyFile` is a JSON-lines log of an earlier run (the
|
|
76
|
+
* launchers append the router's stdout to one): replayed once, so a restart doesn't blank the
|
|
77
|
+
* dashboard.
|
|
78
|
+
*/
|
|
79
|
+
export function createEventLog({ capacity = DEFAULT_CAPACITY, historyFile } = {}) {
|
|
80
|
+
const events = [];
|
|
81
|
+
let last = 0;
|
|
82
|
+
const record = (entry) => {
|
|
83
|
+
const event = toEvent(entry, last + 1);
|
|
84
|
+
if (!event)
|
|
85
|
+
return;
|
|
86
|
+
last = event.seq;
|
|
87
|
+
events.push(event);
|
|
88
|
+
if (events.length > capacity)
|
|
89
|
+
events.shift();
|
|
90
|
+
};
|
|
91
|
+
if (historyFile)
|
|
92
|
+
for (const entry of readTail(historyFile, HISTORY_BYTES))
|
|
93
|
+
record(entry);
|
|
94
|
+
return {
|
|
95
|
+
record,
|
|
96
|
+
since: (seq) => events.filter((event) => event.seq > seq),
|
|
97
|
+
get last() {
|
|
98
|
+
return last;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|