@osqd/bothandlerjs 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +227 -1
- package/README.md +18 -10
- package/dist/adapters/fastify.d.ts +10 -0
- package/dist/adapters/index.cjs +38 -13
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -13
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +1783 -83
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1783 -83
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +33 -21
- package/dist/corpus/index.cjs +282 -8
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +282 -8
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +25 -0
- package/dist/dashboard/client/actions.d.ts +1 -1
- package/dist/dashboard/client/app.d.ts +9 -2
- package/dist/dashboard/client/boot.d.ts +32 -3
- package/dist/dashboard/client/query.d.ts +72 -12
- package/dist/dashboard/client/registry.d.ts +25 -0
- package/dist/dashboard/client/saved.d.ts +29 -0
- package/dist/dashboard/client/store.d.ts +16 -2
- package/dist/dashboard/client/types.d.ts +2 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/types.d.ts +15 -0
- package/dist/detectors/blended-identity.d.ts +34 -0
- package/dist/detectors/challenge-integrity.d.ts +26 -0
- package/dist/detectors/challenge-reaction.d.ts +39 -0
- package/dist/detectors/clearance.d.ts +1 -23
- package/dist/detectors/index.d.ts +16 -1
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +726 -79
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +726 -79
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +1657 -125
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1643 -125
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +0 -3
- package/dist/internal/ip.d.ts +18 -0
- package/dist/internal/text.d.ts +28 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/probe/index.d.ts +153 -0
- package/dist/probe/marker.d.ts +119 -0
- package/dist/site/index.d.ts +122 -0
- package/dist/state.d.ts +130 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +70 -0
- package/docs/course/05-detectors.md +6 -5
- package/docs/course/06-identity.md +1 -1
- package/docs/course/16-proving-it.md +15 -9
- package/docs/course/index.md +1 -1
- package/docs/design/decisions.md +1 -1
- package/docs/detection/correlation.md +284 -0
- package/docs/detection/detectors.md +139 -3
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +1 -1
- package/docs/index.md +3 -2
- package/docs/integration/client-ip.md +16 -0
- package/docs/operations/dashboard.md +40 -1
- package/docs/operations/filters.md +143 -0
- package/docs/operations/index.md +1 -0
- package/docs/operations/metrics.md +18 -0
- package/docs/policy/presets.md +1 -1
- package/docs/start/choosing-a-policy.md +1 -1
- package/docs/start/first-integration.md +1 -1
- package/docs/start/installation.md +2 -2
- package/docs/testing/cli.md +7 -1
- package/docs/testing/corpus.md +12 -8
- package/docs/testing/index.md +1 -1
- package/docs/testing/try-it.md +1 -1
- package/package.json +4 -1
package/dist/element/index.cjs
CHANGED
|
@@ -125,12 +125,23 @@ var init_dom = __esm({
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
// src/dashboard/client/boot.ts
|
|
128
|
-
var
|
|
128
|
+
var boot_exports = {};
|
|
129
|
+
__export(boot_exports, {
|
|
130
|
+
API: () => API,
|
|
131
|
+
BOOT: () => BOOT,
|
|
132
|
+
SECTIONS: () => SECTIONS,
|
|
133
|
+
applyBoot: () => applyBoot
|
|
134
|
+
});
|
|
135
|
+
function applyBoot(next) {
|
|
136
|
+
BOOT = next;
|
|
137
|
+
API = next.base;
|
|
138
|
+
SECTIONS = next.sections;
|
|
139
|
+
}
|
|
140
|
+
var FALLBACK, BOOT, API, SECTIONS;
|
|
129
141
|
var init_boot = __esm({
|
|
130
142
|
"src/dashboard/client/boot.ts"() {
|
|
131
143
|
"use strict";
|
|
132
|
-
|
|
133
|
-
BOOT = raw ?? {
|
|
144
|
+
FALLBACK = {
|
|
134
145
|
base: "",
|
|
135
146
|
title: "bothandlerjs",
|
|
136
147
|
allowReset: false,
|
|
@@ -141,6 +152,7 @@ var init_boot = __esm({
|
|
|
141
152
|
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 },
|
|
142
153
|
links: []
|
|
143
154
|
};
|
|
155
|
+
BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;
|
|
144
156
|
API = BOOT.base;
|
|
145
157
|
SECTIONS = BOOT.sections;
|
|
146
158
|
}
|
|
@@ -215,48 +227,172 @@ var init_outcome = __esm({
|
|
|
215
227
|
});
|
|
216
228
|
|
|
217
229
|
// src/dashboard/client/query.ts
|
|
230
|
+
function suggestFor(input, caret) {
|
|
231
|
+
const before = input.slice(0, caret);
|
|
232
|
+
const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);
|
|
233
|
+
const token = before.slice(start2);
|
|
234
|
+
const to = caret;
|
|
235
|
+
const negated = token.startsWith("-") || token.startsWith("!");
|
|
236
|
+
const body = negated ? token.slice(1) : token;
|
|
237
|
+
const colon = body.indexOf(":");
|
|
238
|
+
if (colon === -1) {
|
|
239
|
+
const partial2 = body.toLowerCase();
|
|
240
|
+
if (partial2.startsWith("$")) {
|
|
241
|
+
return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };
|
|
242
|
+
}
|
|
243
|
+
const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);
|
|
244
|
+
return { options: options2, from: start2, to };
|
|
245
|
+
}
|
|
246
|
+
const field2 = body.slice(0, colon).toLowerCase();
|
|
247
|
+
const values = FIELD_VALUES[field2];
|
|
248
|
+
if (values === void 0) return { options: [], from: start2, to };
|
|
249
|
+
const partial = body.slice(colon + 1).toLowerCase();
|
|
250
|
+
const prefix = `${negated ? token[0] : ""}${field2}:`;
|
|
251
|
+
const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];
|
|
252
|
+
const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];
|
|
253
|
+
return { options, from: start2, to };
|
|
254
|
+
}
|
|
218
255
|
function tokenize(input) {
|
|
219
256
|
const tokens = [];
|
|
220
257
|
let current = "";
|
|
221
258
|
let quoted = false;
|
|
222
|
-
|
|
259
|
+
let depth = 0;
|
|
260
|
+
const flush = () => {
|
|
261
|
+
if (current === "") return;
|
|
262
|
+
const lower = current.toLowerCase();
|
|
263
|
+
if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });
|
|
264
|
+
else tokens.push({ kind: "word", text: current });
|
|
265
|
+
current = "";
|
|
266
|
+
};
|
|
267
|
+
for (let i = 0; i < input.length; i++) {
|
|
268
|
+
const character = input[i];
|
|
223
269
|
if (character === '"') {
|
|
224
270
|
quoted = !quoted;
|
|
225
271
|
continue;
|
|
226
272
|
}
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
273
|
+
if (quoted) {
|
|
274
|
+
current += character;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (depth > 0) {
|
|
278
|
+
current += character;
|
|
279
|
+
if (character === "(") depth++;
|
|
280
|
+
else if (character === ")") {
|
|
281
|
+
depth--;
|
|
282
|
+
if (depth === 0) flush();
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (character === "(") {
|
|
287
|
+
if (/\$(in|notin)$/i.test(current)) {
|
|
288
|
+
current += character;
|
|
289
|
+
depth = 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
flush();
|
|
293
|
+
tokens.push({ kind: "open" });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (character === ")") {
|
|
297
|
+
flush();
|
|
298
|
+
tokens.push({ kind: "close" });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (/\s/.test(character)) {
|
|
302
|
+
flush();
|
|
230
303
|
continue;
|
|
231
304
|
}
|
|
232
305
|
current += character;
|
|
233
306
|
}
|
|
234
|
-
|
|
307
|
+
flush();
|
|
235
308
|
return tokens;
|
|
236
309
|
}
|
|
237
|
-
function
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
310
|
+
function toTerm(word) {
|
|
311
|
+
const negated = word.startsWith("-") || word.startsWith("!");
|
|
312
|
+
const body = negated ? word.slice(1) : word;
|
|
313
|
+
if (body === "") return void 0;
|
|
314
|
+
const colon = body.indexOf(":");
|
|
315
|
+
const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
|
|
316
|
+
const field2 = FIELDS[name];
|
|
317
|
+
if (colon === -1 || field2 === void 0) return { field: void 0, value: body.toLowerCase(), negated };
|
|
318
|
+
const rest = body.slice(colon + 1);
|
|
319
|
+
const set = /^\$(in|notin)\(([\s\S]*)$/i.exec(rest);
|
|
320
|
+
if (set !== null) {
|
|
321
|
+
const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];
|
|
322
|
+
const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
|
|
323
|
+
const inverted = set[1].toLowerCase() === "notin";
|
|
324
|
+
return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };
|
|
325
|
+
}
|
|
326
|
+
let value = rest.toLowerCase();
|
|
327
|
+
let compare;
|
|
328
|
+
if (NUMERIC.has(field2)) {
|
|
329
|
+
compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
|
|
330
|
+
if (compare !== "=") value = value.slice(1);
|
|
331
|
+
}
|
|
332
|
+
if (value === "") return void 0;
|
|
333
|
+
return { field: field2, value, negated, compare };
|
|
334
|
+
}
|
|
335
|
+
function parseTokens(tokens) {
|
|
336
|
+
let at = 0;
|
|
337
|
+
let depth = 0;
|
|
338
|
+
const parseUnary = () => {
|
|
339
|
+
const token = tokens[at];
|
|
340
|
+
if (token === void 0) return void 0;
|
|
341
|
+
if (token.kind === "op" && token.op === "not") {
|
|
342
|
+
at++;
|
|
343
|
+
const of = parseUnary();
|
|
344
|
+
return of === void 0 ? void 0 : { kind: "not", of };
|
|
249
345
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
346
|
+
if (token.kind === "open") {
|
|
347
|
+
at++;
|
|
348
|
+
if (depth >= MAX_GROUP_DEPTH) return void 0;
|
|
349
|
+
depth++;
|
|
350
|
+
const inner = parseOr();
|
|
351
|
+
depth--;
|
|
352
|
+
if (tokens[at]?.kind === "close") at++;
|
|
353
|
+
return inner.kind === "all" ? void 0 : inner;
|
|
255
354
|
}
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
355
|
+
if (token.kind === "close") return void 0;
|
|
356
|
+
if (token.kind === "op") {
|
|
357
|
+
at++;
|
|
358
|
+
return parseUnary();
|
|
359
|
+
}
|
|
360
|
+
at++;
|
|
361
|
+
const term = toTerm(token.text);
|
|
362
|
+
return term === void 0 ? void 0 : { kind: "term", term };
|
|
363
|
+
};
|
|
364
|
+
const parseAnd = () => {
|
|
365
|
+
const parts = [];
|
|
366
|
+
while (at < tokens.length) {
|
|
367
|
+
const token = tokens[at];
|
|
368
|
+
if (token === void 0 || token.kind === "close") break;
|
|
369
|
+
if (token.kind === "op" && token.op === "or") break;
|
|
370
|
+
if (token.kind === "op" && token.op === "and") {
|
|
371
|
+
at++;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const before = at;
|
|
375
|
+
const part = parseUnary();
|
|
376
|
+
if (part !== void 0) parts.push(part);
|
|
377
|
+
if (at === before) at++;
|
|
378
|
+
}
|
|
379
|
+
return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };
|
|
380
|
+
};
|
|
381
|
+
const parseOr = () => {
|
|
382
|
+
const parts = [parseAnd()];
|
|
383
|
+
while (tokens[at]?.kind === "op" && tokens[at].op === "or") {
|
|
384
|
+
at++;
|
|
385
|
+
parts.push(parseAnd());
|
|
386
|
+
}
|
|
387
|
+
const real = parts.filter((part) => part.kind !== "all");
|
|
388
|
+
if (real.length === 0) return { kind: "all" };
|
|
389
|
+
return real.length === 1 ? real[0] : { kind: "or", parts: real };
|
|
390
|
+
};
|
|
391
|
+
return parseOr();
|
|
392
|
+
}
|
|
393
|
+
function parseFilter(input) {
|
|
394
|
+
const trimmed = input.trim();
|
|
395
|
+
return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));
|
|
260
396
|
}
|
|
261
397
|
function searchableText(entry) {
|
|
262
398
|
const parts = [
|
|
@@ -310,6 +446,11 @@ function fieldValue(entry, field2) {
|
|
|
310
446
|
}
|
|
311
447
|
function matchesTerm(term, entry, haystack) {
|
|
312
448
|
if (term.field === void 0) return haystack.includes(term.value);
|
|
449
|
+
if (term.values !== void 0) {
|
|
450
|
+
if (term.values.length === 0) return false;
|
|
451
|
+
const actual = fieldValue(entry, term.field).toLowerCase();
|
|
452
|
+
return term.values.some((value) => actual.includes(value));
|
|
453
|
+
}
|
|
313
454
|
if (term.field === "score") {
|
|
314
455
|
const wanted = Number(term.value);
|
|
315
456
|
if (Number.isNaN(wanted)) return false;
|
|
@@ -319,11 +460,19 @@ function matchesTerm(term, entry, haystack) {
|
|
|
319
460
|
}
|
|
320
461
|
return fieldValue(entry, term.field).toLowerCase().includes(term.value);
|
|
321
462
|
}
|
|
322
|
-
function
|
|
323
|
-
|
|
324
|
-
|
|
463
|
+
function matches(filter, entry, haystack) {
|
|
464
|
+
switch (filter.kind) {
|
|
465
|
+
case "all":
|
|
466
|
+
return true;
|
|
467
|
+
case "term":
|
|
468
|
+
return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;
|
|
469
|
+
case "not":
|
|
470
|
+
return !matches(filter.of, entry, haystack);
|
|
471
|
+
case "and":
|
|
472
|
+
return filter.parts.every((part) => matches(part, entry, haystack));
|
|
473
|
+
case "or":
|
|
474
|
+
return filter.parts.some((part) => matches(part, entry, haystack));
|
|
325
475
|
}
|
|
326
|
-
return true;
|
|
327
476
|
}
|
|
328
477
|
function matchesFilter(filter, entry) {
|
|
329
478
|
switch (filter) {
|
|
@@ -345,7 +494,7 @@ function matchesFilter(filter, entry) {
|
|
|
345
494
|
return true;
|
|
346
495
|
}
|
|
347
496
|
}
|
|
348
|
-
var FIELDS, NUMERIC;
|
|
497
|
+
var FIELDS, NUMERIC, FIELD_NAMES, FIELD_VALUES, OPERATORS, MAX_GROUP_DEPTH, MAX_QUERY_CHARS;
|
|
349
498
|
var init_query = __esm({
|
|
350
499
|
"src/dashboard/client/query.ts"() {
|
|
351
500
|
"use strict";
|
|
@@ -374,6 +523,19 @@ var init_query = __esm({
|
|
|
374
523
|
certain: "certain"
|
|
375
524
|
};
|
|
376
525
|
NUMERIC = /* @__PURE__ */ new Set(["score"]);
|
|
526
|
+
FIELD_NAMES = Object.keys(FIELDS).sort();
|
|
527
|
+
FIELD_VALUES = {
|
|
528
|
+
verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],
|
|
529
|
+
class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
|
|
530
|
+
botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
|
|
531
|
+
action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],
|
|
532
|
+
outcome: ["allow", "mitigate", "deny", "pending"],
|
|
533
|
+
certain: ["true", "false"],
|
|
534
|
+
method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
535
|
+
};
|
|
536
|
+
OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];
|
|
537
|
+
MAX_GROUP_DEPTH = 32;
|
|
538
|
+
MAX_QUERY_CHARS = 8192;
|
|
377
539
|
}
|
|
378
540
|
});
|
|
379
541
|
|
|
@@ -407,7 +569,12 @@ function clearFeed() {
|
|
|
407
569
|
}
|
|
408
570
|
function setSearch(value) {
|
|
409
571
|
state.search = value;
|
|
410
|
-
state.
|
|
572
|
+
state.query = parseFilter(value);
|
|
573
|
+
resetPaging();
|
|
574
|
+
}
|
|
575
|
+
function setTimeframe(from, to) {
|
|
576
|
+
state.fromMs = from;
|
|
577
|
+
state.toMs = to;
|
|
411
578
|
resetPaging();
|
|
412
579
|
}
|
|
413
580
|
function resetPaging() {
|
|
@@ -421,14 +588,16 @@ function textOf(row) {
|
|
|
421
588
|
function sortRows() {
|
|
422
589
|
state.rows.sort((a, b) => a.entry.at - b.entry.at);
|
|
423
590
|
}
|
|
424
|
-
function
|
|
425
|
-
|
|
591
|
+
function matches2(row) {
|
|
592
|
+
if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;
|
|
593
|
+
if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;
|
|
594
|
+
return matchesFilter(state.filter, row.entry) && matches(state.query, row.entry, textOf(row));
|
|
426
595
|
}
|
|
427
596
|
function matchingRows(limit = Number.POSITIVE_INFINITY) {
|
|
428
597
|
const shown = [];
|
|
429
598
|
for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
|
|
430
599
|
const row = state.rows[i];
|
|
431
|
-
if (row !== void 0 &&
|
|
600
|
+
if (row !== void 0 && matches2(row)) shown.push(row);
|
|
432
601
|
}
|
|
433
602
|
return shown;
|
|
434
603
|
}
|
|
@@ -450,7 +619,7 @@ function goToFeedPage(page) {
|
|
|
450
619
|
}
|
|
451
620
|
function matchingCount() {
|
|
452
621
|
let count = 0;
|
|
453
|
-
for (const row of state.rows) if (
|
|
622
|
+
for (const row of state.rows) if (matches2(row)) count++;
|
|
454
623
|
return count;
|
|
455
624
|
}
|
|
456
625
|
function oldestAt() {
|
|
@@ -501,10 +670,11 @@ var init_store = __esm({
|
|
|
501
670
|
policy: void 0,
|
|
502
671
|
actors: [],
|
|
503
672
|
actorsTracked: 0,
|
|
673
|
+
actorScope: "tracked",
|
|
504
674
|
paused: false,
|
|
505
675
|
filter: "all",
|
|
506
676
|
search: "",
|
|
507
|
-
|
|
677
|
+
query: { kind: "all" },
|
|
508
678
|
tab: "live",
|
|
509
679
|
open: /* @__PURE__ */ new Set(),
|
|
510
680
|
actor: void 0,
|
|
@@ -521,6 +691,8 @@ var init_store = __esm({
|
|
|
521
691
|
actorsPage: 0,
|
|
522
692
|
feedPageSize: 50,
|
|
523
693
|
actorsPageSize: 25,
|
|
694
|
+
fromMs: void 0,
|
|
695
|
+
toMs: void 0,
|
|
524
696
|
caughtUp: 0
|
|
525
697
|
};
|
|
526
698
|
}
|
|
@@ -552,6 +724,49 @@ var init_api = __esm({
|
|
|
552
724
|
}
|
|
553
725
|
});
|
|
554
726
|
|
|
727
|
+
// src/dashboard/client/saved.ts
|
|
728
|
+
function read(key, fallback) {
|
|
729
|
+
if (isEmbedded()) return fallback;
|
|
730
|
+
try {
|
|
731
|
+
const raw = localStorage.getItem(key);
|
|
732
|
+
if (raw === null) return fallback;
|
|
733
|
+
const parsed = JSON.parse(raw);
|
|
734
|
+
return Array.isArray(parsed) ? parsed : fallback;
|
|
735
|
+
} catch {
|
|
736
|
+
return fallback;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
function write(key, value) {
|
|
740
|
+
if (isEmbedded()) return;
|
|
741
|
+
try {
|
|
742
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
743
|
+
} catch {
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function savedFilters() {
|
|
747
|
+
return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");
|
|
748
|
+
}
|
|
749
|
+
function saveFilter(entry) {
|
|
750
|
+
const kept = savedFilters().filter((existing) => existing.name !== entry.name);
|
|
751
|
+
const next = [entry, ...kept].slice(0, MAX_SAVED);
|
|
752
|
+
write(FILTERS_KEY, next);
|
|
753
|
+
return next;
|
|
754
|
+
}
|
|
755
|
+
function deleteFilter(name) {
|
|
756
|
+
const next = savedFilters().filter((entry) => entry.name !== name);
|
|
757
|
+
write(FILTERS_KEY, next);
|
|
758
|
+
return next;
|
|
759
|
+
}
|
|
760
|
+
var FILTERS_KEY, MAX_SAVED;
|
|
761
|
+
var init_saved = __esm({
|
|
762
|
+
"src/dashboard/client/saved.ts"() {
|
|
763
|
+
"use strict";
|
|
764
|
+
init_dom();
|
|
765
|
+
FILTERS_KEY = "bothandler.filters";
|
|
766
|
+
MAX_SAVED = 50;
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
|
|
555
770
|
// src/dashboard/client/format.ts
|
|
556
771
|
function n(value) {
|
|
557
772
|
return numbers.format(value ?? 0);
|
|
@@ -715,7 +930,7 @@ var init_replay = __esm({
|
|
|
715
930
|
function isConfirming() {
|
|
716
931
|
return armed > 0;
|
|
717
932
|
}
|
|
718
|
-
function actorActions(key, after) {
|
|
933
|
+
function actorActions(key, after, current) {
|
|
719
934
|
if (!BOOT.allowActing) return [];
|
|
720
935
|
const forget = el("button", null, "Forget");
|
|
721
936
|
forget.title = "Discard this actor's history \u2014 the cure for a false positive that has stuck";
|
|
@@ -727,7 +942,76 @@ function actorActions(key, after) {
|
|
|
727
942
|
clear2.addEventListener("click", () => {
|
|
728
943
|
void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);
|
|
729
944
|
});
|
|
730
|
-
|
|
945
|
+
const label2 = labelControl(key, current, after);
|
|
946
|
+
return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];
|
|
947
|
+
}
|
|
948
|
+
function labelControl(key, current, after) {
|
|
949
|
+
const host = el("span", "label-edit");
|
|
950
|
+
const button = el("button", null, current === void 0 ? "Label" : "Relabel");
|
|
951
|
+
button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";
|
|
952
|
+
const commit = (value) => {
|
|
953
|
+
const trimmed = value.trim().slice(0, 120);
|
|
954
|
+
void act(
|
|
955
|
+
{ key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },
|
|
956
|
+
trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,
|
|
957
|
+
trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,
|
|
958
|
+
after
|
|
959
|
+
);
|
|
960
|
+
};
|
|
961
|
+
button.addEventListener("click", () => {
|
|
962
|
+
const container2 = host.parentElement;
|
|
963
|
+
clear(host);
|
|
964
|
+
const input = el("input", "label-input");
|
|
965
|
+
input.type = "text";
|
|
966
|
+
input.value = current ?? "";
|
|
967
|
+
input.placeholder = "A name for this client";
|
|
968
|
+
input.setAttribute("aria-label", `Name for ${key}`);
|
|
969
|
+
input.maxLength = 120;
|
|
970
|
+
const save = el("button", "label-save", "Save");
|
|
971
|
+
save.title = "Save this name";
|
|
972
|
+
const cancel = el("button", null, "Cancel");
|
|
973
|
+
cancel.title = "Leave the name as it was";
|
|
974
|
+
input.title = "Enter to save, Escape to cancel";
|
|
975
|
+
armed++;
|
|
976
|
+
container2?.classList.add("editing");
|
|
977
|
+
let settled = false;
|
|
978
|
+
const finish = (accept) => {
|
|
979
|
+
if (settled) return;
|
|
980
|
+
settled = true;
|
|
981
|
+
armed--;
|
|
982
|
+
container2?.classList.remove("editing");
|
|
983
|
+
if (accept) commit(input.value);
|
|
984
|
+
else {
|
|
985
|
+
clear(host);
|
|
986
|
+
host.appendChild(button);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
for (const control of [input, save, cancel]) {
|
|
990
|
+
control.addEventListener("keydown", (event) => {
|
|
991
|
+
const pressed = event.key;
|
|
992
|
+
if (pressed === "Escape") {
|
|
993
|
+
event.preventDefault();
|
|
994
|
+
finish(false);
|
|
995
|
+
} else if (pressed === "Enter" && control === input) {
|
|
996
|
+
event.preventDefault();
|
|
997
|
+
finish(true);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());
|
|
1002
|
+
save.addEventListener("click", () => finish(true));
|
|
1003
|
+
cancel.addEventListener("click", () => finish(false));
|
|
1004
|
+
host.addEventListener("focusout", (event) => {
|
|
1005
|
+
const next = event.relatedTarget;
|
|
1006
|
+
if (next !== null && host.contains(next)) return;
|
|
1007
|
+
finish(false);
|
|
1008
|
+
});
|
|
1009
|
+
host.append(input, save, cancel);
|
|
1010
|
+
input.focus();
|
|
1011
|
+
input.select();
|
|
1012
|
+
});
|
|
1013
|
+
host.appendChild(button);
|
|
1014
|
+
return host;
|
|
731
1015
|
}
|
|
732
1016
|
function confirmingButton(label2, confirmation, run2) {
|
|
733
1017
|
const button = el("button", "danger", label2);
|
|
@@ -1025,8 +1309,8 @@ function drawGuard() {
|
|
|
1025
1309
|
preview.addEventListener("click", () => {
|
|
1026
1310
|
void previewGuard();
|
|
1027
1311
|
});
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1312
|
+
const apply2 = el("button", "primary", "Apply");
|
|
1313
|
+
apply2.addEventListener("click", () => {
|
|
1030
1314
|
void applyGuard();
|
|
1031
1315
|
});
|
|
1032
1316
|
const revert = el("button", null, "Revert");
|
|
@@ -1036,7 +1320,7 @@ function drawGuard() {
|
|
|
1036
1320
|
drawGuard();
|
|
1037
1321
|
});
|
|
1038
1322
|
bar.appendChild(preview);
|
|
1039
|
-
bar.appendChild(
|
|
1323
|
+
bar.appendChild(apply2);
|
|
1040
1324
|
bar.appendChild(revert);
|
|
1041
1325
|
panel.appendChild(bar);
|
|
1042
1326
|
}
|
|
@@ -1960,7 +2244,11 @@ function initFeed() {
|
|
|
1960
2244
|
setSearch(search.value.trim());
|
|
1961
2245
|
app.syncUrl();
|
|
1962
2246
|
app.drawNow();
|
|
2247
|
+
showSuggestions(search);
|
|
1963
2248
|
});
|
|
2249
|
+
initSuggestions(search);
|
|
2250
|
+
initTimeframe();
|
|
2251
|
+
initSavedFilters(search);
|
|
1964
2252
|
const exportShown = byId("feed-export");
|
|
1965
2253
|
exportShown.hidden = !SECTIONS.evidence;
|
|
1966
2254
|
exportShown.addEventListener("click", () => {
|
|
@@ -1984,15 +2272,17 @@ function reflectFilterButtons() {
|
|
|
1984
2272
|
}
|
|
1985
2273
|
async function loadSkipped() {
|
|
1986
2274
|
const before = state.rows.length;
|
|
2275
|
+
let served;
|
|
1987
2276
|
try {
|
|
1988
2277
|
const body = await getJson("/api/feed");
|
|
1989
2278
|
for (const entry of body.entries) ingest(entry);
|
|
2279
|
+
served = body.skipped;
|
|
1990
2280
|
sortRows();
|
|
1991
2281
|
} catch {
|
|
1992
2282
|
toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
|
|
1993
2283
|
return;
|
|
1994
2284
|
}
|
|
1995
|
-
state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
|
|
2285
|
+
state.caughtUp = (served ?? state.snapshot?.skipped ?? 0) + state.laggedDrops;
|
|
1996
2286
|
const added = state.rows.length - before;
|
|
1997
2287
|
resetPaging();
|
|
1998
2288
|
app.drawNow();
|
|
@@ -2002,6 +2292,160 @@ async function loadSkipped() {
|
|
|
2002
2292
|
added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."
|
|
2003
2293
|
);
|
|
2004
2294
|
}
|
|
2295
|
+
function suggestionList() {
|
|
2296
|
+
return $("search-suggest");
|
|
2297
|
+
}
|
|
2298
|
+
function closeSuggestions(input) {
|
|
2299
|
+
const list = suggestionList();
|
|
2300
|
+
list.hidden = true;
|
|
2301
|
+
clear(list);
|
|
2302
|
+
highlighted = -1;
|
|
2303
|
+
input.setAttribute("aria-expanded", "false");
|
|
2304
|
+
}
|
|
2305
|
+
function showSuggestions(input) {
|
|
2306
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
2307
|
+
const { options, from, to } = suggestFor(input.value, caret);
|
|
2308
|
+
const list = suggestionList();
|
|
2309
|
+
if (options.length === 0 || input.value.slice(from, to) === options[0]) {
|
|
2310
|
+
closeSuggestions(input);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
clear(list);
|
|
2314
|
+
highlighted = -1;
|
|
2315
|
+
options.slice(0, 12).forEach((option, index) => {
|
|
2316
|
+
const item = el("li", null, option);
|
|
2317
|
+
item.setAttribute("role", "option");
|
|
2318
|
+
item.setAttribute("aria-selected", "false");
|
|
2319
|
+
item.addEventListener("mousedown", (event) => {
|
|
2320
|
+
event.preventDefault();
|
|
2321
|
+
apply(input, option, from, to);
|
|
2322
|
+
});
|
|
2323
|
+
item.dataset["index"] = String(index);
|
|
2324
|
+
list.appendChild(item);
|
|
2325
|
+
});
|
|
2326
|
+
list.hidden = false;
|
|
2327
|
+
input.setAttribute("aria-expanded", "true");
|
|
2328
|
+
}
|
|
2329
|
+
function apply(input, option, from, to) {
|
|
2330
|
+
const before = input.value.slice(0, from);
|
|
2331
|
+
const after = input.value.slice(to);
|
|
2332
|
+
input.value = `${before}${option}${after}`;
|
|
2333
|
+
const caret = before.length + option.length;
|
|
2334
|
+
input.setSelectionRange(caret, caret);
|
|
2335
|
+
setSearch(input.value.trim());
|
|
2336
|
+
app.syncUrl();
|
|
2337
|
+
app.drawNow();
|
|
2338
|
+
closeSuggestions(input);
|
|
2339
|
+
showSuggestions(input);
|
|
2340
|
+
input.focus();
|
|
2341
|
+
}
|
|
2342
|
+
function initSuggestions(input) {
|
|
2343
|
+
input.addEventListener("keydown", (event) => {
|
|
2344
|
+
const list = suggestionList();
|
|
2345
|
+
const items = Array.from(list.querySelectorAll("li"));
|
|
2346
|
+
if (list.hidden || items.length === 0) return;
|
|
2347
|
+
if (event.key === "Escape") {
|
|
2348
|
+
closeSuggestions(input);
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2351
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2352
|
+
event.preventDefault();
|
|
2353
|
+
highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;
|
|
2354
|
+
items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {
|
|
2358
|
+
const chosen = items[highlighted]?.textContent ?? "";
|
|
2359
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
2360
|
+
const { from, to } = suggestFor(input.value, caret);
|
|
2361
|
+
event.preventDefault();
|
|
2362
|
+
apply(input, chosen, from, to);
|
|
2363
|
+
}
|
|
2364
|
+
});
|
|
2365
|
+
input.addEventListener("blur", () => {
|
|
2366
|
+
setTimeout(() => closeSuggestions(input), 120);
|
|
2367
|
+
});
|
|
2368
|
+
input.addEventListener("focus", () => showSuggestions(input));
|
|
2369
|
+
}
|
|
2370
|
+
function initSavedFilters(input) {
|
|
2371
|
+
const host = $("saved-filters");
|
|
2372
|
+
const redraw = () => {
|
|
2373
|
+
clear(host);
|
|
2374
|
+
const entries = savedFilters();
|
|
2375
|
+
const select2 = document.createElement("select");
|
|
2376
|
+
select2.setAttribute("aria-label", "Saved filters");
|
|
2377
|
+
const first = document.createElement("option");
|
|
2378
|
+
first.value = "";
|
|
2379
|
+
first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";
|
|
2380
|
+
select2.appendChild(first);
|
|
2381
|
+
for (const entry of entries) {
|
|
2382
|
+
const option = document.createElement("option");
|
|
2383
|
+
option.value = entry.name;
|
|
2384
|
+
option.textContent = entry.name;
|
|
2385
|
+
select2.appendChild(option);
|
|
2386
|
+
}
|
|
2387
|
+
select2.addEventListener("change", () => {
|
|
2388
|
+
const chosen = entries.find((entry) => entry.name === select2.value);
|
|
2389
|
+
if (chosen === void 0) return;
|
|
2390
|
+
input.value = chosen.query;
|
|
2391
|
+
setSearch(chosen.query);
|
|
2392
|
+
state.filter = chosen.filter;
|
|
2393
|
+
reflectFilterButtons();
|
|
2394
|
+
resetPaging();
|
|
2395
|
+
app.syncUrl();
|
|
2396
|
+
app.drawNow();
|
|
2397
|
+
});
|
|
2398
|
+
host.appendChild(select2);
|
|
2399
|
+
const save = el("button", null, "Save");
|
|
2400
|
+
save.type = "button";
|
|
2401
|
+
save.title = "Save this filter, in this browser, under a name";
|
|
2402
|
+
save.addEventListener("click", () => {
|
|
2403
|
+
const name = prompt("Save this filter as:")?.trim();
|
|
2404
|
+
if (name === void 0 || name === "") return;
|
|
2405
|
+
saveFilter({ name, query: input.value.trim(), filter: state.filter });
|
|
2406
|
+
redraw();
|
|
2407
|
+
toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);
|
|
2408
|
+
});
|
|
2409
|
+
host.appendChild(save);
|
|
2410
|
+
if (select2.value !== "") {
|
|
2411
|
+
const remove = el("button", null, "Delete");
|
|
2412
|
+
remove.type = "button";
|
|
2413
|
+
remove.addEventListener("click", () => {
|
|
2414
|
+
deleteFilter(select2.value);
|
|
2415
|
+
redraw();
|
|
2416
|
+
});
|
|
2417
|
+
host.appendChild(remove);
|
|
2418
|
+
}
|
|
2419
|
+
};
|
|
2420
|
+
redraw();
|
|
2421
|
+
}
|
|
2422
|
+
function initTimeframe() {
|
|
2423
|
+
const from = byId("from-at");
|
|
2424
|
+
const to = byId("to-at");
|
|
2425
|
+
const clearButton = byId("timeframe-clear");
|
|
2426
|
+
const read2 = (input) => {
|
|
2427
|
+
if (input.value === "") return void 0;
|
|
2428
|
+
const parsed = new Date(input.value).getTime();
|
|
2429
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2430
|
+
};
|
|
2431
|
+
const apply2 = () => {
|
|
2432
|
+
const start2 = read2(from);
|
|
2433
|
+
const end = read2(to);
|
|
2434
|
+
if (start2 !== void 0 && end !== void 0 && end < start2) {
|
|
2435
|
+
toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");
|
|
2436
|
+
}
|
|
2437
|
+
setTimeframe(start2, end);
|
|
2438
|
+
clearButton.hidden = start2 === void 0 && end === void 0;
|
|
2439
|
+
app.drawNow();
|
|
2440
|
+
};
|
|
2441
|
+
from.addEventListener("change", apply2);
|
|
2442
|
+
to.addEventListener("change", apply2);
|
|
2443
|
+
clearButton.addEventListener("click", () => {
|
|
2444
|
+
from.value = "";
|
|
2445
|
+
to.value = "";
|
|
2446
|
+
apply2();
|
|
2447
|
+
});
|
|
2448
|
+
}
|
|
2005
2449
|
function drawPager(paged) {
|
|
2006
2450
|
const size = state.feedPageSize;
|
|
2007
2451
|
const hidden = paged.pages <= 1;
|
|
@@ -2170,12 +2614,13 @@ function buildDetail(entry) {
|
|
|
2170
2614
|
} else {
|
|
2171
2615
|
const list = el("div", "ev");
|
|
2172
2616
|
for (const item of entry.evidence) {
|
|
2173
|
-
const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
|
|
2617
|
+
const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}${item.shadow === true ? " shadow" : ""}`);
|
|
2174
2618
|
row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
|
|
2175
2619
|
const body = el("div");
|
|
2176
2620
|
body.appendChild(el("div", null, item.summary));
|
|
2177
2621
|
let meta = `${item.detector} \xB7 points to ${item.direction}`;
|
|
2178
2622
|
if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;
|
|
2623
|
+
if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";
|
|
2179
2624
|
body.appendChild(el("div", "ev-meta", meta));
|
|
2180
2625
|
if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));
|
|
2181
2626
|
row.appendChild(body);
|
|
@@ -2183,6 +2628,17 @@ function buildDetail(entry) {
|
|
|
2183
2628
|
}
|
|
2184
2629
|
cell.appendChild(list);
|
|
2185
2630
|
}
|
|
2631
|
+
if (entry.shadowVerdict !== void 0) {
|
|
2632
|
+
const would = entry.shadowVerdict;
|
|
2633
|
+
const changed = would.verdict !== entry.verdict;
|
|
2634
|
+
cell.appendChild(
|
|
2635
|
+
el(
|
|
2636
|
+
"div",
|
|
2637
|
+
`ev-meta shadow-verdict${changed ? " changed" : ""}`,
|
|
2638
|
+
changed ? `With the shadowed detectors counted, this request would have been ${would.verdict} at ${would.score} instead of ${entry.verdict} at ${entry.score}.` : `With the shadowed detectors counted, this request would still have been ${would.verdict}${would.score === entry.score ? "" : `, at ${would.score} rather than ${entry.score}`}.`
|
|
2639
|
+
)
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2186
2642
|
for (const failure of entry.failures) {
|
|
2187
2643
|
cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));
|
|
2188
2644
|
}
|
|
@@ -2284,12 +2740,14 @@ function showText(text) {
|
|
|
2284
2740
|
selection.addRange(range);
|
|
2285
2741
|
}
|
|
2286
2742
|
}
|
|
2287
|
-
var FILTERS, rendered, FEED_PAGE_SIZES;
|
|
2743
|
+
var FILTERS, rendered, highlighted, FEED_PAGE_SIZES;
|
|
2288
2744
|
var init_feed = __esm({
|
|
2289
2745
|
"src/dashboard/client/feed.ts"() {
|
|
2290
2746
|
"use strict";
|
|
2291
2747
|
init_dom();
|
|
2292
2748
|
init_store();
|
|
2749
|
+
init_saved();
|
|
2750
|
+
init_query();
|
|
2293
2751
|
init_api();
|
|
2294
2752
|
init_pager();
|
|
2295
2753
|
init_boot();
|
|
@@ -2310,6 +2768,7 @@ var init_feed = __esm({
|
|
|
2310
2768
|
["allow", "Served"]
|
|
2311
2769
|
];
|
|
2312
2770
|
rendered = /* @__PURE__ */ new Map();
|
|
2771
|
+
highlighted = -1;
|
|
2313
2772
|
FEED_PAGE_SIZES = [25, 50, 100, 200];
|
|
2314
2773
|
}
|
|
2315
2774
|
});
|
|
@@ -2512,6 +2971,13 @@ function drawStatsPanels() {
|
|
|
2512
2971
|
["Actors tracked", n(metrics.actorsTracked)],
|
|
2513
2972
|
["Guard stops", n(metrics.downgrades)]
|
|
2514
2973
|
];
|
|
2974
|
+
const shadowed = pairs(metrics.shadowFirings);
|
|
2975
|
+
if (shadowed.length > 0) {
|
|
2976
|
+
const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);
|
|
2977
|
+
rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);
|
|
2978
|
+
for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);
|
|
2979
|
+
if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);
|
|
2980
|
+
}
|
|
2515
2981
|
const failures = pairs(metrics.detectorFailures);
|
|
2516
2982
|
for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);
|
|
2517
2983
|
for (const [key, value] of rows) health.appendChild(statRow(key, value));
|
|
@@ -2528,14 +2994,14 @@ function drawStatsPanels() {
|
|
|
2528
2994
|
clear(list);
|
|
2529
2995
|
$("detector-count").textContent = `${snapshot.detectors.length} installed`;
|
|
2530
2996
|
for (const detector of snapshot.detectors) {
|
|
2531
|
-
const row = el("div",
|
|
2997
|
+
const row = el("div", `det${detector.shadow === true ? " shadow" : ""}`);
|
|
2532
2998
|
const left = el("div");
|
|
2533
2999
|
left.appendChild(el("div", "mono", detector.id));
|
|
2534
3000
|
left.appendChild(el("div", "d", detector.description));
|
|
2535
3001
|
row.appendChild(left);
|
|
2536
|
-
const fires = metrics?.detectorFirings[detector.id] ?? 0;
|
|
3002
|
+
const fires = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;
|
|
2537
3003
|
const timing = metrics?.detectorTimings[detector.id];
|
|
2538
|
-
let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
|
|
3004
|
+
let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
|
|
2539
3005
|
if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;
|
|
2540
3006
|
row.appendChild(el("div", "n", right));
|
|
2541
3007
|
list.appendChild(row);
|
|
@@ -3059,6 +3525,7 @@ function trackActors() {
|
|
|
3059
3525
|
void loadActors();
|
|
3060
3526
|
timer = setInterval(() => {
|
|
3061
3527
|
if (state.tab !== "actors" || state.paused || isConfirming()) return;
|
|
3528
|
+
if (state.actorScope === "feed") return;
|
|
3062
3529
|
void loadActors();
|
|
3063
3530
|
}, 4e3);
|
|
3064
3531
|
}
|
|
@@ -3097,26 +3564,95 @@ function drawActorsPager(full) {
|
|
|
3097
3564
|
else renderPager(host, model, { withSize });
|
|
3098
3565
|
}
|
|
3099
3566
|
}
|
|
3567
|
+
function feedActors() {
|
|
3568
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3569
|
+
for (const row of matchingRows()) {
|
|
3570
|
+
const entry = row.entry;
|
|
3571
|
+
let seen = byKey.get(entry.actor);
|
|
3572
|
+
if (seen === void 0) {
|
|
3573
|
+
seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };
|
|
3574
|
+
byKey.set(entry.actor, seen);
|
|
3575
|
+
}
|
|
3576
|
+
seen.rows++;
|
|
3577
|
+
seen.paths.add(entry.path);
|
|
3578
|
+
seen.agents.add(entry.userAgent);
|
|
3579
|
+
if (entry.at < seen.first) seen.first = entry.at;
|
|
3580
|
+
if (entry.at > seen.last) seen.last = entry.at;
|
|
3581
|
+
seen.stats = entry.actorStats ?? seen.stats;
|
|
3582
|
+
}
|
|
3583
|
+
const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));
|
|
3584
|
+
const out = [];
|
|
3585
|
+
for (const [key, seen] of byKey) {
|
|
3586
|
+
const label2 = labels.get(key);
|
|
3587
|
+
out.push({
|
|
3588
|
+
key,
|
|
3589
|
+
...label2 === void 0 ? {} : { label: label2 },
|
|
3590
|
+
requests: seen.rows,
|
|
3591
|
+
recentRate: Number.NaN,
|
|
3592
|
+
distinctPaths: seen.paths.size,
|
|
3593
|
+
distinctUserAgents: seen.agents.size,
|
|
3594
|
+
cadenceCv: void 0,
|
|
3595
|
+
// A dash where the feed cannot know, like the three columns below it. A confident
|
|
3596
|
+
// zero under a heading that means "how many times has this client been proven a bot"
|
|
3597
|
+
// is worse than an admission, and it was the only column here still guessing.
|
|
3598
|
+
priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,
|
|
3599
|
+
unsolvedChallenges: Number.NaN,
|
|
3600
|
+
cleared: seen.stats?.cleared ?? false,
|
|
3601
|
+
firstSeen: seen.stats?.firstSeen ?? seen.first,
|
|
3602
|
+
lastSeen: seen.last
|
|
3603
|
+
});
|
|
3604
|
+
}
|
|
3605
|
+
return out.sort((a, b) => b.requests - a.requests);
|
|
3606
|
+
}
|
|
3607
|
+
function applyActorScope(scope) {
|
|
3608
|
+
if (!SECTIONS.registry) return;
|
|
3609
|
+
state.actorScope = scope;
|
|
3610
|
+
for (const [id, on] of [
|
|
3611
|
+
["actors-scope-tracked", scope === "tracked"],
|
|
3612
|
+
["actors-scope-feed", scope === "feed"]
|
|
3613
|
+
]) {
|
|
3614
|
+
byId(id).className = on ? "on" : "";
|
|
3615
|
+
byId(id).setAttribute("aria-pressed", String(on));
|
|
3616
|
+
}
|
|
3617
|
+
drawActors();
|
|
3618
|
+
}
|
|
3619
|
+
function initActorScope() {
|
|
3620
|
+
if (!SECTIONS.registry) return;
|
|
3621
|
+
const choose = (scope) => {
|
|
3622
|
+
applyActorScope(scope);
|
|
3623
|
+
app.syncUrl({ replace: false });
|
|
3624
|
+
};
|
|
3625
|
+
$("actors-scope-tracked").addEventListener("click", () => choose("tracked"));
|
|
3626
|
+
$("actors-scope-feed").addEventListener("click", () => choose("feed"));
|
|
3627
|
+
}
|
|
3100
3628
|
function drawActors() {
|
|
3101
3629
|
if (!SECTIONS.registry) return;
|
|
3102
3630
|
if (isConfirming()) return;
|
|
3103
3631
|
const body = byId("actor-rows");
|
|
3104
3632
|
clear(body);
|
|
3105
|
-
const
|
|
3106
|
-
|
|
3107
|
-
|
|
3633
|
+
const fromFeed = state.actorScope === "feed";
|
|
3634
|
+
const actors = fromFeed ? feedActors() : state.actors;
|
|
3635
|
+
$("actors-count").textContent = fromFeed ? `${n(actors.length)} in the feed you are looking at \xB7 ${n(state.actorsTracked)} tracked` : `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
|
|
3636
|
+
drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);
|
|
3637
|
+
if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;
|
|
3108
3638
|
byId("actors-empty").hidden = actors.length > 0;
|
|
3109
3639
|
for (const actor of actors) {
|
|
3110
3640
|
const row = el("tr");
|
|
3111
|
-
|
|
3641
|
+
const who = el("td", "who");
|
|
3642
|
+
if (actor.label === void 0) who.textContent = actor.key;
|
|
3643
|
+
else {
|
|
3644
|
+
who.appendChild(el("div", "label", actor.label));
|
|
3645
|
+
who.appendChild(el("div", "sub", actor.key));
|
|
3646
|
+
}
|
|
3647
|
+
row.appendChild(who);
|
|
3112
3648
|
row.appendChild(el("td", "num tnum", n(actor.requests)));
|
|
3113
|
-
row.appendChild(el("td", "num tnum", n(actor.recentRate)));
|
|
3649
|
+
row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : n(actor.recentRate)));
|
|
3114
3650
|
row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));
|
|
3115
3651
|
const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));
|
|
3116
3652
|
if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";
|
|
3117
3653
|
row.appendChild(cadence);
|
|
3118
|
-
row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));
|
|
3119
|
-
const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));
|
|
3654
|
+
row.appendChild(el("td", "num tnum", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));
|
|
3655
|
+
const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : n(actor.unsolvedChallenges));
|
|
3120
3656
|
if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";
|
|
3121
3657
|
row.appendChild(unsolved);
|
|
3122
3658
|
const stateCell = el("td");
|
|
@@ -3137,7 +3673,7 @@ function drawActors() {
|
|
|
3137
3673
|
app.syncUrl();
|
|
3138
3674
|
});
|
|
3139
3675
|
actions.appendChild(inFeed);
|
|
3140
|
-
for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);
|
|
3676
|
+
for (const button of actorActions(actor.key, () => void loadActors(), actor.label)) actions.appendChild(button);
|
|
3141
3677
|
row.appendChild(actions);
|
|
3142
3678
|
body.appendChild(row);
|
|
3143
3679
|
}
|
|
@@ -3170,13 +3706,13 @@ function initTester() {
|
|
|
3170
3706
|
});
|
|
3171
3707
|
}
|
|
3172
3708
|
async function run() {
|
|
3173
|
-
const
|
|
3174
|
-
if (
|
|
3709
|
+
const raw = byId("test-input").value;
|
|
3710
|
+
if (raw.trim() === "") {
|
|
3175
3711
|
toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");
|
|
3176
3712
|
return;
|
|
3177
3713
|
}
|
|
3178
3714
|
const result = await postJson("/api/test", {
|
|
3179
|
-
raw
|
|
3715
|
+
raw,
|
|
3180
3716
|
ip: byId("test-ip").value.trim(),
|
|
3181
3717
|
url: byId("test-url").value.trim()
|
|
3182
3718
|
});
|
|
@@ -3316,12 +3852,12 @@ function initHeader() {
|
|
|
3316
3852
|
}
|
|
3317
3853
|
const header = rootNode().querySelector("header");
|
|
3318
3854
|
if (header !== null) {
|
|
3319
|
-
const
|
|
3855
|
+
const apply2 = () => {
|
|
3320
3856
|
themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);
|
|
3321
3857
|
};
|
|
3322
|
-
|
|
3323
|
-
if (typeof ResizeObserver === "function") new ResizeObserver(
|
|
3324
|
-
else addEventListener("resize",
|
|
3858
|
+
apply2();
|
|
3859
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);
|
|
3860
|
+
else addEventListener("resize", apply2);
|
|
3325
3861
|
}
|
|
3326
3862
|
}
|
|
3327
3863
|
function syncUrl(replace = true) {
|
|
@@ -3329,21 +3865,25 @@ function syncUrl(replace = true) {
|
|
|
3329
3865
|
const params = new URLSearchParams();
|
|
3330
3866
|
if (state.filter !== "all") params.set("f", state.filter);
|
|
3331
3867
|
if (state.search !== "") params.set("q", state.search);
|
|
3868
|
+
if (state.actorScope !== "tracked") params.set("a", state.actorScope);
|
|
3332
3869
|
const query = params.toString();
|
|
3333
3870
|
const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;
|
|
3334
3871
|
if (location.hash === hash) return;
|
|
3335
3872
|
history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);
|
|
3336
3873
|
}
|
|
3337
3874
|
function readUrl() {
|
|
3338
|
-
const
|
|
3339
|
-
const split =
|
|
3340
|
-
const name = split === -1 ?
|
|
3341
|
-
const params = new URLSearchParams(split === -1 ? "" :
|
|
3875
|
+
const raw = isEmbedded() ? "" : location.hash.slice(1);
|
|
3876
|
+
const split = raw.indexOf("?");
|
|
3877
|
+
const name = split === -1 ? raw : raw.slice(0, split);
|
|
3878
|
+
const params = new URLSearchParams(split === -1 ? "" : raw.slice(split + 1));
|
|
3342
3879
|
const filter = params.get("f") ?? "all";
|
|
3343
3880
|
return {
|
|
3344
3881
|
tab: isTab(name) ? name : FIRST,
|
|
3345
3882
|
filter,
|
|
3346
|
-
search: params.get("q") ?? ""
|
|
3883
|
+
search: params.get("q") ?? "",
|
|
3884
|
+
// Anything but the one alternative reads as the default rather than as an error:
|
|
3885
|
+
// a hand-edited URL should land somewhere, and this is the somewhere it lands.
|
|
3886
|
+
actorScope: params.get("a") === "feed" ? "feed" : "tracked"
|
|
3347
3887
|
};
|
|
3348
3888
|
}
|
|
3349
3889
|
function showTab(name, options = {}) {
|
|
@@ -3387,6 +3927,7 @@ function initTabs() {
|
|
|
3387
3927
|
state.filter = url.filter;
|
|
3388
3928
|
setSearch(url.search);
|
|
3389
3929
|
reflectFilterButtons();
|
|
3930
|
+
applyActorScope(url.actorScope);
|
|
3390
3931
|
showTab(url.tab, { push: false });
|
|
3391
3932
|
});
|
|
3392
3933
|
}
|
|
@@ -3528,7 +4069,7 @@ function start() {
|
|
|
3528
4069
|
app.draw = schedule;
|
|
3529
4070
|
app.drawNow = drawNow;
|
|
3530
4071
|
app.showTab = showTab;
|
|
3531
|
-
app.syncUrl = () => syncUrl();
|
|
4072
|
+
app.syncUrl = (options) => syncUrl(options?.replace !== false);
|
|
3532
4073
|
applySections();
|
|
3533
4074
|
initHeader();
|
|
3534
4075
|
drawPeers();
|
|
@@ -3538,6 +4079,7 @@ function start() {
|
|
|
3538
4079
|
initRanges();
|
|
3539
4080
|
if (SECTIONS.feed) initFeed();
|
|
3540
4081
|
initActor();
|
|
4082
|
+
initActorScope();
|
|
3541
4083
|
initTester();
|
|
3542
4084
|
initPolicy();
|
|
3543
4085
|
let resizeTimer;
|
|
@@ -3555,6 +4097,7 @@ function start() {
|
|
|
3555
4097
|
state.filter = url.filter;
|
|
3556
4098
|
setSearch(url.search);
|
|
3557
4099
|
if (SECTIONS.feed) reflectFilterButtons();
|
|
4100
|
+
applyActorScope(url.actorScope);
|
|
3558
4101
|
showTab(url.tab, { replace: true });
|
|
3559
4102
|
suspendScrollAnchoring();
|
|
3560
4103
|
void loadInitialSnapshot().finally(settleScrollAnchoring);
|
|
@@ -3658,12 +4201,12 @@ function resolveSections(fromServer, config) {
|
|
|
3658
4201
|
}
|
|
3659
4202
|
return { sections, warnings };
|
|
3660
4203
|
}
|
|
3661
|
-
function parseMount(
|
|
3662
|
-
const cut =
|
|
3663
|
-
if (cut === -1) return { base:
|
|
4204
|
+
function parseMount(raw) {
|
|
4205
|
+
const cut = raw.search(/[?#]/);
|
|
4206
|
+
if (cut === -1) return { base: raw.replace(/\/$/, "") };
|
|
3664
4207
|
return {
|
|
3665
|
-
base:
|
|
3666
|
-
warning: `src "${
|
|
4208
|
+
base: raw.slice(0, cut).replace(/\/$/, ""),
|
|
4209
|
+
warning: `src "${raw}" has a ${raw[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.`
|
|
3667
4210
|
};
|
|
3668
4211
|
}
|
|
3669
4212
|
function explainStatus(status, base) {
|
|
@@ -3912,6 +4455,33 @@ button.tile {
|
|
|
3912
4455
|
cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
|
|
3913
4456
|
}
|
|
3914
4457
|
button.tile:hover { border-color: var(--focus); }
|
|
4458
|
+
/* The suggestion list under the search box, positioned against the search wrapper. */
|
|
4459
|
+
.search { position: relative; }
|
|
4460
|
+
.suggest {
|
|
4461
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
|
|
4462
|
+
list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
|
|
4463
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
|
|
4464
|
+
}
|
|
4465
|
+
.suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
|
4466
|
+
.suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
|
|
4467
|
+
|
|
4468
|
+
/* Saved filters: a list of small removable things. Quiet, because it is not what
|
|
4469
|
+
somebody came to the page to look at. */
|
|
4470
|
+
.saved { display: flex; align-items: center; gap: 6px; }
|
|
4471
|
+
/* Two open-ended bounds rather than a list of durations: "from the incident until now",
|
|
4472
|
+
"everything up to when it stopped" and "between these two moments" are the same control
|
|
4473
|
+
with one end left empty. */
|
|
4474
|
+
.timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
|
|
4475
|
+
.timeframe label { display: inline-flex; align-items: center; gap: 4px; }
|
|
4476
|
+
.timeframe input {
|
|
4477
|
+
font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
|
|
4478
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
4479
|
+
}
|
|
4480
|
+
.timeframe button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
4481
|
+
.saved select { font: inherit; font-size: 11.5px; padding: 3px 6px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); max-width: 170px; }
|
|
4482
|
+
.saved button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
4483
|
+
.saved button:hover { border-color: var(--focus); }
|
|
4484
|
+
|
|
3915
4485
|
/* The badge's companion: fetches the entries the stream skipped. Sits inline with the
|
|
3916
4486
|
heading, so it is styled to read as part of the sentence rather than as a form control. */
|
|
3917
4487
|
.load-skipped {
|
|
@@ -3933,6 +4503,10 @@ button.tile:hover { border-color: var(--focus); }
|
|
|
3933
4503
|
.pager button:hover:not(:disabled) { border-color: var(--focus); }
|
|
3934
4504
|
.pager button:disabled { opacity: .45; cursor: default; }
|
|
3935
4505
|
.pager .where { font-variant-numeric: tabular-nums; }
|
|
4506
|
+
/* A labelled actor leads with its name and keeps the key underneath: whoever named it did
|
|
4507
|
+
so because the key was not the useful part, and the key is still what you search for. */
|
|
4508
|
+
td.who .label { font-weight: 560; }
|
|
4509
|
+
td.who .sub { color: var(--muted); font-size: 11px; }
|
|
3936
4510
|
.pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
|
|
3937
4511
|
/* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
|
|
3938
4512
|
was thirty-six pixels of mostly empty rule above every screenful of requests. */
|
|
@@ -4053,7 +4627,29 @@ input[type="search"] {
|
|
|
4053
4627
|
}
|
|
4054
4628
|
input[type="search"]::placeholder { color: var(--muted); }
|
|
4055
4629
|
|
|
4056
|
-
|
|
4630
|
+
/* separate with zero spacing rather than collapse, and the difference is the whole
|
|
4631
|
+
reason the column headers work in Safari.
|
|
4632
|
+
|
|
4633
|
+
Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
|
|
4634
|
+
CSSWG has an open issue on collapsed borders not following a cell when it sticks
|
|
4635
|
+
(csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
|
|
4636
|
+
altogether under a collapsed table. Separating the borders is the standard remedy.
|
|
4637
|
+
|
|
4638
|
+
What was actually measured: the header sticks correctly in Chromium and in Firefox,
|
|
4639
|
+
both before and after this change, and it was reported adrift in Safari — which is what
|
|
4640
|
+
a sticky element that has stopped sticking looks like. WebKit could not be run on the
|
|
4641
|
+
machine this was written on, so the Safari half of it rests on that report and on the
|
|
4642
|
+
documented behaviour rather than on a measurement taken here.
|
|
4643
|
+
|
|
4644
|
+
The rendering is all but unchanged. Every border in these tables is a bottom border on
|
|
4645
|
+
the cell itself, plus the per-cell left accent on td.edge; no border is shared between
|
|
4646
|
+
two cells, so there is nothing for collapsing to merge and nothing for separating to
|
|
4647
|
+
double, and zero spacing keeps the cells touching. The one measurable difference is the
|
|
4648
|
+
accent column, which moves two pixels: collapsing centres that 3px border on the cell
|
|
4649
|
+
edge and leaves half of it outside the box, while separating puts all of it inside.
|
|
4650
|
+
Measured rather than assumed, and the leftmost column starting two pixels earlier is
|
|
4651
|
+
both imperceptible and the more correct of the two. */
|
|
4652
|
+
table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
|
4057
4653
|
thead th {
|
|
4058
4654
|
/* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
|
|
4059
4655
|
the instant before the first measurement, and for the tab strip wrapping. */
|
|
@@ -4345,9 +4941,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
4345
4941
|
#actor-rows td { font-size: 12.5px; }
|
|
4346
4942
|
#actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
4347
4943
|
#actor-rows td.acts { text-align: right; white-space: nowrap; }
|
|
4944
|
+
/* The tracked/shown toggle above the actors table. A segmented pair rather than a
|
|
4945
|
+
dropdown: there are two answers and both are worth reading at a glance. */
|
|
4946
|
+
.scope { display: flex; gap: 6px; padding: 0 14px 10px; }
|
|
4947
|
+
.scope button { font-size: 11.5px; padding: 4px 10px; }
|
|
4948
|
+
.scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
|
|
4949
|
+
|
|
4348
4950
|
#actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
|
|
4951
|
+
/* The Label control, which becomes a text box with a Save and a Cancel in place.
|
|
4952
|
+
|
|
4953
|
+
The cell does not wrap, so an editor that sat beside the row's other four buttons put
|
|
4954
|
+
Save off the right edge of the panel, where it could be seen and not clicked. While
|
|
4955
|
+
the editor is open it stands in for those buttons instead — which is also the right
|
|
4956
|
+
thing on its own, since Allowlist and Forget are not what somebody naming a client is
|
|
4957
|
+
reaching for. */
|
|
4958
|
+
.acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
|
|
4959
|
+
/* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
|
|
4960
|
+
line is the layout that cannot spill them past its own edge. */
|
|
4961
|
+
.label-edit { display: inline-flex; align-items: center; gap: 4px; }
|
|
4962
|
+
.label-edit button { flex: 0 0 auto; }
|
|
4963
|
+
#actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
|
|
4964
|
+
/* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
|
|
4965
|
+
outranks a bare class, so the width here was quietly ignored and the box grew to fill
|
|
4966
|
+
whatever it was in — which is what put Save and Cancel outside the panel. */
|
|
4967
|
+
input.label-input {
|
|
4968
|
+
font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
|
|
4969
|
+
color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
|
|
4970
|
+
}
|
|
4971
|
+
input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
4972
|
+
|
|
4349
4973
|
/* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
|
|
4350
4974
|
one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
|
|
4975
|
+
/* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
|
|
4976
|
+
and set behind a rule rather than coloured, because every colour on this page already
|
|
4977
|
+
means something about a verdict and this one took no part in a verdict. */
|
|
4978
|
+
.det.shadow { opacity: 0.72; }
|
|
4979
|
+
.ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
|
|
4980
|
+
.shadow-verdict { margin-top: 8px; font-style: italic; }
|
|
4981
|
+
.shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
|
|
4351
4982
|
.tagline { font-size: 11px; color: var(--muted); }
|
|
4352
4983
|
.tagline b { color: var(--ink-2); font-weight: 600; }
|
|
4353
4984
|
|
|
@@ -4445,8 +5076,16 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4445
5076
|
<div class="toolbar">
|
|
4446
5077
|
<div class="filters" id="filters"></div>
|
|
4447
5078
|
<div class="search">
|
|
4448
|
-
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
5079
|
+
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
5080
|
+
role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
|
|
4449
5081
|
<kbd aria-hidden="true">/</kbd>
|
|
5082
|
+
<ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
|
|
5083
|
+
</div>
|
|
5084
|
+
<div class="saved" id="saved-filters"></div>
|
|
5085
|
+
<div class="timeframe" id="timeframe">
|
|
5086
|
+
<label>From <input type="datetime-local" id="from-at" step="1"></label>
|
|
5087
|
+
<label>To <input type="datetime-local" id="to-at" step="1"></label>
|
|
5088
|
+
<button type="button" id="timeframe-clear" hidden>Clear</button>
|
|
4450
5089
|
</div>
|
|
4451
5090
|
<button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
|
|
4452
5091
|
<div class="pager pager-inline" id="feed-pager-top" hidden></div>
|
|
@@ -4510,6 +5149,12 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4510
5149
|
<div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
|
|
4511
5150
|
population than the feed's ring, which holds requests rather than clients. This is
|
|
4512
5151
|
what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
|
|
5152
|
+
<div class="scope" role="group" aria-label="Which actors to list">
|
|
5153
|
+
<button id="actors-scope-tracked" class="on" aria-pressed="true"
|
|
5154
|
+
title="Every client the engine is remembering, busiest first">Tracked</button>
|
|
5155
|
+
<button id="actors-scope-feed" aria-pressed="false"
|
|
5156
|
+
title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
|
|
5157
|
+
</div>
|
|
4513
5158
|
<div class="pager pager-top" id="actors-pager-top" hidden></div>
|
|
4514
5159
|
<div class="feed-scroll">
|
|
4515
5160
|
<table>
|
|
@@ -4954,6 +5599,8 @@ var BotDashboardElement = class extends ElementBase {
|
|
|
4954
5599
|
const dom = await Promise.resolve().then(() => (init_dom(), dom_exports));
|
|
4955
5600
|
dom.setRoot(frame, this);
|
|
4956
5601
|
globalThis.__BOOTSTRAP__ = boot;
|
|
5602
|
+
const bootModule = await Promise.resolve().then(() => (init_boot(), boot_exports));
|
|
5603
|
+
bootModule.applyBoot(boot);
|
|
4957
5604
|
await Promise.resolve().then(() => (init_client(), client_exports));
|
|
4958
5605
|
this.relabelTabs(shadow);
|
|
4959
5606
|
this.renderPanels(frame);
|