@osqd/bothandlerjs 0.5.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 -10
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -10
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +2256 -103
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +2256 -103
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +48 -21
- package/dist/corpus/index.cjs +365 -7
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +365 -7
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +33 -0
- package/dist/crawler-ranges.d.ts +31 -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/id-enumeration.d.ts +31 -0
- package/dist/detectors/index.d.ts +24 -1
- package/dist/detectors/known-bots.d.ts +11 -0
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/parameter-sweep.d.ts +39 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/probe-volume.d.ts +26 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/transport-coherence.d.ts +31 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +730 -80
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +730 -80
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +2044 -123
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +2025 -123
- 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 +205 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +106 -0
- package/docs/course/05-detectors.md +9 -4
- 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 +259 -1
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +10 -2
- 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.js
CHANGED
|
@@ -113,12 +113,23 @@ var init_dom = __esm({
|
|
|
113
113
|
});
|
|
114
114
|
|
|
115
115
|
// src/dashboard/client/boot.ts
|
|
116
|
-
var
|
|
116
|
+
var boot_exports = {};
|
|
117
|
+
__export(boot_exports, {
|
|
118
|
+
API: () => API,
|
|
119
|
+
BOOT: () => BOOT,
|
|
120
|
+
SECTIONS: () => SECTIONS,
|
|
121
|
+
applyBoot: () => applyBoot
|
|
122
|
+
});
|
|
123
|
+
function applyBoot(next) {
|
|
124
|
+
BOOT = next;
|
|
125
|
+
API = next.base;
|
|
126
|
+
SECTIONS = next.sections;
|
|
127
|
+
}
|
|
128
|
+
var FALLBACK, BOOT, API, SECTIONS;
|
|
117
129
|
var init_boot = __esm({
|
|
118
130
|
"src/dashboard/client/boot.ts"() {
|
|
119
131
|
"use strict";
|
|
120
|
-
|
|
121
|
-
BOOT = raw ?? {
|
|
132
|
+
FALLBACK = {
|
|
122
133
|
base: "",
|
|
123
134
|
title: "bothandlerjs",
|
|
124
135
|
allowReset: false,
|
|
@@ -129,6 +140,7 @@ var init_boot = __esm({
|
|
|
129
140
|
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
141
|
links: []
|
|
131
142
|
};
|
|
143
|
+
BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;
|
|
132
144
|
API = BOOT.base;
|
|
133
145
|
SECTIONS = BOOT.sections;
|
|
134
146
|
}
|
|
@@ -203,48 +215,172 @@ var init_outcome = __esm({
|
|
|
203
215
|
});
|
|
204
216
|
|
|
205
217
|
// src/dashboard/client/query.ts
|
|
218
|
+
function suggestFor(input, caret) {
|
|
219
|
+
const before = input.slice(0, caret);
|
|
220
|
+
const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);
|
|
221
|
+
const token = before.slice(start2);
|
|
222
|
+
const to = caret;
|
|
223
|
+
const negated = token.startsWith("-") || token.startsWith("!");
|
|
224
|
+
const body = negated ? token.slice(1) : token;
|
|
225
|
+
const colon = body.indexOf(":");
|
|
226
|
+
if (colon === -1) {
|
|
227
|
+
const partial2 = body.toLowerCase();
|
|
228
|
+
if (partial2.startsWith("$")) {
|
|
229
|
+
return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };
|
|
230
|
+
}
|
|
231
|
+
const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);
|
|
232
|
+
return { options: options2, from: start2, to };
|
|
233
|
+
}
|
|
234
|
+
const field2 = body.slice(0, colon).toLowerCase();
|
|
235
|
+
const values = FIELD_VALUES[field2];
|
|
236
|
+
if (values === void 0) return { options: [], from: start2, to };
|
|
237
|
+
const partial = body.slice(colon + 1).toLowerCase();
|
|
238
|
+
const prefix = `${negated ? token[0] : ""}${field2}:`;
|
|
239
|
+
const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];
|
|
240
|
+
const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];
|
|
241
|
+
return { options, from: start2, to };
|
|
242
|
+
}
|
|
206
243
|
function tokenize(input) {
|
|
207
244
|
const tokens = [];
|
|
208
245
|
let current = "";
|
|
209
246
|
let quoted = false;
|
|
210
|
-
|
|
247
|
+
let depth = 0;
|
|
248
|
+
const flush = () => {
|
|
249
|
+
if (current === "") return;
|
|
250
|
+
const lower = current.toLowerCase();
|
|
251
|
+
if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });
|
|
252
|
+
else tokens.push({ kind: "word", text: current });
|
|
253
|
+
current = "";
|
|
254
|
+
};
|
|
255
|
+
for (let i = 0; i < input.length; i++) {
|
|
256
|
+
const character = input[i];
|
|
211
257
|
if (character === '"') {
|
|
212
258
|
quoted = !quoted;
|
|
213
259
|
continue;
|
|
214
260
|
}
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
261
|
+
if (quoted) {
|
|
262
|
+
current += character;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (depth > 0) {
|
|
266
|
+
current += character;
|
|
267
|
+
if (character === "(") depth++;
|
|
268
|
+
else if (character === ")") {
|
|
269
|
+
depth--;
|
|
270
|
+
if (depth === 0) flush();
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (character === "(") {
|
|
275
|
+
if (/\$(in|notin)$/i.test(current)) {
|
|
276
|
+
current += character;
|
|
277
|
+
depth = 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
flush();
|
|
281
|
+
tokens.push({ kind: "open" });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (character === ")") {
|
|
285
|
+
flush();
|
|
286
|
+
tokens.push({ kind: "close" });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (/\s/.test(character)) {
|
|
290
|
+
flush();
|
|
218
291
|
continue;
|
|
219
292
|
}
|
|
220
293
|
current += character;
|
|
221
294
|
}
|
|
222
|
-
|
|
295
|
+
flush();
|
|
223
296
|
return tokens;
|
|
224
297
|
}
|
|
225
|
-
function
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
298
|
+
function toTerm(word) {
|
|
299
|
+
const negated = word.startsWith("-") || word.startsWith("!");
|
|
300
|
+
const body = negated ? word.slice(1) : word;
|
|
301
|
+
if (body === "") return void 0;
|
|
302
|
+
const colon = body.indexOf(":");
|
|
303
|
+
const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
|
|
304
|
+
const field2 = FIELDS[name];
|
|
305
|
+
if (colon === -1 || field2 === void 0) return { field: void 0, value: body.toLowerCase(), negated };
|
|
306
|
+
const rest = body.slice(colon + 1);
|
|
307
|
+
const set = /^\$(in|notin)\(([\s\S]*)$/i.exec(rest);
|
|
308
|
+
if (set !== null) {
|
|
309
|
+
const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];
|
|
310
|
+
const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
|
|
311
|
+
const inverted = set[1].toLowerCase() === "notin";
|
|
312
|
+
return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };
|
|
313
|
+
}
|
|
314
|
+
let value = rest.toLowerCase();
|
|
315
|
+
let compare;
|
|
316
|
+
if (NUMERIC.has(field2)) {
|
|
317
|
+
compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
|
|
318
|
+
if (compare !== "=") value = value.slice(1);
|
|
319
|
+
}
|
|
320
|
+
if (value === "") return void 0;
|
|
321
|
+
return { field: field2, value, negated, compare };
|
|
322
|
+
}
|
|
323
|
+
function parseTokens(tokens) {
|
|
324
|
+
let at = 0;
|
|
325
|
+
let depth = 0;
|
|
326
|
+
const parseUnary = () => {
|
|
327
|
+
const token = tokens[at];
|
|
328
|
+
if (token === void 0) return void 0;
|
|
329
|
+
if (token.kind === "op" && token.op === "not") {
|
|
330
|
+
at++;
|
|
331
|
+
const of = parseUnary();
|
|
332
|
+
return of === void 0 ? void 0 : { kind: "not", of };
|
|
237
333
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
334
|
+
if (token.kind === "open") {
|
|
335
|
+
at++;
|
|
336
|
+
if (depth >= MAX_GROUP_DEPTH) return void 0;
|
|
337
|
+
depth++;
|
|
338
|
+
const inner = parseOr();
|
|
339
|
+
depth--;
|
|
340
|
+
if (tokens[at]?.kind === "close") at++;
|
|
341
|
+
return inner.kind === "all" ? void 0 : inner;
|
|
243
342
|
}
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
343
|
+
if (token.kind === "close") return void 0;
|
|
344
|
+
if (token.kind === "op") {
|
|
345
|
+
at++;
|
|
346
|
+
return parseUnary();
|
|
347
|
+
}
|
|
348
|
+
at++;
|
|
349
|
+
const term = toTerm(token.text);
|
|
350
|
+
return term === void 0 ? void 0 : { kind: "term", term };
|
|
351
|
+
};
|
|
352
|
+
const parseAnd = () => {
|
|
353
|
+
const parts = [];
|
|
354
|
+
while (at < tokens.length) {
|
|
355
|
+
const token = tokens[at];
|
|
356
|
+
if (token === void 0 || token.kind === "close") break;
|
|
357
|
+
if (token.kind === "op" && token.op === "or") break;
|
|
358
|
+
if (token.kind === "op" && token.op === "and") {
|
|
359
|
+
at++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const before = at;
|
|
363
|
+
const part = parseUnary();
|
|
364
|
+
if (part !== void 0) parts.push(part);
|
|
365
|
+
if (at === before) at++;
|
|
366
|
+
}
|
|
367
|
+
return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };
|
|
368
|
+
};
|
|
369
|
+
const parseOr = () => {
|
|
370
|
+
const parts = [parseAnd()];
|
|
371
|
+
while (tokens[at]?.kind === "op" && tokens[at].op === "or") {
|
|
372
|
+
at++;
|
|
373
|
+
parts.push(parseAnd());
|
|
374
|
+
}
|
|
375
|
+
const real = parts.filter((part) => part.kind !== "all");
|
|
376
|
+
if (real.length === 0) return { kind: "all" };
|
|
377
|
+
return real.length === 1 ? real[0] : { kind: "or", parts: real };
|
|
378
|
+
};
|
|
379
|
+
return parseOr();
|
|
380
|
+
}
|
|
381
|
+
function parseFilter(input) {
|
|
382
|
+
const trimmed = input.trim();
|
|
383
|
+
return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));
|
|
248
384
|
}
|
|
249
385
|
function searchableText(entry) {
|
|
250
386
|
const parts = [
|
|
@@ -298,6 +434,11 @@ function fieldValue(entry, field2) {
|
|
|
298
434
|
}
|
|
299
435
|
function matchesTerm(term, entry, haystack) {
|
|
300
436
|
if (term.field === void 0) return haystack.includes(term.value);
|
|
437
|
+
if (term.values !== void 0) {
|
|
438
|
+
if (term.values.length === 0) return false;
|
|
439
|
+
const actual = fieldValue(entry, term.field).toLowerCase();
|
|
440
|
+
return term.values.some((value) => actual.includes(value));
|
|
441
|
+
}
|
|
301
442
|
if (term.field === "score") {
|
|
302
443
|
const wanted = Number(term.value);
|
|
303
444
|
if (Number.isNaN(wanted)) return false;
|
|
@@ -307,11 +448,19 @@ function matchesTerm(term, entry, haystack) {
|
|
|
307
448
|
}
|
|
308
449
|
return fieldValue(entry, term.field).toLowerCase().includes(term.value);
|
|
309
450
|
}
|
|
310
|
-
function
|
|
311
|
-
|
|
312
|
-
|
|
451
|
+
function matches(filter, entry, haystack) {
|
|
452
|
+
switch (filter.kind) {
|
|
453
|
+
case "all":
|
|
454
|
+
return true;
|
|
455
|
+
case "term":
|
|
456
|
+
return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;
|
|
457
|
+
case "not":
|
|
458
|
+
return !matches(filter.of, entry, haystack);
|
|
459
|
+
case "and":
|
|
460
|
+
return filter.parts.every((part) => matches(part, entry, haystack));
|
|
461
|
+
case "or":
|
|
462
|
+
return filter.parts.some((part) => matches(part, entry, haystack));
|
|
313
463
|
}
|
|
314
|
-
return true;
|
|
315
464
|
}
|
|
316
465
|
function matchesFilter(filter, entry) {
|
|
317
466
|
switch (filter) {
|
|
@@ -333,7 +482,7 @@ function matchesFilter(filter, entry) {
|
|
|
333
482
|
return true;
|
|
334
483
|
}
|
|
335
484
|
}
|
|
336
|
-
var FIELDS, NUMERIC;
|
|
485
|
+
var FIELDS, NUMERIC, FIELD_NAMES, FIELD_VALUES, OPERATORS, MAX_GROUP_DEPTH, MAX_QUERY_CHARS;
|
|
337
486
|
var init_query = __esm({
|
|
338
487
|
"src/dashboard/client/query.ts"() {
|
|
339
488
|
"use strict";
|
|
@@ -362,6 +511,19 @@ var init_query = __esm({
|
|
|
362
511
|
certain: "certain"
|
|
363
512
|
};
|
|
364
513
|
NUMERIC = /* @__PURE__ */ new Set(["score"]);
|
|
514
|
+
FIELD_NAMES = Object.keys(FIELDS).sort();
|
|
515
|
+
FIELD_VALUES = {
|
|
516
|
+
verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],
|
|
517
|
+
class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
|
|
518
|
+
botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
|
|
519
|
+
action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],
|
|
520
|
+
outcome: ["allow", "mitigate", "deny", "pending"],
|
|
521
|
+
certain: ["true", "false"],
|
|
522
|
+
method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]
|
|
523
|
+
};
|
|
524
|
+
OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];
|
|
525
|
+
MAX_GROUP_DEPTH = 32;
|
|
526
|
+
MAX_QUERY_CHARS = 8192;
|
|
365
527
|
}
|
|
366
528
|
});
|
|
367
529
|
|
|
@@ -395,7 +557,12 @@ function clearFeed() {
|
|
|
395
557
|
}
|
|
396
558
|
function setSearch(value) {
|
|
397
559
|
state.search = value;
|
|
398
|
-
state.
|
|
560
|
+
state.query = parseFilter(value);
|
|
561
|
+
resetPaging();
|
|
562
|
+
}
|
|
563
|
+
function setTimeframe(from, to) {
|
|
564
|
+
state.fromMs = from;
|
|
565
|
+
state.toMs = to;
|
|
399
566
|
resetPaging();
|
|
400
567
|
}
|
|
401
568
|
function resetPaging() {
|
|
@@ -409,14 +576,16 @@ function textOf(row) {
|
|
|
409
576
|
function sortRows() {
|
|
410
577
|
state.rows.sort((a, b) => a.entry.at - b.entry.at);
|
|
411
578
|
}
|
|
412
|
-
function
|
|
413
|
-
|
|
579
|
+
function matches2(row) {
|
|
580
|
+
if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;
|
|
581
|
+
if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;
|
|
582
|
+
return matchesFilter(state.filter, row.entry) && matches(state.query, row.entry, textOf(row));
|
|
414
583
|
}
|
|
415
584
|
function matchingRows(limit = Number.POSITIVE_INFINITY) {
|
|
416
585
|
const shown = [];
|
|
417
586
|
for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
|
|
418
587
|
const row = state.rows[i];
|
|
419
|
-
if (row !== void 0 &&
|
|
588
|
+
if (row !== void 0 && matches2(row)) shown.push(row);
|
|
420
589
|
}
|
|
421
590
|
return shown;
|
|
422
591
|
}
|
|
@@ -438,7 +607,7 @@ function goToFeedPage(page) {
|
|
|
438
607
|
}
|
|
439
608
|
function matchingCount() {
|
|
440
609
|
let count = 0;
|
|
441
|
-
for (const row of state.rows) if (
|
|
610
|
+
for (const row of state.rows) if (matches2(row)) count++;
|
|
442
611
|
return count;
|
|
443
612
|
}
|
|
444
613
|
function oldestAt() {
|
|
@@ -489,10 +658,11 @@ var init_store = __esm({
|
|
|
489
658
|
policy: void 0,
|
|
490
659
|
actors: [],
|
|
491
660
|
actorsTracked: 0,
|
|
661
|
+
actorScope: "tracked",
|
|
492
662
|
paused: false,
|
|
493
663
|
filter: "all",
|
|
494
664
|
search: "",
|
|
495
|
-
|
|
665
|
+
query: { kind: "all" },
|
|
496
666
|
tab: "live",
|
|
497
667
|
open: /* @__PURE__ */ new Set(),
|
|
498
668
|
actor: void 0,
|
|
@@ -509,6 +679,8 @@ var init_store = __esm({
|
|
|
509
679
|
actorsPage: 0,
|
|
510
680
|
feedPageSize: 50,
|
|
511
681
|
actorsPageSize: 25,
|
|
682
|
+
fromMs: void 0,
|
|
683
|
+
toMs: void 0,
|
|
512
684
|
caughtUp: 0
|
|
513
685
|
};
|
|
514
686
|
}
|
|
@@ -540,6 +712,49 @@ var init_api = __esm({
|
|
|
540
712
|
}
|
|
541
713
|
});
|
|
542
714
|
|
|
715
|
+
// src/dashboard/client/saved.ts
|
|
716
|
+
function read(key, fallback) {
|
|
717
|
+
if (isEmbedded()) return fallback;
|
|
718
|
+
try {
|
|
719
|
+
const raw = localStorage.getItem(key);
|
|
720
|
+
if (raw === null) return fallback;
|
|
721
|
+
const parsed = JSON.parse(raw);
|
|
722
|
+
return Array.isArray(parsed) ? parsed : fallback;
|
|
723
|
+
} catch {
|
|
724
|
+
return fallback;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function write(key, value) {
|
|
728
|
+
if (isEmbedded()) return;
|
|
729
|
+
try {
|
|
730
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
function savedFilters() {
|
|
735
|
+
return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");
|
|
736
|
+
}
|
|
737
|
+
function saveFilter(entry) {
|
|
738
|
+
const kept = savedFilters().filter((existing) => existing.name !== entry.name);
|
|
739
|
+
const next = [entry, ...kept].slice(0, MAX_SAVED);
|
|
740
|
+
write(FILTERS_KEY, next);
|
|
741
|
+
return next;
|
|
742
|
+
}
|
|
743
|
+
function deleteFilter(name) {
|
|
744
|
+
const next = savedFilters().filter((entry) => entry.name !== name);
|
|
745
|
+
write(FILTERS_KEY, next);
|
|
746
|
+
return next;
|
|
747
|
+
}
|
|
748
|
+
var FILTERS_KEY, MAX_SAVED;
|
|
749
|
+
var init_saved = __esm({
|
|
750
|
+
"src/dashboard/client/saved.ts"() {
|
|
751
|
+
"use strict";
|
|
752
|
+
init_dom();
|
|
753
|
+
FILTERS_KEY = "bothandler.filters";
|
|
754
|
+
MAX_SAVED = 50;
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
|
|
543
758
|
// src/dashboard/client/format.ts
|
|
544
759
|
function n(value) {
|
|
545
760
|
return numbers.format(value ?? 0);
|
|
@@ -703,7 +918,7 @@ var init_replay = __esm({
|
|
|
703
918
|
function isConfirming() {
|
|
704
919
|
return armed > 0;
|
|
705
920
|
}
|
|
706
|
-
function actorActions(key, after) {
|
|
921
|
+
function actorActions(key, after, current) {
|
|
707
922
|
if (!BOOT.allowActing) return [];
|
|
708
923
|
const forget = el("button", null, "Forget");
|
|
709
924
|
forget.title = "Discard this actor's history \u2014 the cure for a false positive that has stuck";
|
|
@@ -715,7 +930,76 @@ function actorActions(key, after) {
|
|
|
715
930
|
clear2.addEventListener("click", () => {
|
|
716
931
|
void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);
|
|
717
932
|
});
|
|
718
|
-
|
|
933
|
+
const label2 = labelControl(key, current, after);
|
|
934
|
+
return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];
|
|
935
|
+
}
|
|
936
|
+
function labelControl(key, current, after) {
|
|
937
|
+
const host = el("span", "label-edit");
|
|
938
|
+
const button = el("button", null, current === void 0 ? "Label" : "Relabel");
|
|
939
|
+
button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";
|
|
940
|
+
const commit = (value) => {
|
|
941
|
+
const trimmed = value.trim().slice(0, 120);
|
|
942
|
+
void act(
|
|
943
|
+
{ key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },
|
|
944
|
+
trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,
|
|
945
|
+
trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,
|
|
946
|
+
after
|
|
947
|
+
);
|
|
948
|
+
};
|
|
949
|
+
button.addEventListener("click", () => {
|
|
950
|
+
const container2 = host.parentElement;
|
|
951
|
+
clear(host);
|
|
952
|
+
const input = el("input", "label-input");
|
|
953
|
+
input.type = "text";
|
|
954
|
+
input.value = current ?? "";
|
|
955
|
+
input.placeholder = "A name for this client";
|
|
956
|
+
input.setAttribute("aria-label", `Name for ${key}`);
|
|
957
|
+
input.maxLength = 120;
|
|
958
|
+
const save = el("button", "label-save", "Save");
|
|
959
|
+
save.title = "Save this name";
|
|
960
|
+
const cancel = el("button", null, "Cancel");
|
|
961
|
+
cancel.title = "Leave the name as it was";
|
|
962
|
+
input.title = "Enter to save, Escape to cancel";
|
|
963
|
+
armed++;
|
|
964
|
+
container2?.classList.add("editing");
|
|
965
|
+
let settled = false;
|
|
966
|
+
const finish = (accept) => {
|
|
967
|
+
if (settled) return;
|
|
968
|
+
settled = true;
|
|
969
|
+
armed--;
|
|
970
|
+
container2?.classList.remove("editing");
|
|
971
|
+
if (accept) commit(input.value);
|
|
972
|
+
else {
|
|
973
|
+
clear(host);
|
|
974
|
+
host.appendChild(button);
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
for (const control of [input, save, cancel]) {
|
|
978
|
+
control.addEventListener("keydown", (event) => {
|
|
979
|
+
const pressed = event.key;
|
|
980
|
+
if (pressed === "Escape") {
|
|
981
|
+
event.preventDefault();
|
|
982
|
+
finish(false);
|
|
983
|
+
} else if (pressed === "Enter" && control === input) {
|
|
984
|
+
event.preventDefault();
|
|
985
|
+
finish(true);
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());
|
|
990
|
+
save.addEventListener("click", () => finish(true));
|
|
991
|
+
cancel.addEventListener("click", () => finish(false));
|
|
992
|
+
host.addEventListener("focusout", (event) => {
|
|
993
|
+
const next = event.relatedTarget;
|
|
994
|
+
if (next !== null && host.contains(next)) return;
|
|
995
|
+
finish(false);
|
|
996
|
+
});
|
|
997
|
+
host.append(input, save, cancel);
|
|
998
|
+
input.focus();
|
|
999
|
+
input.select();
|
|
1000
|
+
});
|
|
1001
|
+
host.appendChild(button);
|
|
1002
|
+
return host;
|
|
719
1003
|
}
|
|
720
1004
|
function confirmingButton(label2, confirmation, run2) {
|
|
721
1005
|
const button = el("button", "danger", label2);
|
|
@@ -1013,8 +1297,8 @@ function drawGuard() {
|
|
|
1013
1297
|
preview.addEventListener("click", () => {
|
|
1014
1298
|
void previewGuard();
|
|
1015
1299
|
});
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1300
|
+
const apply2 = el("button", "primary", "Apply");
|
|
1301
|
+
apply2.addEventListener("click", () => {
|
|
1018
1302
|
void applyGuard();
|
|
1019
1303
|
});
|
|
1020
1304
|
const revert = el("button", null, "Revert");
|
|
@@ -1024,7 +1308,7 @@ function drawGuard() {
|
|
|
1024
1308
|
drawGuard();
|
|
1025
1309
|
});
|
|
1026
1310
|
bar.appendChild(preview);
|
|
1027
|
-
bar.appendChild(
|
|
1311
|
+
bar.appendChild(apply2);
|
|
1028
1312
|
bar.appendChild(revert);
|
|
1029
1313
|
panel.appendChild(bar);
|
|
1030
1314
|
}
|
|
@@ -1948,7 +2232,11 @@ function initFeed() {
|
|
|
1948
2232
|
setSearch(search.value.trim());
|
|
1949
2233
|
app.syncUrl();
|
|
1950
2234
|
app.drawNow();
|
|
2235
|
+
showSuggestions(search);
|
|
1951
2236
|
});
|
|
2237
|
+
initSuggestions(search);
|
|
2238
|
+
initTimeframe();
|
|
2239
|
+
initSavedFilters(search);
|
|
1952
2240
|
const exportShown = byId("feed-export");
|
|
1953
2241
|
exportShown.hidden = !SECTIONS.evidence;
|
|
1954
2242
|
exportShown.addEventListener("click", () => {
|
|
@@ -1972,15 +2260,17 @@ function reflectFilterButtons() {
|
|
|
1972
2260
|
}
|
|
1973
2261
|
async function loadSkipped() {
|
|
1974
2262
|
const before = state.rows.length;
|
|
2263
|
+
let served;
|
|
1975
2264
|
try {
|
|
1976
2265
|
const body = await getJson("/api/feed");
|
|
1977
2266
|
for (const entry of body.entries) ingest(entry);
|
|
2267
|
+
served = body.skipped;
|
|
1978
2268
|
sortRows();
|
|
1979
2269
|
} catch {
|
|
1980
2270
|
toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
|
|
1981
2271
|
return;
|
|
1982
2272
|
}
|
|
1983
|
-
state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
|
|
2273
|
+
state.caughtUp = (served ?? state.snapshot?.skipped ?? 0) + state.laggedDrops;
|
|
1984
2274
|
const added = state.rows.length - before;
|
|
1985
2275
|
resetPaging();
|
|
1986
2276
|
app.drawNow();
|
|
@@ -1990,6 +2280,160 @@ async function loadSkipped() {
|
|
|
1990
2280
|
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."
|
|
1991
2281
|
);
|
|
1992
2282
|
}
|
|
2283
|
+
function suggestionList() {
|
|
2284
|
+
return $("search-suggest");
|
|
2285
|
+
}
|
|
2286
|
+
function closeSuggestions(input) {
|
|
2287
|
+
const list = suggestionList();
|
|
2288
|
+
list.hidden = true;
|
|
2289
|
+
clear(list);
|
|
2290
|
+
highlighted = -1;
|
|
2291
|
+
input.setAttribute("aria-expanded", "false");
|
|
2292
|
+
}
|
|
2293
|
+
function showSuggestions(input) {
|
|
2294
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
2295
|
+
const { options, from, to } = suggestFor(input.value, caret);
|
|
2296
|
+
const list = suggestionList();
|
|
2297
|
+
if (options.length === 0 || input.value.slice(from, to) === options[0]) {
|
|
2298
|
+
closeSuggestions(input);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
clear(list);
|
|
2302
|
+
highlighted = -1;
|
|
2303
|
+
options.slice(0, 12).forEach((option, index) => {
|
|
2304
|
+
const item = el("li", null, option);
|
|
2305
|
+
item.setAttribute("role", "option");
|
|
2306
|
+
item.setAttribute("aria-selected", "false");
|
|
2307
|
+
item.addEventListener("mousedown", (event) => {
|
|
2308
|
+
event.preventDefault();
|
|
2309
|
+
apply(input, option, from, to);
|
|
2310
|
+
});
|
|
2311
|
+
item.dataset["index"] = String(index);
|
|
2312
|
+
list.appendChild(item);
|
|
2313
|
+
});
|
|
2314
|
+
list.hidden = false;
|
|
2315
|
+
input.setAttribute("aria-expanded", "true");
|
|
2316
|
+
}
|
|
2317
|
+
function apply(input, option, from, to) {
|
|
2318
|
+
const before = input.value.slice(0, from);
|
|
2319
|
+
const after = input.value.slice(to);
|
|
2320
|
+
input.value = `${before}${option}${after}`;
|
|
2321
|
+
const caret = before.length + option.length;
|
|
2322
|
+
input.setSelectionRange(caret, caret);
|
|
2323
|
+
setSearch(input.value.trim());
|
|
2324
|
+
app.syncUrl();
|
|
2325
|
+
app.drawNow();
|
|
2326
|
+
closeSuggestions(input);
|
|
2327
|
+
showSuggestions(input);
|
|
2328
|
+
input.focus();
|
|
2329
|
+
}
|
|
2330
|
+
function initSuggestions(input) {
|
|
2331
|
+
input.addEventListener("keydown", (event) => {
|
|
2332
|
+
const list = suggestionList();
|
|
2333
|
+
const items = Array.from(list.querySelectorAll("li"));
|
|
2334
|
+
if (list.hidden || items.length === 0) return;
|
|
2335
|
+
if (event.key === "Escape") {
|
|
2336
|
+
closeSuggestions(input);
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
2340
|
+
event.preventDefault();
|
|
2341
|
+
highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;
|
|
2342
|
+
items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {
|
|
2346
|
+
const chosen = items[highlighted]?.textContent ?? "";
|
|
2347
|
+
const caret = input.selectionStart ?? input.value.length;
|
|
2348
|
+
const { from, to } = suggestFor(input.value, caret);
|
|
2349
|
+
event.preventDefault();
|
|
2350
|
+
apply(input, chosen, from, to);
|
|
2351
|
+
}
|
|
2352
|
+
});
|
|
2353
|
+
input.addEventListener("blur", () => {
|
|
2354
|
+
setTimeout(() => closeSuggestions(input), 120);
|
|
2355
|
+
});
|
|
2356
|
+
input.addEventListener("focus", () => showSuggestions(input));
|
|
2357
|
+
}
|
|
2358
|
+
function initSavedFilters(input) {
|
|
2359
|
+
const host = $("saved-filters");
|
|
2360
|
+
const redraw = () => {
|
|
2361
|
+
clear(host);
|
|
2362
|
+
const entries = savedFilters();
|
|
2363
|
+
const select2 = document.createElement("select");
|
|
2364
|
+
select2.setAttribute("aria-label", "Saved filters");
|
|
2365
|
+
const first = document.createElement("option");
|
|
2366
|
+
first.value = "";
|
|
2367
|
+
first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";
|
|
2368
|
+
select2.appendChild(first);
|
|
2369
|
+
for (const entry of entries) {
|
|
2370
|
+
const option = document.createElement("option");
|
|
2371
|
+
option.value = entry.name;
|
|
2372
|
+
option.textContent = entry.name;
|
|
2373
|
+
select2.appendChild(option);
|
|
2374
|
+
}
|
|
2375
|
+
select2.addEventListener("change", () => {
|
|
2376
|
+
const chosen = entries.find((entry) => entry.name === select2.value);
|
|
2377
|
+
if (chosen === void 0) return;
|
|
2378
|
+
input.value = chosen.query;
|
|
2379
|
+
setSearch(chosen.query);
|
|
2380
|
+
state.filter = chosen.filter;
|
|
2381
|
+
reflectFilterButtons();
|
|
2382
|
+
resetPaging();
|
|
2383
|
+
app.syncUrl();
|
|
2384
|
+
app.drawNow();
|
|
2385
|
+
});
|
|
2386
|
+
host.appendChild(select2);
|
|
2387
|
+
const save = el("button", null, "Save");
|
|
2388
|
+
save.type = "button";
|
|
2389
|
+
save.title = "Save this filter, in this browser, under a name";
|
|
2390
|
+
save.addEventListener("click", () => {
|
|
2391
|
+
const name = prompt("Save this filter as:")?.trim();
|
|
2392
|
+
if (name === void 0 || name === "") return;
|
|
2393
|
+
saveFilter({ name, query: input.value.trim(), filter: state.filter });
|
|
2394
|
+
redraw();
|
|
2395
|
+
toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);
|
|
2396
|
+
});
|
|
2397
|
+
host.appendChild(save);
|
|
2398
|
+
if (select2.value !== "") {
|
|
2399
|
+
const remove = el("button", null, "Delete");
|
|
2400
|
+
remove.type = "button";
|
|
2401
|
+
remove.addEventListener("click", () => {
|
|
2402
|
+
deleteFilter(select2.value);
|
|
2403
|
+
redraw();
|
|
2404
|
+
});
|
|
2405
|
+
host.appendChild(remove);
|
|
2406
|
+
}
|
|
2407
|
+
};
|
|
2408
|
+
redraw();
|
|
2409
|
+
}
|
|
2410
|
+
function initTimeframe() {
|
|
2411
|
+
const from = byId("from-at");
|
|
2412
|
+
const to = byId("to-at");
|
|
2413
|
+
const clearButton = byId("timeframe-clear");
|
|
2414
|
+
const read2 = (input) => {
|
|
2415
|
+
if (input.value === "") return void 0;
|
|
2416
|
+
const parsed = new Date(input.value).getTime();
|
|
2417
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
2418
|
+
};
|
|
2419
|
+
const apply2 = () => {
|
|
2420
|
+
const start2 = read2(from);
|
|
2421
|
+
const end = read2(to);
|
|
2422
|
+
if (start2 !== void 0 && end !== void 0 && end < start2) {
|
|
2423
|
+
toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");
|
|
2424
|
+
}
|
|
2425
|
+
setTimeframe(start2, end);
|
|
2426
|
+
clearButton.hidden = start2 === void 0 && end === void 0;
|
|
2427
|
+
app.drawNow();
|
|
2428
|
+
};
|
|
2429
|
+
from.addEventListener("change", apply2);
|
|
2430
|
+
to.addEventListener("change", apply2);
|
|
2431
|
+
clearButton.addEventListener("click", () => {
|
|
2432
|
+
from.value = "";
|
|
2433
|
+
to.value = "";
|
|
2434
|
+
apply2();
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
1993
2437
|
function drawPager(paged) {
|
|
1994
2438
|
const size = state.feedPageSize;
|
|
1995
2439
|
const hidden = paged.pages <= 1;
|
|
@@ -2158,12 +2602,13 @@ function buildDetail(entry) {
|
|
|
2158
2602
|
} else {
|
|
2159
2603
|
const list = el("div", "ev");
|
|
2160
2604
|
for (const item of entry.evidence) {
|
|
2161
|
-
const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
|
|
2605
|
+
const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}${item.shadow === true ? " shadow" : ""}`);
|
|
2162
2606
|
row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
|
|
2163
2607
|
const body = el("div");
|
|
2164
2608
|
body.appendChild(el("div", null, item.summary));
|
|
2165
2609
|
let meta = `${item.detector} \xB7 points to ${item.direction}`;
|
|
2166
2610
|
if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;
|
|
2611
|
+
if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";
|
|
2167
2612
|
body.appendChild(el("div", "ev-meta", meta));
|
|
2168
2613
|
if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));
|
|
2169
2614
|
row.appendChild(body);
|
|
@@ -2171,6 +2616,17 @@ function buildDetail(entry) {
|
|
|
2171
2616
|
}
|
|
2172
2617
|
cell.appendChild(list);
|
|
2173
2618
|
}
|
|
2619
|
+
if (entry.shadowVerdict !== void 0) {
|
|
2620
|
+
const would = entry.shadowVerdict;
|
|
2621
|
+
const changed = would.verdict !== entry.verdict;
|
|
2622
|
+
cell.appendChild(
|
|
2623
|
+
el(
|
|
2624
|
+
"div",
|
|
2625
|
+
`ev-meta shadow-verdict${changed ? " changed" : ""}`,
|
|
2626
|
+
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}`}.`
|
|
2627
|
+
)
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2174
2630
|
for (const failure of entry.failures) {
|
|
2175
2631
|
cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));
|
|
2176
2632
|
}
|
|
@@ -2272,12 +2728,14 @@ function showText(text) {
|
|
|
2272
2728
|
selection.addRange(range);
|
|
2273
2729
|
}
|
|
2274
2730
|
}
|
|
2275
|
-
var FILTERS, rendered, FEED_PAGE_SIZES;
|
|
2731
|
+
var FILTERS, rendered, highlighted, FEED_PAGE_SIZES;
|
|
2276
2732
|
var init_feed = __esm({
|
|
2277
2733
|
"src/dashboard/client/feed.ts"() {
|
|
2278
2734
|
"use strict";
|
|
2279
2735
|
init_dom();
|
|
2280
2736
|
init_store();
|
|
2737
|
+
init_saved();
|
|
2738
|
+
init_query();
|
|
2281
2739
|
init_api();
|
|
2282
2740
|
init_pager();
|
|
2283
2741
|
init_boot();
|
|
@@ -2298,6 +2756,7 @@ var init_feed = __esm({
|
|
|
2298
2756
|
["allow", "Served"]
|
|
2299
2757
|
];
|
|
2300
2758
|
rendered = /* @__PURE__ */ new Map();
|
|
2759
|
+
highlighted = -1;
|
|
2301
2760
|
FEED_PAGE_SIZES = [25, 50, 100, 200];
|
|
2302
2761
|
}
|
|
2303
2762
|
});
|
|
@@ -2500,6 +2959,13 @@ function drawStatsPanels() {
|
|
|
2500
2959
|
["Actors tracked", n(metrics.actorsTracked)],
|
|
2501
2960
|
["Guard stops", n(metrics.downgrades)]
|
|
2502
2961
|
];
|
|
2962
|
+
const shadowed = pairs(metrics.shadowFirings);
|
|
2963
|
+
if (shadowed.length > 0) {
|
|
2964
|
+
const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);
|
|
2965
|
+
rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);
|
|
2966
|
+
for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);
|
|
2967
|
+
if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);
|
|
2968
|
+
}
|
|
2503
2969
|
const failures = pairs(metrics.detectorFailures);
|
|
2504
2970
|
for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);
|
|
2505
2971
|
for (const [key, value] of rows) health.appendChild(statRow(key, value));
|
|
@@ -2516,14 +2982,14 @@ function drawStatsPanels() {
|
|
|
2516
2982
|
clear(list);
|
|
2517
2983
|
$("detector-count").textContent = `${snapshot.detectors.length} installed`;
|
|
2518
2984
|
for (const detector of snapshot.detectors) {
|
|
2519
|
-
const row = el("div",
|
|
2985
|
+
const row = el("div", `det${detector.shadow === true ? " shadow" : ""}`);
|
|
2520
2986
|
const left = el("div");
|
|
2521
2987
|
left.appendChild(el("div", "mono", detector.id));
|
|
2522
2988
|
left.appendChild(el("div", "d", detector.description));
|
|
2523
2989
|
row.appendChild(left);
|
|
2524
|
-
const fires = metrics?.detectorFirings[detector.id] ?? 0;
|
|
2990
|
+
const fires = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;
|
|
2525
2991
|
const timing = metrics?.detectorTimings[detector.id];
|
|
2526
|
-
let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
|
|
2992
|
+
let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
|
|
2527
2993
|
if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;
|
|
2528
2994
|
row.appendChild(el("div", "n", right));
|
|
2529
2995
|
list.appendChild(row);
|
|
@@ -3047,6 +3513,7 @@ function trackActors() {
|
|
|
3047
3513
|
void loadActors();
|
|
3048
3514
|
timer = setInterval(() => {
|
|
3049
3515
|
if (state.tab !== "actors" || state.paused || isConfirming()) return;
|
|
3516
|
+
if (state.actorScope === "feed") return;
|
|
3050
3517
|
void loadActors();
|
|
3051
3518
|
}, 4e3);
|
|
3052
3519
|
}
|
|
@@ -3085,26 +3552,95 @@ function drawActorsPager(full) {
|
|
|
3085
3552
|
else renderPager(host, model, { withSize });
|
|
3086
3553
|
}
|
|
3087
3554
|
}
|
|
3555
|
+
function feedActors() {
|
|
3556
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
3557
|
+
for (const row of matchingRows()) {
|
|
3558
|
+
const entry = row.entry;
|
|
3559
|
+
let seen = byKey.get(entry.actor);
|
|
3560
|
+
if (seen === void 0) {
|
|
3561
|
+
seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };
|
|
3562
|
+
byKey.set(entry.actor, seen);
|
|
3563
|
+
}
|
|
3564
|
+
seen.rows++;
|
|
3565
|
+
seen.paths.add(entry.path);
|
|
3566
|
+
seen.agents.add(entry.userAgent);
|
|
3567
|
+
if (entry.at < seen.first) seen.first = entry.at;
|
|
3568
|
+
if (entry.at > seen.last) seen.last = entry.at;
|
|
3569
|
+
seen.stats = entry.actorStats ?? seen.stats;
|
|
3570
|
+
}
|
|
3571
|
+
const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));
|
|
3572
|
+
const out = [];
|
|
3573
|
+
for (const [key, seen] of byKey) {
|
|
3574
|
+
const label2 = labels.get(key);
|
|
3575
|
+
out.push({
|
|
3576
|
+
key,
|
|
3577
|
+
...label2 === void 0 ? {} : { label: label2 },
|
|
3578
|
+
requests: seen.rows,
|
|
3579
|
+
recentRate: Number.NaN,
|
|
3580
|
+
distinctPaths: seen.paths.size,
|
|
3581
|
+
distinctUserAgents: seen.agents.size,
|
|
3582
|
+
cadenceCv: void 0,
|
|
3583
|
+
// A dash where the feed cannot know, like the three columns below it. A confident
|
|
3584
|
+
// zero under a heading that means "how many times has this client been proven a bot"
|
|
3585
|
+
// is worse than an admission, and it was the only column here still guessing.
|
|
3586
|
+
priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,
|
|
3587
|
+
unsolvedChallenges: Number.NaN,
|
|
3588
|
+
cleared: seen.stats?.cleared ?? false,
|
|
3589
|
+
firstSeen: seen.stats?.firstSeen ?? seen.first,
|
|
3590
|
+
lastSeen: seen.last
|
|
3591
|
+
});
|
|
3592
|
+
}
|
|
3593
|
+
return out.sort((a, b) => b.requests - a.requests);
|
|
3594
|
+
}
|
|
3595
|
+
function applyActorScope(scope) {
|
|
3596
|
+
if (!SECTIONS.registry) return;
|
|
3597
|
+
state.actorScope = scope;
|
|
3598
|
+
for (const [id, on] of [
|
|
3599
|
+
["actors-scope-tracked", scope === "tracked"],
|
|
3600
|
+
["actors-scope-feed", scope === "feed"]
|
|
3601
|
+
]) {
|
|
3602
|
+
byId(id).className = on ? "on" : "";
|
|
3603
|
+
byId(id).setAttribute("aria-pressed", String(on));
|
|
3604
|
+
}
|
|
3605
|
+
drawActors();
|
|
3606
|
+
}
|
|
3607
|
+
function initActorScope() {
|
|
3608
|
+
if (!SECTIONS.registry) return;
|
|
3609
|
+
const choose = (scope) => {
|
|
3610
|
+
applyActorScope(scope);
|
|
3611
|
+
app.syncUrl({ replace: false });
|
|
3612
|
+
};
|
|
3613
|
+
$("actors-scope-tracked").addEventListener("click", () => choose("tracked"));
|
|
3614
|
+
$("actors-scope-feed").addEventListener("click", () => choose("feed"));
|
|
3615
|
+
}
|
|
3088
3616
|
function drawActors() {
|
|
3089
3617
|
if (!SECTIONS.registry) return;
|
|
3090
3618
|
if (isConfirming()) return;
|
|
3091
3619
|
const body = byId("actor-rows");
|
|
3092
3620
|
clear(body);
|
|
3093
|
-
const
|
|
3094
|
-
|
|
3095
|
-
|
|
3621
|
+
const fromFeed = state.actorScope === "feed";
|
|
3622
|
+
const actors = fromFeed ? feedActors() : state.actors;
|
|
3623
|
+
$("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`;
|
|
3624
|
+
drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);
|
|
3625
|
+
if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;
|
|
3096
3626
|
byId("actors-empty").hidden = actors.length > 0;
|
|
3097
3627
|
for (const actor of actors) {
|
|
3098
3628
|
const row = el("tr");
|
|
3099
|
-
|
|
3629
|
+
const who = el("td", "who");
|
|
3630
|
+
if (actor.label === void 0) who.textContent = actor.key;
|
|
3631
|
+
else {
|
|
3632
|
+
who.appendChild(el("div", "label", actor.label));
|
|
3633
|
+
who.appendChild(el("div", "sub", actor.key));
|
|
3634
|
+
}
|
|
3635
|
+
row.appendChild(who);
|
|
3100
3636
|
row.appendChild(el("td", "num tnum", n(actor.requests)));
|
|
3101
|
-
row.appendChild(el("td", "num tnum", n(actor.recentRate)));
|
|
3637
|
+
row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : n(actor.recentRate)));
|
|
3102
3638
|
row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));
|
|
3103
3639
|
const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));
|
|
3104
3640
|
if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";
|
|
3105
3641
|
row.appendChild(cadence);
|
|
3106
|
-
row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));
|
|
3107
|
-
const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));
|
|
3642
|
+
row.appendChild(el("td", "num tnum", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));
|
|
3643
|
+
const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : n(actor.unsolvedChallenges));
|
|
3108
3644
|
if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";
|
|
3109
3645
|
row.appendChild(unsolved);
|
|
3110
3646
|
const stateCell = el("td");
|
|
@@ -3125,7 +3661,7 @@ function drawActors() {
|
|
|
3125
3661
|
app.syncUrl();
|
|
3126
3662
|
});
|
|
3127
3663
|
actions.appendChild(inFeed);
|
|
3128
|
-
for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);
|
|
3664
|
+
for (const button of actorActions(actor.key, () => void loadActors(), actor.label)) actions.appendChild(button);
|
|
3129
3665
|
row.appendChild(actions);
|
|
3130
3666
|
body.appendChild(row);
|
|
3131
3667
|
}
|
|
@@ -3158,13 +3694,13 @@ function initTester() {
|
|
|
3158
3694
|
});
|
|
3159
3695
|
}
|
|
3160
3696
|
async function run() {
|
|
3161
|
-
const
|
|
3162
|
-
if (
|
|
3697
|
+
const raw = byId("test-input").value;
|
|
3698
|
+
if (raw.trim() === "") {
|
|
3163
3699
|
toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");
|
|
3164
3700
|
return;
|
|
3165
3701
|
}
|
|
3166
3702
|
const result = await postJson("/api/test", {
|
|
3167
|
-
raw
|
|
3703
|
+
raw,
|
|
3168
3704
|
ip: byId("test-ip").value.trim(),
|
|
3169
3705
|
url: byId("test-url").value.trim()
|
|
3170
3706
|
});
|
|
@@ -3304,12 +3840,12 @@ function initHeader() {
|
|
|
3304
3840
|
}
|
|
3305
3841
|
const header = rootNode().querySelector("header");
|
|
3306
3842
|
if (header !== null) {
|
|
3307
|
-
const
|
|
3843
|
+
const apply2 = () => {
|
|
3308
3844
|
themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);
|
|
3309
3845
|
};
|
|
3310
|
-
|
|
3311
|
-
if (typeof ResizeObserver === "function") new ResizeObserver(
|
|
3312
|
-
else addEventListener("resize",
|
|
3846
|
+
apply2();
|
|
3847
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);
|
|
3848
|
+
else addEventListener("resize", apply2);
|
|
3313
3849
|
}
|
|
3314
3850
|
}
|
|
3315
3851
|
function syncUrl(replace = true) {
|
|
@@ -3317,21 +3853,25 @@ function syncUrl(replace = true) {
|
|
|
3317
3853
|
const params = new URLSearchParams();
|
|
3318
3854
|
if (state.filter !== "all") params.set("f", state.filter);
|
|
3319
3855
|
if (state.search !== "") params.set("q", state.search);
|
|
3856
|
+
if (state.actorScope !== "tracked") params.set("a", state.actorScope);
|
|
3320
3857
|
const query = params.toString();
|
|
3321
3858
|
const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;
|
|
3322
3859
|
if (location.hash === hash) return;
|
|
3323
3860
|
history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);
|
|
3324
3861
|
}
|
|
3325
3862
|
function readUrl() {
|
|
3326
|
-
const
|
|
3327
|
-
const split =
|
|
3328
|
-
const name = split === -1 ?
|
|
3329
|
-
const params = new URLSearchParams(split === -1 ? "" :
|
|
3863
|
+
const raw = isEmbedded() ? "" : location.hash.slice(1);
|
|
3864
|
+
const split = raw.indexOf("?");
|
|
3865
|
+
const name = split === -1 ? raw : raw.slice(0, split);
|
|
3866
|
+
const params = new URLSearchParams(split === -1 ? "" : raw.slice(split + 1));
|
|
3330
3867
|
const filter = params.get("f") ?? "all";
|
|
3331
3868
|
return {
|
|
3332
3869
|
tab: isTab(name) ? name : FIRST,
|
|
3333
3870
|
filter,
|
|
3334
|
-
search: params.get("q") ?? ""
|
|
3871
|
+
search: params.get("q") ?? "",
|
|
3872
|
+
// Anything but the one alternative reads as the default rather than as an error:
|
|
3873
|
+
// a hand-edited URL should land somewhere, and this is the somewhere it lands.
|
|
3874
|
+
actorScope: params.get("a") === "feed" ? "feed" : "tracked"
|
|
3335
3875
|
};
|
|
3336
3876
|
}
|
|
3337
3877
|
function showTab(name, options = {}) {
|
|
@@ -3375,6 +3915,7 @@ function initTabs() {
|
|
|
3375
3915
|
state.filter = url.filter;
|
|
3376
3916
|
setSearch(url.search);
|
|
3377
3917
|
reflectFilterButtons();
|
|
3918
|
+
applyActorScope(url.actorScope);
|
|
3378
3919
|
showTab(url.tab, { push: false });
|
|
3379
3920
|
});
|
|
3380
3921
|
}
|
|
@@ -3516,7 +4057,7 @@ function start() {
|
|
|
3516
4057
|
app.draw = schedule;
|
|
3517
4058
|
app.drawNow = drawNow;
|
|
3518
4059
|
app.showTab = showTab;
|
|
3519
|
-
app.syncUrl = () => syncUrl();
|
|
4060
|
+
app.syncUrl = (options) => syncUrl(options?.replace !== false);
|
|
3520
4061
|
applySections();
|
|
3521
4062
|
initHeader();
|
|
3522
4063
|
drawPeers();
|
|
@@ -3526,6 +4067,7 @@ function start() {
|
|
|
3526
4067
|
initRanges();
|
|
3527
4068
|
if (SECTIONS.feed) initFeed();
|
|
3528
4069
|
initActor();
|
|
4070
|
+
initActorScope();
|
|
3529
4071
|
initTester();
|
|
3530
4072
|
initPolicy();
|
|
3531
4073
|
let resizeTimer;
|
|
@@ -3543,6 +4085,7 @@ function start() {
|
|
|
3543
4085
|
state.filter = url.filter;
|
|
3544
4086
|
setSearch(url.search);
|
|
3545
4087
|
if (SECTIONS.feed) reflectFilterButtons();
|
|
4088
|
+
applyActorScope(url.actorScope);
|
|
3546
4089
|
showTab(url.tab, { replace: true });
|
|
3547
4090
|
suspendScrollAnchoring();
|
|
3548
4091
|
void loadInitialSnapshot().finally(settleScrollAnchoring);
|
|
@@ -3640,12 +4183,12 @@ function resolveSections(fromServer, config) {
|
|
|
3640
4183
|
}
|
|
3641
4184
|
return { sections, warnings };
|
|
3642
4185
|
}
|
|
3643
|
-
function parseMount(
|
|
3644
|
-
const cut =
|
|
3645
|
-
if (cut === -1) return { base:
|
|
4186
|
+
function parseMount(raw) {
|
|
4187
|
+
const cut = raw.search(/[?#]/);
|
|
4188
|
+
if (cut === -1) return { base: raw.replace(/\/$/, "") };
|
|
3646
4189
|
return {
|
|
3647
|
-
base:
|
|
3648
|
-
warning: `src "${
|
|
4190
|
+
base: raw.slice(0, cut).replace(/\/$/, ""),
|
|
4191
|
+
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.`
|
|
3649
4192
|
};
|
|
3650
4193
|
}
|
|
3651
4194
|
function explainStatus(status, base) {
|
|
@@ -3860,7 +4403,10 @@ button[disabled] { opacity: .5; cursor: default; }
|
|
|
3860
4403
|
.tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
|
|
3861
4404
|
|
|
3862
4405
|
/* --- layout ------------------------------------------------------------- */
|
|
3863
|
-
|
|
4406
|
+
/* The top padding is the gap under the sticky header. At 18px the counter row sat almost
|
|
4407
|
+
against the header's border and read as part of it; the tiles carry their own border, so
|
|
4408
|
+
two lines were meeting with nothing between them. */
|
|
4409
|
+
main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
3864
4410
|
.stack { display: grid; gap: 16px; }
|
|
3865
4411
|
/* Everything above the feed is drawn by script once the first snapshot arrives, which
|
|
3866
4412
|
inserts a block of content above what is already laid out. The browser's scroll
|
|
@@ -3891,6 +4437,33 @@ button.tile {
|
|
|
3891
4437
|
cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
|
|
3892
4438
|
}
|
|
3893
4439
|
button.tile:hover { border-color: var(--focus); }
|
|
4440
|
+
/* The suggestion list under the search box, positioned against the search wrapper. */
|
|
4441
|
+
.search { position: relative; }
|
|
4442
|
+
.suggest {
|
|
4443
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
|
|
4444
|
+
list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
|
|
4445
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
|
|
4446
|
+
}
|
|
4447
|
+
.suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
|
4448
|
+
.suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
|
|
4449
|
+
|
|
4450
|
+
/* Saved filters: a list of small removable things. Quiet, because it is not what
|
|
4451
|
+
somebody came to the page to look at. */
|
|
4452
|
+
.saved { display: flex; align-items: center; gap: 6px; }
|
|
4453
|
+
/* Two open-ended bounds rather than a list of durations: "from the incident until now",
|
|
4454
|
+
"everything up to when it stopped" and "between these two moments" are the same control
|
|
4455
|
+
with one end left empty. */
|
|
4456
|
+
.timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
|
|
4457
|
+
.timeframe label { display: inline-flex; align-items: center; gap: 4px; }
|
|
4458
|
+
.timeframe input {
|
|
4459
|
+
font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
|
|
4460
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
4461
|
+
}
|
|
4462
|
+
.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; }
|
|
4463
|
+
.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; }
|
|
4464
|
+
.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; }
|
|
4465
|
+
.saved button:hover { border-color: var(--focus); }
|
|
4466
|
+
|
|
3894
4467
|
/* The badge's companion: fetches the entries the stream skipped. Sits inline with the
|
|
3895
4468
|
heading, so it is styled to read as part of the sentence rather than as a form control. */
|
|
3896
4469
|
.load-skipped {
|
|
@@ -3912,6 +4485,10 @@ button.tile:hover { border-color: var(--focus); }
|
|
|
3912
4485
|
.pager button:hover:not(:disabled) { border-color: var(--focus); }
|
|
3913
4486
|
.pager button:disabled { opacity: .45; cursor: default; }
|
|
3914
4487
|
.pager .where { font-variant-numeric: tabular-nums; }
|
|
4488
|
+
/* A labelled actor leads with its name and keeps the key underneath: whoever named it did
|
|
4489
|
+
so because the key was not the useful part, and the key is still what you search for. */
|
|
4490
|
+
td.who .label { font-weight: 560; }
|
|
4491
|
+
td.who .sub { color: var(--muted); font-size: 11px; }
|
|
3915
4492
|
.pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
|
|
3916
4493
|
/* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
|
|
3917
4494
|
was thirty-six pixels of mostly empty rule above every screenful of requests. */
|
|
@@ -4032,7 +4609,29 @@ input[type="search"] {
|
|
|
4032
4609
|
}
|
|
4033
4610
|
input[type="search"]::placeholder { color: var(--muted); }
|
|
4034
4611
|
|
|
4035
|
-
|
|
4612
|
+
/* separate with zero spacing rather than collapse, and the difference is the whole
|
|
4613
|
+
reason the column headers work in Safari.
|
|
4614
|
+
|
|
4615
|
+
Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
|
|
4616
|
+
CSSWG has an open issue on collapsed borders not following a cell when it sticks
|
|
4617
|
+
(csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
|
|
4618
|
+
altogether under a collapsed table. Separating the borders is the standard remedy.
|
|
4619
|
+
|
|
4620
|
+
What was actually measured: the header sticks correctly in Chromium and in Firefox,
|
|
4621
|
+
both before and after this change, and it was reported adrift in Safari — which is what
|
|
4622
|
+
a sticky element that has stopped sticking looks like. WebKit could not be run on the
|
|
4623
|
+
machine this was written on, so the Safari half of it rests on that report and on the
|
|
4624
|
+
documented behaviour rather than on a measurement taken here.
|
|
4625
|
+
|
|
4626
|
+
The rendering is all but unchanged. Every border in these tables is a bottom border on
|
|
4627
|
+
the cell itself, plus the per-cell left accent on td.edge; no border is shared between
|
|
4628
|
+
two cells, so there is nothing for collapsing to merge and nothing for separating to
|
|
4629
|
+
double, and zero spacing keeps the cells touching. The one measurable difference is the
|
|
4630
|
+
accent column, which moves two pixels: collapsing centres that 3px border on the cell
|
|
4631
|
+
edge and leaves half of it outside the box, while separating puts all of it inside.
|
|
4632
|
+
Measured rather than assumed, and the leftmost column starting two pixels earlier is
|
|
4633
|
+
both imperceptible and the more correct of the two. */
|
|
4634
|
+
table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
|
4036
4635
|
thead th {
|
|
4037
4636
|
/* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
|
|
4038
4637
|
the instant before the first measurement, and for the tab strip wrapping. */
|
|
@@ -4324,9 +4923,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
4324
4923
|
#actor-rows td { font-size: 12.5px; }
|
|
4325
4924
|
#actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
4326
4925
|
#actor-rows td.acts { text-align: right; white-space: nowrap; }
|
|
4926
|
+
/* The tracked/shown toggle above the actors table. A segmented pair rather than a
|
|
4927
|
+
dropdown: there are two answers and both are worth reading at a glance. */
|
|
4928
|
+
.scope { display: flex; gap: 6px; padding: 0 14px 10px; }
|
|
4929
|
+
.scope button { font-size: 11.5px; padding: 4px 10px; }
|
|
4930
|
+
.scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
|
|
4931
|
+
|
|
4327
4932
|
#actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
|
|
4933
|
+
/* The Label control, which becomes a text box with a Save and a Cancel in place.
|
|
4934
|
+
|
|
4935
|
+
The cell does not wrap, so an editor that sat beside the row's other four buttons put
|
|
4936
|
+
Save off the right edge of the panel, where it could be seen and not clicked. While
|
|
4937
|
+
the editor is open it stands in for those buttons instead — which is also the right
|
|
4938
|
+
thing on its own, since Allowlist and Forget are not what somebody naming a client is
|
|
4939
|
+
reaching for. */
|
|
4940
|
+
.acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
|
|
4941
|
+
/* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
|
|
4942
|
+
line is the layout that cannot spill them past its own edge. */
|
|
4943
|
+
.label-edit { display: inline-flex; align-items: center; gap: 4px; }
|
|
4944
|
+
.label-edit button { flex: 0 0 auto; }
|
|
4945
|
+
#actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
|
|
4946
|
+
/* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
|
|
4947
|
+
outranks a bare class, so the width here was quietly ignored and the box grew to fill
|
|
4948
|
+
whatever it was in — which is what put Save and Cancel outside the panel. */
|
|
4949
|
+
input.label-input {
|
|
4950
|
+
font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
|
|
4951
|
+
color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
|
|
4952
|
+
}
|
|
4953
|
+
input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
4954
|
+
|
|
4328
4955
|
/* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
|
|
4329
4956
|
one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
|
|
4957
|
+
/* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
|
|
4958
|
+
and set behind a rule rather than coloured, because every colour on this page already
|
|
4959
|
+
means something about a verdict and this one took no part in a verdict. */
|
|
4960
|
+
.det.shadow { opacity: 0.72; }
|
|
4961
|
+
.ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
|
|
4962
|
+
.shadow-verdict { margin-top: 8px; font-style: italic; }
|
|
4963
|
+
.shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
|
|
4330
4964
|
.tagline { font-size: 11px; color: var(--muted); }
|
|
4331
4965
|
.tagline b { color: var(--ink-2); font-weight: 600; }
|
|
4332
4966
|
|
|
@@ -4424,8 +5058,16 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4424
5058
|
<div class="toolbar">
|
|
4425
5059
|
<div class="filters" id="filters"></div>
|
|
4426
5060
|
<div class="search">
|
|
4427
|
-
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
5061
|
+
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
5062
|
+
role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
|
|
4428
5063
|
<kbd aria-hidden="true">/</kbd>
|
|
5064
|
+
<ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
|
|
5065
|
+
</div>
|
|
5066
|
+
<div class="saved" id="saved-filters"></div>
|
|
5067
|
+
<div class="timeframe" id="timeframe">
|
|
5068
|
+
<label>From <input type="datetime-local" id="from-at" step="1"></label>
|
|
5069
|
+
<label>To <input type="datetime-local" id="to-at" step="1"></label>
|
|
5070
|
+
<button type="button" id="timeframe-clear" hidden>Clear</button>
|
|
4429
5071
|
</div>
|
|
4430
5072
|
<button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
|
|
4431
5073
|
<div class="pager pager-inline" id="feed-pager-top" hidden></div>
|
|
@@ -4489,6 +5131,12 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4489
5131
|
<div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
|
|
4490
5132
|
population than the feed's ring, which holds requests rather than clients. This is
|
|
4491
5133
|
what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
|
|
5134
|
+
<div class="scope" role="group" aria-label="Which actors to list">
|
|
5135
|
+
<button id="actors-scope-tracked" class="on" aria-pressed="true"
|
|
5136
|
+
title="Every client the engine is remembering, busiest first">Tracked</button>
|
|
5137
|
+
<button id="actors-scope-feed" aria-pressed="false"
|
|
5138
|
+
title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
|
|
5139
|
+
</div>
|
|
4492
5140
|
<div class="pager pager-top" id="actors-pager-top" hidden></div>
|
|
4493
5141
|
<div class="feed-scroll">
|
|
4494
5142
|
<table>
|
|
@@ -4933,6 +5581,8 @@ var BotDashboardElement = class extends ElementBase {
|
|
|
4933
5581
|
const dom = await Promise.resolve().then(() => (init_dom(), dom_exports));
|
|
4934
5582
|
dom.setRoot(frame, this);
|
|
4935
5583
|
globalThis.__BOOTSTRAP__ = boot;
|
|
5584
|
+
const bootModule = await Promise.resolve().then(() => (init_boot(), boot_exports));
|
|
5585
|
+
bootModule.applyBoot(boot);
|
|
4936
5586
|
await Promise.resolve().then(() => (init_client(), client_exports));
|
|
4937
5587
|
this.relabelTabs(shadow);
|
|
4938
5588
|
this.renderPanels(frame);
|