@osqd/bothandlerjs 0.3.0 → 0.4.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +157 -1
  2. package/README.md +1 -1
  3. package/dist/challenge/index.d.ts +18 -0
  4. package/dist/challenge/interaction.d.ts +215 -0
  5. package/dist/challenge/page.d.ts +18 -0
  6. package/dist/cli.cjs +653 -51
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +653 -51
  9. package/dist/cli.js.map +1 -1
  10. package/dist/core.d.ts +15 -0
  11. package/dist/corpus/index.cjs +20 -0
  12. package/dist/corpus/index.cjs.map +1 -1
  13. package/dist/corpus/index.js +20 -0
  14. package/dist/corpus/index.js.map +1 -1
  15. package/dist/dashboard/client/actions.d.ts +17 -0
  16. package/dist/dashboard/client/actor.d.ts +5 -0
  17. package/dist/dashboard/client/api.d.ts +15 -0
  18. package/dist/dashboard/client/app.d.ts +37 -0
  19. package/dist/dashboard/client/bars.d.ts +4 -0
  20. package/dist/dashboard/client/boot.d.ts +5 -0
  21. package/dist/dashboard/client/charts.d.ts +25 -0
  22. package/dist/dashboard/client/css.d.ts +11 -0
  23. package/dist/dashboard/client/dom.d.ts +55 -0
  24. package/dist/dashboard/client/draft.d.ts +46 -0
  25. package/dist/dashboard/client/feed.d.ts +6 -0
  26. package/dist/dashboard/client/format.d.ts +19 -0
  27. package/dist/dashboard/client/guard.d.ts +3 -0
  28. package/dist/dashboard/client/index.d.ts +1 -0
  29. package/dist/dashboard/client/outcome.d.ts +33 -0
  30. package/dist/dashboard/client/panels.d.ts +40 -0
  31. package/dist/dashboard/client/policy.d.ts +25 -0
  32. package/dist/dashboard/client/query.d.ts +42 -0
  33. package/dist/dashboard/client/ranges.d.ts +2 -0
  34. package/dist/dashboard/client/registry.d.ts +4 -0
  35. package/dist/dashboard/client/replay.d.ts +14 -0
  36. package/dist/dashboard/client/result.d.ts +10 -0
  37. package/dist/dashboard/client/store.d.ts +68 -0
  38. package/dist/dashboard/client/stream.d.ts +14 -0
  39. package/dist/dashboard/client/tester.d.ts +1 -0
  40. package/dist/dashboard/client/types.d.ts +69 -0
  41. package/dist/dashboard/client.generated.d.ts +1 -1
  42. package/dist/dashboard/page.d.ts +25 -0
  43. package/dist/dashboard/sections.d.ts +92 -0
  44. package/dist/dashboard/types.d.ts +2 -82
  45. package/dist/element/config.d.ts +135 -0
  46. package/dist/element/index.cjs +4844 -0
  47. package/dist/element/index.cjs.map +1 -0
  48. package/dist/element/index.d.ts +137 -0
  49. package/dist/element/index.js +4825 -0
  50. package/dist/element/index.js.map +1 -0
  51. package/dist/index.cjs +644 -51
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +635 -51
  55. package/dist/index.js.map +1 -1
  56. package/dist/metrics.d.ts +27 -0
  57. package/dist/policy/index.d.ts +1 -1
  58. package/dist/policy/presets.d.ts +47 -0
  59. package/docs/challenge/index.md +1 -0
  60. package/docs/challenge/interaction.md +325 -0
  61. package/docs/course/11-the-challenge.md +36 -1
  62. package/docs/course/13-operating-it.md +20 -2
  63. package/docs/index.md +2 -0
  64. package/docs/operations/dashboard.md +2 -0
  65. package/docs/operations/embedding.md +348 -0
  66. package/docs/operations/index.md +1 -0
  67. package/docs/policy/presets.md +53 -0
  68. package/docs/start/choosing-a-policy.md +1 -0
  69. package/package.json +8 -3
@@ -0,0 +1,4825 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/dashboard/client/css.ts
12
+ function cssEscape(value) {
13
+ return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\w-]/g, "\\$&");
14
+ }
15
+ var init_css = __esm({
16
+ "src/dashboard/client/css.ts"() {
17
+ "use strict";
18
+ }
19
+ });
20
+
21
+ // src/dashboard/client/dom.ts
22
+ var dom_exports = {};
23
+ __export(dom_exports, {
24
+ $: () => $,
25
+ byId: () => byId,
26
+ clear: () => clear,
27
+ css: () => css,
28
+ cssEscape: () => cssEscape,
29
+ el: () => el,
30
+ eventTarget: () => eventTarget,
31
+ isEmbedded: () => isEmbedded,
32
+ label: () => label,
33
+ rootNode: () => rootNode,
34
+ setRoot: () => setRoot,
35
+ setThemeHost: () => setThemeHost,
36
+ svgEl: () => svgEl,
37
+ svgText: () => svgText,
38
+ themeElement: () => themeElement
39
+ });
40
+ function el(tag, className, value) {
41
+ const node = document.createElement(tag);
42
+ if (className !== void 0 && className !== null && className !== "") node.className = className;
43
+ if (value !== void 0 && value !== null) node.textContent = String(value);
44
+ return node;
45
+ }
46
+ function svgEl(name, attributes = {}) {
47
+ const node = document.createElementNS("http://www.w3.org/2000/svg", name);
48
+ for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));
49
+ return node;
50
+ }
51
+ function svgText(attributes, text) {
52
+ const node = svgEl("text", attributes);
53
+ node.textContent = String(text);
54
+ return node;
55
+ }
56
+ function clear(node) {
57
+ while (node.firstChild) node.removeChild(node.firstChild);
58
+ }
59
+ function setRoot(node, host) {
60
+ root = node;
61
+ embedded = node !== globalThis.document;
62
+ if (host !== void 0) themeHost = host;
63
+ }
64
+ function setThemeHost(host) {
65
+ themeHost = host;
66
+ }
67
+ function isEmbedded() {
68
+ return embedded;
69
+ }
70
+ function eventTarget() {
71
+ return embedded ? root : globalThis;
72
+ }
73
+ function rootNode() {
74
+ return root;
75
+ }
76
+ function themeElement() {
77
+ return themeHost;
78
+ }
79
+ function $(id) {
80
+ const node = root.querySelector(`#${cssEscape(id)}`);
81
+ if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);
82
+ return node;
83
+ }
84
+ function byId(id) {
85
+ return $(id);
86
+ }
87
+ function css(name) {
88
+ return getComputedStyle(themeHost).getPropertyValue(name).trim();
89
+ }
90
+ function label(text, control) {
91
+ const node = document.createElement("label");
92
+ node.textContent = text;
93
+ const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;
94
+ if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {
95
+ if (single.id === "") single.id = `field-${++sequence}`;
96
+ node.htmlFor = single.id;
97
+ } else {
98
+ const group = Array.isArray(control) ? control : [control];
99
+ for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);
100
+ }
101
+ return node;
102
+ }
103
+ var root, themeHost, embedded, sequence;
104
+ var init_dom = __esm({
105
+ "src/dashboard/client/dom.ts"() {
106
+ "use strict";
107
+ init_css();
108
+ root = typeof document === "undefined" ? void 0 : document;
109
+ themeHost = typeof document === "undefined" ? void 0 : document.documentElement;
110
+ embedded = false;
111
+ sequence = 0;
112
+ }
113
+ });
114
+
115
+ // src/dashboard/client/boot.ts
116
+ var raw, BOOT, API, SECTIONS;
117
+ var init_boot = __esm({
118
+ "src/dashboard/client/boot.ts"() {
119
+ "use strict";
120
+ raw = globalThis.__BOOTSTRAP__;
121
+ BOOT = raw ?? {
122
+ base: "",
123
+ title: "bothandlerjs",
124
+ allowReset: false,
125
+ allowEdit: false,
126
+ allowGuardEdit: false,
127
+ allowActing: false,
128
+ peers: [],
129
+ sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },
130
+ links: []
131
+ };
132
+ API = BOOT.base;
133
+ SECTIONS = BOOT.sections;
134
+ }
135
+ });
136
+
137
+ // src/dashboard/client/app.ts
138
+ function toast(kind, title, detail = "") {
139
+ const node = el("div", `toast ${kind}`);
140
+ node.appendChild(el("b", null, title));
141
+ if (detail !== "") node.appendChild(el("span", null, detail));
142
+ const host = rootNode().querySelector("#toasts");
143
+ if (host === null) return;
144
+ host.appendChild(node);
145
+ setTimeout(() => node.remove(), 6e3);
146
+ }
147
+ function download(text, filename, type) {
148
+ const blob = new Blob([text], { type });
149
+ const url = URL.createObjectURL(blob);
150
+ const anchor = el("a");
151
+ anchor.href = url;
152
+ anchor.download = filename;
153
+ anchor.click();
154
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
155
+ }
156
+ function today() {
157
+ return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
158
+ }
159
+ var app;
160
+ var init_app = __esm({
161
+ "src/dashboard/client/app.ts"() {
162
+ "use strict";
163
+ init_dom();
164
+ app = {
165
+ draw: () => {
166
+ },
167
+ drawNow: () => {
168
+ },
169
+ showTab: () => {
170
+ },
171
+ syncUrl: () => {
172
+ }
173
+ };
174
+ }
175
+ });
176
+
177
+ // src/dashboard/client/outcome.ts
178
+ function outcome(entry) {
179
+ return actionKind(entry.action);
180
+ }
181
+ function actionKind(action) {
182
+ if (action === void 0) return "pending";
183
+ if (DENY.has(action)) return "deny";
184
+ if (MITIGATE.has(action)) return "mitigate";
185
+ return "allow";
186
+ }
187
+ function verdictBadge(entry) {
188
+ if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];
189
+ if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];
190
+ if (entry.verdict === "human") return ["b-human", "human"];
191
+ return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];
192
+ }
193
+ function provenBots(verdicts) {
194
+ return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);
195
+ }
196
+ var DENY, MITIGATE;
197
+ var init_outcome = __esm({
198
+ "src/dashboard/client/outcome.ts"() {
199
+ "use strict";
200
+ DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);
201
+ MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);
202
+ }
203
+ });
204
+
205
+ // src/dashboard/client/query.ts
206
+ function tokenize(input) {
207
+ const tokens = [];
208
+ let current = "";
209
+ let quoted = false;
210
+ for (const character of input) {
211
+ if (character === '"') {
212
+ quoted = !quoted;
213
+ continue;
214
+ }
215
+ if (!quoted && /\s/.test(character)) {
216
+ if (current !== "") tokens.push(current);
217
+ current = "";
218
+ continue;
219
+ }
220
+ current += character;
221
+ }
222
+ if (current !== "") tokens.push(current);
223
+ return tokens;
224
+ }
225
+ function parseQuery(input) {
226
+ const terms = [];
227
+ for (const token of tokenize(input.trim())) {
228
+ const negated = token.startsWith("-") || token.startsWith("!");
229
+ const body = negated ? token.slice(1) : token;
230
+ if (body === "") continue;
231
+ const colon = body.indexOf(":");
232
+ const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
233
+ const field2 = FIELDS[name];
234
+ if (colon === -1 || field2 === void 0) {
235
+ terms.push({ field: void 0, value: body.toLowerCase(), negated });
236
+ continue;
237
+ }
238
+ let value = body.slice(colon + 1).toLowerCase();
239
+ let compare;
240
+ if (NUMERIC.has(field2)) {
241
+ compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
242
+ if (compare !== "=") value = value.slice(1);
243
+ }
244
+ if (value === "") continue;
245
+ terms.push({ field: field2, value, negated, compare });
246
+ }
247
+ return terms;
248
+ }
249
+ function searchableText(entry) {
250
+ const parts = [
251
+ entry.method,
252
+ entry.path,
253
+ entry.actor,
254
+ entry.userAgent,
255
+ entry.verdict,
256
+ entry.botClass,
257
+ entry.identity ?? "",
258
+ entry.action ?? "",
259
+ entry.rule ?? "",
260
+ entry.requestId
261
+ ];
262
+ for (const item of entry.evidence) parts.push(item.detector, item.summary);
263
+ return parts.join(" ").toLowerCase();
264
+ }
265
+ function fieldValue(entry, field2) {
266
+ switch (field2) {
267
+ case "path":
268
+ return entry.path;
269
+ case "actor":
270
+ return entry.actor;
271
+ case "userAgent":
272
+ return entry.userAgent;
273
+ case "verdict":
274
+ return entry.verdict;
275
+ case "action":
276
+ return entry.action ?? "";
277
+ case "rule":
278
+ return entry.rule ?? "";
279
+ case "identity":
280
+ return entry.identity ?? "";
281
+ case "method":
282
+ return entry.method;
283
+ case "botClass":
284
+ return entry.botClass;
285
+ case "requestId":
286
+ return entry.requestId;
287
+ case "bypass":
288
+ return entry.bypass ?? "";
289
+ case "outcome":
290
+ return outcome(entry);
291
+ case "certain":
292
+ return String(entry.certain);
293
+ case "detector":
294
+ return entry.evidence.map((item) => item.detector).join(" ");
295
+ default:
296
+ return "";
297
+ }
298
+ }
299
+ function matchesTerm(term, entry, haystack) {
300
+ if (term.field === void 0) return haystack.includes(term.value);
301
+ if (term.field === "score") {
302
+ const wanted = Number(term.value);
303
+ if (Number.isNaN(wanted)) return false;
304
+ if (term.compare === ">") return entry.score > wanted;
305
+ if (term.compare === "<") return entry.score < wanted;
306
+ return entry.score === wanted;
307
+ }
308
+ return fieldValue(entry, term.field).toLowerCase().includes(term.value);
309
+ }
310
+ function matchesQuery(terms, entry, haystack) {
311
+ for (const term of terms) {
312
+ if (matchesTerm(term, entry, haystack) === term.negated) return false;
313
+ }
314
+ return true;
315
+ }
316
+ function matchesFilter(filter, entry) {
317
+ switch (filter) {
318
+ case "proven":
319
+ return entry.certain;
320
+ case "suspected":
321
+ return entry.verdict === "suspected-bot";
322
+ case "human":
323
+ return entry.verdict === "human";
324
+ case "guard":
325
+ return entry.downgradedFrom !== void 0;
326
+ case "deny":
327
+ return outcome(entry) === "deny";
328
+ case "mitigate":
329
+ return outcome(entry) === "mitigate";
330
+ case "allow":
331
+ return outcome(entry) === "allow";
332
+ default:
333
+ return true;
334
+ }
335
+ }
336
+ var FIELDS, NUMERIC;
337
+ var init_query = __esm({
338
+ "src/dashboard/client/query.ts"() {
339
+ "use strict";
340
+ init_outcome();
341
+ FIELDS = {
342
+ path: "path",
343
+ url: "path",
344
+ actor: "actor",
345
+ ip: "actor",
346
+ ua: "userAgent",
347
+ useragent: "userAgent",
348
+ agent: "userAgent",
349
+ verdict: "verdict",
350
+ action: "action",
351
+ rule: "rule",
352
+ detector: "detector",
353
+ identity: "identity",
354
+ method: "method",
355
+ class: "botClass",
356
+ botclass: "botClass",
357
+ id: "requestId",
358
+ request: "requestId",
359
+ bypass: "bypass",
360
+ score: "score",
361
+ outcome: "outcome",
362
+ certain: "certain"
363
+ };
364
+ NUMERIC = /* @__PURE__ */ new Set(["score"]);
365
+ }
366
+ });
367
+
368
+ // src/dashboard/client/store.ts
369
+ function ingest(entry) {
370
+ const existing = state.byId.get(entry.requestId);
371
+ if (existing !== void 0) {
372
+ existing.entry = entry;
373
+ existing.rev++;
374
+ existing.text = void 0;
375
+ return;
376
+ }
377
+ const row = { entry, rev: 0 };
378
+ state.byId.set(entry.requestId, row);
379
+ state.rows.push(row);
380
+ if (state.rows.length > MAX_ROWS) {
381
+ for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {
382
+ state.byId.delete(dropped.entry.requestId);
383
+ state.open.delete(dropped.entry.requestId);
384
+ }
385
+ }
386
+ }
387
+ function clearFeed() {
388
+ state.rows = [];
389
+ state.byId = /* @__PURE__ */ new Map();
390
+ state.open.clear();
391
+ state.actor = void 0;
392
+ state.bufferedWhilePaused = 0;
393
+ state.laggedDrops = 0;
394
+ }
395
+ function setSearch(value) {
396
+ state.search = value;
397
+ state.terms = parseQuery(value);
398
+ }
399
+ function textOf(row) {
400
+ if (row.text === void 0) row.text = searchableText(row.entry);
401
+ return row.text;
402
+ }
403
+ function matches(row) {
404
+ return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));
405
+ }
406
+ function visibleRows(limit = FEED_LIMIT) {
407
+ const shown = [];
408
+ for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
409
+ const row = state.rows[i];
410
+ if (row !== void 0 && matches(row)) shown.push(row);
411
+ }
412
+ return shown;
413
+ }
414
+ function matchingCount() {
415
+ let count = 0;
416
+ for (const row of state.rows) if (matches(row)) count++;
417
+ return count;
418
+ }
419
+ function oldestAt() {
420
+ return state.rows[0]?.entry.at;
421
+ }
422
+ function bump(counter, key) {
423
+ counter.set(key, (counter.get(key) ?? 0) + 1);
424
+ }
425
+ function aggregate(rows) {
426
+ const totals = {
427
+ detectors: /* @__PURE__ */ new Map(),
428
+ actors: /* @__PURE__ */ new Map(),
429
+ identities: /* @__PURE__ */ new Map(),
430
+ paths: /* @__PURE__ */ new Map(),
431
+ deniedPaths: /* @__PURE__ */ new Map(),
432
+ guardStops: /* @__PURE__ */ new Map(),
433
+ ruleHits: /* @__PURE__ */ new Map(),
434
+ bypassed: /* @__PURE__ */ new Map()
435
+ };
436
+ for (const { entry } of rows) {
437
+ for (const item of entry.evidence) bump(totals.detectors, item.detector);
438
+ bump(totals.actors, entry.actor);
439
+ if (entry.bypass !== void 0) {
440
+ bump(totals.bypassed, `${entry.path} (${entry.bypass})`);
441
+ continue;
442
+ }
443
+ bump(totals.paths, entry.path);
444
+ if (entry.identity !== void 0 && entry.identity !== "") {
445
+ bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);
446
+ }
447
+ if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);
448
+ if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);
449
+ if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);
450
+ }
451
+ return totals;
452
+ }
453
+ var MAX_ROWS, FEED_LIMIT, state;
454
+ var init_store = __esm({
455
+ "src/dashboard/client/store.ts"() {
456
+ "use strict";
457
+ init_query();
458
+ init_outcome();
459
+ MAX_ROWS = 1e3;
460
+ FEED_LIMIT = 300;
461
+ state = {
462
+ rows: [],
463
+ byId: /* @__PURE__ */ new Map(),
464
+ snapshot: void 0,
465
+ policy: void 0,
466
+ actors: [],
467
+ actorsTracked: 0,
468
+ paused: false,
469
+ filter: "all",
470
+ search: "",
471
+ terms: [],
472
+ tab: "live",
473
+ open: /* @__PURE__ */ new Set(),
474
+ actor: void 0,
475
+ rangeMs: 3e5,
476
+ scoreScope: "run",
477
+ editorRules: [],
478
+ editorDirty: false,
479
+ editorMode: "gui",
480
+ guardDirty: false,
481
+ bufferedWhilePaused: 0,
482
+ laggedDrops: 0
483
+ };
484
+ }
485
+ });
486
+
487
+ // src/dashboard/client/api.ts
488
+ async function getJson(path) {
489
+ const response = await fetch(API + path);
490
+ if (!response.ok) throw new Error(await errorFrom(response));
491
+ return await response.json();
492
+ }
493
+ async function postJson(path, body) {
494
+ const response = await fetch(API + path, {
495
+ method: "POST",
496
+ headers: { "content-type": "application/json" },
497
+ body: JSON.stringify(body)
498
+ });
499
+ const data = await response.json().catch(() => ({}));
500
+ return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };
501
+ }
502
+ async function errorFrom(response) {
503
+ const body = await response.json().catch(() => ({}));
504
+ return body.error ?? `${response.status} ${response.statusText}`;
505
+ }
506
+ var init_api = __esm({
507
+ "src/dashboard/client/api.ts"() {
508
+ "use strict";
509
+ init_boot();
510
+ }
511
+ });
512
+
513
+ // src/dashboard/client/format.ts
514
+ function n(value) {
515
+ return numbers.format(value ?? 0);
516
+ }
517
+ function pct(part, whole) {
518
+ return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";
519
+ }
520
+ function ms(value) {
521
+ return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;
522
+ }
523
+ function uptime(milliseconds) {
524
+ const seconds = Math.max(0, Math.round(milliseconds / 1e3));
525
+ if (seconds < 90) return `${seconds}s`;
526
+ const minutes = Math.round(seconds / 60);
527
+ if (minutes < 90) return `${minutes}m`;
528
+ return `${Math.round(minutes / 60)}h`;
529
+ }
530
+ function rangeLabel(milliseconds) {
531
+ const seconds = Math.round(milliseconds / 1e3);
532
+ if (seconds < 90) return `${seconds}s`;
533
+ const minutes = Math.round(seconds / 60);
534
+ return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;
535
+ }
536
+ function clockTime(at) {
537
+ return new Date(at).toLocaleTimeString();
538
+ }
539
+ function windowLabel(count, oldestAt2, now) {
540
+ if (count === 0) return "this window \xB7 empty";
541
+ const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);
542
+ return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;
543
+ }
544
+ var numbers;
545
+ var init_format = __esm({
546
+ "src/dashboard/client/format.ts"() {
547
+ "use strict";
548
+ numbers = new Intl.NumberFormat();
549
+ }
550
+ });
551
+
552
+ // src/dashboard/client/replay.ts
553
+ function replayLine(entry) {
554
+ const headers = {};
555
+ for (const [name, value] of entry.headers ?? []) headers[name] = value;
556
+ const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");
557
+ return JSON.stringify({
558
+ method: entry.method,
559
+ url: entry.path + (query === "" ? "" : `?${query}`),
560
+ headers,
561
+ ip: entry.actor,
562
+ timestamp: new Date(entry.at).toISOString(),
563
+ protocol: entry.protocol ?? "https",
564
+ httpVersion: entry.httpVersion ?? "1.1"
565
+ });
566
+ }
567
+ function replayFile(entries) {
568
+ return entries.map(replayLine).join("\n");
569
+ }
570
+ function corpusCase(entry) {
571
+ const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\n");
572
+ return [
573
+ "bot({",
574
+ ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,
575
+ ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,
576
+ ' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure',
577
+ ' category: "observed",',
578
+ ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,
579
+ " requests: [",
580
+ " {",
581
+ " headers: [",
582
+ headers,
583
+ " ],",
584
+ ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,
585
+ ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,
586
+ ` path: ${JSON.stringify(entry.path)},`,
587
+ " },",
588
+ " ],",
589
+ ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,
590
+ "}),"
591
+ ].join("\n");
592
+ }
593
+ var init_replay = __esm({
594
+ "src/dashboard/client/replay.ts"() {
595
+ "use strict";
596
+ }
597
+ });
598
+
599
+ // src/dashboard/client/actions.ts
600
+ function isConfirming() {
601
+ return armed > 0;
602
+ }
603
+ function actorActions(key, after) {
604
+ if (!BOOT.allowActing) return [];
605
+ const forget = el("button", null, "Forget");
606
+ forget.title = "Discard this actor's history \u2014 the cure for a false positive that has stuck";
607
+ forget.addEventListener("click", () => {
608
+ void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);
609
+ });
610
+ const clear2 = el("button", null, "Clear as human");
611
+ clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";
612
+ clear2.addEventListener("click", () => {
613
+ void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);
614
+ });
615
+ return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];
616
+ }
617
+ function confirmingButton(label2, confirmation, run2) {
618
+ const button = el("button", "danger", label2);
619
+ let pending2 = false;
620
+ let timer2;
621
+ const disarm = () => {
622
+ if (!pending2) return;
623
+ pending2 = false;
624
+ armed--;
625
+ button.textContent = label2;
626
+ button.className = "danger";
627
+ };
628
+ button.addEventListener("click", () => {
629
+ if (pending2) {
630
+ if (timer2 !== void 0) clearTimeout(timer2);
631
+ disarm();
632
+ run2();
633
+ return;
634
+ }
635
+ pending2 = true;
636
+ armed++;
637
+ button.textContent = confirmation;
638
+ button.className = "danger primary";
639
+ timer2 = setTimeout(disarm, 5e3);
640
+ });
641
+ return button;
642
+ }
643
+ async function allowlist(key, after) {
644
+ const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });
645
+ if (!result.ok) {
646
+ toast("bad", "Not allowlisted", result.error ?? "");
647
+ return;
648
+ }
649
+ toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");
650
+ after();
651
+ }
652
+ async function act(body, title, detail, after) {
653
+ const result = await postJson("/api/actor", body);
654
+ if (!result.ok) {
655
+ toast("bad", "Refused", result.error ?? "");
656
+ return;
657
+ }
658
+ toast("ok", title, detail);
659
+ after();
660
+ }
661
+ var armed;
662
+ var init_actions = __esm({
663
+ "src/dashboard/client/actions.ts"() {
664
+ "use strict";
665
+ init_dom();
666
+ init_boot();
667
+ init_api();
668
+ init_app();
669
+ armed = 0;
670
+ }
671
+ });
672
+
673
+ // src/dashboard/client/bars.ts
674
+ function drawBars(target, rows, emptyText) {
675
+ clear(target);
676
+ const filtered = [...rows].filter(([, value]) => value > 0);
677
+ if (filtered.length === 0) {
678
+ target.appendChild(el("div", "note", emptyText));
679
+ return;
680
+ }
681
+ filtered.sort((a, b) => b[1] - a[1]);
682
+ const max = filtered[0]?.[1] ?? 1;
683
+ for (const [label2, value] of filtered.slice(0, 14)) {
684
+ const bar = el("div", "bar");
685
+ const track = el("div", "track");
686
+ const fill = el("div", "fill");
687
+ fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;
688
+ track.appendChild(fill);
689
+ track.appendChild(el("div", "lbl", label2));
690
+ bar.appendChild(track);
691
+ bar.appendChild(el("div", "v tnum", n(value)));
692
+ target.appendChild(bar);
693
+ }
694
+ }
695
+ function pairs(record) {
696
+ return Object.entries(record ?? {});
697
+ }
698
+ var init_bars = __esm({
699
+ "src/dashboard/client/bars.ts"() {
700
+ "use strict";
701
+ init_dom();
702
+ init_format();
703
+ }
704
+ });
705
+
706
+ // src/dashboard/client/actor.ts
707
+ function initActor() {
708
+ if (!SECTIONS.actors) return;
709
+ $("actor-close").addEventListener("click", closeActor);
710
+ }
711
+ function openActor(key) {
712
+ state.actor = key;
713
+ app.drawNow();
714
+ $("actor-panel").scrollIntoView({ block: "nearest" });
715
+ }
716
+ function closeActor() {
717
+ state.actor = void 0;
718
+ app.drawNow();
719
+ }
720
+ function drawActor() {
721
+ if (!SECTIONS.actors) return;
722
+ const panel = $("actor-panel");
723
+ if (state.actor === void 0) {
724
+ panel.hidden = true;
725
+ return;
726
+ }
727
+ panel.hidden = false;
728
+ $("actor-key").textContent = state.actor;
729
+ const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);
730
+ const latest = mine[mine.length - 1];
731
+ const stats = latest?.actorStats;
732
+ const gaps = [];
733
+ for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);
734
+ const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;
735
+ const box = $("actor-stats");
736
+ clear(box);
737
+ const rows = [
738
+ ["In this window", `${n(mine.length)} requests`],
739
+ ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],
740
+ ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],
741
+ ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],
742
+ ["First seen", stats !== void 0 ? clockTime(stats.firstSeen) : "\u2014"],
743
+ ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]
744
+ ];
745
+ for (const [key, value] of rows) {
746
+ box.appendChild(el("dt", null, key));
747
+ box.appendChild(el("dd", null, value));
748
+ }
749
+ const verdicts = /* @__PURE__ */ new Map();
750
+ const actions = /* @__PURE__ */ new Map();
751
+ for (const entry of mine) {
752
+ verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);
753
+ if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);
754
+ }
755
+ drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");
756
+ const bar = $("actor-actions");
757
+ clear(bar);
758
+ const buttons = actorActions(state.actor, () => app.drawNow());
759
+ bar.hidden = buttons.length === 0;
760
+ for (const button of buttons) bar.appendChild(button);
761
+ }
762
+ var init_actor = __esm({
763
+ "src/dashboard/client/actor.ts"() {
764
+ "use strict";
765
+ init_dom();
766
+ init_actions();
767
+ init_boot();
768
+ init_app();
769
+ init_format();
770
+ init_bars();
771
+ init_store();
772
+ }
773
+ });
774
+
775
+ // src/dashboard/client/result.ts
776
+ function showResult(kind, build) {
777
+ const box = $("policy-result");
778
+ box.hidden = false;
779
+ box.className = `result ${kind}`;
780
+ clear(box);
781
+ build(box);
782
+ }
783
+ function renderPreview(preview, notes = []) {
784
+ showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {
785
+ const head = el("div");
786
+ head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));
787
+ box.appendChild(head);
788
+ for (const note of notes) box.appendChild(el("div", "ev-meta", note));
789
+ if (preview.newDenials > 0) {
790
+ box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));
791
+ }
792
+ for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));
793
+ const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");
794
+ if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));
795
+ if (preview.samples.length > 0) {
796
+ const table = el("table", "diff");
797
+ const head2 = el("tr");
798
+ for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));
799
+ table.appendChild(head2);
800
+ for (const sample of preview.samples) {
801
+ const row = el("tr");
802
+ const what = el("td");
803
+ what.appendChild(el("div", "mono", sample.path));
804
+ what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));
805
+ row.appendChild(what);
806
+ const from = el("td", "from");
807
+ from.appendChild(el("div", null, sample.from));
808
+ from.appendChild(el("div", "ev-meta", sample.fromRule));
809
+ row.appendChild(from);
810
+ const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";
811
+ const to = el("td", `to ${kind}`);
812
+ to.appendChild(el("div", null, sample.to));
813
+ to.appendChild(el("div", "ev-meta", sample.toRule));
814
+ row.appendChild(to);
815
+ table.appendChild(row);
816
+ }
817
+ box.appendChild(table);
818
+ } else if (preview.evaluated === 0) {
819
+ box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));
820
+ }
821
+ });
822
+ }
823
+ var init_result = __esm({
824
+ "src/dashboard/client/result.ts"() {
825
+ "use strict";
826
+ init_dom();
827
+ init_format();
828
+ }
829
+ });
830
+
831
+ // src/dashboard/client/guard.ts
832
+ function drawGuard() {
833
+ if (!SECTIONS.guard) return;
834
+ const document_ = state.policy;
835
+ const snapshot = state.snapshot;
836
+ if (snapshot === void 0) return;
837
+ const editable = document_?.guardEditable === true;
838
+ const panel = $("stat-policy");
839
+ clear(panel);
840
+ if (!editable || document_ === void 0) {
841
+ const rows = [
842
+ ["False-positive policy", snapshot.policy.falsePositivePolicy],
843
+ ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],
844
+ ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],
845
+ ["Suspect threshold", String(snapshot.policy.suspectThreshold)],
846
+ ["Action when no rule matches", snapshot.policy.defaultAction],
847
+ ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],
848
+ ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]
849
+ ];
850
+ for (const [key, value] of rows) {
851
+ const line = el("div", "stat-row");
852
+ line.appendChild(el("span", "k", key));
853
+ line.appendChild(el("span", "v", value));
854
+ panel.appendChild(line);
855
+ }
856
+ $("guard-mode").textContent = "not editable here";
857
+ $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";
858
+ return;
859
+ }
860
+ if (draft === void 0) draft = { ...document_.guard };
861
+ const vocabulary = document_.vocabulary;
862
+ $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";
863
+ $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";
864
+ panel.appendChild(
865
+ guardField(
866
+ // "Mode" rather than "Guard": the panel is already called that, and a field
867
+ // repeating its own panel's name reads as a heading rather than as a control.
868
+ "Mode",
869
+ segmented(
870
+ vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),
871
+ draft.falsePositivePolicy,
872
+ (value) => {
873
+ setDraft({ falsePositivePolicy: value });
874
+ drawGuard();
875
+ }
876
+ )
877
+ )
878
+ );
879
+ panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));
880
+ panel.appendChild(
881
+ guardField(
882
+ "Fallback",
883
+ select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),
884
+ "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"
885
+ )
886
+ );
887
+ panel.appendChild(
888
+ guardField(
889
+ "Default action",
890
+ select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),
891
+ "when no rule matches"
892
+ )
893
+ );
894
+ panel.appendChild(
895
+ guardField(
896
+ "Terminal score",
897
+ number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),
898
+ "balanced mode only: the score a probabilistic verdict must clear"
899
+ )
900
+ );
901
+ panel.appendChild(
902
+ guardField(
903
+ "Suspect at",
904
+ number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),
905
+ "the score at which a request becomes suspected-bot"
906
+ )
907
+ );
908
+ const bar = el("div", "bar-actions");
909
+ const preview = el("button", null, "Preview");
910
+ preview.addEventListener("click", () => {
911
+ void previewGuard();
912
+ });
913
+ const apply = el("button", "primary", "Apply");
914
+ apply.addEventListener("click", () => {
915
+ void applyGuard();
916
+ });
917
+ const revert = el("button", null, "Revert");
918
+ revert.addEventListener("click", () => {
919
+ draft = { ...document_.guard };
920
+ state.guardDirty = false;
921
+ drawGuard();
922
+ });
923
+ bar.appendChild(preview);
924
+ bar.appendChild(apply);
925
+ bar.appendChild(revert);
926
+ panel.appendChild(bar);
927
+ }
928
+ function setDraft(change) {
929
+ if (draft === void 0) return;
930
+ draft = { ...draft, ...change };
931
+ state.guardDirty = true;
932
+ $("guard-mode").textContent = "unsaved changes";
933
+ }
934
+ function resetGuardDraft() {
935
+ draft = void 0;
936
+ state.guardDirty = false;
937
+ }
938
+ function liveRules() {
939
+ return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);
940
+ }
941
+ async function previewGuard() {
942
+ if (draft === void 0) return;
943
+ const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });
944
+ if (!result.ok) {
945
+ showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));
946
+ toast("bad", "Refused", result.error ?? "");
947
+ return;
948
+ }
949
+ renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);
950
+ app.showTab("policy");
951
+ }
952
+ async function applyGuard() {
953
+ if (draft === void 0) return;
954
+ const result = await postJson("/api/guard", draft);
955
+ if (!result.ok) {
956
+ showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));
957
+ toast("bad", "Guard unchanged", result.error ?? "");
958
+ return;
959
+ }
960
+ state.guardDirty = false;
961
+ draft = { ...result.data.guard };
962
+ if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };
963
+ toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);
964
+ showResult("warn", (box) => {
965
+ box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));
966
+ if (result.data.guard.falsePositivePolicy !== "strict") {
967
+ box.appendChild(
968
+ el(
969
+ "div",
970
+ "ev-meta",
971
+ "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."
972
+ )
973
+ );
974
+ }
975
+ });
976
+ app.draw();
977
+ }
978
+ function guardField(name, control, hint) {
979
+ const row = el("div", "field");
980
+ row.appendChild(label(name, control));
981
+ const right = el("div", "field-row");
982
+ right.appendChild(control);
983
+ if (hint !== void 0) right.appendChild(el("span", "hint", hint));
984
+ row.appendChild(right);
985
+ return row;
986
+ }
987
+ function segmented(options, value, onChange) {
988
+ const box = el("div", "seg");
989
+ for (const [label2, option] of options) {
990
+ const button = el("button", null, label2);
991
+ button.type = "button";
992
+ button.setAttribute("aria-pressed", String(option === value));
993
+ button.addEventListener("click", () => onChange(option));
994
+ box.appendChild(button);
995
+ }
996
+ return box;
997
+ }
998
+ function select(options, value, onChange) {
999
+ const node = el("select");
1000
+ for (const option of options) {
1001
+ const item = el("option", null, option);
1002
+ item.value = option;
1003
+ if (option === value) item.selected = true;
1004
+ node.appendChild(item);
1005
+ }
1006
+ node.addEventListener("change", () => onChange(node.value));
1007
+ return node;
1008
+ }
1009
+ function number(value, onChange) {
1010
+ const input = el("input");
1011
+ input.type = "number";
1012
+ input.min = "1";
1013
+ input.max = "100";
1014
+ input.value = String(value);
1015
+ input.addEventListener("input", () => {
1016
+ if (input.value !== "") onChange(Number(input.value));
1017
+ });
1018
+ return input;
1019
+ }
1020
+ var draft, MODE_NOTES;
1021
+ var init_guard = __esm({
1022
+ "src/dashboard/client/guard.ts"() {
1023
+ "use strict";
1024
+ init_dom();
1025
+ init_boot();
1026
+ init_app();
1027
+ init_api();
1028
+ init_result();
1029
+ init_store();
1030
+ MODE_NOTES = {
1031
+ strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",
1032
+ balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",
1033
+ aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."
1034
+ };
1035
+ }
1036
+ });
1037
+
1038
+ // src/dashboard/client/ranges.ts
1039
+ async function loadRanges() {
1040
+ if (!SECTIONS.ranges) return;
1041
+ try {
1042
+ const body = await getJson("/api/ranges");
1043
+ sets = body.ranges;
1044
+ drawRanges();
1045
+ } catch {
1046
+ }
1047
+ }
1048
+ function drawRanges() {
1049
+ if (!SECTIONS.ranges) return;
1050
+ const body = $("ranges-body");
1051
+ clear(body);
1052
+ $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";
1053
+ $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";
1054
+ if (sets.length === 0) {
1055
+ body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));
1056
+ }
1057
+ for (const set of sets) {
1058
+ const block = el("div", "rangeset");
1059
+ const heading = el("h3");
1060
+ heading.appendChild(document.createTextNode(set.name));
1061
+ heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));
1062
+ block.appendChild(heading);
1063
+ const list = el("div", "cidrs");
1064
+ for (const entry of set.entries) {
1065
+ const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");
1066
+ chip.appendChild(document.createTextNode(entry));
1067
+ if (BOOT.allowActing) {
1068
+ const remove = el("button", null, "\xD7");
1069
+ remove.title = `Remove ${entry} from ${set.name}`;
1070
+ remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);
1071
+ remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));
1072
+ chip.appendChild(remove);
1073
+ }
1074
+ list.appendChild(chip);
1075
+ }
1076
+ if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));
1077
+ block.appendChild(list);
1078
+ body.appendChild(block);
1079
+ }
1080
+ if (!BOOT.allowActing) return;
1081
+ const form = el("div", "rangeset");
1082
+ const row = el("div", "field-row");
1083
+ const name = el("input", "mono-input");
1084
+ name.type = "text";
1085
+ name.value = "allowlist";
1086
+ name.setAttribute("aria-label", "Range set");
1087
+ name.style.maxWidth = "150px";
1088
+ const value = el("input", "mono-input");
1089
+ value.type = "text";
1090
+ value.placeholder = "203.0.113.0/24";
1091
+ value.setAttribute("aria-label", "Address or CIDR to add");
1092
+ const add = el("button", null, "Add");
1093
+ const submit = () => {
1094
+ const entry = value.value.trim();
1095
+ if (entry === "") return;
1096
+ value.value = "";
1097
+ void update(name.value.trim(), { add: [entry] });
1098
+ };
1099
+ add.addEventListener("click", submit);
1100
+ value.addEventListener("keydown", (event) => {
1101
+ if (event.key === "Enter") submit();
1102
+ });
1103
+ row.appendChild(name);
1104
+ row.appendChild(value);
1105
+ row.appendChild(add);
1106
+ form.appendChild(row);
1107
+ body.appendChild(form);
1108
+ }
1109
+ async function update(name, change) {
1110
+ const result = await postJson("/api/ranges", { name, ...change });
1111
+ if (!result.ok) {
1112
+ toast("bad", "Ranges unchanged", result.error ?? "");
1113
+ return;
1114
+ }
1115
+ toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);
1116
+ await loadRanges();
1117
+ }
1118
+ var sets;
1119
+ var init_ranges = __esm({
1120
+ "src/dashboard/client/ranges.ts"() {
1121
+ "use strict";
1122
+ init_dom();
1123
+ init_boot();
1124
+ init_api();
1125
+ init_format();
1126
+ init_app();
1127
+ sets = [];
1128
+ }
1129
+ });
1130
+
1131
+ // src/dashboard/client/draft.ts
1132
+ function draftRule(entry, existingIds = []) {
1133
+ const match = {};
1134
+ let because;
1135
+ let stem;
1136
+ const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");
1137
+ const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];
1138
+ if (entry.identity !== void 0 && entry.identity !== "") {
1139
+ match["identity"] = [entry.identity];
1140
+ if (entry.certain) match["certain"] = true;
1141
+ stem = entry.identity;
1142
+ because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;
1143
+ } else if (proven.length > 0) {
1144
+ match["detector"] = detectors;
1145
+ match["certain"] = true;
1146
+ stem = detectors[0] ?? "proven";
1147
+ because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;
1148
+ } else if (detectors.length > 0) {
1149
+ match["verdict"] = [entry.verdict];
1150
+ match["detector"] = detectors;
1151
+ match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);
1152
+ stem = detectors[0] ?? entry.verdict;
1153
+ because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;
1154
+ } else {
1155
+ match["verdict"] = [entry.verdict];
1156
+ stem = entry.verdict;
1157
+ because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;
1158
+ }
1159
+ return {
1160
+ rule: {
1161
+ id: uniqueId(`from-${slug(stem)}`, existingIds),
1162
+ match,
1163
+ action: "tag",
1164
+ reason: "Drafted from a request on the dashboard.",
1165
+ _open: true
1166
+ },
1167
+ because
1168
+ };
1169
+ }
1170
+ function slug(value) {
1171
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1172
+ return cleaned === "" ? "request" : cleaned.slice(0, 40);
1173
+ }
1174
+ function uniqueId(wanted, taken) {
1175
+ if (!taken.includes(wanted)) return wanted;
1176
+ for (let suffix = 2; suffix < 1e3; suffix++) {
1177
+ const candidate = `${wanted}-${suffix}`;
1178
+ if (!taken.includes(candidate)) return candidate;
1179
+ }
1180
+ return `${wanted}-${Date.now()}`;
1181
+ }
1182
+ var init_draft = __esm({
1183
+ "src/dashboard/client/draft.ts"() {
1184
+ "use strict";
1185
+ }
1186
+ });
1187
+
1188
+ // src/dashboard/client/policy.ts
1189
+ async function loadPolicy() {
1190
+ if (!SECTIONS.policy) return;
1191
+ try {
1192
+ const document_ = await getJson("/api/policy");
1193
+ state.policy = document_;
1194
+ if (!state.editorDirty) {
1195
+ setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));
1196
+ }
1197
+ if (!state.guardDirty) resetGuardDraft();
1198
+ drawPolicyTab();
1199
+ } catch {
1200
+ }
1201
+ }
1202
+ function setEditorRules(rules) {
1203
+ state.editorRules = JSON.parse(JSON.stringify(rules ?? []));
1204
+ renderEditor();
1205
+ }
1206
+ function markDirty() {
1207
+ state.editorDirty = true;
1208
+ $("policy-dirty").hidden = false;
1209
+ }
1210
+ function editorRules() {
1211
+ if (state.editorMode === "json") {
1212
+ try {
1213
+ const parsed = JSON.parse(byId("policy-json").value);
1214
+ if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };
1215
+ return { rules: parsed };
1216
+ } catch (error) {
1217
+ return { error: `The editor does not contain valid JSON: ${String(error)}` };
1218
+ }
1219
+ }
1220
+ return { rules: state.editorRules };
1221
+ }
1222
+ function cleanRule(rule) {
1223
+ const match = {};
1224
+ for (const [key, value] of Object.entries(rule.match ?? {})) {
1225
+ if (value === void 0 || value === null || value === "") continue;
1226
+ if (Array.isArray(value) && value.length === 0) continue;
1227
+ match[key] = value;
1228
+ }
1229
+ const params = {};
1230
+ for (const [key, value] of Object.entries(rule.params ?? {})) {
1231
+ if (value === void 0 || value === null || value === "") continue;
1232
+ if (key === "limit") {
1233
+ const limit = value;
1234
+ if (limit.max === void 0 || limit.windowMs === void 0) continue;
1235
+ }
1236
+ params[key] = value;
1237
+ }
1238
+ const out = { id: rule.id, match, action: rule.action };
1239
+ if (Object.keys(params).length > 0) out["params"] = params;
1240
+ if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;
1241
+ return out;
1242
+ }
1243
+ function cleanRules(rules) {
1244
+ return rules.map(cleanRule);
1245
+ }
1246
+ function field(name, control, hint) {
1247
+ const row = el("div", "field");
1248
+ row.appendChild(label(name, control));
1249
+ const right = el("div", "field-row");
1250
+ for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);
1251
+ if (hint !== void 0) right.appendChild(el("span", "hint", hint));
1252
+ row.appendChild(right);
1253
+ return row;
1254
+ }
1255
+ function chipSelect(options, selected, onChange) {
1256
+ const box = el("div", "chips-select");
1257
+ const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];
1258
+ for (const option of options) {
1259
+ const button = el("button", null, option);
1260
+ button.type = "button";
1261
+ button.setAttribute("aria-pressed", String(chosen.includes(option)));
1262
+ button.addEventListener("click", () => {
1263
+ const at = chosen.indexOf(option);
1264
+ if (at === -1) chosen.push(option);
1265
+ else chosen.splice(at, 1);
1266
+ button.setAttribute("aria-pressed", String(at === -1));
1267
+ onChange(chosen.length === 0 ? void 0 : [...chosen]);
1268
+ });
1269
+ box.appendChild(button);
1270
+ }
1271
+ return box;
1272
+ }
1273
+ function segmented2(options, value, onChange) {
1274
+ const box = el("div", "seg");
1275
+ for (const [label2, option] of options) {
1276
+ const button = el("button", null, label2);
1277
+ button.type = "button";
1278
+ button.setAttribute("aria-pressed", String(option === value));
1279
+ button.addEventListener("click", () => {
1280
+ for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");
1281
+ button.setAttribute("aria-pressed", "true");
1282
+ onChange(option);
1283
+ });
1284
+ box.appendChild(button);
1285
+ }
1286
+ return box;
1287
+ }
1288
+ function textInput(value, placeholder, onChange, mono = false) {
1289
+ const input = el("input", mono ? "mono-input" : null);
1290
+ input.type = "text";
1291
+ input.value = value === void 0 || value === null ? "" : String(value);
1292
+ input.placeholder = placeholder;
1293
+ input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));
1294
+ return input;
1295
+ }
1296
+ function listInput(value, placeholder, onChange) {
1297
+ const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);
1298
+ const input = textInput(current, placeholder, () => {
1299
+ }, true);
1300
+ input.addEventListener("input", () => {
1301
+ const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);
1302
+ onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);
1303
+ });
1304
+ return input;
1305
+ }
1306
+ function numberInput(value, placeholder, onChange) {
1307
+ const input = el("input");
1308
+ input.type = "number";
1309
+ input.value = value === void 0 || value === null ? "" : String(value);
1310
+ input.placeholder = placeholder;
1311
+ input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));
1312
+ return input;
1313
+ }
1314
+ function matchSummary(rule) {
1315
+ const box = el("div", "rule-summary");
1316
+ const match = rule.match ?? {};
1317
+ const parts = [];
1318
+ for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {
1319
+ const value = match[key];
1320
+ if (value === void 0) continue;
1321
+ parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);
1322
+ }
1323
+ if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);
1324
+ if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {
1325
+ parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);
1326
+ }
1327
+ if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);
1328
+ if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);
1329
+ if (parts.length === 0) {
1330
+ box.appendChild(el("span", "none", "matches everything"));
1331
+ return box;
1332
+ }
1333
+ for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));
1334
+ if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));
1335
+ return box;
1336
+ }
1337
+ function ruleCard(rule, index) {
1338
+ const vocabulary = state.policy?.vocabulary;
1339
+ const open = rule._open === true;
1340
+ const card = el("div", `rule${open ? "" : " collapsed"}`);
1341
+ const head = el("div", "rule-head");
1342
+ const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");
1343
+ chevron.title = open ? "Collapse" : "Expand";
1344
+ chevron.setAttribute("aria-expanded", String(open));
1345
+ chevron.addEventListener("click", () => {
1346
+ rule._open = !open;
1347
+ renderEditor();
1348
+ });
1349
+ head.appendChild(chevron);
1350
+ head.appendChild(el("span", "ord", index + 1));
1351
+ const id = textInput(rule.id, "rule-id", (value) => {
1352
+ rule.id = value ?? "";
1353
+ markDirty();
1354
+ }, true);
1355
+ id.setAttribute("aria-label", "Rule id");
1356
+ head.appendChild(id);
1357
+ if (open) {
1358
+ const action = el("select");
1359
+ action.setAttribute("aria-label", "Action");
1360
+ for (const name of vocabulary?.actions ?? []) {
1361
+ const option = el("option", null, name);
1362
+ option.value = name;
1363
+ if (name === rule.action) option.selected = true;
1364
+ action.appendChild(option);
1365
+ }
1366
+ action.addEventListener("change", () => {
1367
+ rule.action = action.value;
1368
+ rule.params = {};
1369
+ markDirty();
1370
+ renderEditor();
1371
+ });
1372
+ head.appendChild(action);
1373
+ } else {
1374
+ head.appendChild(matchSummary(rule));
1375
+ head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));
1376
+ }
1377
+ const up = el("button", "icon", "\u2191");
1378
+ up.title = "Move earlier \u2014 the first matching rule wins";
1379
+ up.addEventListener("click", () => moveRule(index, -1));
1380
+ const down = el("button", "icon", "\u2193");
1381
+ down.title = "Move later";
1382
+ down.addEventListener("click", () => moveRule(index, 1));
1383
+ const remove = el("button", "icon danger", "Remove");
1384
+ remove.addEventListener("click", () => {
1385
+ state.editorRules.splice(index, 1);
1386
+ markDirty();
1387
+ renderEditor();
1388
+ });
1389
+ head.appendChild(up);
1390
+ head.appendChild(down);
1391
+ head.appendChild(remove);
1392
+ card.appendChild(head);
1393
+ if (!open) return card;
1394
+ const body = el("div", "rule-body");
1395
+ rule.match = rule.match ?? {};
1396
+ const match = rule.match;
1397
+ body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {
1398
+ match["verdict"] = value;
1399
+ markDirty();
1400
+ })));
1401
+ body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {
1402
+ match["botClass"] = value;
1403
+ markDirty();
1404
+ })));
1405
+ body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {
1406
+ match["category"] = value;
1407
+ markDirty();
1408
+ })));
1409
+ body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {
1410
+ match["detector"] = value;
1411
+ markDirty();
1412
+ })));
1413
+ body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {
1414
+ match["method"] = value;
1415
+ markDirty();
1416
+ })));
1417
+ body.appendChild(
1418
+ field(
1419
+ "Evidence",
1420
+ segmented2(
1421
+ [
1422
+ ["any", void 0],
1423
+ ["proven", true],
1424
+ ["unproven", false]
1425
+ ],
1426
+ match["certain"],
1427
+ (value) => {
1428
+ match["certain"] = value;
1429
+ markDirty();
1430
+ }
1431
+ ),
1432
+ "proven means at least one piece of certain evidence \u2014 including a proven human"
1433
+ )
1434
+ );
1435
+ body.appendChild(
1436
+ field("Score", [
1437
+ numberInput(match["minScore"], "min", (value) => {
1438
+ match["minScore"] = value;
1439
+ markDirty();
1440
+ }),
1441
+ el("span", "hint", "to"),
1442
+ numberInput(match["maxScore"], "max", (value) => {
1443
+ match["maxScore"] = value;
1444
+ markDirty();
1445
+ })
1446
+ ])
1447
+ );
1448
+ body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {
1449
+ match["identity"] = value;
1450
+ markDirty();
1451
+ })));
1452
+ body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {
1453
+ match["path"] = value;
1454
+ markDirty();
1455
+ }), "a string matches as a prefix"));
1456
+ body.appendChild(
1457
+ field(
1458
+ "Prior bots",
1459
+ numberInput(match["minPriorConfirmations"], "0", (value) => {
1460
+ match["minPriorConfirmations"] = value;
1461
+ markDirty();
1462
+ }),
1463
+ "times this actor was already proven a bot"
1464
+ )
1465
+ );
1466
+ body.appendChild(
1467
+ field(
1468
+ "Unsolved",
1469
+ numberInput(match["minUnsolvedChallenges"], "0", (value) => {
1470
+ match["minUnsolvedChallenges"] = value;
1471
+ markDirty();
1472
+ }),
1473
+ "challenges issued to this actor that were never answered \u2014 solving one clears the count"
1474
+ )
1475
+ );
1476
+ rule.params = rule.params ?? {};
1477
+ const params = rule.params;
1478
+ for (const node of paramFields(rule.action, params)) body.appendChild(node);
1479
+ body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {
1480
+ rule.reason = value ?? "";
1481
+ markDirty();
1482
+ })));
1483
+ card.appendChild(body);
1484
+ return card;
1485
+ }
1486
+ function paramFields(action, params) {
1487
+ switch (action) {
1488
+ case "block":
1489
+ return [
1490
+ field("Status", numberInput(params["status"], "403", (value) => {
1491
+ params["status"] = value;
1492
+ markDirty();
1493
+ })),
1494
+ field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {
1495
+ params["body"] = value;
1496
+ markDirty();
1497
+ }))
1498
+ ];
1499
+ case "redirect":
1500
+ return [field("Location", textInput(params["location"], "/too-fast", (value) => {
1501
+ params["location"] = value;
1502
+ markDirty();
1503
+ }, true))];
1504
+ case "delay":
1505
+ return [field("Delay", numberInput(params["delayMs"], "250", (value) => {
1506
+ params["delayMs"] = value;
1507
+ markDirty();
1508
+ }), "milliseconds")];
1509
+ case "rate-limit": {
1510
+ const limit = params["limit"] ?? {};
1511
+ params["limit"] = limit;
1512
+ return [
1513
+ field("Limit", [
1514
+ numberInput(limit["max"], "60", (value) => {
1515
+ limit["max"] = value;
1516
+ markDirty();
1517
+ }),
1518
+ el("span", "hint", "requests per"),
1519
+ numberInput(limit["windowMs"], "60000", (value) => {
1520
+ limit["windowMs"] = value;
1521
+ markDirty();
1522
+ }),
1523
+ el("span", "hint", "ms")
1524
+ ])
1525
+ ];
1526
+ }
1527
+ case "custom":
1528
+ return [field("Handler", textInput(params["handler"], "handler-id", (value) => {
1529
+ params["handler"] = value;
1530
+ markDirty();
1531
+ }, true), "id of a handler you registered")];
1532
+ default:
1533
+ return [];
1534
+ }
1535
+ }
1536
+ function lockedCard(row) {
1537
+ const card = el("div", "rule locked");
1538
+ const head = el("div", "rule-head");
1539
+ head.appendChild(el("span", "ord", `#${row.index + 1}`));
1540
+ head.appendChild(el("span", "mono", row.id));
1541
+ head.appendChild(el("span", "grow"));
1542
+ head.appendChild(el("span", "pill", "predicate \u2014 locked"));
1543
+ card.appendChild(head);
1544
+ card.appendChild(
1545
+ el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")
1546
+ );
1547
+ return card;
1548
+ }
1549
+ function moveRule(index, delta) {
1550
+ const target = index + delta;
1551
+ if (target < 0 || target >= state.editorRules.length) return;
1552
+ const moved = state.editorRules.splice(index, 1)[0];
1553
+ if (moved === void 0) return;
1554
+ state.editorRules.splice(target, 0, moved);
1555
+ markDirty();
1556
+ renderEditor();
1557
+ }
1558
+ function renderEditor() {
1559
+ if (!SECTIONS.policy) return;
1560
+ const list = $("rulelist");
1561
+ clear(list);
1562
+ const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);
1563
+ if (state.editorRules.length === 0 && locked.length === 0) {
1564
+ list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));
1565
+ }
1566
+ const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));
1567
+ for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));
1568
+ for (const node of rendered2) list.appendChild(node);
1569
+ if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);
1570
+ }
1571
+ function drawPolicyTab() {
1572
+ const document_ = state.policy;
1573
+ if (document_ === void 0) return;
1574
+ const editable = document_.editable;
1575
+ $("policy-apply").hidden = !editable;
1576
+ byId("policy-json").readOnly = !editable;
1577
+ $("policy-mode").textContent = editable ? "editable" : "read-only";
1578
+ $("rule-add").hidden = !editable;
1579
+ $("policy-import").hidden = !editable;
1580
+ const preserved = document_.rules.filter((row) => !row.editable);
1581
+ let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";
1582
+ if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;
1583
+ $("policy-note").textContent = note;
1584
+ renderPresetButtons();
1585
+ drawGuard();
1586
+ drawRanges();
1587
+ if (SECTIONS.robots) {
1588
+ $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;
1589
+ const notes = $("robots-notes");
1590
+ clear(notes);
1591
+ for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));
1592
+ }
1593
+ const rules = $("stat-rules");
1594
+ clear(rules);
1595
+ const installed = state.snapshot?.rules ?? [];
1596
+ if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));
1597
+ else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));
1598
+ }
1599
+ async function draftIntoEditor(entry) {
1600
+ if (state.policy === void 0) await loadPolicy();
1601
+ const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));
1602
+ state.editorRules.push(drafted.rule);
1603
+ markDirty();
1604
+ if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);
1605
+ renderEditor();
1606
+ app.showTab("policy");
1607
+ const notes = [
1608
+ `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,
1609
+ drafted.because,
1610
+ "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."
1611
+ ];
1612
+ showResult("warn", (box) => {
1613
+ for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));
1614
+ });
1615
+ toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");
1616
+ await runPreview(notes);
1617
+ }
1618
+ function switchToGui() {
1619
+ if (state.editorMode === "gui") return;
1620
+ const parsed = editorRules();
1621
+ if (parsed.error !== void 0) {
1622
+ toast("bad", "That JSON will not parse", parsed.error);
1623
+ return;
1624
+ }
1625
+ state.editorRules = parsed.rules ?? [];
1626
+ state.editorMode = "gui";
1627
+ $("mode-gui").setAttribute("aria-pressed", "true");
1628
+ $("mode-json").setAttribute("aria-pressed", "false");
1629
+ $("editor-gui").hidden = false;
1630
+ $("editor-json").hidden = true;
1631
+ renderEditor();
1632
+ }
1633
+ function switchToJson() {
1634
+ state.editorMode = "json";
1635
+ $("mode-gui").setAttribute("aria-pressed", "false");
1636
+ $("mode-json").setAttribute("aria-pressed", "true");
1637
+ $("editor-gui").hidden = true;
1638
+ $("editor-json").hidden = false;
1639
+ byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);
1640
+ }
1641
+ async function exportSettings() {
1642
+ try {
1643
+ const response = await fetch(`${API}/api/settings`);
1644
+ const text = await response.text();
1645
+ if (!response.ok) throw new Error(text);
1646
+ download(text, `bothandler-settings-${today()}.json`, "application/json");
1647
+ toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");
1648
+ } catch (error) {
1649
+ toast("bad", "Export failed", String(error));
1650
+ }
1651
+ }
1652
+ function readSettingsFile(file) {
1653
+ const reader = new FileReader();
1654
+ reader.onload = () => {
1655
+ try {
1656
+ applyImported(JSON.parse(String(reader.result)), file.name);
1657
+ } catch (error) {
1658
+ toast("bad", "That file is not JSON", String(error));
1659
+ }
1660
+ };
1661
+ reader.onerror = () => toast("bad", "Could not read that file", "");
1662
+ reader.readAsText(file);
1663
+ }
1664
+ function applyImported(document_, name) {
1665
+ const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;
1666
+ if (rules === void 0) {
1667
+ toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");
1668
+ return;
1669
+ }
1670
+ setEditorRules(rules);
1671
+ markDirty();
1672
+ switchToGui();
1673
+ const ignored = [];
1674
+ const readOnly = document_.readOnly;
1675
+ if (readOnly !== void 0) {
1676
+ ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");
1677
+ if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {
1678
+ ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);
1679
+ }
1680
+ }
1681
+ showResult("warn", (box) => {
1682
+ box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));
1683
+ for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));
1684
+ });
1685
+ toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");
1686
+ }
1687
+ function collectForSubmit() {
1688
+ const parsed = editorRules();
1689
+ if (parsed.error !== void 0) {
1690
+ showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));
1691
+ toast("bad", "That JSON will not parse", parsed.error);
1692
+ return void 0;
1693
+ }
1694
+ const cleaned = cleanRules(parsed.rules ?? []);
1695
+ const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;
1696
+ if (blank > 0) {
1697
+ toast("bad", "Every rule needs an id", "It is what every decision and log line names.");
1698
+ return void 0;
1699
+ }
1700
+ return cleaned;
1701
+ }
1702
+ async function runPreview(notes = []) {
1703
+ const rules = collectForSubmit();
1704
+ if (rules === void 0) return;
1705
+ const result = await postJson("/api/policy/preview", { rules });
1706
+ if (!result.ok) {
1707
+ showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));
1708
+ toast("bad", "Refused", result.error ?? "");
1709
+ return;
1710
+ }
1711
+ renderPreview(result.data, notes);
1712
+ }
1713
+ async function applyPolicy() {
1714
+ const rules = collectForSubmit();
1715
+ if (rules === void 0) return;
1716
+ const result = await postJson("/api/policy/apply", { rules });
1717
+ if (!result.ok) {
1718
+ showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));
1719
+ toast("bad", "Refused", result.error ?? "");
1720
+ return;
1721
+ }
1722
+ state.editorDirty = false;
1723
+ $("policy-dirty").hidden = true;
1724
+ await loadPolicy();
1725
+ toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);
1726
+ showResult("ok", (box) => {
1727
+ box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));
1728
+ for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));
1729
+ });
1730
+ }
1731
+ function renderPresetButtons() {
1732
+ const box = $("preset-buttons");
1733
+ const presets = state.policy?.vocabulary.presets ?? [];
1734
+ if (box.childElementCount === presets.length) return;
1735
+ clear(box);
1736
+ for (const preset of presets) {
1737
+ const button = el("button", null, preset);
1738
+ button.addEventListener("click", () => {
1739
+ void (async () => {
1740
+ const result = await postJson("/api/policy/preview", { preset });
1741
+ if (!result.ok) {
1742
+ toast("bad", "Refused", result.error ?? "");
1743
+ return;
1744
+ }
1745
+ renderPreview(result.data);
1746
+ })();
1747
+ });
1748
+ box.appendChild(button);
1749
+ }
1750
+ }
1751
+ function initPolicy() {
1752
+ if (!SECTIONS.policy) return;
1753
+ $("mode-gui").addEventListener("click", switchToGui);
1754
+ $("mode-json").addEventListener("click", switchToJson);
1755
+ $("rule-add").addEventListener("click", () => {
1756
+ state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });
1757
+ markDirty();
1758
+ renderEditor();
1759
+ });
1760
+ $("rule-expand").addEventListener("click", () => {
1761
+ const anyClosed = state.editorRules.some((rule) => rule._open !== true);
1762
+ for (const rule of state.editorRules) rule._open = anyClosed;
1763
+ $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";
1764
+ renderEditor();
1765
+ });
1766
+ byId("policy-json").addEventListener("input", markDirty);
1767
+ $("policy-preview").addEventListener("click", () => void runPreview());
1768
+ $("policy-apply").addEventListener("click", () => void applyPolicy());
1769
+ $("policy-revert").addEventListener("click", () => {
1770
+ state.editorDirty = false;
1771
+ $("policy-dirty").hidden = true;
1772
+ $("policy-result").hidden = true;
1773
+ resetGuardDraft();
1774
+ void loadPolicy();
1775
+ });
1776
+ $("policy-export").addEventListener("click", () => void exportSettings());
1777
+ $("policy-import").addEventListener("click", () => byId("policy-file").click());
1778
+ byId("policy-file").addEventListener("change", () => {
1779
+ const input = byId("policy-file");
1780
+ const file = input.files?.[0];
1781
+ if (file !== void 0) readSettingsFile(file);
1782
+ input.value = "";
1783
+ });
1784
+ const panel = $("editor-panel");
1785
+ for (const name of ["dragenter", "dragover"]) {
1786
+ panel.addEventListener(name, (event) => {
1787
+ if (state.policy?.editable !== true) return;
1788
+ event.preventDefault();
1789
+ panel.classList.add("drop");
1790
+ });
1791
+ }
1792
+ for (const name of ["dragleave", "drop"]) {
1793
+ panel.addEventListener(name, (event) => {
1794
+ panel.classList.remove("drop");
1795
+ if (name !== "drop") return;
1796
+ event.preventDefault();
1797
+ const file = event.dataTransfer?.files?.[0];
1798
+ if (file !== void 0) readSettingsFile(file);
1799
+ });
1800
+ }
1801
+ }
1802
+ var init_policy = __esm({
1803
+ "src/dashboard/client/policy.ts"() {
1804
+ "use strict";
1805
+ init_dom();
1806
+ init_boot();
1807
+ init_app();
1808
+ init_api();
1809
+ init_outcome();
1810
+ init_guard();
1811
+ init_ranges();
1812
+ init_format();
1813
+ init_result();
1814
+ init_store();
1815
+ init_draft();
1816
+ }
1817
+ });
1818
+
1819
+ // src/dashboard/client/feed.ts
1820
+ function initFeed() {
1821
+ const filters = $("filters");
1822
+ for (const [name, label2] of FILTERS) {
1823
+ const button = el("button", null, label2);
1824
+ button.setAttribute("aria-pressed", String(name === state.filter));
1825
+ button.dataset["filter"] = name;
1826
+ button.addEventListener("click", () => {
1827
+ state.filter = name;
1828
+ for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");
1829
+ button.setAttribute("aria-pressed", "true");
1830
+ app.syncUrl();
1831
+ app.drawNow();
1832
+ });
1833
+ filters.appendChild(button);
1834
+ }
1835
+ const search = byId("search");
1836
+ search.addEventListener("input", () => {
1837
+ setSearch(search.value.trim());
1838
+ app.syncUrl();
1839
+ app.drawNow();
1840
+ });
1841
+ const exportShown = byId("feed-export");
1842
+ exportShown.hidden = !SECTIONS.evidence;
1843
+ exportShown.addEventListener("click", () => {
1844
+ const rows = visibleRows(Number.POSITIVE_INFINITY);
1845
+ if (rows.length === 0) {
1846
+ toast("warn", "Nothing to export", "No request in the window matches this filter.");
1847
+ return;
1848
+ }
1849
+ const entries = rows.map((row) => row.entry).reverse();
1850
+ download(`${replayFile(entries)}
1851
+ `, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");
1852
+ toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");
1853
+ });
1854
+ }
1855
+ function reflectFilterButtons() {
1856
+ for (const button of Array.from($("filters").children)) {
1857
+ button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));
1858
+ }
1859
+ const search = byId("search");
1860
+ if (search.value !== state.search) search.value = state.search;
1861
+ }
1862
+ function drawFeed() {
1863
+ const body = byId("rows");
1864
+ const shown = visibleRows();
1865
+ let index = 0;
1866
+ const place = (node) => {
1867
+ const current = body.childNodes[index] ?? null;
1868
+ if (current !== node) body.insertBefore(node, current);
1869
+ index++;
1870
+ };
1871
+ for (const row of shown) {
1872
+ const id = row.entry.requestId;
1873
+ const open = state.open.has(id);
1874
+ let cached = rendered.get(id);
1875
+ if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {
1876
+ cached = {
1877
+ row: buildRow(row.entry, open),
1878
+ detail: open ? buildDetail(row.entry) : void 0,
1879
+ rev: row.rev,
1880
+ open
1881
+ };
1882
+ rendered.set(id, cached);
1883
+ }
1884
+ place(cached.row);
1885
+ if (cached.detail !== void 0) place(cached.detail);
1886
+ }
1887
+ while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);
1888
+ if (rendered.size > shown.length * 2 + 100) {
1889
+ const live = new Set(shown.map((row) => row.entry.requestId));
1890
+ for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);
1891
+ }
1892
+ const total = state.rows.length;
1893
+ const matching = matchingCount();
1894
+ $("empty").hidden = total > 0;
1895
+ $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}${matching > FEED_LIMIT ? ` \xB7 showing ${n(FEED_LIMIT)}` : ""}`;
1896
+ const skipped = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
1897
+ const note = $("feed-skipped");
1898
+ note.hidden = skipped === 0;
1899
+ note.textContent = `${n(skipped)} not streamed`;
1900
+ note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";
1901
+ }
1902
+ function resetFeedCache() {
1903
+ rendered.clear();
1904
+ }
1905
+ function buildRow(entry, open) {
1906
+ const out = outcome(entry);
1907
+ const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);
1908
+ tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));
1909
+ const request = el("td", "edge req");
1910
+ const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);
1911
+ toggle.type = "button";
1912
+ toggle.setAttribute("aria-expanded", String(open));
1913
+ toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);
1914
+ toggle.dataset["request"] = entry.requestId;
1915
+ request.appendChild(toggle);
1916
+ const ua = el("span", "ua");
1917
+ if (SECTIONS.actors) {
1918
+ const actorLink = el("a", null, entry.actor);
1919
+ actorLink.href = "#actor";
1920
+ actorLink.title = "Show everything from this actor";
1921
+ actorLink.addEventListener("click", (event) => {
1922
+ event.preventDefault();
1923
+ event.stopPropagation();
1924
+ openActor(entry.actor);
1925
+ });
1926
+ ua.appendChild(actorLink);
1927
+ ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));
1928
+ } else {
1929
+ ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));
1930
+ }
1931
+ ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;
1932
+ request.appendChild(ua);
1933
+ tr.appendChild(request);
1934
+ const verdictCell = el("td");
1935
+ const [badgeClass, badgeLabel] = verdictBadge(entry);
1936
+ verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));
1937
+ if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));
1938
+ tr.appendChild(verdictCell);
1939
+ tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));
1940
+ const actionCell = el("td");
1941
+ if (entry.action !== void 0) {
1942
+ const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";
1943
+ actionCell.appendChild(el("span", `act ${kind}`, entry.action));
1944
+ if (entry.rule !== void 0) {
1945
+ const ruleLabel = el("span", "sub", entry.rule);
1946
+ ruleLabel.title = entry.rule;
1947
+ actionCell.appendChild(ruleLabel);
1948
+ }
1949
+ if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));
1950
+ } else {
1951
+ actionCell.appendChild(el("span", "sub", "assessed only"));
1952
+ }
1953
+ tr.appendChild(actionCell);
1954
+ tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));
1955
+ tr.dataset["request"] = entry.requestId;
1956
+ const flip = () => {
1957
+ if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);
1958
+ else state.open.add(entry.requestId);
1959
+ app.drawNow();
1960
+ rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();
1961
+ };
1962
+ toggle.addEventListener("click", (event) => {
1963
+ event.stopPropagation();
1964
+ flip();
1965
+ });
1966
+ tr.addEventListener("click", flip);
1967
+ return tr;
1968
+ }
1969
+ function buildDetail(entry) {
1970
+ const tr = el("tr", "detail");
1971
+ const cell = el("td");
1972
+ cell.colSpan = 6;
1973
+ if (!SECTIONS.evidence) {
1974
+ cell.appendChild(
1975
+ el(
1976
+ "div",
1977
+ "ev-meta",
1978
+ "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."
1979
+ )
1980
+ );
1981
+ } else if (entry.evidence.length === 0) {
1982
+ cell.appendChild(
1983
+ el(
1984
+ "div",
1985
+ "ev-meta",
1986
+ entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."
1987
+ )
1988
+ );
1989
+ } else {
1990
+ const list = el("div", "ev");
1991
+ for (const item of entry.evidence) {
1992
+ const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
1993
+ row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
1994
+ const body = el("div");
1995
+ body.appendChild(el("div", null, item.summary));
1996
+ let meta = `${item.detector} \xB7 points to ${item.direction}`;
1997
+ if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;
1998
+ body.appendChild(el("div", "ev-meta", meta));
1999
+ if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));
2000
+ row.appendChild(body);
2001
+ list.appendChild(row);
2002
+ }
2003
+ cell.appendChild(list);
2004
+ }
2005
+ for (const failure of entry.failures) {
2006
+ cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));
2007
+ }
2008
+ if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));
2009
+ const queryNames = Object.keys(entry.query);
2010
+ if (queryNames.length > 0) {
2011
+ const queryTable = el("table", "hdr");
2012
+ for (const name of queryNames) {
2013
+ const value = entry.query[name] ?? "";
2014
+ const row = el("tr");
2015
+ row.appendChild(el("td", "n", `?${name}`));
2016
+ row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));
2017
+ queryTable.appendChild(row);
2018
+ }
2019
+ cell.appendChild(queryTable);
2020
+ }
2021
+ if (entry.headers !== void 0 && entry.headers.length > 0) {
2022
+ const table = el("table", "hdr");
2023
+ for (const [name, value] of entry.headers) {
2024
+ const row = el("tr");
2025
+ row.appendChild(el("td", "n", `${name}:`));
2026
+ row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));
2027
+ table.appendChild(row);
2028
+ }
2029
+ cell.appendChild(table);
2030
+ }
2031
+ const tools = el("div", "tools");
2032
+ if (SECTIONS.evidence) {
2033
+ tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));
2034
+ tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));
2035
+ tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));
2036
+ }
2037
+ if (SECTIONS.policy) {
2038
+ const draft2 = el("button", null, "Draft a rule");
2039
+ draft2.title = "Start a rule from this request, in the policy editor";
2040
+ draft2.addEventListener("click", (event) => {
2041
+ event.stopPropagation();
2042
+ void draftIntoEditor(entry);
2043
+ });
2044
+ tools.appendChild(draft2);
2045
+ }
2046
+ if (SECTIONS.actors) {
2047
+ const actorButton = el("button", null, "Show this actor");
2048
+ actorButton.addEventListener("click", (event) => {
2049
+ event.stopPropagation();
2050
+ openActor(entry.actor);
2051
+ });
2052
+ tools.appendChild(actorButton);
2053
+ }
2054
+ cell.appendChild(tools);
2055
+ const foot = el("div", "detail-foot");
2056
+ foot.appendChild(el("span", null, clockTime(entry.at)));
2057
+ foot.appendChild(el("span", null, `actor ${entry.actor}`));
2058
+ if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));
2059
+ foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));
2060
+ foot.appendChild(el("span", "mono", entry.requestId));
2061
+ cell.appendChild(foot);
2062
+ tr.appendChild(cell);
2063
+ return tr;
2064
+ }
2065
+ function copyButton(label2, produce) {
2066
+ const button = el("button", null, label2);
2067
+ button.addEventListener("click", (event) => {
2068
+ event.stopPropagation();
2069
+ const text = produce();
2070
+ const done = () => {
2071
+ button.textContent = "Copied";
2072
+ setTimeout(() => {
2073
+ button.textContent = label2;
2074
+ }, 1200);
2075
+ };
2076
+ if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));
2077
+ else showText(text);
2078
+ });
2079
+ return button;
2080
+ }
2081
+ function downloadButton(label2, produce, filename) {
2082
+ const button = el("button", null, label2);
2083
+ button.addEventListener("click", (event) => {
2084
+ event.stopPropagation();
2085
+ download(`${produce()}
2086
+ `, filename, "application/x-ndjson");
2087
+ });
2088
+ return button;
2089
+ }
2090
+ function showText(text) {
2091
+ const box = el("pre", "code", text);
2092
+ const host = $("policy-result");
2093
+ host.hidden = false;
2094
+ host.className = "result";
2095
+ clear(host);
2096
+ host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));
2097
+ host.appendChild(box);
2098
+ const selection = getSelection();
2099
+ if (selection !== null) {
2100
+ const range = document.createRange();
2101
+ range.selectNodeContents(box);
2102
+ selection.removeAllRanges();
2103
+ selection.addRange(range);
2104
+ }
2105
+ }
2106
+ var FILTERS, rendered;
2107
+ var init_feed = __esm({
2108
+ "src/dashboard/client/feed.ts"() {
2109
+ "use strict";
2110
+ init_dom();
2111
+ init_store();
2112
+ init_boot();
2113
+ init_app();
2114
+ init_format();
2115
+ init_replay();
2116
+ init_actor();
2117
+ init_outcome();
2118
+ init_policy();
2119
+ FILTERS = [
2120
+ ["all", "All"],
2121
+ ["proven", "Proven"],
2122
+ ["suspected", "Suspected"],
2123
+ ["human", "Human"],
2124
+ ["guard", "Guard stops"],
2125
+ ["deny", "Denied"],
2126
+ ["mitigate", "Mitigated"],
2127
+ ["allow", "Served"]
2128
+ ];
2129
+ rendered = /* @__PURE__ */ new Map();
2130
+ }
2131
+ });
2132
+
2133
+ // src/dashboard/client/stream.ts
2134
+ var stream_exports = {};
2135
+ __export(stream_exports, {
2136
+ connectStream: () => connectStream,
2137
+ loadInitialSnapshot: () => loadInitialSnapshot,
2138
+ suspendStream: () => suspendStream
2139
+ });
2140
+ function suspendStream() {
2141
+ source?.close();
2142
+ source = void 0;
2143
+ }
2144
+ function connectStream() {
2145
+ if (!SECTIONS.feed) {
2146
+ $("dot").className = "dot";
2147
+ $("conn").textContent = "feed off";
2148
+ return;
2149
+ }
2150
+ if (source !== void 0) return;
2151
+ source = new EventSource(`${API}/api/stream`);
2152
+ source.addEventListener("open", () => {
2153
+ $("dot").className = "dot on";
2154
+ $("conn").textContent = "live";
2155
+ });
2156
+ source.addEventListener("sync", (event) => {
2157
+ const detail = JSON.parse(event.data);
2158
+ if (!detail.replace) return;
2159
+ clearFeed();
2160
+ resetFeedCache();
2161
+ });
2162
+ source.addEventListener("entry", (event) => {
2163
+ ingest(JSON.parse(event.data));
2164
+ if (state.paused) {
2165
+ state.bufferedWhilePaused++;
2166
+ $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;
2167
+ }
2168
+ app.draw();
2169
+ });
2170
+ source.addEventListener("update", (event) => {
2171
+ ingest(JSON.parse(event.data));
2172
+ app.draw();
2173
+ });
2174
+ source.addEventListener("reset", () => {
2175
+ clearFeed();
2176
+ resetFeedCache();
2177
+ app.draw();
2178
+ });
2179
+ source.addEventListener("lagged", (event) => {
2180
+ const detail = JSON.parse(event.data);
2181
+ state.laggedDrops += detail.dropped;
2182
+ app.draw();
2183
+ });
2184
+ source.addEventListener("stats", (event) => {
2185
+ state.snapshot = JSON.parse(event.data);
2186
+ app.draw();
2187
+ });
2188
+ source.addEventListener("error", () => {
2189
+ $("dot").className = "dot off";
2190
+ $("conn").textContent = "reconnecting\u2026";
2191
+ });
2192
+ }
2193
+ async function loadInitialSnapshot() {
2194
+ try {
2195
+ state.snapshot = await getJson("/api/stats");
2196
+ app.drawNow();
2197
+ } catch {
2198
+ }
2199
+ }
2200
+ var source;
2201
+ var init_stream = __esm({
2202
+ "src/dashboard/client/stream.ts"() {
2203
+ "use strict";
2204
+ init_dom();
2205
+ init_boot();
2206
+ init_app();
2207
+ init_store();
2208
+ init_api();
2209
+ init_feed();
2210
+ }
2211
+ });
2212
+
2213
+ // src/dashboard/client/panels.ts
2214
+ function drawTiles(force = false) {
2215
+ if (!SECTIONS.statistics) return;
2216
+ const box = $("tiles");
2217
+ const snapshot = state.snapshot;
2218
+ if (snapshot === void 0) return;
2219
+ if (!force && paintedSnapshot === snapshot) return;
2220
+ paintedSnapshot = snapshot;
2221
+ const metrics = snapshot.metrics;
2222
+ clear(box);
2223
+ if (metrics === void 0) {
2224
+ box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));
2225
+ return;
2226
+ }
2227
+ const actions = metrics.actions;
2228
+ const denied = actions.block + actions.drop + actions.redirect;
2229
+ const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;
2230
+ const served = actions.allow + actions.tag + actions.log;
2231
+ const total = metrics.requests;
2232
+ const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;
2233
+ const provenBotCount = provenBots(metrics.verdicts);
2234
+ const tiles = [
2235
+ ["", n(total), "Requests", "since start"],
2236
+ ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`],
2237
+ ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied on this alone"],
2238
+ ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`],
2239
+ ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "rules asking for more than the evidence" : "no rule overreached"],
2240
+ ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`],
2241
+ [
2242
+ "",
2243
+ n(mitigated),
2244
+ "Mitigated",
2245
+ metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged, limited or delayed"
2246
+ ],
2247
+ ["good", n(served), "Served", `${pct(served, total)} of traffic`],
2248
+ ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now"]
2249
+ ];
2250
+ for (const [kind, value, key, sub] of tiles) {
2251
+ const tile = el("div", `tile ${kind}`);
2252
+ tile.appendChild(el("div", "v tnum", value));
2253
+ tile.appendChild(el("div", "k", key));
2254
+ tile.appendChild(el("div", "s", sub));
2255
+ box.appendChild(tile);
2256
+ }
2257
+ }
2258
+ function drawChips() {
2259
+ const snapshot = state.snapshot;
2260
+ if (snapshot === void 0) return;
2261
+ const box = $("chips");
2262
+ clear(box);
2263
+ const facts = [
2264
+ // Which process this is, first, because everything after it is a fact about this
2265
+ // process and nothing else. Behind a load balancer there are as many of these
2266
+ // dashboards as there are pods, each showing its own share of the traffic.
2267
+ ["instance", snapshot.instance],
2268
+ ["guard", snapshot.policy.falsePositivePolicy],
2269
+ ["suspect at", String(snapshot.policy.suspectThreshold)]
2270
+ ];
2271
+ if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);
2272
+ if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);
2273
+ facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);
2274
+ for (const [label2, value] of facts) {
2275
+ const fact = el("span");
2276
+ fact.appendChild(document.createTextNode(`${label2} `));
2277
+ fact.appendChild(el("b", null, value));
2278
+ box.appendChild(fact);
2279
+ }
2280
+ }
2281
+ function updateWindowLabels() {
2282
+ const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());
2283
+ for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;
2284
+ }
2285
+ function drawLivePanels() {
2286
+ if (!SECTIONS.statistics) return;
2287
+ const totals = aggregate(state.rows);
2288
+ drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");
2289
+ if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");
2290
+ }
2291
+ function drawStatsPanels() {
2292
+ const snapshot = state.snapshot;
2293
+ if (snapshot === void 0) return;
2294
+ const metrics = snapshot.metrics;
2295
+ if (metrics !== void 0) {
2296
+ drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");
2297
+ drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");
2298
+ drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");
2299
+ drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");
2300
+ const challenges = $("stat-challenges");
2301
+ clear(challenges);
2302
+ if (!snapshot.policy.challengeEnabled) {
2303
+ challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));
2304
+ } else {
2305
+ const funnel = [
2306
+ ["Issued", n(metrics.challenges.issued)],
2307
+ ["Solved", n(metrics.challenges.solved)],
2308
+ ["Rejected", n(metrics.challenges.rejected)],
2309
+ ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]
2310
+ ];
2311
+ for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));
2312
+ }
2313
+ const health = $("stat-health");
2314
+ clear(health);
2315
+ const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];
2316
+ const rows = [
2317
+ ["Requests assessed", n(metrics.requests)],
2318
+ ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],
2319
+ ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],
2320
+ ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],
2321
+ ["Actors tracked", n(metrics.actorsTracked)],
2322
+ ["Guard stops", n(metrics.downgrades)]
2323
+ ];
2324
+ const failures = pairs(metrics.detectorFailures);
2325
+ for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);
2326
+ for (const [key, value] of rows) health.appendChild(statRow(key, value));
2327
+ if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));
2328
+ }
2329
+ const totals = aggregate(state.rows);
2330
+ if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");
2331
+ drawBars($("stat-paths"), totals.paths, "No traffic in this window.");
2332
+ drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");
2333
+ drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");
2334
+ drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");
2335
+ drawRuleHits(totals);
2336
+ const list = $("stat-detector-list");
2337
+ clear(list);
2338
+ $("detector-count").textContent = `${snapshot.detectors.length} installed`;
2339
+ for (const detector of snapshot.detectors) {
2340
+ const row = el("div", "det");
2341
+ const left = el("div");
2342
+ left.appendChild(el("div", "mono", detector.id));
2343
+ left.appendChild(el("div", "d", detector.description));
2344
+ row.appendChild(left);
2345
+ const fires = metrics?.detectorFirings[detector.id] ?? 0;
2346
+ const timing = metrics?.detectorTimings[detector.id];
2347
+ let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
2348
+ if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;
2349
+ row.appendChild(el("div", "n", right));
2350
+ list.appendChild(row);
2351
+ }
2352
+ }
2353
+ function statRow(key, value) {
2354
+ const line = el("div", "stat-row");
2355
+ line.appendChild(el("span", "k", key));
2356
+ line.appendChild(el("span", "v", value));
2357
+ return line;
2358
+ }
2359
+ function drawRuleHits(totals) {
2360
+ const target = $("stat-rule-hits");
2361
+ clear(target);
2362
+ const rules = state.snapshot?.rules ?? [];
2363
+ if (rules.length === 0) {
2364
+ target.appendChild(el("div", "note", "No rules configured."));
2365
+ return;
2366
+ }
2367
+ let max = 1;
2368
+ for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);
2369
+ for (const rule of rules) {
2370
+ const value = totals.ruleHits.get(rule) ?? 0;
2371
+ const bar = el("div", `bar${value === 0 ? " dead" : ""}`);
2372
+ const track = el("div", "track");
2373
+ if (value > 0) {
2374
+ const fill = el("div", "fill");
2375
+ fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;
2376
+ track.appendChild(fill);
2377
+ }
2378
+ track.appendChild(el("div", "lbl", rule));
2379
+ bar.appendChild(track);
2380
+ bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));
2381
+ target.appendChild(bar);
2382
+ }
2383
+ }
2384
+ function drawAudit() {
2385
+ const snapshot = state.snapshot;
2386
+ if (snapshot === void 0) return;
2387
+ const body = $("audit-body");
2388
+ const checks = $("audit-checks");
2389
+ clear(body);
2390
+ clear(checks);
2391
+ const audit = snapshot.audit;
2392
+ if (audit === void 0) {
2393
+ $("audit-spans").textContent = "";
2394
+ body.appendChild(
2395
+ el(
2396
+ "div",
2397
+ "note",
2398
+ "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."
2399
+ )
2400
+ );
2401
+ return;
2402
+ }
2403
+ const current = audit.window;
2404
+ const baseline = audit.baseline;
2405
+ $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;
2406
+ const rows = [
2407
+ ["Requests", n(current.requests), n(baseline.requests)],
2408
+ ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],
2409
+ ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],
2410
+ ["Bots", n(current.bots), n(baseline.bots)],
2411
+ ["Humans", n(current.humans), n(baseline.humans)],
2412
+ ["Denials", n(current.denials), n(baseline.denials)],
2413
+ ["Challenges", n(current.challenges), n(baseline.challenges)],
2414
+ ["Guard stops", n(current.downgrades), n(baseline.downgrades)],
2415
+ ["Detector failures", n(current.failures), n(baseline.failures)],
2416
+ ["Bypassed", n(current.bypassed), n(baseline.bypassed)]
2417
+ ];
2418
+ for (const [key, now, was] of rows) {
2419
+ const line = el("div", "stat-row");
2420
+ line.appendChild(el("span", "k", key));
2421
+ const values = el("span", "v");
2422
+ values.appendChild(el("b", null, now));
2423
+ values.appendChild(el("span", "was", ` was ${was}`));
2424
+ line.appendChild(values);
2425
+ body.appendChild(line);
2426
+ }
2427
+ if (audit.checks.length === 0) {
2428
+ checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));
2429
+ return;
2430
+ }
2431
+ for (const check of audit.checks) {
2432
+ const row = el("div", "det");
2433
+ const left = el("div");
2434
+ left.appendChild(el("div", "mono", check.id));
2435
+ left.appendChild(el("div", "d", check.description));
2436
+ row.appendChild(left);
2437
+ checks.appendChild(row);
2438
+ }
2439
+ }
2440
+ function drawNoticeBadge() {
2441
+ const notices = state.snapshot?.notices ?? [];
2442
+ const badge = $("notice-badge");
2443
+ badge.hidden = notices.length === 0 || !SECTIONS.notices;
2444
+ badge.textContent = String(notices.length);
2445
+ }
2446
+ function drawNotices() {
2447
+ const box = $("stat-notices");
2448
+ const notices = state.snapshot?.notices ?? [];
2449
+ clear(box);
2450
+ $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";
2451
+ if (notices.length === 0) {
2452
+ box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));
2453
+ return;
2454
+ }
2455
+ for (const notice2 of notices.slice().reverse().slice(0, 40)) {
2456
+ const row = el("div", `notice ${notice2.kind}`);
2457
+ const when = el("div", "when");
2458
+ when.appendChild(el("div", "tag", notice2.kind));
2459
+ when.appendChild(el("div", null, clockTime(notice2.at)));
2460
+ row.appendChild(when);
2461
+ const body = el("div");
2462
+ body.appendChild(el("div", null, notice2.message));
2463
+ if (notice2.source !== void 0) body.appendChild(el("div", "ev-meta", notice2.source));
2464
+ row.appendChild(body);
2465
+ box.appendChild(row);
2466
+ }
2467
+ }
2468
+ function drawChanges() {
2469
+ if (!SECTIONS.changes) return;
2470
+ const box = $("stat-changes");
2471
+ const changes = state.snapshot?.changes ?? [];
2472
+ clear(box);
2473
+ $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";
2474
+ if (changes.length === 0) {
2475
+ box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));
2476
+ return;
2477
+ }
2478
+ for (const change of changes.slice().reverse()) {
2479
+ const row = el("div", "notice");
2480
+ const when = el("div", "when");
2481
+ when.appendChild(el("div", "tag", change.kind));
2482
+ when.appendChild(el("div", null, clockTime(change.at)));
2483
+ row.appendChild(when);
2484
+ const body = el("div");
2485
+ body.appendChild(el("div", null, change.summary));
2486
+ body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener's auth carries no identity" : `by ${change.by}`));
2487
+ row.appendChild(body);
2488
+ box.appendChild(row);
2489
+ }
2490
+ }
2491
+ function drawPeers() {
2492
+ const box = $("peers");
2493
+ if (BOOT.peers.length === 0) {
2494
+ box.hidden = true;
2495
+ return;
2496
+ }
2497
+ if (box.childElementCount > 0) return;
2498
+ box.appendChild(el("span", "hint", "also:"));
2499
+ for (const peer of BOOT.peers) {
2500
+ const link = el("a", "linkbtn", peer.label);
2501
+ link.href = peer.href;
2502
+ link.rel = "noreferrer noopener";
2503
+ box.appendChild(link);
2504
+ }
2505
+ }
2506
+ var paintedSnapshot;
2507
+ var init_panels = __esm({
2508
+ "src/dashboard/client/panels.ts"() {
2509
+ "use strict";
2510
+ init_dom();
2511
+ init_boot();
2512
+ init_store();
2513
+ init_bars();
2514
+ init_format();
2515
+ init_outcome();
2516
+ }
2517
+ });
2518
+
2519
+ // src/dashboard/client/charts.ts
2520
+ function positionTip(tip, host, clientX, boxLeft) {
2521
+ tip.style.opacity = "1";
2522
+ tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;
2523
+ tip.style.top = "14px";
2524
+ }
2525
+ function tipRow(label2, value, colour) {
2526
+ const line = el("div", "r");
2527
+ const left = el("em");
2528
+ if (colour !== void 0) {
2529
+ const swatch = el("span", "swatch");
2530
+ swatch.style.background = colour;
2531
+ left.appendChild(swatch);
2532
+ }
2533
+ left.appendChild(document.createTextNode(label2));
2534
+ line.appendChild(left);
2535
+ line.appendChild(el("b", null, value));
2536
+ return line;
2537
+ }
2538
+ function timeline() {
2539
+ const bucketMs = state.rangeMs / BUCKETS;
2540
+ const now = Date.now();
2541
+ const start2 = now - state.rangeMs;
2542
+ const buckets = [];
2543
+ for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });
2544
+ for (const { entry } of state.rows) {
2545
+ const index = Math.floor((entry.at - start2) / bucketMs);
2546
+ if (index < 0 || index >= BUCKETS) continue;
2547
+ const bucket = buckets[index];
2548
+ if (bucket === void 0) continue;
2549
+ bucket.total++;
2550
+ const out = outcome(entry);
2551
+ if (out === "deny") bucket.denied++;
2552
+ else if (out === "mitigate") bucket.mitigated++;
2553
+ else bucket.served++;
2554
+ }
2555
+ return buckets;
2556
+ }
2557
+ function drawTraffic() {
2558
+ const host = $("traffic-chart");
2559
+ const svg = $("traffic");
2560
+ const width = Math.max(320, host.clientWidth - 30);
2561
+ const height = 190;
2562
+ const padBottom = 26;
2563
+ const markerRow = 8;
2564
+ const plot = height - padBottom - markerRow;
2565
+ const buckets = timeline();
2566
+ const now = Date.now();
2567
+ const start2 = now - state.rangeMs;
2568
+ let peak = 1;
2569
+ for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;
2570
+ const max = Math.max(2, Math.ceil(peak / 2) * 2);
2571
+ clear(svg);
2572
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
2573
+ svg.setAttribute("height", String(height));
2574
+ const s1 = css("--s1");
2575
+ const s2 = css("--s2");
2576
+ const crit = css("--crit");
2577
+ const grid = css("--grid");
2578
+ const muted = css("--muted");
2579
+ const step = width / BUCKETS;
2580
+ const barWidth = Math.max(2, step - 2);
2581
+ for (const fraction of [0, 0.5, 1]) {
2582
+ const y = markerRow + plot - fraction * plot;
2583
+ svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));
2584
+ if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));
2585
+ }
2586
+ buckets.forEach((bucket, index) => {
2587
+ const x = index * step + 1;
2588
+ const servedHeight = bucket.served / max * plot;
2589
+ const mitigatedHeight = bucket.mitigated / max * plot;
2590
+ let y = markerRow + plot;
2591
+ if (servedHeight > 0) {
2592
+ y -= servedHeight;
2593
+ svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));
2594
+ }
2595
+ if (mitigatedHeight > 0) {
2596
+ y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);
2597
+ svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));
2598
+ }
2599
+ if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));
2600
+ });
2601
+ svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));
2602
+ const span = rangeLabel(state.rangeMs);
2603
+ const labels = [
2604
+ [0, `${span} ago`],
2605
+ [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],
2606
+ [BUCKETS - 1, "now"]
2607
+ ];
2608
+ for (const [position, text] of labels) {
2609
+ svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));
2610
+ }
2611
+ const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);
2612
+ const markColour = css("--proven-text");
2613
+ for (const change of changes) {
2614
+ const x = (change.at - start2) / state.rangeMs * width;
2615
+ svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));
2616
+ const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });
2617
+ const title = svgEl("title");
2618
+ title.textContent = `${new Date(change.at).toLocaleTimeString()} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;
2619
+ dot.appendChild(title);
2620
+ svg.appendChild(dot);
2621
+ }
2622
+ const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });
2623
+ svg.appendChild(hover);
2624
+ const tip = $("traffic-tip");
2625
+ host.onmousemove = (event) => {
2626
+ const box = svg.getBoundingClientRect();
2627
+ const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);
2628
+ const bucket = buckets[index];
2629
+ if (bucket === void 0) {
2630
+ tip.style.opacity = "0";
2631
+ hover.setAttribute("width", "0");
2632
+ return;
2633
+ }
2634
+ hover.setAttribute("x", String(index * step));
2635
+ hover.setAttribute("width", String(step));
2636
+ clear(tip);
2637
+ tip.appendChild(el("div", "t", `${new Date(bucket.at).toLocaleTimeString()} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));
2638
+ tip.appendChild(tipRow("Served", n(bucket.served), s1));
2639
+ tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));
2640
+ tip.appendChild(tipRow("Denied", n(bucket.denied), crit));
2641
+ positionTip(tip, host, event.clientX, box.left);
2642
+ };
2643
+ host.onmouseleave = () => {
2644
+ tip.style.opacity = "0";
2645
+ hover.setAttribute("width", "0");
2646
+ };
2647
+ const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);
2648
+ $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;
2649
+ const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);
2650
+ const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);
2651
+ const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);
2652
+ const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });
2653
+ $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${new Date(busiest.at).toLocaleTimeString()}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);
2654
+ const legend = $("traffic-legend");
2655
+ clear(legend);
2656
+ legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));
2657
+ const oldest = oldestAt();
2658
+ if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {
2659
+ legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));
2660
+ }
2661
+ for (const [label2, colour] of [
2662
+ ["Served", s1],
2663
+ ["Mitigated \u2014 challenged, limited or delayed", s2],
2664
+ ["Denied", crit]
2665
+ ]) {
2666
+ const item = el("span");
2667
+ const swatch = el("span", "swatch");
2668
+ swatch.style.background = colour;
2669
+ item.appendChild(swatch);
2670
+ item.appendChild(document.createTextNode(label2));
2671
+ legend.appendChild(item);
2672
+ }
2673
+ if (changes.length > 0) {
2674
+ const item = el("span");
2675
+ item.appendChild(el("span", "mark"));
2676
+ item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));
2677
+ legend.appendChild(item);
2678
+ }
2679
+ }
2680
+ function differences(cumulative) {
2681
+ const counts = [];
2682
+ for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));
2683
+ return counts;
2684
+ }
2685
+ function drawScores() {
2686
+ const host = $("score-chart");
2687
+ const svg = $("scores");
2688
+ clear(svg);
2689
+ const metrics = state.snapshot?.metrics;
2690
+ const fromRun = state.scoreScope === "run" && metrics !== void 0;
2691
+ let buckets;
2692
+ let scored;
2693
+ let proven;
2694
+ if (fromRun && metrics !== void 0) {
2695
+ buckets = differences(metrics.scores.buckets);
2696
+ scored = metrics.scores.count;
2697
+ proven = metrics.proven;
2698
+ } else {
2699
+ buckets = new Array(SCORE_BOUNDS).fill(0);
2700
+ scored = 0;
2701
+ proven = 0;
2702
+ for (const { entry } of state.rows) {
2703
+ if (entry.bypass !== void 0) continue;
2704
+ if (entry.certain) {
2705
+ proven++;
2706
+ continue;
2707
+ }
2708
+ const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));
2709
+ buckets[index] = (buckets[index] ?? 0) + 1;
2710
+ scored++;
2711
+ }
2712
+ }
2713
+ const width = Math.max(280, host.clientWidth - 30);
2714
+ const height = 190;
2715
+ const padBottom = 30;
2716
+ const plot = height - padBottom;
2717
+ let max = 1;
2718
+ for (const value of buckets) if (value > max) max = value;
2719
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
2720
+ svg.setAttribute("height", String(height));
2721
+ const fill = css("--s1");
2722
+ const muted = css("--muted");
2723
+ const grid = css("--grid");
2724
+ const crit = css("--crit");
2725
+ svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));
2726
+ const step = width / SCORE_BOUNDS;
2727
+ buckets.forEach((value, index) => {
2728
+ const barHeight = value / max * (plot - 8);
2729
+ const barWidth = Math.max(2, Math.min(step - 6, 56));
2730
+ if (barHeight > 0) {
2731
+ svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));
2732
+ }
2733
+ svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));
2734
+ });
2735
+ const threshold = state.snapshot?.policy.suspectThreshold ?? 60;
2736
+ const x = threshold / 100 * width;
2737
+ svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));
2738
+ svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));
2739
+ svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));
2740
+ const tip = $("score-tip");
2741
+ host.onmousemove = (event) => {
2742
+ const box = svg.getBoundingClientRect();
2743
+ const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);
2744
+ const value = buckets[index];
2745
+ if (value === void 0) {
2746
+ tip.style.opacity = "0";
2747
+ return;
2748
+ }
2749
+ clear(tip);
2750
+ tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));
2751
+ tip.appendChild(tipRow("requests", n(value)));
2752
+ tip.appendChild(tipRow("share", pct(value, scored)));
2753
+ positionTip(tip, host, event.clientX, box.left);
2754
+ };
2755
+ host.onmouseleave = () => {
2756
+ tip.style.opacity = "0";
2757
+ };
2758
+ let over = 0;
2759
+ for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;
2760
+ const legend = $("score-legend");
2761
+ clear(legend);
2762
+ legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));
2763
+ $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);
2764
+ $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());
2765
+ if (state.scoreScope === "run" && metrics === void 0) {
2766
+ legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));
2767
+ }
2768
+ }
2769
+ function drawLatency() {
2770
+ const host = $("latency-chart");
2771
+ const svg = $("latency");
2772
+ clear(svg);
2773
+ const metrics = state.snapshot?.metrics;
2774
+ if (metrics === void 0 || metrics.duration.count === 0) {
2775
+ $("latency-summary").textContent = "No assessments yet.";
2776
+ $("latency-alt").textContent = "Assessment latency: no assessments yet.";
2777
+ svg.setAttribute("viewBox", "0 0 100 40");
2778
+ svg.setAttribute("height", "40");
2779
+ svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));
2780
+ return;
2781
+ }
2782
+ const cumulative = metrics.duration.buckets;
2783
+ const counts = differences(cumulative);
2784
+ const width = Math.max(280, host.clientWidth - 30);
2785
+ const height = 190;
2786
+ const padBottom = 30;
2787
+ const plot = height - padBottom;
2788
+ let max = 1;
2789
+ for (const value of counts) if (value > max) max = value;
2790
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
2791
+ svg.setAttribute("height", String(height));
2792
+ const fill = css("--s1");
2793
+ const muted = css("--muted");
2794
+ const grid = css("--grid");
2795
+ svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));
2796
+ const step = width / counts.length;
2797
+ counts.forEach((value, index) => {
2798
+ const barHeight = value / max * (plot - 6);
2799
+ if (barHeight > 0) {
2800
+ svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));
2801
+ }
2802
+ if (index % 2 === 0) {
2803
+ svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));
2804
+ }
2805
+ });
2806
+ svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));
2807
+ const tip = $("latency-tip");
2808
+ host.onmousemove = (event) => {
2809
+ const box = svg.getBoundingClientRect();
2810
+ const index = Math.floor((event.clientX - box.left) / box.width * counts.length);
2811
+ const value = counts[index];
2812
+ if (value === void 0) {
2813
+ tip.style.opacity = "0";
2814
+ return;
2815
+ }
2816
+ clear(tip);
2817
+ tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));
2818
+ tip.appendChild(tipRow("requests", n(value)));
2819
+ tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));
2820
+ positionTip(tip, host, event.clientX, box.left);
2821
+ };
2822
+ host.onmouseleave = () => {
2823
+ tip.style.opacity = "0";
2824
+ };
2825
+ const mean = metrics.duration.totalMs / metrics.duration.count;
2826
+ const p95 = percentile(cumulative, metrics.duration.count, 0.95);
2827
+ $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;
2828
+ $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;
2829
+ }
2830
+ function percentile(cumulative, count, fraction) {
2831
+ const target = count * fraction;
2832
+ const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;
2833
+ for (let i = 0; i < cumulative.length; i++) {
2834
+ if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;
2835
+ }
2836
+ return last;
2837
+ }
2838
+ var BUCKETS, LATENCY_BOUNDS, SCORE_BOUNDS;
2839
+ var init_charts = __esm({
2840
+ "src/dashboard/client/charts.ts"() {
2841
+ "use strict";
2842
+ init_dom();
2843
+ init_format();
2844
+ init_store();
2845
+ init_outcome();
2846
+ BUCKETS = 60;
2847
+ LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];
2848
+ SCORE_BOUNDS = 10;
2849
+ }
2850
+ });
2851
+
2852
+ // src/dashboard/client/registry.ts
2853
+ async function loadActors() {
2854
+ if (!SECTIONS.registry) return;
2855
+ try {
2856
+ const body = await getJson("/api/actors?limit=100");
2857
+ state.actors = body.actors;
2858
+ state.actorsTracked = body.tracked;
2859
+ drawActors();
2860
+ } catch {
2861
+ }
2862
+ }
2863
+ function trackActors() {
2864
+ if (timer !== void 0) clearInterval(timer);
2865
+ timer = void 0;
2866
+ if (state.tab !== "actors") return;
2867
+ void loadActors();
2868
+ timer = setInterval(() => {
2869
+ if (state.tab !== "actors" || state.paused || isConfirming()) return;
2870
+ void loadActors();
2871
+ }, 4e3);
2872
+ }
2873
+ function drawActors() {
2874
+ if (!SECTIONS.registry) return;
2875
+ if (isConfirming()) return;
2876
+ const body = byId("actor-rows");
2877
+ clear(body);
2878
+ const actors = state.actors;
2879
+ $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
2880
+ byId("actors-empty").hidden = actors.length > 0;
2881
+ for (const actor of actors) {
2882
+ const row = el("tr");
2883
+ row.appendChild(el("td", "who", actor.key));
2884
+ row.appendChild(el("td", "num tnum", n(actor.requests)));
2885
+ row.appendChild(el("td", "num tnum", n(actor.recentRate)));
2886
+ row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));
2887
+ const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));
2888
+ if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";
2889
+ row.appendChild(cadence);
2890
+ row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));
2891
+ const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));
2892
+ if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";
2893
+ row.appendChild(unsolved);
2894
+ const stateCell = el("td");
2895
+ const tags = [];
2896
+ if (actor.cleared) tags.push("cleared as human");
2897
+ if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);
2898
+ tags.push(`first seen ${clockTime(actor.firstSeen)}`);
2899
+ stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));
2900
+ row.appendChild(stateCell);
2901
+ const actions = el("td", "acts");
2902
+ const inFeed = el("button", null, "In feed");
2903
+ inFeed.title = "Show this actor's requests in the live feed";
2904
+ inFeed.addEventListener("click", () => {
2905
+ setSearch(`actor:${actor.key}`);
2906
+ const search = byId("search");
2907
+ search.value = state.search;
2908
+ app.showTab("live");
2909
+ app.syncUrl();
2910
+ });
2911
+ actions.appendChild(inFeed);
2912
+ for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);
2913
+ row.appendChild(actions);
2914
+ body.appendChild(row);
2915
+ }
2916
+ }
2917
+ var timer;
2918
+ var init_registry = __esm({
2919
+ "src/dashboard/client/registry.ts"() {
2920
+ "use strict";
2921
+ init_dom();
2922
+ init_boot();
2923
+ init_actions();
2924
+ init_app();
2925
+ init_format();
2926
+ init_api();
2927
+ init_store();
2928
+ }
2929
+ });
2930
+
2931
+ // src/dashboard/client/tester.ts
2932
+ function initTester() {
2933
+ if (!SECTIONS.tester) return;
2934
+ $("test-run").addEventListener("click", () => void run());
2935
+ byId("test-input").addEventListener("keydown", (event) => {
2936
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
2937
+ event.preventDefault();
2938
+ void run();
2939
+ }
2940
+ });
2941
+ }
2942
+ async function run() {
2943
+ const raw2 = byId("test-input").value;
2944
+ if (raw2.trim() === "") {
2945
+ toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");
2946
+ return;
2947
+ }
2948
+ const result = await postJson("/api/test", {
2949
+ raw: raw2,
2950
+ ip: byId("test-ip").value.trim(),
2951
+ url: byId("test-url").value.trim()
2952
+ });
2953
+ const box = $("test-result");
2954
+ box.hidden = false;
2955
+ clear(box);
2956
+ if (!result.ok) {
2957
+ box.className = "result bad";
2958
+ box.appendChild(el("div", null, result.error ?? "The server could not read that."));
2959
+ return;
2960
+ }
2961
+ const { entry, reason, assumed } = result.data;
2962
+ const out = outcome(entry);
2963
+ box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;
2964
+ const head = el("div");
2965
+ const [badgeClass, badgeLabel] = verdictBadge(entry);
2966
+ head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));
2967
+ head.appendChild(document.createTextNode(" "));
2968
+ head.appendChild(el("b", null, entry.action ?? "no decision"));
2969
+ if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));
2970
+ box.appendChild(head);
2971
+ box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));
2972
+ if (entry.downgradedFrom !== void 0) {
2973
+ box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));
2974
+ if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));
2975
+ }
2976
+ if (entry.evidence.length === 0) {
2977
+ box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));
2978
+ } else {
2979
+ const list = el("div", "ev");
2980
+ for (const item of entry.evidence) {
2981
+ const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
2982
+ row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
2983
+ const detail = el("div");
2984
+ detail.appendChild(el("div", null, item.summary));
2985
+ detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));
2986
+ if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));
2987
+ row.appendChild(detail);
2988
+ list.appendChild(row);
2989
+ }
2990
+ box.appendChild(list);
2991
+ }
2992
+ box.appendChild(el("div", "ev-meta", reason));
2993
+ const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];
2994
+ box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));
2995
+ }
2996
+ var init_tester = __esm({
2997
+ "src/dashboard/client/tester.ts"() {
2998
+ "use strict";
2999
+ init_dom();
3000
+ init_boot();
3001
+ init_format();
3002
+ init_outcome();
3003
+ init_api();
3004
+ init_app();
3005
+ }
3006
+ });
3007
+
3008
+ // src/dashboard/client/index.ts
3009
+ var client_exports = {};
3010
+ function tabIndexOf(name) {
3011
+ const at = available.findIndex(([, tab]) => tab === name);
3012
+ return at === -1 ? 0 : at;
3013
+ }
3014
+ function isTab(value) {
3015
+ return available.some(([, name]) => name === value);
3016
+ }
3017
+ function initSkipLink() {
3018
+ if (!isEmbedded()) return;
3019
+ const skip = rootNode().querySelector("a.skip");
3020
+ if (skip === null) return;
3021
+ skip.addEventListener("click", (event) => {
3022
+ event.preventDefault();
3023
+ const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");
3024
+ if (target === null) return;
3025
+ target.tabIndex = -1;
3026
+ target.focus();
3027
+ target.scrollIntoView({ block: "start" });
3028
+ });
3029
+ }
3030
+ function initHeader() {
3031
+ const box = $("links");
3032
+ for (const link of BOOT.links) {
3033
+ const anchor = el("a", "linkbtn", link.label);
3034
+ anchor.href = link.href;
3035
+ anchor.rel = "noreferrer noopener";
3036
+ box.appendChild(anchor);
3037
+ }
3038
+ let stored = null;
3039
+ try {
3040
+ stored = localStorage.getItem("bothandler-dashboard-theme");
3041
+ } catch {
3042
+ stored = null;
3043
+ }
3044
+ if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);
3045
+ $("theme").addEventListener("click", () => {
3046
+ let current = themeElement().getAttribute("data-theme");
3047
+ if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
3048
+ const next = current === "dark" ? "light" : "dark";
3049
+ themeElement().setAttribute("data-theme", next);
3050
+ try {
3051
+ localStorage.setItem("bothandler-dashboard-theme", next);
3052
+ } catch {
3053
+ }
3054
+ drawNow();
3055
+ });
3056
+ const pause = byId("pause");
3057
+ pause.hidden = !SECTIONS.feed;
3058
+ pause.addEventListener("click", () => {
3059
+ state.paused = !state.paused;
3060
+ pause.setAttribute("aria-pressed", String(state.paused));
3061
+ pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";
3062
+ if (!state.paused) {
3063
+ state.bufferedWhilePaused = 0;
3064
+ drawNow();
3065
+ }
3066
+ });
3067
+ if (BOOT.allowReset) {
3068
+ const reset = byId("reset");
3069
+ reset.hidden = false;
3070
+ reset.addEventListener("click", () => {
3071
+ reset.disabled = true;
3072
+ void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {
3073
+ if (!response.ok) {
3074
+ const body = await response.json().catch(() => ({}));
3075
+ toast("bad", "Reset refused", body.error ?? String(response.status));
3076
+ return;
3077
+ }
3078
+ clearFeed();
3079
+ resetFeedCache();
3080
+ drawNow();
3081
+ }).catch(() => {
3082
+ }).finally(() => {
3083
+ reset.disabled = false;
3084
+ });
3085
+ });
3086
+ }
3087
+ const header = rootNode().querySelector("header");
3088
+ if (header !== null) {
3089
+ const apply = () => {
3090
+ themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);
3091
+ };
3092
+ apply();
3093
+ if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);
3094
+ else addEventListener("resize", apply);
3095
+ }
3096
+ }
3097
+ function syncUrl(replace = true) {
3098
+ if (isEmbedded()) return;
3099
+ const params = new URLSearchParams();
3100
+ if (state.filter !== "all") params.set("f", state.filter);
3101
+ if (state.search !== "") params.set("q", state.search);
3102
+ const query = params.toString();
3103
+ const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;
3104
+ if (location.hash === hash) return;
3105
+ history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);
3106
+ }
3107
+ function readUrl() {
3108
+ const raw2 = isEmbedded() ? "" : location.hash.slice(1);
3109
+ const split = raw2.indexOf("?");
3110
+ const name = split === -1 ? raw2 : raw2.slice(0, split);
3111
+ const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));
3112
+ const filter = params.get("f") ?? "all";
3113
+ return {
3114
+ tab: isTab(name) ? name : FIRST,
3115
+ filter,
3116
+ search: params.get("q") ?? ""
3117
+ };
3118
+ }
3119
+ function showTab(name, options = {}) {
3120
+ const target = isTab(name) ? name : FIRST;
3121
+ state.tab = target;
3122
+ for (const [id, tab, enabled] of TABS) {
3123
+ const selected = tab === target;
3124
+ const node = $(id);
3125
+ node.hidden = !enabled;
3126
+ node.setAttribute("aria-selected", String(selected));
3127
+ node.tabIndex = selected ? 0 : -1;
3128
+ $(`view-${tab}`).hidden = !selected || !enabled;
3129
+ }
3130
+ if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();
3131
+ if (target === "policy" && state.policy === void 0) {
3132
+ void loadPolicy();
3133
+ void loadRanges();
3134
+ }
3135
+ trackActors();
3136
+ if (options.push !== false) syncUrl(options.replace !== false);
3137
+ drawNow();
3138
+ }
3139
+ function initTabs() {
3140
+ available.forEach(([id, name], index) => {
3141
+ const tab = $(id);
3142
+ tab.addEventListener("click", () => showTab(name, { replace: false }));
3143
+ tab.addEventListener("keydown", (event) => {
3144
+ let next = -1;
3145
+ if (event.key === "ArrowRight") next = (index + 1) % available.length;
3146
+ else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;
3147
+ else if (event.key === "Home") next = 0;
3148
+ else if (event.key === "End") next = available.length - 1;
3149
+ if (next === -1) return;
3150
+ event.preventDefault();
3151
+ showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });
3152
+ });
3153
+ });
3154
+ if (isEmbedded()) return;
3155
+ addEventListener("popstate", () => {
3156
+ const url = readUrl();
3157
+ state.filter = url.filter;
3158
+ setSearch(url.search);
3159
+ reflectFilterButtons();
3160
+ showTab(url.tab, { push: false });
3161
+ });
3162
+ }
3163
+ function initKeyboard() {
3164
+ eventTarget().addEventListener("keydown", ((event) => {
3165
+ const target = event.target;
3166
+ const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");
3167
+ if (event.key === "Escape") {
3168
+ if (typing && target?.id === "search" && target.value !== "") {
3169
+ target.value = "";
3170
+ setSearch("");
3171
+ syncUrl();
3172
+ drawNow();
3173
+ return;
3174
+ }
3175
+ if (typing) {
3176
+ target?.blur();
3177
+ return;
3178
+ }
3179
+ if (state.actor !== void 0) {
3180
+ state.actor = void 0;
3181
+ drawNow();
3182
+ return;
3183
+ }
3184
+ if (state.open.size > 0) {
3185
+ state.open.clear();
3186
+ drawNow();
3187
+ }
3188
+ return;
3189
+ }
3190
+ if (typing || event.metaKey || event.ctrlKey || event.altKey) return;
3191
+ if (event.key === "/" && SECTIONS.feed) {
3192
+ event.preventDefault();
3193
+ showTab("live");
3194
+ const search = byId("search");
3195
+ search.focus();
3196
+ search.select();
3197
+ return;
3198
+ }
3199
+ const digit = ["1", "2", "3", "4"].indexOf(event.key);
3200
+ if (digit !== -1 && digit < available.length) {
3201
+ event.preventDefault();
3202
+ showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });
3203
+ }
3204
+ }));
3205
+ }
3206
+ function initRanges() {
3207
+ const ranges = [
3208
+ ["1m", 6e4],
3209
+ ["5m", 3e5],
3210
+ ["15m", 9e5],
3211
+ ["1h", 36e5]
3212
+ ];
3213
+ const host = $("traffic-range");
3214
+ for (const [label2, value] of ranges) {
3215
+ const button = el("button", null, label2);
3216
+ button.setAttribute("aria-pressed", String(value === state.rangeMs));
3217
+ button.addEventListener("click", () => {
3218
+ state.rangeMs = value;
3219
+ for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");
3220
+ button.setAttribute("aria-pressed", "true");
3221
+ drawTraffic();
3222
+ });
3223
+ host.appendChild(button);
3224
+ }
3225
+ const scopes = [
3226
+ ["since start", "run"],
3227
+ ["this window", "window"]
3228
+ ];
3229
+ const scopeHost = $("score-scope");
3230
+ for (const [label2, value] of scopes) {
3231
+ const button = el("button", null, label2);
3232
+ button.setAttribute("aria-pressed", String(value === state.scoreScope));
3233
+ button.addEventListener("click", () => {
3234
+ state.scoreScope = value;
3235
+ for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");
3236
+ button.setAttribute("aria-pressed", "true");
3237
+ drawScores();
3238
+ });
3239
+ scopeHost.appendChild(button);
3240
+ }
3241
+ }
3242
+ function schedule() {
3243
+ if (state.paused || pending) return;
3244
+ pending = true;
3245
+ requestAnimationFrame(() => {
3246
+ pending = false;
3247
+ draw();
3248
+ });
3249
+ }
3250
+ function draw() {
3251
+ drawChips();
3252
+ drawTiles();
3253
+ drawNoticeBadge();
3254
+ updateWindowLabels();
3255
+ if (state.tab === "live") {
3256
+ drawActor();
3257
+ drawFeed();
3258
+ drawLivePanels();
3259
+ } else if (state.tab === "actors") {
3260
+ drawActors();
3261
+ } else if (state.tab === "stats") {
3262
+ drawTraffic();
3263
+ drawLatency();
3264
+ drawScores();
3265
+ drawStatsPanels();
3266
+ if (SECTIONS.audit) drawAudit();
3267
+ } else {
3268
+ drawPolicyTab();
3269
+ if (SECTIONS.notices) drawNotices();
3270
+ if (SECTIONS.changes) drawChanges();
3271
+ }
3272
+ }
3273
+ function drawNow() {
3274
+ draw();
3275
+ }
3276
+ function applySections() {
3277
+ const gated = [
3278
+ ["tiles", SECTIONS.statistics],
3279
+ ["tester-panel", SECTIONS.tester],
3280
+ ["ranges-panel", SECTIONS.ranges],
3281
+ ["changes-panel", SECTIONS.changes],
3282
+ ["actor-panel", SECTIONS.actors],
3283
+ ["live-actors-panel", SECTIONS.actors],
3284
+ ["audit-panel", SECTIONS.audit],
3285
+ ["audit-checks-panel", SECTIONS.audit],
3286
+ ["notices-panel", SECTIONS.notices],
3287
+ ["guard-panel", SECTIONS.guard],
3288
+ ["robots-panel", SECTIONS.robots],
3289
+ ["identities-panel", SECTIONS.actors],
3290
+ ["evidence-legend", SECTIONS.evidence]
3291
+ ];
3292
+ for (const [id, enabled] of gated) {
3293
+ const node = rootNode().querySelector(`#${id}`);
3294
+ if (node !== null && !enabled) node.remove();
3295
+ }
3296
+ }
3297
+ function start() {
3298
+ app.draw = schedule;
3299
+ app.drawNow = drawNow;
3300
+ app.showTab = showTab;
3301
+ app.syncUrl = () => syncUrl();
3302
+ applySections();
3303
+ initHeader();
3304
+ drawPeers();
3305
+ initTabs();
3306
+ initSkipLink();
3307
+ initKeyboard();
3308
+ initRanges();
3309
+ if (SECTIONS.feed) initFeed();
3310
+ initActor();
3311
+ initTester();
3312
+ initPolicy();
3313
+ let resizeTimer;
3314
+ addEventListener("resize", () => {
3315
+ if (resizeTimer !== void 0) clearTimeout(resizeTimer);
3316
+ resizeTimer = setTimeout(() => {
3317
+ if (state.tab === "stats") {
3318
+ drawTraffic();
3319
+ drawLatency();
3320
+ drawScores();
3321
+ }
3322
+ }, 120);
3323
+ });
3324
+ const url = readUrl();
3325
+ state.filter = url.filter;
3326
+ setSearch(url.search);
3327
+ if (SECTIONS.feed) reflectFilterButtons();
3328
+ showTab(url.tab, { replace: true });
3329
+ void loadInitialSnapshot();
3330
+ connectStream();
3331
+ }
3332
+ var TABS, available, FIRST, pending;
3333
+ var init_client = __esm({
3334
+ "src/dashboard/client/index.ts"() {
3335
+ "use strict";
3336
+ init_dom();
3337
+ init_boot();
3338
+ init_app();
3339
+ init_store();
3340
+ init_actor();
3341
+ init_stream();
3342
+ init_panels();
3343
+ init_feed();
3344
+ init_charts();
3345
+ init_registry();
3346
+ init_policy();
3347
+ init_ranges();
3348
+ init_tester();
3349
+ TABS = [
3350
+ ["tab-live", "live", SECTIONS.feed],
3351
+ ["tab-actors", "actors", SECTIONS.registry],
3352
+ ["tab-stats", "stats", SECTIONS.statistics],
3353
+ ["tab-policy", "policy", SECTIONS.policy]
3354
+ ];
3355
+ available = TABS.filter(([, , enabled]) => enabled);
3356
+ FIRST = available[0]?.[1] ?? "live";
3357
+ pending = false;
3358
+ start();
3359
+ }
3360
+ });
3361
+
3362
+ // src/element/index.ts
3363
+ init_css();
3364
+
3365
+ // src/element/config.ts
3366
+ var TAB_SECTION = {
3367
+ live: "feed",
3368
+ actors: "registry",
3369
+ stats: "statistics",
3370
+ policy: "policy"
3371
+ };
3372
+ function invert(hide) {
3373
+ const out = {};
3374
+ for (const [key, value] of Object.entries(hide)) if (value === true) out[key] = false;
3375
+ return out;
3376
+ }
3377
+ function resolveSections(fromServer, config) {
3378
+ const sections = { ...fromServer };
3379
+ const warnings = [];
3380
+ for (const key of Object.keys(config.hide ?? {})) {
3381
+ if (Object.hasOwn(sections, key)) continue;
3382
+ const suggestion = TAB_SECTION[key];
3383
+ warnings.push(
3384
+ suggestion === void 0 ? `hide.${key} is not a section this dashboard has, so it did nothing. It has: ${Object.keys(sections).sort().join(", ")}.` : `hide.${key} did nothing \u2014 "${key}" is a tab name, and \`hide\` takes section names. You want hide.${suggestion}, or leave "${key}" out of \`tabs\`.`
3385
+ );
3386
+ }
3387
+ for (const [key, value] of Object.entries(invert(config.hide ?? {}))) sections[key] = value;
3388
+ const wanted = config.tabs;
3389
+ if (wanted !== void 0 && wanted.length > 0) {
3390
+ const keep = new Set(wanted.map((tab) => tab.id));
3391
+ for (const id of keep) {
3392
+ if (TAB_SECTION[id] === void 0) {
3393
+ warnings.push(`tabs lists "${id}", which is not a screen. The screens are: ${Object.keys(TAB_SECTION).join(", ")}.`);
3394
+ continue;
3395
+ }
3396
+ if (fromServer[TAB_SECTION[id]] === false && config.hide?.[TAB_SECTION[id]] !== true) {
3397
+ warnings.push(
3398
+ `tabs lists "${id}", but this dashboard's server has the "${TAB_SECTION[id]}" section switched off, so that screen does not exist. That is the \`sections\` option on createDashboardHandler, and it is enforced there rather than here.`
3399
+ );
3400
+ }
3401
+ }
3402
+ for (const id of Object.keys(TAB_SECTION)) {
3403
+ if (!keep.has(id)) sections[TAB_SECTION[id]] = false;
3404
+ }
3405
+ }
3406
+ return { sections, warnings };
3407
+ }
3408
+ function parseMount(raw2) {
3409
+ const cut = raw2.search(/[?#]/);
3410
+ if (cut === -1) return { base: raw2.replace(/\/$/, "") };
3411
+ return {
3412
+ base: raw2.slice(0, cut).replace(/\/$/, ""),
3413
+ warning: `src "${raw2}" has a ${raw2[cut] === "?" ? "query string" : "fragment"} on it. The element asks for \`<src>/api/bootstrap\`, so only the path can mean anything here; the rest was ignored.`
3414
+ };
3415
+ }
3416
+ function explainStatus(status, base) {
3417
+ if (status === 401) {
3418
+ return `it answered 401 Unauthorized. The dashboard has \`auth\` set, and the element fetches in the background, where a browser cannot offer the sign-in prompt it would show for a normal navigation. Open ${base || "the dashboard"} directly and sign in once \u2014 the browser then sends those credentials with the element's requests too \u2014 or put this page behind the same authentication.`;
3419
+ }
3420
+ if (status === 403) {
3421
+ return `it answered 403 Forbidden, which is this dashboard refusing the caller rather than the password: check \`allowedHosts\` and \`allowedClients\`, and that this page is on the same origin as \`src\`.`;
3422
+ }
3423
+ return `it answered ${status}`;
3424
+ }
3425
+ function shapeOf(config) {
3426
+ const tabs = (config.tabs ?? []).map((tab) => tab.id).join(",");
3427
+ const panels = (config.panels ?? []).map((panel) => `${panel.id}@${panel.screen}:${panel.title}`).join(",");
3428
+ return `${tabs}|${panels}`;
3429
+ }
3430
+ function cellText(value) {
3431
+ if (typeof value === "string") return value.slice(0, 200);
3432
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
3433
+ if (value === null || value === void 0) return "";
3434
+ try {
3435
+ return JSON.stringify(value)?.slice(0, 200) ?? "";
3436
+ } catch {
3437
+ return "";
3438
+ }
3439
+ }
3440
+
3441
+ // src/dashboard/page.ts
3442
+ var DASHBOARD_CSS = String.raw`/* ---------------------------------------------------------------------------
3443
+ Tokens.
3444
+
3445
+ The two series colours and the critical status step are the validated data
3446
+ palette: blue and orange clear every colour-vision gate against both surfaces as
3447
+ an adjacent pair, and every chart that uses them also carries a legend and a
3448
+ label, so hue is never the only thing distinguishing anything. Text never wears a
3449
+ series colour — the ink tokens below are all at least 4.5:1 on their surface.
3450
+ --------------------------------------------------------------------------- */
3451
+ :root, :host {
3452
+ color-scheme: light;
3453
+ --page: #f4f5f7;
3454
+ --surface: #ffffff;
3455
+ --surface-2: #fafafa;
3456
+ --raised: #ffffff;
3457
+ --line: #e4e6ea;
3458
+ --line-soft: #eef0f3;
3459
+ --ink: #0b0d12;
3460
+ --ink-2: #3c414b;
3461
+ --muted: #5f646e;
3462
+ --s1: #2a78d6;
3463
+ --s2: #eb6834;
3464
+ --crit: #d03b3b;
3465
+ /* The series blue carries white text at about 3.9:1, which is under the 4.5 floor —
3466
+ fine for a bar, not for a label. Solid buttons get a darker blue of their own
3467
+ rather than a lighter series colour, because the series colour is validated for
3468
+ charts and moving it would move every chart with it. */
3469
+ --accent-solid: #1b5fb0;
3470
+ --good-text: #006300;
3471
+ /* Darker than it looks like it needs to be, and measured rather than judged. This ink
3472
+ appears on a 16%-amber chip, and that chip can sit on the open row's blue tint —
3473
+ three layers, each lightening the background under the same text. At #8a5a00 the
3474
+ suspected badge measured 3.52:1 there, under the 4.5 floor for 11px bold. */
3475
+ --warn-text: #6d4700;
3476
+ --crit-text: #b02525;
3477
+ --info-text: #1c5cab;
3478
+ --proven-text: #5b34a8;
3479
+ --grid: #eceef1;
3480
+ --shadow: 0 1px 2px rgba(11,13,18,.06), 0 1px 8px rgba(11,13,18,.04);
3481
+ --focus: #2a78d6;
3482
+ }
3483
+ @media (prefers-color-scheme: dark) {
3484
+ :root:not([data-theme="light"]), :host(:not([data-theme="light"])) {
3485
+ color-scheme: dark;
3486
+ --page: #0d0f12;
3487
+ --surface: #16181d;
3488
+ --surface-2: #1b1e24;
3489
+ --raised: #1f232a;
3490
+ --line: #272b33;
3491
+ --line-soft: #202329;
3492
+ --ink: #f2f4f7;
3493
+ --ink-2: #c4cad3;
3494
+ --muted: #8b929c;
3495
+ --s1: #3987e5;
3496
+ --s2: #d95926;
3497
+ --crit: #d03b3b;
3498
+ --accent-solid: #2364b4;
3499
+ --good-text: #4ec97a;
3500
+ --warn-text: #fab219;
3501
+ --crit-text: #ff8078;
3502
+ --info-text: #86b6ef;
3503
+ --proven-text: #b3a4f5;
3504
+ --grid: #23272e;
3505
+ --shadow: none;
3506
+ --focus: #86b6ef;
3507
+ }
3508
+ }
3509
+ :root[data-theme="dark"], :host([data-theme="dark"]) {
3510
+ color-scheme: dark;
3511
+ --page: #0d0f12;
3512
+ --surface: #16181d;
3513
+ --surface-2: #1b1e24;
3514
+ --raised: #1f232a;
3515
+ --line: #272b33;
3516
+ --line-soft: #202329;
3517
+ --ink: #f2f4f7;
3518
+ --ink-2: #c4cad3;
3519
+ --muted: #8b929c;
3520
+ --s1: #3987e5;
3521
+ --s2: #d95926;
3522
+ --crit: #d03b3b;
3523
+ --accent-solid: #2364b4;
3524
+ --good-text: #4ec97a;
3525
+ --warn-text: #fab219;
3526
+ --crit-text: #ff8078;
3527
+ --info-text: #86b6ef;
3528
+ --proven-text: #b3a4f5;
3529
+ --grid: #23272e;
3530
+ --shadow: none;
3531
+ --focus: #86b6ef;
3532
+ }
3533
+
3534
+ * { box-sizing: border-box; }
3535
+ html, body { height: 100%; }
3536
+ body {
3537
+ margin: 0; background: var(--page); color: var(--ink);
3538
+ font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
3539
+ -webkit-font-smoothing: antialiased;
3540
+ }
3541
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3542
+ .tnum { font-variant-numeric: tabular-nums; }
3543
+ :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; }
3544
+
3545
+ /* --- header -------------------------------------------------------------
3546
+ Two rows, and the split is the whole idea.
3547
+
3548
+ The first carries identity, connection state and the controls — everything you
3549
+ can *press*. The second carries the tab strip and, at the far end, the handful of
3550
+ configuration facts that used to sit up top wearing the same bordered pill as the
3551
+ buttons beside them. Ten identical rounded rectangles in a row, five of which did
3552
+ nothing when clicked, is not a toolbar; it is a guessing game. The facts are now
3553
+ plain text — the one treatment nothing else on the page uses for a control — and
3554
+ they fill the tab strip's empty right-hand side rather than needing room of their
3555
+ own.
3556
+
3557
+ Nothing here has a fixed height. The old row was pinned to 54px with children that
3558
+ refused to shrink, so on a narrow window the title and the live indicator drew on
3559
+ top of each other rather than wrapping.
3560
+ ------------------------------------------------------------------------- */
3561
+ header {
3562
+ position: sticky; top: 0; z-index: 20;
3563
+ background: color-mix(in srgb, var(--surface) 92%, transparent);
3564
+ backdrop-filter: saturate(180%) blur(8px);
3565
+ border-bottom: 1px solid var(--line);
3566
+ padding: 0 20px;
3567
+ }
3568
+ .head-row { display: flex; align-items: center; gap: 10px 14px; min-height: 54px; flex-wrap: wrap; padding: 7px 0; }
3569
+ .brand { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
3570
+ /* A real top-level heading, because the page had none: a screen reader's "jump to the
3571
+ heading" found nothing to jump to. Styled back down to the size it always was — it is
3572
+ a name in a toolbar, not a title on a poster. */
3573
+ .brand h1 { font-size: 15px; font-weight: 640; letter-spacing: -.015em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin: 0; }
3574
+ .brand span { color: var(--muted); font-size: 12.5px; white-space: nowrap; }
3575
+
3576
+ /* Status, not a control: a filled pill with no border, so it never reads as pressable. */
3577
+ .live {
3578
+ display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--ink-2);
3579
+ white-space: nowrap; background: color-mix(in srgb, var(--ink) 5%, transparent);
3580
+ border-radius: 999px; padding: 3px 10px 3px 8px; font-weight: 520;
3581
+ }
3582
+ .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
3583
+ .dot.on { background: var(--good-text); box-shadow: 0 0 0 3px color-mix(in srgb, var(--good-text) 22%, transparent); }
3584
+ .dot.off { background: var(--crit-text); box-shadow: 0 0 0 3px color-mix(in srgb, var(--crit-text) 20%, transparent); }
3585
+ .grow { flex: 1 1 auto; min-width: 8px; }
3586
+
3587
+ .head-links { display: flex; gap: 6px; flex-wrap: wrap; }
3588
+ /* A hairline between "somewhere else" and "do something here". */
3589
+ .head-actions { display: flex; gap: 6px; align-items: center; }
3590
+ .head-links:not(:empty) + .head-actions { padding-left: 12px; border-left: 1px solid var(--line); }
3591
+
3592
+ .nav-row { display: flex; align-items: flex-end; gap: 20px; }
3593
+ .facts {
3594
+ display: flex; gap: 14px; flex-wrap: wrap; margin-left: auto;
3595
+ font-size: 11.5px; color: var(--muted); padding-bottom: 9px; min-width: 0;
3596
+ }
3597
+ .facts span { white-space: nowrap; }
3598
+ /* Sibling instances. A dashboard reports on one process, and this is the honest amount
3599
+ of help the page can give with that: a way to reach the others. */
3600
+ .peers { display: flex; gap: 6px; align-items: center; padding-bottom: 7px; flex-wrap: wrap; }
3601
+ .peers a { font-size: 11.5px; padding: 2px 8px; border-radius: 999px; }
3602
+ @media (max-width: 900px) { .peers { display: none; } }
3603
+ .facts b { font-weight: 600; color: var(--ink-2); font-variant-numeric: tabular-nums; }
3604
+
3605
+ /* Secondary by definition, so it is the first thing to go when room runs out. */
3606
+ @media (max-width: 900px) { .facts { display: none; } }
3607
+ @media (max-width: 560px) { .brand span { display: none; } }
3608
+ button, .linkbtn {
3609
+ font: inherit; font-size: 12.5px; line-height: 1.4; padding: 6px 11px; border-radius: 8px;
3610
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink-2);
3611
+ cursor: pointer; white-space: nowrap; text-decoration: none; display: inline-block;
3612
+ }
3613
+ button:hover, .linkbtn:hover { background: var(--surface-2); color: var(--ink); border-color: color-mix(in srgb, var(--ink) 22%, var(--line)); }
3614
+ button[aria-pressed="true"] { background: color-mix(in srgb, var(--s1) 12%, var(--surface)); border-color: color-mix(in srgb, var(--s1) 45%, var(--line)); color: var(--ink); }
3615
+ button[disabled] { opacity: .5; cursor: default; }
3616
+
3617
+ .tabs { display: flex; gap: 2px; margin-bottom: -1px; flex: none; }
3618
+ .tab {
3619
+ border: 0; background: none; border-bottom: 2px solid transparent; border-radius: 0;
3620
+ padding: 9px 12px; font-size: 13px; color: var(--muted); font-weight: 500;
3621
+ }
3622
+ .tab:hover { background: none; color: var(--ink); }
3623
+ .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
3624
+
3625
+ /* --- layout ------------------------------------------------------------- */
3626
+ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
3627
+ .stack { display: grid; gap: 16px; }
3628
+ .tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); }
3629
+ .tile {
3630
+ background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
3631
+ padding: 12px 14px 13px; box-shadow: var(--shadow);
3632
+ }
3633
+ .tile .v { font-size: 26px; font-weight: 620; letter-spacing: -.025em; line-height: 1.1; }
3634
+ .tile .k { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 4px; font-weight: 560; }
3635
+ .tile .s { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
3636
+ .tile.good .v { color: var(--good-text); }
3637
+ .tile.warn .v { color: var(--warn-text); }
3638
+ .tile.crit .v { color: var(--crit-text); }
3639
+ .tile.proven .v { color: var(--proven-text); }
3640
+
3641
+ /* "clip" rather than "hidden", and the difference is load-bearing. Both round off the
3642
+ corners, but "hidden" makes the panel a scroll container — which becomes the
3643
+ containing block for anything sticky inside it, so the feed's column headers were
3644
+ anchored to a box that never scrolls and simply never stuck. They have not worked
3645
+ since they were written; the hard-coded offset above them was dead code. "clip"
3646
+ clips without creating a scrollport, so the headers stick to the viewport, under
3647
+ the page header, where they were always meant to. */
3648
+ .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: clip; }
3649
+ .panel > h2 {
3650
+ margin: 0; padding: 12px 15px; font-size: 12px; font-weight: 620; color: var(--muted);
3651
+ text-transform: uppercase; letter-spacing: .055em; border-bottom: 1px solid var(--line);
3652
+ display: flex; align-items: center; gap: 10px;
3653
+ }
3654
+ .panel > h2 .sub { font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 11.5px; margin-left: auto; }
3655
+ /* A container, so the two-column grid below can ask how much room it actually has.
3656
+
3657
+ It used to ask the viewport, which is the same mistake the feed table's breakpoints
3658
+ made one level down: the constraint is the width of this column, not of the window.
3659
+ Embedded in a 320px sidebar on a 1280px screen the media query never fired, the second
3660
+ column held its 280px minimum, and the feed was squeezed to twenty-two pixels. */
3661
+ .stack { container: dash / inline-size; }
3662
+ .two { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); align-items: start; }
3663
+ .grid3 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
3664
+ @container dash (max-width: 1080px) { .two { grid-template-columns: minmax(0, 1fr); } }
3665
+
3666
+ /* The feed table has a floor — six columns of request text will not go below about
3667
+ 940px — and under it the panel was simply amputating the right-hand columns.
3668
+
3669
+ These are **container** queries, not media queries, and the difference is the whole
3670
+ fix. What the table has to fit inside is the panel, and the panel is one column of a
3671
+ two-column grid: at a 1400px viewport it is about 880px wide, so a viewport-based
3672
+ breakpoint at 980px never fires and the right-hand columns are clipped on a perfectly
3673
+ ordinary desktop. That is exactly the bug a media query was written to fix, and the
3674
+ media query was measuring the wrong box the whole time. It only appeared to work
3675
+ because five columns happened to fit.
3676
+
3677
+ Four things happen on the way down, in the order that costs least. First the
3678
+ duration goes: it is the column people narrow a window least to read and it is still
3679
+ in the row detail. Then the timestamp, which is also still in the row detail and is
3680
+ implied by the order while the stream is live. Then the User-Agent line stops
3681
+ ellipsing and wraps, which is what actually buys the room — it takes the table's floor
3682
+ from about 100ch to about 56ch, because the cap on that one line is most of what the
3683
+ table cannot go below. Only under *that* does the table get a scrollbar of its own.
3684
+
3685
+ The scrollbar is deliberately last and deliberately rare, and it is worth being
3686
+ precise about why, because it is the rung that gets set too high. A scroll container
3687
+ is the containing block for anything sticky inside it, so the moment it turns on the
3688
+ column headers stop tracking the viewport — and a row becomes wider than the box it
3689
+ sits in, which is enough to make it hard to click and, for a keyboard or a pointer
3690
+ being driven by a test, hard to reach at all. It cost both when it fired at 1440px on
3691
+ a wider face: the headers scrolled away with the rows and a click on a row never
3692
+ landed. Wrapping costs a line of height and nothing else, so it goes first and this
3693
+ goes last.
3694
+
3695
+ The thresholds are in **ch**, and that is the second half of the same lesson. They
3696
+ used to be pixels, which quietly assumed a font: what six columns of request text
3697
+ actually need is set by the width of a glyph — the User-Agent below is capped in ch —
3698
+ and a hard pixel breakpoint is only correct for the font it was measured on. On this
3699
+ machine the six columns needed 951px and the breakpoint sat at 960px, so it passed by
3700
+ nine pixels; on a CI runner with a wider default face the same six columns needed more
3701
+ than the panel had, the query did not fire, and the right-hand columns were clipped on
3702
+ an ordinary 1600px desktop. The five-column rung was already over its own floor by
3703
+ 8px and nobody had noticed, because nothing measures a layout that merely looks fine.
3704
+
3705
+ In ch, both sides move together: a wider face makes the content wider and makes the
3706
+ threshold wider by the same proportion. The floors, measured, are 119ch / 111ch /
3707
+ 100ch ellipsing and 56ch wrapped, and each rung sits above its own floor with room to
3708
+ spare so that the arithmetic does not have to be exact. */
3709
+ .feed-panel { container: feed / inline-size; }
3710
+
3711
+ /* The rungs themselves are further down, immediately after the table's own rules, and
3712
+ they have to be: a container query adds no specificity, so an override written above
3713
+ the declaration it overrides simply loses on source order and does nothing. The wrap
3714
+ rung sat here for exactly that reason and was dead the whole time — the table never
3715
+ wrapped, its floor never dropped, and nothing said so. See "the column ladder" below. */
3716
+
3717
+ /* The header's own subtitle, which is a viewport thing rather than a panel thing: it
3718
+ sits in the page header and has no container to be measured against. */
3719
+ @media (max-width: 700px) {
3720
+ .sub { white-space: normal; max-width: none; }
3721
+ }
3722
+
3723
+ /* --- feed table --------------------------------------------------------- */
3724
+ .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 15px; border-bottom: 1px solid var(--line); }
3725
+ .filters { display: flex; gap: 5px; flex-wrap: wrap; }
3726
+ .filters button { font-size: 11.5px; padding: 3px 10px; border-radius: 999px; }
3727
+ input[type="search"] {
3728
+ font: inherit; font-size: 12.5px; padding: 5px 10px; min-width: 180px; flex: 1 1 180px;
3729
+ border: 1px solid var(--line); border-radius: 8px; background: var(--surface-2); color: var(--ink);
3730
+ }
3731
+ input[type="search"]::placeholder { color: var(--muted); }
3732
+
3733
+ table { width: 100%; border-collapse: collapse; }
3734
+ thead th {
3735
+ /* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
3736
+ the instant before the first measurement, and for the tab strip wrapping. */
3737
+ position: sticky; top: var(--header-h, 91px); z-index: 2; background: var(--surface);
3738
+ text-align: left; font-size: 11px; font-weight: 600; color: var(--muted);
3739
+ text-transform: uppercase; letter-spacing: .05em; padding: 9px 15px; border-bottom: 1px solid var(--line);
3740
+ }
3741
+ tbody td { padding: 9px 15px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
3742
+ /* Narrow, quiet, and never the reason a row wraps: the time is for scanning down, not
3743
+ for reading across. */
3744
+ tbody td.when { color: var(--muted); font-size: 11.5px; white-space: nowrap; width: 1%; padding-right: 4px; }
3745
+ tbody tr.row { cursor: pointer; }
3746
+ tbody tr.row:hover { background: color-mix(in srgb, var(--ink) 3.5%, transparent); }
3747
+ tbody tr.row.open { background: color-mix(in srgb, var(--s1) 7%, transparent); }
3748
+ td.edge { border-left: 3px solid transparent; padding-left: 12px; }
3749
+ tr.a-allow td.edge { border-left-color: var(--good-text); }
3750
+ tr.a-mitigate td.edge { border-left-color: var(--s2); }
3751
+ tr.a-deny td.edge { border-left-color: var(--crit); }
3752
+ tr.a-guard td.edge { border-left-color: var(--warn-text); }
3753
+ .req { font-size: 12.5px; overflow-wrap: anywhere; }
3754
+ /* The disclosure for a row.
3755
+ The whole row used to be role="button", with the actor link inside it — a control
3756
+ nested in a control, which leaves a screen reader with two things to announce and no
3757
+ way to say which one Enter belongs to. The row still opens on click, because that is
3758
+ a convenience rather than a role; the keyboard gets this, which is one control with
3759
+ one name and one job. */
3760
+ .row-toggle {
3761
+ border: 0; background: none; padding: 0; margin: 0; border-radius: 4px;
3762
+ font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3763
+ font-size: 12.5px; color: inherit; text-align: left; cursor: pointer; display: block; width: 100%;
3764
+ }
3765
+ .row-toggle:hover { background: none; color: inherit; border-color: transparent; }
3766
+ .req .ua { color: var(--muted); font-size: 11.5px; display: block; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 64ch; }
3767
+ .req .ua a { color: inherit; text-decoration: none; border-bottom: 1px dotted color-mix(in srgb, var(--muted) 60%, transparent); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3768
+ .req .ua a:hover { color: var(--ink); border-bottom-color: var(--ink); }
3769
+ .num { text-align: right; }
3770
+
3771
+ /* The column ladder. Read the note beside .feed-panel above for what it is for and why
3772
+ the thresholds are in ch; this is where it has to live, below every declaration it
3773
+ overrides. */
3774
+ @container feed (max-width: 128ch) {
3775
+ thead th:nth-child(6), tbody td:nth-child(6) { display: none; }
3776
+ }
3777
+ @container feed (max-width: 119ch) {
3778
+ thead th:nth-child(1), tbody td.when { display: none; }
3779
+ }
3780
+ @container feed (max-width: 107ch) {
3781
+ .req .ua { white-space: normal; max-width: none; }
3782
+ }
3783
+ @container feed (max-width: 62ch) {
3784
+ .feed-scroll { overflow-x: auto; }
3785
+ }
3786
+
3787
+ .badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; border: 1px solid transparent; }
3788
+ .b-proven { background: color-mix(in srgb, var(--proven-text) 14%, transparent); color: var(--proven-text); }
3789
+ .b-suspected { background: color-mix(in srgb, var(--warn-text) 16%, transparent); color: var(--warn-text); }
3790
+ .b-human { background: color-mix(in srgb, var(--good-text) 14%, transparent); color: var(--good-text); }
3791
+ /* The secondary ink rather than the muted one, for the same reason as the weak tier:
3792
+ muted ink on its own muted chip is 4.31:1 on a plain row and 3.70:1 on an open one. */
3793
+ .b-unknown { background: color-mix(in srgb, var(--muted) 14%, transparent); color: var(--ink-2); }
3794
+ .act { font-weight: 600; font-size: 12.5px; }
3795
+ .act-allow { color: var(--good-text); }
3796
+ .act-mitigate { color: var(--warn-text); }
3797
+ .act-deny { color: var(--crit-text); }
3798
+ .act-tag { color: var(--ink-2); }
3799
+ .sub { color: var(--muted); font-size: 11.5px; display: block; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 22ch; }
3800
+ .guard { color: var(--warn-text); font-size: 11.5px; display: block; margin-top: 3px; font-weight: 500; }
3801
+
3802
+ tr.detail > td { background: var(--surface-2); padding: 14px 15px 16px; border-bottom: 1px solid var(--line); }
3803
+ .ev { display: grid; gap: 9px; }
3804
+ .ev-item { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 12px; align-items: start; font-size: 12.5px; }
3805
+ .tier { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; padding: 3px 6px; border-radius: 6px; text-align: center; }
3806
+ .t-certain { background: color-mix(in srgb, var(--proven-text) 15%, transparent); color: var(--proven-text); }
3807
+ .t-strong { background: color-mix(in srgb, var(--crit-text) 14%, transparent); color: var(--crit-text); }
3808
+ .t-moderate { background: color-mix(in srgb, var(--warn-text) 16%, transparent); color: var(--warn-text); }
3809
+ /* The secondary ink rather than the muted one: muted ink on its own muted chip clears
3810
+ 4.5:1 on the light surface and does not on the dark one, and this is 10px bold text
3811
+ saying how much a piece of evidence is worth. It still reads as the quiet tier — the
3812
+ only one with no hue — without being the one nobody can read at night. */
3813
+ .t-weak { background: color-mix(in srgb, var(--muted) 14%, transparent); color: var(--ink-2); }
3814
+ .ev-item.human .tier { background: color-mix(in srgb, var(--good-text) 14%, transparent); color: var(--good-text); }
3815
+ .ev-meta { color: var(--muted); font-size: 11.5px; margin-top: 2px; }
3816
+ .basis { margin-top: 5px; color: var(--ink-2); font-size: 12px; border-left: 2px solid color-mix(in srgb, var(--proven-text) 40%, transparent); padding: 2px 0 2px 9px; }
3817
+ .detail-foot { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11.5px; display: flex; gap: 14px; flex-wrap: wrap; }
3818
+
3819
+ /* --- bars --------------------------------------------------------------- */
3820
+ .bars { padding: 12px 15px 14px; display: grid; gap: 8px; }
3821
+ .bar { display: grid; grid-template-columns: minmax(0, 1fr) 46px; gap: 10px; align-items: center; font-size: 12.5px; }
3822
+ .bar .track { position: relative; height: 20px; background: color-mix(in srgb, var(--ink) 5%, transparent); border-radius: 6px; overflow: hidden; }
3823
+ .bar .fill { position: absolute; inset: 0 auto 0 0; background: color-mix(in srgb, var(--s1) 26%, transparent); border-radius: 0 5px 5px 0; }
3824
+ .bar .lbl { position: relative; padding: 0 8px; line-height: 20px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3825
+ .bar .v { text-align: right; color: var(--ink-2); font-size: 12px; }
3826
+
3827
+ /* --- charts ------------------------------------------------------------- */
3828
+ .chart { padding: 12px 15px 14px; position: relative; }
3829
+ .chart svg { display: block; width: 100%; overflow: visible; }
3830
+ .legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 0 15px 12px; font-size: 12px; color: var(--ink-2); }
3831
+ .legend span { display: inline-flex; align-items: center; gap: 6px; }
3832
+ /* The marker for a runtime change. A line rather than a swatch, because it is an
3833
+ instant rather than a quantity. */
3834
+ .legend .mark { width: 2px; height: 11px; background: var(--proven-text); border-radius: 1px; flex: none; }
3835
+ .swatch { width: 10px; height: 10px; border-radius: 3px; flex: none; }
3836
+ .tip {
3837
+ position: absolute; pointer-events: none; opacity: 0; transition: opacity .1s;
3838
+ background: var(--raised); border: 1px solid var(--line); border-radius: 8px;
3839
+ padding: 8px 10px; font-size: 12px; box-shadow: 0 4px 16px rgba(0,0,0,.16); z-index: 10; min-width: 128px;
3840
+ }
3841
+ .tip .t { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
3842
+ .tip .r { display: flex; align-items: center; gap: 7px; justify-content: space-between; }
3843
+ .tip .r em { font-style: normal; color: var(--ink-2); display: inline-flex; align-items: center; gap: 6px; }
3844
+ .tip .r b { font-weight: 600; font-variant-numeric: tabular-nums; }
3845
+
3846
+ .stat-row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 15px; font-size: 12.5px; border-bottom: 1px solid var(--line-soft); }
3847
+ .stat-row:last-child { border-bottom: 0; }
3848
+ .stat-row .k { color: var(--muted); }
3849
+ .stat-row .v { font-variant-numeric: tabular-nums; color: var(--ink); font-weight: 560; }
3850
+ /* The baseline a window is being read against. Quieter than the current value, and
3851
+ beside it rather than under it, because the comparison is the whole point. */
3852
+ .stat-row .v .was { color: var(--muted); font-weight: 400; font-size: 11.5px; }
3853
+ .note { padding: 13px 15px; font-size: 12.5px; color: var(--ink-2); }
3854
+ .note p { margin: 0 0 9px; }
3855
+ .note p:last-child { margin: 0; }
3856
+ .note b { color: var(--ink); }
3857
+ .empty { padding: 40px 15px; text-align: center; color: var(--muted); font-size: 13px; }
3858
+ .empty code { background: color-mix(in srgb, var(--ink) 7%, transparent); padding: .15em .4em; border-radius: 5px; }
3859
+ .detectors { padding: 4px 0; }
3860
+ .det { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 10px; padding: 8px 15px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
3861
+ .det:last-child { border-bottom: 0; }
3862
+ .det .d { color: var(--muted); font-size: 11.5px; margin-top: 2px; }
3863
+ .det .n { color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; }
3864
+ .rules { padding: 10px 15px; display: flex; gap: 6px; flex-wrap: wrap; }
3865
+ .rules .chip { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
3866
+ [hidden] { display: none !important; }
3867
+ /* Present to a screen reader, absent to everyone else. Clipped rather than moved
3868
+ off-screen or hidden with display:none, both of which take an element out of the
3869
+ accessibility tree along with the viewport. */
3870
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%); border: 0; }
3871
+
3872
+ /* --- navigation --------------------------------------------------------- */
3873
+ /* Present for a keyboard, out of the way of everything else. */
3874
+ .skip {
3875
+ position: absolute; left: 10px; top: 10px; z-index: 40;
3876
+ background: var(--raised); color: var(--ink); border: 1px solid var(--line);
3877
+ border-radius: 8px; padding: 8px 13px; font-size: 12.5px; text-decoration: none;
3878
+ /* Hidden by clipping rather than by being moved off-screen. An element parked
3879
+ above the viewport has its whole border box outside it, and Chrome's sequential
3880
+ focus navigation skips those — so a skip link done that way is invisible to the
3881
+ one input device it exists for. Clipped to a pixel it stays in the tab order. */
3882
+ width: 1px; height: 1px; padding: 0; overflow: hidden; white-space: nowrap; clip-path: inset(50%);
3883
+ }
3884
+ .skip:focus { width: auto; height: auto; padding: 8px 13px; overflow: visible; clip-path: none; }
3885
+
3886
+ /* A row is a control now, so it has to look like one when focused. outline-offset
3887
+ is negative because an outline drawn outside a table row is clipped by the cell. */
3888
+ tbody tr.row:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
3889
+ tbody tr.row:focus-visible td.edge { border-left-color: var(--focus); }
3890
+
3891
+ .search { position: relative; display: flex; flex: 1 1 180px; min-width: 180px; }
3892
+ .search input[type="search"] { flex: 1 1 auto; padding-right: 26px; }
3893
+ .search kbd {
3894
+ position: absolute; right: 7px; top: 50%; transform: translateY(-50%);
3895
+ font: 500 10.5px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3896
+ color: var(--muted); background: color-mix(in srgb, var(--ink) 6%, transparent);
3897
+ border: 1px solid var(--line); border-radius: 4px; padding: 3px 5px; pointer-events: none;
3898
+ }
3899
+ /* The hint is an affordance for a mouse user who has not found the key yet. Once the
3900
+ box is in use it is noise, and it sits where the clear button wants to be. */
3901
+ .search input[type="search"]:focus + kbd, .search input[type="search"]:not(:placeholder-shown) + kbd { display: none; }
3902
+ /* --- added panels ------------------------------------------------------- */
3903
+ .range { display: flex; gap: 4px; }
3904
+ .range button { font-size: 11px; padding: 2px 8px; border-radius: 999px; }
3905
+
3906
+ .kv { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; padding: 12px 15px; font-size: 12.5px; }
3907
+ .kv dt { color: var(--muted); }
3908
+ .kv dd { margin: 0; font-variant-numeric: tabular-nums; }
3909
+
3910
+ .hdr { width: 100%; border-collapse: collapse; margin-top: 10px; }
3911
+ .hdr td { padding: 3px 8px 3px 0; vertical-align: top; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; border: 0; }
3912
+ .hdr td.n { color: var(--muted); white-space: nowrap; width: 1%; }
3913
+ .hdr td.v { overflow-wrap: anywhere; }
3914
+ .hdr td.v.red { color: var(--muted); font-style: italic; }
3915
+
3916
+ .tools { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; }
3917
+ .tools button { font-size: 11.5px; padding: 4px 9px; }
3918
+
3919
+ .editor { padding: 12px 15px 14px; display: grid; gap: 10px; }
3920
+ textarea {
3921
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5;
3922
+ width: 100%; min-height: 320px; resize: vertical; padding: 11px 12px; tab-size: 2;
3923
+ border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--ink);
3924
+ }
3925
+ textarea[readonly] { opacity: .8; }
3926
+ .editor-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
3927
+ .result { font-size: 12.5px; border-radius: 9px; padding: 10px 12px; border: 1px solid var(--line); background: var(--surface-2); }
3928
+ .result.ok { border-color: color-mix(in srgb, var(--good-text) 45%, var(--line)); }
3929
+ .result.bad { border-color: color-mix(in srgb, var(--crit-text) 45%, var(--line)); color: var(--crit-text); }
3930
+ .result.warn { border-color: color-mix(in srgb, var(--warn-text) 45%, var(--line)); }
3931
+ .result b { font-weight: 620; }
3932
+ .diff { width: 100%; border-collapse: collapse; margin-top: 8px; font-size: 12px; }
3933
+ .diff th { text-align: left; font-size: 10.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 4px 8px 4px 0; border-bottom: 1px solid var(--line); }
3934
+ .diff td { padding: 5px 8px 5px 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
3935
+ .diff .from { color: var(--muted); }
3936
+ .diff .to { font-weight: 600; }
3937
+ .diff .to.deny { color: var(--crit-text); }
3938
+ .diff .to.allow { color: var(--good-text); }
3939
+ pre.code {
3940
+ margin: 0; padding: 11px 12px; border-radius: 9px; border: 1px solid var(--line);
3941
+ background: var(--surface-2); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3942
+ font-size: 11.5px; line-height: 1.5; overflow-x: auto; white-space: pre; color: var(--ink-2);
3943
+ }
3944
+ .notice { display: grid; grid-template-columns: 74px 1fr; gap: 10px; padding: 8px 15px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
3945
+ .notice:last-child { border-bottom: 0; }
3946
+ .notice .when { color: var(--muted); font-size: 11.5px; font-variant-numeric: tabular-nums; }
3947
+ .notice.warning .tag { color: var(--warn-text); font-weight: 600; }
3948
+ .notice.error .tag { color: var(--crit-text); font-weight: 600; }
3949
+ .dead { color: var(--muted); }
3950
+ .dead .lbl { text-decoration: line-through; }
3951
+ .pill { font-size: 10.5px; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); margin-left: 6px; }
3952
+ .actor-head { display: flex; align-items: center; gap: 10px; padding: 12px 15px; border-bottom: 1px solid var(--line); }
3953
+ .actor-head .who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; font-weight: 600; }
3954
+ /* --- the rule editor ----------------------------------------------------- */
3955
+ .bar-actions { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 10px 15px; border-bottom: 1px solid var(--line); background: var(--surface); }
3956
+ .bar-actions + .bar-actions, .rulelist + .bar-actions { border-bottom: 0; border-top: 1px solid var(--line); }
3957
+ .bar-actions .grow { flex: 1 1 auto; }
3958
+ button.primary { background: var(--accent-solid); border-color: var(--accent-solid); color: #fff; font-weight: 560; }
3959
+ button.primary:hover { background: color-mix(in srgb, var(--accent-solid) 86%, #000); border-color: transparent; color: #fff; }
3960
+ button.danger { color: var(--crit-text); }
3961
+ button.danger:hover { border-color: color-mix(in srgb, var(--crit-text) 50%, var(--line)); }
3962
+ button.icon { padding: 3px 8px; font-size: 12px; line-height: 1.2; }
3963
+ .dirty { font-size: 11.5px; color: var(--warn-text); font-weight: 560; display: inline-flex; align-items: center; gap: 6px; }
3964
+ .dirty::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--warn-text); }
3965
+
3966
+ .rulelist { display: grid; gap: 12px; padding: 14px 15px 16px; }
3967
+ .rule {
3968
+ border: 1px solid var(--line); border-radius: 11px; background: var(--surface-2);
3969
+ transition: border-color .12s, box-shadow .12s;
3970
+ }
3971
+ .rule:focus-within { border-color: color-mix(in srgb, var(--s1) 55%, var(--line)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--s1) 12%, transparent); }
3972
+ .rule.locked { background: color-mix(in srgb, var(--ink) 3%, var(--surface)); }
3973
+ .rule-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; flex-wrap: nowrap; }
3974
+ .rule:not(.collapsed) .rule-head { border-bottom: 1px solid var(--line); }
3975
+ .rule-head .ord { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; min-width: 20px; text-align: right; }
3976
+ .rule-head input[type="text"] { flex: 0 1 240px; width: auto; min-width: 130px; }
3977
+ .rule-head select { flex: 0 0 auto; }
3978
+ .chev { border: 0; background: none; padding: 2px 4px; color: var(--muted); font-size: 11px; line-height: 1; }
3979
+ .chev:hover { background: none; color: var(--ink); }
3980
+ .rule-summary { flex: 1 1 auto; display: flex; gap: 5px; flex-wrap: wrap; align-items: center; min-width: 0; overflow: hidden; }
3981
+ .rule-summary .k { font-size: 10.5px; color: var(--muted); }
3982
+ .rule-summary .t { font-size: 10.5px; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, var(--ink) 6%, transparent); color: var(--ink-2); white-space: nowrap; }
3983
+ .rule-summary .none { font-size: 11px; color: var(--muted); font-style: italic; }
3984
+ .act-pill { font-size: 11px; font-weight: 620; padding: 2px 9px; border-radius: 999px; white-space: nowrap; }
3985
+ .act-pill.deny { background: color-mix(in srgb, var(--crit-text) 14%, transparent); color: var(--crit-text); }
3986
+ .act-pill.mitigate { background: color-mix(in srgb, var(--warn-text) 16%, transparent); color: var(--warn-text); }
3987
+ .act-pill.allow { background: color-mix(in srgb, var(--good-text) 14%, transparent); color: var(--good-text); }
3988
+ .act-pill.tag { background: color-mix(in srgb, var(--ink) 8%, transparent); color: var(--ink-2); }
3989
+ .rule-body { padding: 11px 12px 13px; display: grid; gap: 11px; }
3990
+ .rule.collapsed .rule-body { display: none; }
3991
+ .field { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 10px; align-items: start; font-size: 12.5px; }
3992
+ .field > label { color: var(--muted); padding-top: 5px; }
3993
+ .field-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
3994
+ .hint { color: var(--muted); font-size: 11px; }
3995
+
3996
+ input[type="text"], input[type="number"], select {
3997
+ font: inherit; font-size: 12.5px; padding: 5px 9px; border-radius: 7px;
3998
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink); min-width: 0;
3999
+ }
4000
+ input[type="text"] { width: 100%; }
4001
+ input[type="number"] { width: 92px; }
4002
+ select { cursor: pointer; }
4003
+ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
4004
+ .mono-input { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
4005
+
4006
+ .chips-select { display: flex; gap: 4px; flex-wrap: wrap; }
4007
+ .chips-select button {
4008
+ font-size: 11px; padding: 2px 8px; border-radius: 999px; color: var(--muted);
4009
+ background: var(--surface); border: 1px solid var(--line);
4010
+ }
4011
+ .chips-select button[aria-pressed="true"] { background: color-mix(in srgb, var(--s1) 14%, var(--surface)); border-color: color-mix(in srgb, var(--s1) 50%, var(--line)); color: var(--ink); font-weight: 560; }
4012
+
4013
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
4014
+ .seg button { border: 0; border-radius: 0; padding: 4px 10px; font-size: 11.5px; background: var(--surface); }
4015
+ .seg button + button { border-left: 1px solid var(--line); }
4016
+ .seg button[aria-pressed="true"] { background: color-mix(in srgb, var(--s1) 14%, var(--surface)); color: var(--ink); font-weight: 560; }
4017
+
4018
+ .drop { outline: 2px dashed color-mix(in srgb, var(--s1) 60%, transparent); outline-offset: -6px; }
4019
+
4020
+ /* --- the actors table, the tester and the range sets --------------------- */
4021
+ .warn-text { color: var(--warn-text); }
4022
+ #actor-rows td { font-size: 12.5px; }
4023
+ #actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
4024
+ #actor-rows td.acts { text-align: right; white-space: nowrap; }
4025
+ #actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
4026
+ /* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
4027
+ one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
4028
+ .tagline { font-size: 11px; color: var(--muted); }
4029
+ .tagline b { color: var(--ink-2); font-weight: 600; }
4030
+
4031
+ #test-input { min-height: 82px; }
4032
+ #tester-panel .editor { gap: 8px; }
4033
+ #tester-panel .field-row { gap: 6px; }
4034
+ #tester-panel input[type="text"] { flex: 1 1 120px; min-width: 90px; }
4035
+ .assumed { color: var(--muted); font-size: 11px; margin-top: 6px; }
4036
+
4037
+ .rangeset { border-bottom: 1px solid var(--line-soft); padding: 10px 15px; }
4038
+ .rangeset:last-child { border-bottom: 0; }
4039
+ .rangeset h3 { margin: 0 0 6px; font-size: 12.5px; font-weight: 620; display: flex; align-items: baseline; gap: 8px; }
4040
+ .rangeset h3 span { font-weight: 400; color: var(--muted); font-size: 11.5px; }
4041
+ .cidrs { display: flex; gap: 5px; flex-wrap: wrap; }
4042
+ .cidr {
4043
+ display: inline-flex; align-items: center; gap: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
4044
+ font-size: 11px; background: color-mix(in srgb, var(--ink) 6%, transparent); border-radius: 999px; padding: 2px 4px 2px 9px;
4045
+ }
4046
+ .cidr button { border: 0; background: none; padding: 0 4px; font-size: 12px; line-height: 1; color: var(--muted); border-radius: 999px; }
4047
+ .cidr button:hover { background: none; color: var(--crit-text); }
4048
+ .cidr.readonly { padding-right: 9px; }
4049
+ .toasts { position: fixed; right: 18px; bottom: 18px; z-index: 60; display: grid; gap: 8px; max-width: 380px; }
4050
+ .toast {
4051
+ background: var(--raised); border: 1px solid var(--line); border-left: 3px solid var(--s1);
4052
+ border-radius: 9px; padding: 10px 12px; font-size: 12.5px; box-shadow: 0 6px 22px rgba(0,0,0,.18);
4053
+ animation: toast-in .16s ease-out;
4054
+ }
4055
+ .toast.ok { border-left-color: var(--good-text); }
4056
+ .toast.bad { border-left-color: var(--crit-text); }
4057
+ .toast.warn { border-left-color: var(--warn-text); }
4058
+ .toast b { display: block; margin-bottom: 2px; }
4059
+ .toast span { color: var(--muted); }
4060
+ @keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
4061
+ @media (prefers-reduced-motion: reduce) { .toast { animation: none; } * { transition: none !important; } }
4062
+
4063
+ /* Forced colours — Windows High Contrast and the like.
4064
+ The browser repaints text, backgrounds and borders from the user's palette but leaves
4065
+ SVG fill and stroke alone, which is the right outcome for the two series: they stay
4066
+ tellable apart instead of collapsing into one system colour. The gridlines are the
4067
+ casualty. They are drawn in --grid, a colour picked to recede against *this* dashboard's
4068
+ background, and against a forced black one they recede to 1.4:1 — measured, on the
4069
+ statistics charts — which is not recessive, it is gone. GrayText is the palette's own
4070
+ answer to "present but secondary", so the grid follows the user's colours while the data
4071
+ keeps the ones that carry meaning. */
4072
+ @media (forced-colors: active) {
4073
+ .gridline { stroke: GrayText; }
4074
+ }`;
4075
+ var DASHBOARD_MARKUP = String.raw`
4076
+ <a class="skip" href="#view-live">Skip to the feed</a>
4077
+
4078
+ <header>
4079
+ <div class="head-row">
4080
+ <div class="brand"><h1 id="title">__TITLE__</h1> <span>bot dashboard</span></div>
4081
+ <output class="live" id="live" aria-live="polite"><span class="dot" id="dot"></span><span id="conn">connecting…</span></output>
4082
+ <div class="grow"></div>
4083
+ <nav class="head-links" id="links" aria-label="Related"></nav>
4084
+ <div class="head-actions">
4085
+ <button id="theme" aria-label="Switch between light and dark">Theme</button>
4086
+ <button id="pause" aria-pressed="false">Pause</button>
4087
+ <button id="reset" class="danger" hidden>Reset</button>
4088
+ </div>
4089
+ </div>
4090
+ <div class="nav-row">
4091
+ <div class="tabs" role="tablist" aria-label="Dashboard views">
4092
+ <button class="tab" id="tab-live" role="tab" aria-selected="true" aria-controls="view-live">Live feed</button>
4093
+ <button class="tab" id="tab-actors" role="tab" aria-selected="false" aria-controls="view-actors">Actors</button>
4094
+ <button class="tab" id="tab-stats" role="tab" aria-selected="false" aria-controls="view-stats">Statistics</button>
4095
+ <button class="tab" id="tab-policy" role="tab" aria-selected="false" aria-controls="view-policy">Policy<span id="notice-badge" class="pill" hidden></span></button>
4096
+ </div>
4097
+ <div class="facts" id="chips"></div>
4098
+ <div class="peers" id="peers"></div>
4099
+ </div>
4100
+ </header>
4101
+
4102
+ <main class="stack">
4103
+ <section class="tiles" id="tiles"></section>
4104
+
4105
+ <div id="view-live" role="tabpanel" aria-labelledby="tab-live" class="stack">
4106
+ <section class="panel" id="actor-panel" hidden>
4107
+ <div class="actor-head">
4108
+ <span class="who" id="actor-key"></span>
4109
+ <span class="grow"></span>
4110
+ <button id="actor-close">Close</button>
4111
+ </div>
4112
+ <div class="two">
4113
+ <dl class="kv" id="actor-stats"></dl>
4114
+ <div class="bars" id="actor-mix"></div>
4115
+ </div>
4116
+ <div class="bar-actions" id="actor-actions"></div>
4117
+ </section>
4118
+
4119
+ <div class="two">
4120
+ <section class="panel feed-panel">
4121
+ <h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span></h2>
4122
+ <div class="toolbar">
4123
+ <div class="filters" id="filters"></div>
4124
+ <div class="search">
4125
+ <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off">
4126
+ <kbd aria-hidden="true">/</kbd>
4127
+ </div>
4128
+ <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
4129
+ </div>
4130
+ <div class="feed-scroll">
4131
+ <table>
4132
+ <thead>
4133
+ <tr>
4134
+ <th class="num">Time</th><th>Request</th><th>Verdict</th><th class="num">Score</th><th>Action</th><th class="num">ms</th>
4135
+ </tr>
4136
+ </thead>
4137
+ <tbody id="rows"></tbody>
4138
+ </table>
4139
+ </div>
4140
+ <div class="empty" id="empty">Nothing assessed yet. Send some traffic through the handler and it appears here within a moment.</div>
4141
+ </section>
4142
+
4143
+ <div class="stack">
4144
+ <section class="panel">
4145
+ <h2>Detectors firing <span class="sub win"></span></h2>
4146
+ <div class="bars" id="live-detectors"></div>
4147
+ </section>
4148
+ <section class="panel" id="live-actors-panel">
4149
+ <h2>Busiest actors <span class="sub win"></span></h2>
4150
+ <div class="bars" id="live-actors"></div>
4151
+ </section>
4152
+ <section class="panel" id="tester-panel">
4153
+ <h2>Test a request <span class="sub">nothing is recorded</span></h2>
4154
+ <div class="editor">
4155
+ <textarea id="test-input" rows="4" spellcheck="false" autocomplete="off" aria-label="A User-Agent, a curl command, or a block of request headers"
4156
+ placeholder="Paste a User-Agent, a curl command, or a block of request headers"></textarea>
4157
+ <div class="field-row">
4158
+ <input type="text" id="test-ip" class="mono-input" placeholder="client address (optional)" aria-label="Client address">
4159
+ <input type="text" id="test-url" class="mono-input" placeholder="/path (optional)" aria-label="Path">
4160
+ <button id="test-run" class="primary">Assess</button>
4161
+ </div>
4162
+ <div class="result" id="test-result" hidden></div>
4163
+ </div>
4164
+ </section>
4165
+
4166
+ <section class="panel" id="evidence-legend">
4167
+ <h2>Reading this</h2>
4168
+ <div class="note">
4169
+ <p><b>Purple</b> evidence is <em>proven</em>: a self-declaration, a refuted
4170
+ identity, a trap, a protocol violation. Only proven evidence may deny anybody.</p>
4171
+ <p><b>Red, amber and grey</b> are probabilistic. They accumulate into a score
4172
+ and may tag, delay, rate-limit or challenge — never block.</p>
4173
+ <p>An <b>amber left edge</b> is the safety guard stopping a rule: the policy
4174
+ asked for a terminal action the evidence could not support, and got the
4175
+ fallback instead. Those rows are the library working, not failing.</p>
4176
+ </div>
4177
+ </section>
4178
+ </div>
4179
+ </div>
4180
+ </div>
4181
+
4182
+ <div id="view-actors" role="tabpanel" aria-labelledby="tab-actors" class="stack" hidden>
4183
+ <section class="panel">
4184
+ <h2>Actors in the registry <span class="sub" id="actors-count"></span></h2>
4185
+ <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
4186
+ population than the feed's ring, which holds requests rather than clients. This is
4187
+ what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
4188
+ <div class="feed-scroll">
4189
+ <table>
4190
+ <thead>
4191
+ <tr>
4192
+ <th>Actor</th><th class="num">Requests</th><th class="num">Per min</th><th class="num">Paths</th>
4193
+ <th class="num">Cadence</th><th class="num">Proven</th><th class="num">Unsolved</th><th>State</th><th><span class="sr-only">Actions</span></th>
4194
+ </tr>
4195
+ </thead>
4196
+ <tbody id="actor-rows"></tbody>
4197
+ </table>
4198
+ </div>
4199
+ <div class="empty" id="actors-empty" hidden>Nothing in the registry yet.</div>
4200
+ </section>
4201
+ </div>
4202
+
4203
+ <div id="view-stats" role="tabpanel" aria-labelledby="tab-stats" class="stack" hidden>
4204
+ <div class="two">
4205
+ <section class="panel">
4206
+ <h2>Traffic <span class="range" id="traffic-range"></span><span class="sub" id="traffic-window"></span></h2>
4207
+ <div class="chart" id="traffic-chart"><svg id="traffic" role="img" aria-labelledby="traffic-alt"></svg><div class="tip" id="traffic-tip"></div></div>
4208
+ <p class="sr-only" id="traffic-alt"></p>
4209
+ <div class="legend" id="traffic-legend"></div>
4210
+ </section>
4211
+ <section class="panel">
4212
+ <h2>Assessment latency <span class="sub">since start</span></h2>
4213
+ <div class="chart" id="latency-chart"><svg id="latency" role="img" aria-labelledby="latency-alt"></svg><div class="tip" id="latency-tip"></div></div>
4214
+ <p class="sr-only" id="latency-alt"></p>
4215
+ <div class="legend"><span id="latency-summary"></span></div>
4216
+ </section>
4217
+ </div>
4218
+
4219
+ <div class="two">
4220
+ <section class="panel">
4221
+ <h2>Score distribution <span class="range" id="score-scope"></span><span class="sub" id="score-window"></span></h2>
4222
+ <div class="chart" id="score-chart"><svg id="scores" role="img" aria-labelledby="score-alt"></svg><div class="tip" id="score-tip"></div></div>
4223
+ <p class="sr-only" id="score-alt"></p>
4224
+ <div class="legend" id="score-legend"></div>
4225
+ </section>
4226
+ <section class="panel">
4227
+ <h2>Guard stops by rule <span class="sub win"></span></h2>
4228
+ <div class="bars" id="stat-guard"></div>
4229
+ </section>
4230
+ </div>
4231
+
4232
+ <div class="grid3">
4233
+ <section class="panel"><h2>Verdicts <span class="sub">since start</span></h2><div class="bars" id="stat-verdicts"></div></section>
4234
+ <section class="panel"><h2>Actions taken <span class="sub">since start</span></h2><div class="bars" id="stat-actions"></div></section>
4235
+ <section class="panel"><h2>Bot classes <span class="sub">since start</span></h2><div class="bars" id="stat-classes"></div></section>
4236
+ </div>
4237
+
4238
+ <div class="grid3">
4239
+ <section class="panel"><h2>Detector firings <span class="sub">since start</span></h2><div class="bars" id="stat-detectors"></div></section>
4240
+ <section class="panel">
4241
+ <h2>Challenges</h2>
4242
+ <div id="stat-challenges"></div>
4243
+ </section>
4244
+ <section class="panel">
4245
+ <h2>Health</h2>
4246
+ <div id="stat-health"></div>
4247
+ </section>
4248
+ </div>
4249
+
4250
+ <div class="two">
4251
+ <section class="panel" id="audit-panel">
4252
+ <h2>Audit <span class="sub" id="audit-spans"></span></h2>
4253
+ <div id="audit-body"></div>
4254
+ </section>
4255
+ <section class="panel" id="audit-checks-panel">
4256
+ <h2>Checks installed</h2>
4257
+ <div class="detectors" id="audit-checks"></div>
4258
+ </section>
4259
+ </div>
4260
+
4261
+ <div class="grid3">
4262
+ <section class="panel" id="identities-panel"><h2>Identities seen <span class="sub win"></span></h2><div class="bars" id="stat-identities"></div></section>
4263
+ <section class="panel"><h2>Busiest paths <span class="sub win"></span></h2><div class="bars" id="stat-paths"></div></section>
4264
+ <section class="panel"><h2>Denied paths <span class="sub win"></span></h2><div class="bars" id="stat-denied-paths"></div></section>
4265
+ </div>
4266
+
4267
+ <div class="two">
4268
+ <section class="panel">
4269
+ <h2>Installed detectors <span class="sub" id="detector-count"></span></h2>
4270
+ <div class="detectors" id="stat-detector-list"></div>
4271
+ </section>
4272
+ <div class="stack">
4273
+ <section class="panel">
4274
+ <h2>Rules that fired <span class="sub win"></span></h2>
4275
+ <div class="bars" id="stat-rule-hits"></div>
4276
+ </section>
4277
+ <section class="panel">
4278
+ <h2>Bypassed <span class="sub">detection never ran</span></h2>
4279
+ <div class="bars" id="stat-bypassed"></div>
4280
+ </section>
4281
+ </div>
4282
+ </div>
4283
+ </div>
4284
+
4285
+ <div id="view-policy" role="tabpanel" aria-labelledby="tab-policy" class="stack" hidden>
4286
+ <div class="two">
4287
+ <section class="panel" id="editor-panel">
4288
+ <h2>Rules <span class="sub" id="policy-mode"></span></h2>
4289
+ <div class="bar-actions">
4290
+ <div class="seg" id="editor-mode">
4291
+ <button id="mode-gui" aria-pressed="true">Editor</button>
4292
+ <button id="mode-json" aria-pressed="false">JSON</button>
4293
+ </div>
4294
+ <button id="policy-preview">Preview</button>
4295
+ <button id="policy-apply" class="primary" hidden>Apply</button>
4296
+ <button id="policy-revert">Revert</button>
4297
+ <span class="grow"></span>
4298
+ <button id="policy-import">Import…</button>
4299
+ <button id="policy-export">Export</button>
4300
+ <span class="dirty" id="policy-dirty" hidden>unsaved</span>
4301
+ </div>
4302
+ <div id="editor-gui">
4303
+ <div class="rulelist" id="rulelist"></div>
4304
+ <div class="bar-actions">
4305
+ <button id="rule-add">+ Add rule</button>
4306
+ <button id="rule-expand">Expand all</button>
4307
+ <span class="hint" id="policy-note"></span>
4308
+ </div>
4309
+ </div>
4310
+ <div class="editor" id="editor-json" hidden>
4311
+ <textarea id="policy-json" spellcheck="false" autocomplete="off" aria-label="Policy rules as JSON"></textarea>
4312
+ </div>
4313
+ <div class="editor"><div class="result" id="policy-result" hidden></div></div>
4314
+ <input type="file" id="policy-file" accept="application/json,.json" hidden>
4315
+ </section>
4316
+
4317
+ <div class="stack">
4318
+ <section class="panel" id="guard-panel">
4319
+ <h2>Guard <span class="sub" id="guard-mode"></span></h2>
4320
+ <div id="stat-policy"></div>
4321
+ <div class="note" id="guard-note"></div>
4322
+ </section>
4323
+ <section class="panel" id="ranges-panel">
4324
+ <h2>Range sets <span class="sub" id="ranges-mode"></span></h2>
4325
+ <div id="ranges-body"></div>
4326
+ <div class="note" id="ranges-note"></div>
4327
+ </section>
4328
+ <section class="panel">
4329
+ <h2>Preview a shipped preset</h2>
4330
+ <div class="rules" id="preset-buttons"></div>
4331
+ <div class="note">Runs the preset against the requests still in the window and shows what would change. Nothing is applied.</div>
4332
+ </section>
4333
+ <section class="panel" id="changes-panel">
4334
+ <h2>Changes <span class="sub" id="change-count"></span></h2>
4335
+ <div id="stat-changes"></div>
4336
+ <div class="note">What was applied to this process at runtime, and by whom when the dashboard's
4337
+ <code>auth</code> could say. Bounded and in memory: the durable copy is the
4338
+ <code>policy-change</code>, <code>guard-change</code>, <code>range-change</code> and
4339
+ <code>actor-change</code> events.</div>
4340
+ </section>
4341
+ <section class="panel" id="notices-panel">
4342
+ <h2>Notices <span class="sub" id="notice-count"></span></h2>
4343
+ <div id="stat-notices"></div>
4344
+ </section>
4345
+ </div>
4346
+ </div>
4347
+
4348
+ <div class="two">
4349
+ <section class="panel">
4350
+ <h2>Rules, in evaluation order</h2>
4351
+ <div class="rules" id="stat-rules"></div>
4352
+ </section>
4353
+ <section class="panel" id="robots-panel">
4354
+ <h2>robots.txt this policy implies</h2>
4355
+ <div class="editor"><pre class="code" id="robots-preview"></pre><div id="robots-notes"></div></div>
4356
+ </section>
4357
+ </div>
4358
+ </div>
4359
+ </main>
4360
+
4361
+ <div class="toasts" id="toasts" aria-live="polite"></div>
4362
+ `;
4363
+ var PAGE = String.raw`<!doctype html>
4364
+ <html lang="en">
4365
+ <head>
4366
+ <meta charset="utf-8">
4367
+ <meta name="viewport" content="width=device-width, initial-scale=1">
4368
+ <meta name="robots" content="noindex, nofollow">
4369
+ <title>__TITLE__ · bot dashboard</title>
4370
+ <style nonce="__NONCE__">
4371
+ ${DASHBOARD_CSS}
4372
+ </style>
4373
+ </head>
4374
+ <body>
4375
+ ${DASHBOARD_MARKUP}
4376
+ <script nonce="__NONCE__">
4377
+ window.__BOOTSTRAP__ = JSON.parse(__BOOT_JSON__);
4378
+ __SCRIPT__
4379
+ </script>
4380
+ </body>
4381
+ </html>
4382
+ `;
4383
+
4384
+ // src/element/index.ts
4385
+ var MAX_PANEL_ROWS = 200;
4386
+ var TAB_ELEMENT = {
4387
+ live: "tab-live",
4388
+ actors: "tab-actors",
4389
+ stats: "tab-stats",
4390
+ policy: "tab-policy"
4391
+ };
4392
+ var EXTRA_CSS = `
4393
+ /* What the body element carries on the standalone page.
4394
+ A shadow root has no body, so without this nothing sets the base colour, background or
4395
+ type: every element inherited the *host page's* colour instead. On a white page that
4396
+ passed for correct; in dark mode it was near-black text on a near-black surface, which
4397
+ axe reported as a serious contrast failure and which is exactly the kind of thing this
4398
+ library is not supposed to ship. */
4399
+ :host {
4400
+ display: block;
4401
+ background: var(--page);
4402
+ color: var(--ink);
4403
+ font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
4404
+ -webkit-font-smoothing: antialiased;
4405
+ }
4406
+ :host([hidden]) { display: none; }
4407
+ /* The page's own chrome assumes it owns the viewport. Inside somebody else's layout the
4408
+ element is the viewport, so the sticky header sticks to the element and the body
4409
+ metrics come from the host box. */
4410
+ :host { position: relative; }
4411
+ :host header { position: sticky; top: 0; }
4412
+ .bd-extra { display: grid; gap: 10px; padding: 14px 15px; }
4413
+ .bd-notice { font: 14px/1.5 system-ui, sans-serif; padding: 14px; margin: 0; color: var(--crit-text, #8a1c1c); }
4414
+ .bd-extra .row { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; }
4415
+ .bd-extra .row b { font-weight: 600; font-variant-numeric: tabular-nums; }
4416
+ .bd-extra .row span { color: var(--muted); font-size: 12px; }
4417
+ :host([data-density="compact"]) tbody td,
4418
+ :host([data-density="compact"]) thead th { padding-top: 5px; padding-bottom: 5px; }
4419
+ :host([data-density="compact"]) .panel > h2 { padding-top: 8px; padding-bottom: 8px; }
4420
+ `;
4421
+ var container;
4422
+ var owner;
4423
+ var lastTheme;
4424
+ function defineBotDashboard(name = "bot-dashboard") {
4425
+ if (typeof customElements === "undefined") return;
4426
+ const taken = customElements.get(name);
4427
+ if (taken !== void 0) {
4428
+ if (taken !== BotDashboardElement && !(taken.prototype instanceof BotDashboardElement) && !clashed.has(name)) {
4429
+ clashed.add(name);
4430
+ console.warn(`bot-dashboard: <${name}> is already registered on this page by something else, so this did nothing and your <${name}> elements are not this dashboard. Register it under a name of your own instead: defineBotDashboard("ops-dashboard").`);
4431
+ }
4432
+ return;
4433
+ }
4434
+ customElements.define(name, registered ? class extends BotDashboardElement {
4435
+ } : BotDashboardElement);
4436
+ registered = true;
4437
+ }
4438
+ var registered = false;
4439
+ var clashed = /* @__PURE__ */ new Set();
4440
+ var ElementBase = typeof HTMLElement === "undefined" ? class {
4441
+ } : HTMLElement;
4442
+ var BotDashboardElement = class extends ElementBase {
4443
+ settings = {};
4444
+ booted = false;
4445
+ /**
4446
+ * Booted *and* finished drawing. Distinct from `booted`, which is set on the way in:
4447
+ * `connectedCallback` replays a config assigned before the upgrade through the setter,
4448
+ * and at that moment `booted` is already true — so a warning keyed on it fired on a
4449
+ * perfectly ordinary mount, complaining that the config had changed when what had
4450
+ * happened was that it had arrived.
4451
+ */
4452
+ rendered = false;
4453
+ /**
4454
+ * Set before the element is attached, or pass the same things as attributes.
4455
+ *
4456
+ * An accessor rather than a field, and the difference is load-bearing. A page that
4457
+ * writes `el.config = {...}` before the element is defined puts an *own property* on
4458
+ * the instance, and a class field initialiser then runs at upgrade and overwrites it —
4459
+ * so the configuration silently disappears and the dashboard renders its defaults. The
4460
+ * accessor plus the upgrade dance in `connectedCallback` is the documented way round
4461
+ * it, and the order it protects (write the config, then define the element) is the
4462
+ * natural one to write.
4463
+ */
4464
+ get config() {
4465
+ return this.settings;
4466
+ }
4467
+ set config(value) {
4468
+ const before = this.rendered && this.isConnected ? shapeOf(this.settings) : void 0;
4469
+ this.settings = value ?? {};
4470
+ if (this.booted && this.isConnected) {
4471
+ this.applyTheme();
4472
+ if (before !== void 0 && shapeOf(this.settings) !== before) {
4473
+ console.warn("bot-dashboard: `tabs` and `panels` are read once, when the element first mounts. Changing them afterwards has no effect \u2014 remount the element instead. (`theme` does update live.)");
4474
+ }
4475
+ }
4476
+ }
4477
+ timers = [];
4478
+ /** Custom properties this element put on itself, so it can take them off again. */
4479
+ appliedTokens = /* @__PURE__ */ new Set();
4480
+ warned = /* @__PURE__ */ new Set();
4481
+ static get observedAttributes() {
4482
+ return ["src", "scheme", "density"];
4483
+ }
4484
+ connectedCallback() {
4485
+ if (this.booted) return;
4486
+ this.booted = true;
4487
+ if (Object.prototype.hasOwnProperty.call(this, "config")) {
4488
+ const preset = this.config;
4489
+ Reflect.deleteProperty(this, "config");
4490
+ this.config = preset;
4491
+ }
4492
+ void this.boot();
4493
+ }
4494
+ disconnectedCallback() {
4495
+ for (const timer2 of this.timers) clearInterval(timer2);
4496
+ this.timers = [];
4497
+ if (owner === this) owner = void 0;
4498
+ void Promise.resolve().then(() => (init_stream(), stream_exports)).then((stream) => {
4499
+ if (!this.isConnected) stream.suspendStream();
4500
+ });
4501
+ }
4502
+ attributeChangedCallback(attribute, previous, value) {
4503
+ if (attribute === "scheme") this.applyScheme(value);
4504
+ if (attribute === "density") this.applyDensity(value);
4505
+ if (attribute === "src" && previous !== null && previous !== value && this.booted) {
4506
+ console.warn("bot-dashboard: `src` is read once, when the element first mounts. Changing it afterwards has no effect \u2014 replace the element instead.");
4507
+ }
4508
+ }
4509
+ /**
4510
+ * Give up on this attempt without giving up on the element.
4511
+ *
4512
+ * Every way a boot can end early — it lost the one-at-a-time race, its `src` is
4513
+ * cross-origin, its handler did not answer, it was unmounted mid-flight — is about this
4514
+ * moment rather than about this element, and all four are things a remount can fix: the
4515
+ * other dashboard has gone, the attribute was corrected, the server finished starting.
4516
+ * Releasing ownership without clearing `booted` was the bug: `connectedCallback` returns
4517
+ * early on a latched `booted`, so the element stayed dead for the life of the instance
4518
+ * and a router that remounts it drew a stale error over a handler that now works.
4519
+ */
4520
+ standDown() {
4521
+ if (owner === this) owner = void 0;
4522
+ this.booted = false;
4523
+ this.rendered = false;
4524
+ }
4525
+ /**
4526
+ * Where the handler is mounted, from `config.src` or the attribute.
4527
+ *
4528
+ * A trailing slash is dropped, and so are a query string and a fragment — the element
4529
+ * appends `/api/bootstrap` to this, so anything after the path cannot survive that
4530
+ * concatenation and never could. Left in, `src="/_bots?token=x"` failed with "it
4531
+ * answered text/html — is createDashboardHandler mounted at /_bots?token=x?", which
4532
+ * sends somebody to check the one thing that was right.
4533
+ */
4534
+ mountPath() {
4535
+ const { base, warning } = parseMount(this.config.src ?? this.getAttribute("src") ?? "");
4536
+ if (warning !== void 0) this.warnOnce(`bot-dashboard: ${warning}`);
4537
+ return base;
4538
+ }
4539
+ async boot() {
4540
+ const base = this.mountPath();
4541
+ const shadow = this.shadowRoot ?? this.attachShadow({ mode: "open" });
4542
+ for (const child of Array.from(shadow.children)) {
4543
+ if (child.classList.contains("bd-notice")) child.remove();
4544
+ }
4545
+ if (owner !== void 0 && owner !== this && owner.isConnected) {
4546
+ shadow.append(notice("A bot dashboard is already running on this page. Only one can be."));
4547
+ this.standDown();
4548
+ return;
4549
+ }
4550
+ owner = this;
4551
+ if (container !== void 0) {
4552
+ const dom2 = await Promise.resolve().then(() => (init_dom(), dom_exports));
4553
+ dom2.setThemeHost(this);
4554
+ this.applyTheme();
4555
+ adoptStyles(shadow);
4556
+ shadow.append(container);
4557
+ this.renderPanels(container);
4558
+ const stream = await Promise.resolve().then(() => (init_stream(), stream_exports));
4559
+ stream.connectStream();
4560
+ this.rendered = true;
4561
+ this.dispatchEvent(new CustomEvent("bot-dashboard-ready", { bubbles: true }));
4562
+ return;
4563
+ }
4564
+ if (base !== "" && /^https?:\/\//i.test(base)) {
4565
+ try {
4566
+ if (new URL(base).origin !== location.origin) {
4567
+ const why = `src points at ${new URL(base).origin}, which is not this page's origin. The dashboard is same-origin only: it sends no CORS headers, which is what stops another site reading your traffic. Mount it under this origin and use a path, e.g. src="/_bots".`;
4568
+ shadow.append(notice(`The bot dashboard could not start: ${why}`));
4569
+ console.error("bot-dashboard:", why);
4570
+ this.standDown();
4571
+ return;
4572
+ }
4573
+ } catch {
4574
+ }
4575
+ }
4576
+ let boot;
4577
+ try {
4578
+ const response = await fetch(`${base}/api/bootstrap`, { credentials: "same-origin", headers: { accept: "application/json" } });
4579
+ if (!response.ok) throw new Error(explainStatus(response.status, base));
4580
+ const type = response.headers.get("content-type") ?? "";
4581
+ if (!type.includes("json")) {
4582
+ throw new Error(
4583
+ base === "" ? 'no `src` was given, so it asked this page\'s own origin and got HTML back. Point `src` at where createDashboardHandler is mounted, e.g. src="/_bots"' : `it answered ${type || "no content type"} rather than JSON. Is createDashboardHandler mounted at ${base}?`
4584
+ );
4585
+ }
4586
+ boot = await response.json();
4587
+ if (!this.isConnected) {
4588
+ this.standDown();
4589
+ return;
4590
+ }
4591
+ } catch (error) {
4592
+ const why = String(error instanceof Error ? error.message : error);
4593
+ shadow.append(notice(`The bot dashboard could not start: ${why}`));
4594
+ console.error("bot-dashboard:", why);
4595
+ this.standDown();
4596
+ return;
4597
+ }
4598
+ boot["base"] = base;
4599
+ const { sections, warnings } = resolveSections(boot["sections"], this.config);
4600
+ for (const warning of warnings) this.warnOnce(`bot-dashboard: ${warning}`);
4601
+ boot["sections"] = sections;
4602
+ adoptStyles(shadow);
4603
+ const frame = document.createElement("div");
4604
+ frame.className = "bd-root";
4605
+ frame.innerHTML = DASHBOARD_MARKUP;
4606
+ shadow.append(frame);
4607
+ const title = typeof boot["title"] === "string" ? boot["title"] : "bothandlerjs";
4608
+ const main = frame.querySelector("main");
4609
+ if (main !== null) {
4610
+ const region = document.createElement("div");
4611
+ region.className = main.className;
4612
+ region.setAttribute("role", "region");
4613
+ region.setAttribute("aria-label", `${title} bot dashboard`);
4614
+ while (main.firstChild !== null) region.append(main.firstChild);
4615
+ main.replaceWith(region);
4616
+ }
4617
+ frame.querySelector("header")?.setAttribute("role", "none");
4618
+ for (const node of Array.from(frame.querySelectorAll("*"))) {
4619
+ for (const child of Array.from(node.childNodes)) {
4620
+ if (child.nodeType === 3 && child.nodeValue?.includes("__TITLE__") === true) {
4621
+ child.nodeValue = child.nodeValue.replace(/__TITLE__/g, title);
4622
+ }
4623
+ }
4624
+ }
4625
+ this.applyTheme();
4626
+ container = frame;
4627
+ const dom = await Promise.resolve().then(() => (init_dom(), dom_exports));
4628
+ dom.setRoot(frame, this);
4629
+ globalThis.__BOOTSTRAP__ = boot;
4630
+ await Promise.resolve().then(() => (init_client(), client_exports));
4631
+ this.relabelTabs(shadow);
4632
+ this.renderPanels(frame);
4633
+ warnIfFramed();
4634
+ this.rendered = true;
4635
+ this.dispatchEvent(new CustomEvent("bot-dashboard-ready", { bubbles: true }));
4636
+ }
4637
+ applyTheme() {
4638
+ const theme = this.config.theme ?? lastTheme ?? {};
4639
+ if (this.config.theme !== void 0) lastTheme = this.config.theme;
4640
+ this.applyScheme(theme.scheme ?? this.getAttribute("scheme"));
4641
+ this.applyDensity(theme.density ?? this.getAttribute("density"));
4642
+ const next = /* @__PURE__ */ new Set();
4643
+ for (const [token, value] of Object.entries(theme.tokens ?? {})) {
4644
+ const name = token.startsWith("--") ? token : `--${token}`;
4645
+ next.add(name);
4646
+ this.style.setProperty(name, value);
4647
+ }
4648
+ for (const name of this.appliedTokens) {
4649
+ if (!next.has(name)) this.style.removeProperty(name);
4650
+ }
4651
+ this.appliedTokens = next;
4652
+ }
4653
+ /**
4654
+ * `scheme` and `density` take two values each, and until now anything else was accepted
4655
+ * in silence — a mis-typed `scheme` did nothing, and a mis-typed `density` was written
4656
+ * onto the element as `data-density="cozy"`, matching no rule and looking, from the
4657
+ * outside, exactly like a dashboard that ignores its configuration.
4658
+ *
4659
+ * Warned once per bad value per element, because `applyTheme` runs on every re-render
4660
+ * and a typo that repeats a hundred times is a typo the console has stopped conveying.
4661
+ */
4662
+ applyScheme(value) {
4663
+ if (value === null || value === void 0) return;
4664
+ if (value === "light" || value === "dark") {
4665
+ this.setAttribute("data-theme", value);
4666
+ return;
4667
+ }
4668
+ this.warnOnce(`bot-dashboard: scheme "${value}" is not "light" or "dark", so it was ignored. Leave it unset to follow the viewer's own setting.`);
4669
+ }
4670
+ applyDensity(value) {
4671
+ if (value === null || value === void 0) return;
4672
+ if (value === "comfortable" || value === "compact") {
4673
+ this.setAttribute("data-density", value);
4674
+ return;
4675
+ }
4676
+ this.warnOnce(`bot-dashboard: density "${value}" is not "comfortable" or "compact", so it was ignored.`);
4677
+ }
4678
+ warnOnce(message) {
4679
+ if (this.warned.has(message)) return;
4680
+ this.warned.add(message);
4681
+ console.warn(message);
4682
+ }
4683
+ /**
4684
+ * Reorders and relabels the strip the client has just drawn.
4685
+ *
4686
+ * Only the tabs that survived: which screens *exist* was settled in the boot object
4687
+ * before the client ran, because the client builds its list from there and looks each
4688
+ * one up by id — deleting an element here instead crashed it with "no element
4689
+ * #tab-actors", which is what this comment is standing in for.
4690
+ */
4691
+ relabelTabs(shadow) {
4692
+ const wanted = this.config.tabs;
4693
+ if (wanted === void 0 || wanted.length === 0) return;
4694
+ const strip = shadow.querySelector(".tabs");
4695
+ if (strip === null) return;
4696
+ for (const tab of wanted) {
4697
+ const node = shadow.querySelector(`#${TAB_ELEMENT[tab.id]}`);
4698
+ if (node === null) continue;
4699
+ if (tab.label !== void 0) node.textContent = tab.label;
4700
+ strip.append(node);
4701
+ }
4702
+ }
4703
+ renderPanels(within) {
4704
+ const panels = this.config.panels ?? [];
4705
+ const wanted = new Set(panels.map((panel) => `${panel.id}@${panel.screen}`));
4706
+ for (const section of Array.from(within.querySelectorAll("[data-bd-panel]"))) {
4707
+ const key = `${section.getAttribute("data-bd-panel")}@${section.getAttribute("data-bd-screen")}`;
4708
+ if (!wanted.has(key)) section.remove();
4709
+ }
4710
+ const drawn = /* @__PURE__ */ new Set();
4711
+ for (const panel of panels) {
4712
+ const key = `${panel.id}@${panel.screen}`;
4713
+ if (drawn.has(key)) {
4714
+ this.warnOnce(`bot-dashboard: two panels share the id "${panel.id}" on screen "${panel.screen}". Ids identify a panel, so the second was ignored \u2014 give them different ids.`);
4715
+ continue;
4716
+ }
4717
+ drawn.add(key);
4718
+ const host = within.querySelector(`#view-${cssEscape(panel.screen)}`);
4719
+ if (host === null) {
4720
+ const known = Array.from(within.querySelectorAll("[id^='view-']")).map((node) => node.id.slice("view-".length)).join(", ");
4721
+ console.warn(`bot-dashboard: panel "${panel.id}" asks for screen "${panel.screen}", which this dashboard does not have. It has: ${known}.`);
4722
+ continue;
4723
+ }
4724
+ const existing = host.querySelector(`[data-bd-panel="${cssEscape(panel.id)}"]`);
4725
+ let body;
4726
+ if (existing !== null) {
4727
+ body = existing.querySelector(".bd-extra");
4728
+ } else {
4729
+ const section = document.createElement("section");
4730
+ section.className = "panel";
4731
+ section.setAttribute("data-bd-panel", panel.id);
4732
+ section.setAttribute("data-bd-screen", panel.screen);
4733
+ const heading = document.createElement("h2");
4734
+ heading.textContent = panel.title;
4735
+ body = document.createElement("div");
4736
+ body.className = "bd-extra";
4737
+ section.append(heading, body);
4738
+ host.append(section);
4739
+ }
4740
+ const paint = async () => {
4741
+ try {
4742
+ const data = typeof panel.source === "string" ? await (await fetch(panel.source, { credentials: "same-origin" })).json() : await panel.source();
4743
+ while (body.firstChild) body.removeChild(body.firstChild);
4744
+ if (!Array.isArray(data?.rows)) {
4745
+ body.append(notice(`${panel.title} returned no rows.`));
4746
+ return;
4747
+ }
4748
+ const all = data.rows;
4749
+ for (const row of all.slice(0, MAX_PANEL_ROWS)) {
4750
+ if (typeof row !== "object" || row === null || row.label === void 0 || row.value === void 0) continue;
4751
+ const line = document.createElement("div");
4752
+ line.className = "row";
4753
+ const label2 = document.createElement("span");
4754
+ label2.textContent = cellText(row.label);
4755
+ const value = document.createElement("b");
4756
+ value.textContent = cellText(row.value);
4757
+ line.append(label2, value);
4758
+ if (row.note !== void 0) {
4759
+ const note = document.createElement("span");
4760
+ note.textContent = cellText(row.note);
4761
+ line.append(note);
4762
+ }
4763
+ body.append(line);
4764
+ }
4765
+ if (all.length > MAX_PANEL_ROWS) {
4766
+ const more = document.createElement("div");
4767
+ more.className = "row";
4768
+ const label2 = document.createElement("span");
4769
+ label2.textContent = `${all.length - MAX_PANEL_ROWS} more not shown`;
4770
+ more.append(label2);
4771
+ body.append(more);
4772
+ }
4773
+ } catch {
4774
+ while (body.firstChild) body.removeChild(body.firstChild);
4775
+ body.append(notice(`Could not load ${panel.title}.`));
4776
+ }
4777
+ };
4778
+ void paint();
4779
+ if (panel.refreshMs !== void 0 && panel.refreshMs > 0) {
4780
+ this.timers.push(setInterval(paint, Math.max(1e3, panel.refreshMs)));
4781
+ }
4782
+ }
4783
+ }
4784
+ };
4785
+ var sheet;
4786
+ function adoptStyles(shadow) {
4787
+ if (typeof CSSStyleSheet !== "undefined" && "replaceSync" in CSSStyleSheet.prototype && "adoptedStyleSheets" in shadow) {
4788
+ if (sheet === void 0) {
4789
+ sheet = new CSSStyleSheet();
4790
+ sheet.replaceSync(`${DASHBOARD_CSS}
4791
+ ${EXTRA_CSS}`);
4792
+ }
4793
+ shadow.adoptedStyleSheets = [...shadow.adoptedStyleSheets, sheet];
4794
+ return;
4795
+ }
4796
+ const style = document.createElement("style");
4797
+ style.textContent = `${DASHBOARD_CSS}
4798
+ ${EXTRA_CSS}`;
4799
+ shadow.append(style);
4800
+ }
4801
+ var framingWarned = false;
4802
+ function warnIfFramed() {
4803
+ if (framingWarned) return;
4804
+ if (globalThis.top === globalThis.self) return;
4805
+ try {
4806
+ void globalThis.top.location.origin;
4807
+ return;
4808
+ } catch {
4809
+ framingWarned = true;
4810
+ console.warn(
4811
+ "bot-dashboard: this page is framed by another origin, and a framed dashboard can be clickjacked \u2014 an operator clicks a control they cannot see. The standalone page refuses framing with `frame-ancestors 'none'`; a page you serve yourself has to send that header itself, or `X-Frame-Options: DENY`."
4812
+ );
4813
+ }
4814
+ }
4815
+ function notice(text) {
4816
+ const node = document.createElement("p");
4817
+ node.className = "bd-notice";
4818
+ node.textContent = text;
4819
+ return node;
4820
+ }
4821
+ export {
4822
+ BotDashboardElement,
4823
+ defineBotDashboard
4824
+ };
4825
+ //# sourceMappingURL=index.js.map