@osqd/bothandlerjs 0.4.0 → 0.5.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/dist/cli.cjs +177 -21
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +177 -21
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/dashboard/client/feed.d.ts +16 -0
- package/dist/dashboard/client/format.d.ts +18 -0
- package/dist/dashboard/client/pager.d.ts +32 -0
- package/dist/dashboard/client/store.d.ts +60 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/detectors/crawler-verification.d.ts +34 -1
- package/dist/detectors/index.d.ts +4 -1
- package/dist/detectors/known-bots.d.ts +49 -1
- package/dist/element/index.cjs +345 -39
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +345 -39
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +177 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +177 -21
- package/dist/index.js.map +1 -1
- package/dist/state.d.ts +1 -1
- package/docs/detection/signatures.md +36 -0
- package/docs/operations/dashboard.md +26 -0
- package/package.json +1 -1
package/dist/element/index.js
CHANGED
|
@@ -391,19 +391,28 @@ function clearFeed() {
|
|
|
391
391
|
state.actor = void 0;
|
|
392
392
|
state.bufferedWhilePaused = 0;
|
|
393
393
|
state.laggedDrops = 0;
|
|
394
|
+
resetPaging();
|
|
394
395
|
}
|
|
395
396
|
function setSearch(value) {
|
|
396
397
|
state.search = value;
|
|
397
398
|
state.terms = parseQuery(value);
|
|
399
|
+
resetPaging();
|
|
400
|
+
}
|
|
401
|
+
function resetPaging() {
|
|
402
|
+
state.feedPage = 0;
|
|
403
|
+
state.feedFrozen = void 0;
|
|
398
404
|
}
|
|
399
405
|
function textOf(row) {
|
|
400
406
|
if (row.text === void 0) row.text = searchableText(row.entry);
|
|
401
407
|
return row.text;
|
|
402
408
|
}
|
|
409
|
+
function sortRows() {
|
|
410
|
+
state.rows.sort((a, b) => a.entry.at - b.entry.at);
|
|
411
|
+
}
|
|
403
412
|
function matches(row) {
|
|
404
413
|
return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));
|
|
405
414
|
}
|
|
406
|
-
function
|
|
415
|
+
function matchingRows(limit = Number.POSITIVE_INFINITY) {
|
|
407
416
|
const shown = [];
|
|
408
417
|
for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
|
|
409
418
|
const row = state.rows[i];
|
|
@@ -411,6 +420,22 @@ function visibleRows(limit = FEED_LIMIT) {
|
|
|
411
420
|
}
|
|
412
421
|
return shown;
|
|
413
422
|
}
|
|
423
|
+
function feedPage(size) {
|
|
424
|
+
const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;
|
|
425
|
+
const pages = Math.max(1, Math.ceil(all.length / size));
|
|
426
|
+
const page = Math.min(Math.max(0, state.feedPage), pages - 1);
|
|
427
|
+
if (page !== state.feedPage) state.feedPage = page;
|
|
428
|
+
return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };
|
|
429
|
+
}
|
|
430
|
+
function goToFeedPage(page) {
|
|
431
|
+
const next = Math.max(0, page);
|
|
432
|
+
if (next === 0) {
|
|
433
|
+
state.feedFrozen = void 0;
|
|
434
|
+
} else if (state.feedFrozen === void 0) {
|
|
435
|
+
state.feedFrozen = matchingRows();
|
|
436
|
+
}
|
|
437
|
+
state.feedPage = next;
|
|
438
|
+
}
|
|
414
439
|
function matchingCount() {
|
|
415
440
|
let count = 0;
|
|
416
441
|
for (const row of state.rows) if (matches(row)) count++;
|
|
@@ -450,14 +475,13 @@ function aggregate(rows) {
|
|
|
450
475
|
}
|
|
451
476
|
return totals;
|
|
452
477
|
}
|
|
453
|
-
var MAX_ROWS,
|
|
478
|
+
var MAX_ROWS, state;
|
|
454
479
|
var init_store = __esm({
|
|
455
480
|
"src/dashboard/client/store.ts"() {
|
|
456
481
|
"use strict";
|
|
457
482
|
init_query();
|
|
458
483
|
init_outcome();
|
|
459
484
|
MAX_ROWS = 1e3;
|
|
460
|
-
FEED_LIMIT = 300;
|
|
461
485
|
state = {
|
|
462
486
|
rows: [],
|
|
463
487
|
byId: /* @__PURE__ */ new Map(),
|
|
@@ -479,7 +503,13 @@ var init_store = __esm({
|
|
|
479
503
|
editorMode: "gui",
|
|
480
504
|
guardDirty: false,
|
|
481
505
|
bufferedWhilePaused: 0,
|
|
482
|
-
laggedDrops: 0
|
|
506
|
+
laggedDrops: 0,
|
|
507
|
+
feedPage: 0,
|
|
508
|
+
feedFrozen: void 0,
|
|
509
|
+
actorsPage: 0,
|
|
510
|
+
feedPageSize: 50,
|
|
511
|
+
actorsPageSize: 25,
|
|
512
|
+
caughtUp: 0
|
|
483
513
|
};
|
|
484
514
|
}
|
|
485
515
|
});
|
|
@@ -534,7 +564,18 @@ function rangeLabel(milliseconds) {
|
|
|
534
564
|
return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;
|
|
535
565
|
}
|
|
536
566
|
function clockTime(at) {
|
|
537
|
-
|
|
567
|
+
const when = new Date(at);
|
|
568
|
+
return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;
|
|
569
|
+
}
|
|
570
|
+
function clockDate(at) {
|
|
571
|
+
const when = new Date(at);
|
|
572
|
+
return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;
|
|
573
|
+
}
|
|
574
|
+
function clockStamp(at) {
|
|
575
|
+
return `${clockDate(at)} ${clockTime(at)}`;
|
|
576
|
+
}
|
|
577
|
+
function pad(value) {
|
|
578
|
+
return String(value).padStart(2, "0");
|
|
538
579
|
}
|
|
539
580
|
function windowLabel(count, oldestAt2, now) {
|
|
540
581
|
if (count === 0) return "this window \xB7 empty";
|
|
@@ -549,6 +590,68 @@ var init_format = __esm({
|
|
|
549
590
|
}
|
|
550
591
|
});
|
|
551
592
|
|
|
593
|
+
// src/dashboard/client/pager.ts
|
|
594
|
+
function renderPager(host, model, options) {
|
|
595
|
+
const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);
|
|
596
|
+
if (painted.get(host) === signature && host.childElementCount > 0) return;
|
|
597
|
+
painted.set(host, signature);
|
|
598
|
+
clear(host);
|
|
599
|
+
const steps = [
|
|
600
|
+
{ to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },
|
|
601
|
+
{ to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }
|
|
602
|
+
];
|
|
603
|
+
const [previous, next] = steps;
|
|
604
|
+
host.appendChild(stepButton(previous, model));
|
|
605
|
+
const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;
|
|
606
|
+
const where = el("span", "where", range);
|
|
607
|
+
where.setAttribute("aria-live", "polite");
|
|
608
|
+
host.appendChild(where);
|
|
609
|
+
host.appendChild(stepButton(next, model));
|
|
610
|
+
if (model.held !== void 0) {
|
|
611
|
+
const held = el("span", "held", model.held);
|
|
612
|
+
held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";
|
|
613
|
+
host.appendChild(held);
|
|
614
|
+
}
|
|
615
|
+
if (options.withSize && model.size !== void 0) {
|
|
616
|
+
const size = model.size;
|
|
617
|
+
const label2 = el("label", "size");
|
|
618
|
+
label2.appendChild(document.createTextNode("Per page"));
|
|
619
|
+
const select2 = document.createElement("select");
|
|
620
|
+
for (const choice of size.choices) {
|
|
621
|
+
const option = document.createElement("option");
|
|
622
|
+
option.value = String(choice);
|
|
623
|
+
option.textContent = String(choice);
|
|
624
|
+
option.selected = choice === size.current;
|
|
625
|
+
select2.appendChild(option);
|
|
626
|
+
}
|
|
627
|
+
select2.addEventListener("change", () => {
|
|
628
|
+
const chosen = Number(select2.value);
|
|
629
|
+
if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);
|
|
630
|
+
});
|
|
631
|
+
label2.appendChild(select2);
|
|
632
|
+
host.appendChild(label2);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function stepButton(step, model) {
|
|
636
|
+
const button = el("button", "step", step.glyph);
|
|
637
|
+
const element = button;
|
|
638
|
+
element.type = "button";
|
|
639
|
+
element.disabled = step.disabled;
|
|
640
|
+
button.setAttribute("aria-label", step.label);
|
|
641
|
+
button.title = step.label;
|
|
642
|
+
button.addEventListener("click", () => model.go(Math.max(0, step.to)));
|
|
643
|
+
return button;
|
|
644
|
+
}
|
|
645
|
+
var painted;
|
|
646
|
+
var init_pager = __esm({
|
|
647
|
+
"src/dashboard/client/pager.ts"() {
|
|
648
|
+
"use strict";
|
|
649
|
+
init_dom();
|
|
650
|
+
init_format();
|
|
651
|
+
painted = /* @__PURE__ */ new WeakMap();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
|
|
552
655
|
// src/dashboard/client/replay.ts
|
|
553
656
|
function replayLine(entry) {
|
|
554
657
|
const headers = {};
|
|
@@ -739,7 +842,7 @@ function drawActor() {
|
|
|
739
842
|
["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],
|
|
740
843
|
["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],
|
|
741
844
|
["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],
|
|
742
|
-
["First seen", stats !== void 0 ?
|
|
845
|
+
["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],
|
|
743
846
|
["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]
|
|
744
847
|
];
|
|
745
848
|
for (const [key, value] of rows) {
|
|
@@ -1818,6 +1921,13 @@ var init_policy = __esm({
|
|
|
1818
1921
|
|
|
1819
1922
|
// src/dashboard/client/feed.ts
|
|
1820
1923
|
function initFeed() {
|
|
1924
|
+
const loadThem = byId("feed-load-skipped");
|
|
1925
|
+
loadThem.addEventListener("click", () => {
|
|
1926
|
+
loadThem.disabled = true;
|
|
1927
|
+
void loadSkipped().finally(() => {
|
|
1928
|
+
loadThem.disabled = false;
|
|
1929
|
+
});
|
|
1930
|
+
});
|
|
1821
1931
|
const filters = $("filters");
|
|
1822
1932
|
for (const [name, label2] of FILTERS) {
|
|
1823
1933
|
const button = el("button", null, label2);
|
|
@@ -1825,6 +1935,7 @@ function initFeed() {
|
|
|
1825
1935
|
button.dataset["filter"] = name;
|
|
1826
1936
|
button.addEventListener("click", () => {
|
|
1827
1937
|
state.filter = name;
|
|
1938
|
+
resetPaging();
|
|
1828
1939
|
for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");
|
|
1829
1940
|
button.setAttribute("aria-pressed", "true");
|
|
1830
1941
|
app.syncUrl();
|
|
@@ -1841,7 +1952,7 @@ function initFeed() {
|
|
|
1841
1952
|
const exportShown = byId("feed-export");
|
|
1842
1953
|
exportShown.hidden = !SECTIONS.evidence;
|
|
1843
1954
|
exportShown.addEventListener("click", () => {
|
|
1844
|
-
const rows =
|
|
1955
|
+
const rows = matchingRows();
|
|
1845
1956
|
if (rows.length === 0) {
|
|
1846
1957
|
toast("warn", "Nothing to export", "No request in the window matches this filter.");
|
|
1847
1958
|
return;
|
|
@@ -1859,9 +1970,65 @@ function reflectFilterButtons() {
|
|
|
1859
1970
|
const search = byId("search");
|
|
1860
1971
|
if (search.value !== state.search) search.value = state.search;
|
|
1861
1972
|
}
|
|
1973
|
+
async function loadSkipped() {
|
|
1974
|
+
const before = state.rows.length;
|
|
1975
|
+
try {
|
|
1976
|
+
const body = await getJson("/api/feed");
|
|
1977
|
+
for (const entry of body.entries) ingest(entry);
|
|
1978
|
+
sortRows();
|
|
1979
|
+
} catch {
|
|
1980
|
+
toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
|
|
1984
|
+
const added = state.rows.length - before;
|
|
1985
|
+
resetPaging();
|
|
1986
|
+
app.drawNow();
|
|
1987
|
+
toast(
|
|
1988
|
+
added > 0 ? "ok" : "warn",
|
|
1989
|
+
added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",
|
|
1990
|
+
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
|
+
);
|
|
1992
|
+
}
|
|
1993
|
+
function drawPager(paged) {
|
|
1994
|
+
const size = state.feedPageSize;
|
|
1995
|
+
const hidden = paged.pages <= 1;
|
|
1996
|
+
const model = {
|
|
1997
|
+
page: paged.page,
|
|
1998
|
+
from: paged.page * size + 1,
|
|
1999
|
+
to: Math.min(paged.total, (paged.page + 1) * size),
|
|
2000
|
+
total: paged.total,
|
|
2001
|
+
atStart: paged.page === 0,
|
|
2002
|
+
atEnd: paged.page >= paged.pages - 1,
|
|
2003
|
+
...paged.page > 0 ? { held: "held while you read" } : {},
|
|
2004
|
+
go: (page) => {
|
|
2005
|
+
goToFeedPage(page);
|
|
2006
|
+
app.drawNow();
|
|
2007
|
+
},
|
|
2008
|
+
size: {
|
|
2009
|
+
current: size,
|
|
2010
|
+
choices: FEED_PAGE_SIZES,
|
|
2011
|
+
set: (next) => {
|
|
2012
|
+
state.feedPageSize = next;
|
|
2013
|
+
resetPaging();
|
|
2014
|
+
app.drawNow();
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
};
|
|
2018
|
+
for (const [id, withSize] of [
|
|
2019
|
+
["feed-pager-top", true],
|
|
2020
|
+
["feed-pager", false]
|
|
2021
|
+
]) {
|
|
2022
|
+
const host = $(id);
|
|
2023
|
+
host.hidden = hidden;
|
|
2024
|
+
if (hidden) clear(host);
|
|
2025
|
+
else renderPager(host, model, { withSize });
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
1862
2028
|
function drawFeed() {
|
|
1863
2029
|
const body = byId("rows");
|
|
1864
|
-
const
|
|
2030
|
+
const paged = feedPage(state.feedPageSize);
|
|
2031
|
+
const shown = paged.rows;
|
|
1865
2032
|
let index = 0;
|
|
1866
2033
|
const place = (node) => {
|
|
1867
2034
|
const current = body.childNodes[index] ?? null;
|
|
@@ -1892,11 +2059,13 @@ function drawFeed() {
|
|
|
1892
2059
|
const total = state.rows.length;
|
|
1893
2060
|
const matching = matchingCount();
|
|
1894
2061
|
$("empty").hidden = total > 0;
|
|
1895
|
-
$("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}
|
|
1896
|
-
|
|
2062
|
+
$("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;
|
|
2063
|
+
drawPager(paged);
|
|
2064
|
+
const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);
|
|
1897
2065
|
const note = $("feed-skipped");
|
|
1898
2066
|
note.hidden = skipped === 0;
|
|
1899
2067
|
note.textContent = `${n(skipped)} not streamed`;
|
|
2068
|
+
byId("feed-load-skipped").hidden = skipped === 0;
|
|
1900
2069
|
note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";
|
|
1901
2070
|
}
|
|
1902
2071
|
function resetFeedCache() {
|
|
@@ -2103,12 +2272,14 @@ function showText(text) {
|
|
|
2103
2272
|
selection.addRange(range);
|
|
2104
2273
|
}
|
|
2105
2274
|
}
|
|
2106
|
-
var FILTERS, rendered;
|
|
2275
|
+
var FILTERS, rendered, FEED_PAGE_SIZES;
|
|
2107
2276
|
var init_feed = __esm({
|
|
2108
2277
|
"src/dashboard/client/feed.ts"() {
|
|
2109
2278
|
"use strict";
|
|
2110
2279
|
init_dom();
|
|
2111
2280
|
init_store();
|
|
2281
|
+
init_api();
|
|
2282
|
+
init_pager();
|
|
2112
2283
|
init_boot();
|
|
2113
2284
|
init_app();
|
|
2114
2285
|
init_format();
|
|
@@ -2127,6 +2298,7 @@ var init_feed = __esm({
|
|
|
2127
2298
|
["allow", "Served"]
|
|
2128
2299
|
];
|
|
2129
2300
|
rendered = /* @__PURE__ */ new Map();
|
|
2301
|
+
FEED_PAGE_SIZES = [25, 50, 100, 200];
|
|
2130
2302
|
}
|
|
2131
2303
|
});
|
|
2132
2304
|
|
|
@@ -2231,27 +2403,34 @@ function drawTiles(force = false) {
|
|
|
2231
2403
|
const total = metrics.requests;
|
|
2232
2404
|
const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;
|
|
2233
2405
|
const provenBotCount = provenBots(metrics.verdicts);
|
|
2406
|
+
const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;
|
|
2234
2407
|
const tiles = [
|
|
2235
|
-
["", n(total), "Requests", "since start"],
|
|
2236
|
-
["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic
|
|
2237
|
-
["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied
|
|
2238
|
-
["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic
|
|
2239
|
-
["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "
|
|
2240
|
-
["crit", n(denied), "Denied", `${pct(denied, total)} of traffic
|
|
2408
|
+
["", n(total), "Requests", "since start", void 0],
|
|
2409
|
+
["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],
|
|
2410
|
+
["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],
|
|
2411
|
+
["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],
|
|
2412
|
+
["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],
|
|
2413
|
+
["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],
|
|
2241
2414
|
[
|
|
2242
2415
|
"",
|
|
2243
2416
|
n(mitigated),
|
|
2244
2417
|
"Mitigated",
|
|
2245
|
-
metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged
|
|
2418
|
+
metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",
|
|
2419
|
+
void 0
|
|
2246
2420
|
],
|
|
2247
|
-
["good", n(served), "Served", `${pct(served, total)} of traffic
|
|
2248
|
-
["", n(metrics.actorsTracked), "Actors tracked", "in the registry now"]
|
|
2421
|
+
["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],
|
|
2422
|
+
["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]
|
|
2249
2423
|
];
|
|
2250
|
-
for (const [kind, value, key, sub] of tiles) {
|
|
2251
|
-
const tile = el("div", `tile ${kind}`);
|
|
2424
|
+
for (const [kind, value, key, sub, goes] of tiles) {
|
|
2425
|
+
const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);
|
|
2252
2426
|
tile.appendChild(el("div", "v tnum", value));
|
|
2253
2427
|
tile.appendChild(el("div", "k", key));
|
|
2254
2428
|
tile.appendChild(el("div", "s", sub));
|
|
2429
|
+
if (goes !== void 0) {
|
|
2430
|
+
tile.type = "button";
|
|
2431
|
+
tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));
|
|
2432
|
+
tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));
|
|
2433
|
+
}
|
|
2255
2434
|
box.appendChild(tile);
|
|
2256
2435
|
}
|
|
2257
2436
|
}
|
|
@@ -2513,6 +2692,7 @@ var init_panels = __esm({
|
|
|
2513
2692
|
init_bars();
|
|
2514
2693
|
init_format();
|
|
2515
2694
|
init_outcome();
|
|
2695
|
+
init_app();
|
|
2516
2696
|
}
|
|
2517
2697
|
});
|
|
2518
2698
|
|
|
@@ -2615,7 +2795,7 @@ function drawTraffic() {
|
|
|
2615
2795
|
svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));
|
|
2616
2796
|
const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });
|
|
2617
2797
|
const title = svgEl("title");
|
|
2618
|
-
title.textContent = `${
|
|
2798
|
+
title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;
|
|
2619
2799
|
dot.appendChild(title);
|
|
2620
2800
|
svg.appendChild(dot);
|
|
2621
2801
|
}
|
|
@@ -2634,7 +2814,7 @@ function drawTraffic() {
|
|
|
2634
2814
|
hover.setAttribute("x", String(index * step));
|
|
2635
2815
|
hover.setAttribute("width", String(step));
|
|
2636
2816
|
clear(tip);
|
|
2637
|
-
tip.appendChild(el("div", "t", `${
|
|
2817
|
+
tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));
|
|
2638
2818
|
tip.appendChild(tipRow("Served", n(bucket.served), s1));
|
|
2639
2819
|
tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));
|
|
2640
2820
|
tip.appendChild(tipRow("Denied", n(bucket.denied), crit));
|
|
@@ -2650,7 +2830,7 @@ function drawTraffic() {
|
|
|
2650
2830
|
const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);
|
|
2651
2831
|
const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);
|
|
2652
2832
|
const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });
|
|
2653
|
-
$("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${
|
|
2833
|
+
$("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${clockTime(busiest.at)}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);
|
|
2654
2834
|
const legend = $("traffic-legend");
|
|
2655
2835
|
clear(legend);
|
|
2656
2836
|
legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));
|
|
@@ -2853,7 +3033,7 @@ var init_charts = __esm({
|
|
|
2853
3033
|
async function loadActors() {
|
|
2854
3034
|
if (!SECTIONS.registry) return;
|
|
2855
3035
|
try {
|
|
2856
|
-
const body = await getJson(
|
|
3036
|
+
const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);
|
|
2857
3037
|
state.actors = body.actors;
|
|
2858
3038
|
state.actorsTracked = body.tracked;
|
|
2859
3039
|
drawActors();
|
|
@@ -2870,6 +3050,41 @@ function trackActors() {
|
|
|
2870
3050
|
void loadActors();
|
|
2871
3051
|
}, 4e3);
|
|
2872
3052
|
}
|
|
3053
|
+
function drawActorsPager(full) {
|
|
3054
|
+
const page = state.actorsPage;
|
|
3055
|
+
const hidden = page === 0 && !full;
|
|
3056
|
+
const from = page * state.actorsPageSize + 1;
|
|
3057
|
+
const model = {
|
|
3058
|
+
page,
|
|
3059
|
+
from,
|
|
3060
|
+
to: from + state.actors.length - 1,
|
|
3061
|
+
total: state.actorsTracked,
|
|
3062
|
+
atStart: page === 0,
|
|
3063
|
+
atEnd: !full,
|
|
3064
|
+
go: (next) => {
|
|
3065
|
+
state.actorsPage = Math.max(0, next);
|
|
3066
|
+
void loadActors();
|
|
3067
|
+
},
|
|
3068
|
+
size: {
|
|
3069
|
+
current: state.actorsPageSize,
|
|
3070
|
+
choices: ACTORS_PAGE_SIZES,
|
|
3071
|
+
set: (next) => {
|
|
3072
|
+
state.actorsPageSize = next;
|
|
3073
|
+
state.actorsPage = 0;
|
|
3074
|
+
void loadActors();
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
};
|
|
3078
|
+
for (const [id, withSize] of [
|
|
3079
|
+
["actors-pager-top", true],
|
|
3080
|
+
["actors-pager", false]
|
|
3081
|
+
]) {
|
|
3082
|
+
const host = $(id);
|
|
3083
|
+
host.hidden = hidden;
|
|
3084
|
+
if (hidden) clear(host);
|
|
3085
|
+
else renderPager(host, model, { withSize });
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
2873
3088
|
function drawActors() {
|
|
2874
3089
|
if (!SECTIONS.registry) return;
|
|
2875
3090
|
if (isConfirming()) return;
|
|
@@ -2877,6 +3092,7 @@ function drawActors() {
|
|
|
2877
3092
|
clear(body);
|
|
2878
3093
|
const actors = state.actors;
|
|
2879
3094
|
$("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
|
|
3095
|
+
drawActorsPager(actors.length === state.actorsPageSize);
|
|
2880
3096
|
byId("actors-empty").hidden = actors.length > 0;
|
|
2881
3097
|
for (const actor of actors) {
|
|
2882
3098
|
const row = el("tr");
|
|
@@ -2895,7 +3111,7 @@ function drawActors() {
|
|
|
2895
3111
|
const tags = [];
|
|
2896
3112
|
if (actor.cleared) tags.push("cleared as human");
|
|
2897
3113
|
if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);
|
|
2898
|
-
tags.push(`first seen ${
|
|
3114
|
+
tags.push(`first seen ${clockStamp(actor.firstSeen)}`);
|
|
2899
3115
|
stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));
|
|
2900
3116
|
row.appendChild(stateCell);
|
|
2901
3117
|
const actions = el("td", "acts");
|
|
@@ -2914,7 +3130,7 @@ function drawActors() {
|
|
|
2914
3130
|
body.appendChild(row);
|
|
2915
3131
|
}
|
|
2916
3132
|
}
|
|
2917
|
-
var timer;
|
|
3133
|
+
var timer, ACTORS_PAGE_SIZES;
|
|
2918
3134
|
var init_registry = __esm({
|
|
2919
3135
|
"src/dashboard/client/registry.ts"() {
|
|
2920
3136
|
"use strict";
|
|
@@ -2925,6 +3141,8 @@ var init_registry = __esm({
|
|
|
2925
3141
|
init_format();
|
|
2926
3142
|
init_api();
|
|
2927
3143
|
init_store();
|
|
3144
|
+
init_pager();
|
|
3145
|
+
ACTORS_PAGE_SIZES = [25, 50, 100, 200];
|
|
2928
3146
|
}
|
|
2929
3147
|
});
|
|
2930
3148
|
|
|
@@ -3326,9 +3544,26 @@ function start() {
|
|
|
3326
3544
|
setSearch(url.search);
|
|
3327
3545
|
if (SECTIONS.feed) reflectFilterButtons();
|
|
3328
3546
|
showTab(url.tab, { replace: true });
|
|
3329
|
-
|
|
3547
|
+
suspendScrollAnchoring();
|
|
3548
|
+
void loadInitialSnapshot().finally(settleScrollAnchoring);
|
|
3330
3549
|
connectStream();
|
|
3331
3550
|
}
|
|
3551
|
+
function htmlElement() {
|
|
3552
|
+
const root2 = rootNode();
|
|
3553
|
+
return root2 instanceof Document ? root2.documentElement : void 0;
|
|
3554
|
+
}
|
|
3555
|
+
function suspendScrollAnchoring() {
|
|
3556
|
+
htmlElement()?.classList.add("settling");
|
|
3557
|
+
}
|
|
3558
|
+
function settleScrollAnchoring() {
|
|
3559
|
+
const html = htmlElement();
|
|
3560
|
+
if (html === void 0) return;
|
|
3561
|
+
requestAnimationFrame(() => {
|
|
3562
|
+
requestAnimationFrame(() => {
|
|
3563
|
+
html.classList.remove("settling");
|
|
3564
|
+
});
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3332
3567
|
var TABS, available, FIRST, pending;
|
|
3333
3568
|
var init_client = __esm({
|
|
3334
3569
|
"src/dashboard/client/index.ts"() {
|
|
@@ -3533,6 +3768,8 @@ var DASHBOARD_CSS = String.raw`/* ----------------------------------------------
|
|
|
3533
3768
|
|
|
3534
3769
|
* { box-sizing: border-box; }
|
|
3535
3770
|
html, body { height: 100%; }
|
|
3771
|
+
/* Restored by the client once the first render has settled. See the note on .tiles. */
|
|
3772
|
+
html.settling { overflow-anchor: none; }
|
|
3536
3773
|
body {
|
|
3537
3774
|
margin: 0; background: var(--page); color: var(--ink);
|
|
3538
3775
|
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
@@ -3625,14 +3862,68 @@ button[disabled] { opacity: .5; cursor: default; }
|
|
|
3625
3862
|
/* --- layout ------------------------------------------------------------- */
|
|
3626
3863
|
main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
3627
3864
|
.stack { display: grid; gap: 16px; }
|
|
3628
|
-
|
|
3865
|
+
/* Everything above the feed is drawn by script once the first snapshot arrives, which
|
|
3866
|
+
inserts a block of content above what is already laid out. The browser's scroll
|
|
3867
|
+
anchoring compensates for that by scrolling down by its height — so on any window narrow
|
|
3868
|
+
enough for the counter row to wrap, the dashboard opened with its own counters already
|
|
3869
|
+
off the top of the screen, every time.
|
|
3870
|
+
|
|
3871
|
+
Anchoring is switched off for the first render and switched back on once it has settled
|
|
3872
|
+
(see settleScrollAnchoring in the client). It is not simply left off: the feed puts new requests at
|
|
3873
|
+
the top, and anchoring is exactly what keeps somebody's place while they read a screen
|
|
3874
|
+
that grows above them. */
|
|
3875
|
+
.tiles { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); }
|
|
3629
3876
|
.tile {
|
|
3630
|
-
background: var(--surface); border: 1px solid var(--line); border-radius:
|
|
3631
|
-
padding: 12px
|
|
3632
|
-
}
|
|
3633
|
-
.tile .v { font-size:
|
|
3634
|
-
.tile .k { font-size:
|
|
3635
|
-
|
|
3877
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
|
|
3878
|
+
padding: 8px 12px 9px; box-shadow: var(--shadow);
|
|
3879
|
+
}
|
|
3880
|
+
.tile .v { font-size: 21px; font-weight: 620; letter-spacing: -.025em; line-height: 1.15; }
|
|
3881
|
+
.tile .k { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 2px; font-weight: 560; }
|
|
3882
|
+
/* One line, always. These are grid items, so the tallest sets the height of all nine —
|
|
3883
|
+
two captions wrapping to a second line was costing every tile forty pixels of nothing.
|
|
3884
|
+
The full text stays available on hover rather than being cut from the page. */
|
|
3885
|
+
.tile .s { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3886
|
+
/* A tile that navigates is a real button, so it arrives carrying the browser's own
|
|
3887
|
+
font, centring and chrome. Reset to match its inert neighbours exactly, then given
|
|
3888
|
+
back the one thing a div must not have: something that says it can be pressed. */
|
|
3889
|
+
button.tile {
|
|
3890
|
+
font: inherit; color: inherit; text-align: left; width: 100%; display: block;
|
|
3891
|
+
cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
|
|
3892
|
+
}
|
|
3893
|
+
button.tile:hover { border-color: var(--focus); }
|
|
3894
|
+
/* The badge's companion: fetches the entries the stream skipped. Sits inline with the
|
|
3895
|
+
heading, so it is styled to read as part of the sentence rather than as a form control. */
|
|
3896
|
+
.load-skipped {
|
|
3897
|
+
font: inherit; margin-left: 6px; padding: 1px 8px; border-radius: 6px; cursor: pointer;
|
|
3898
|
+
border: 1px solid var(--warn-text); background: transparent; color: var(--warn-text);
|
|
3899
|
+
}
|
|
3900
|
+
.load-skipped:hover { background: color-mix(in srgb, var(--warn-text) 12%, transparent); }
|
|
3901
|
+
.load-skipped:disabled { opacity: .5; cursor: default; }
|
|
3902
|
+
|
|
3903
|
+
/* Prev/next under a table. Quiet: it is navigation for a list, not an action on it. */
|
|
3904
|
+
.pager {
|
|
3905
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
3906
|
+
padding: 9px 2px 2px; font-size: 12px; color: var(--muted);
|
|
3907
|
+
}
|
|
3908
|
+
.pager button {
|
|
3909
|
+
font: inherit; padding: 4px 11px; border-radius: 7px;
|
|
3910
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer;
|
|
3911
|
+
}
|
|
3912
|
+
.pager button:hover:not(:disabled) { border-color: var(--focus); }
|
|
3913
|
+
.pager button:disabled { opacity: .45; cursor: default; }
|
|
3914
|
+
.pager .where { font-variant-numeric: tabular-nums; }
|
|
3915
|
+
.pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
|
|
3916
|
+
/* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
|
|
3917
|
+
was thirty-six pixels of mostly empty rule above every screenful of requests. */
|
|
3918
|
+
.pager.pager-inline { padding: 0; margin-left: auto; gap: 8px; }
|
|
3919
|
+
.pager.pager-inline .size { margin-left: 0; }
|
|
3920
|
+
.pager .step { min-width: 30px; font-size: 15px; line-height: 1; padding: 3px 8px 5px; }
|
|
3921
|
+
.pager .size { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
|
3922
|
+
.pager .size select {
|
|
3923
|
+
font: inherit; padding: 3px 6px; border-radius: 6px;
|
|
3924
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
3925
|
+
}
|
|
3926
|
+
.pager .held { color: var(--warn-text); }
|
|
3636
3927
|
.tile.good .v { color: var(--good-text); }
|
|
3637
3928
|
.tile.warn .v { color: var(--warn-text); }
|
|
3638
3929
|
.tile.crit .v { color: var(--crit-text); }
|
|
@@ -3647,7 +3938,7 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
|
3647
3938
|
the page header, where they were always meant to. */
|
|
3648
3939
|
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: clip; }
|
|
3649
3940
|
.panel > h2 {
|
|
3650
|
-
margin: 0; padding:
|
|
3941
|
+
margin: 0; padding: 9px 14px; font-size: 11.5px; font-weight: 620; color: var(--muted);
|
|
3651
3942
|
text-transform: uppercase; letter-spacing: .055em; border-bottom: 1px solid var(--line);
|
|
3652
3943
|
display: flex; align-items: center; gap: 10px;
|
|
3653
3944
|
}
|
|
@@ -3659,6 +3950,17 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
|
3659
3950
|
Embedded in a 320px sidebar on a 1280px screen the media query never fired, the second
|
|
3660
3951
|
column held its 280px minimum, and the feed was squeezed to twenty-two pixels. */
|
|
3661
3952
|
.stack { container: dash / inline-size; }
|
|
3953
|
+
/* A grid item's min-width is auto, so a panel wrapping a wide table refuses to shrink and
|
|
3954
|
+
pushes the whole document sideways instead — which is what the Actors screen did under
|
|
3955
|
+
about 600px, where the table is widest and the viewport narrowest. The .two grid already
|
|
3956
|
+
says minmax(0, ...) for this reason; .stack needs the same permission. With it the panel
|
|
3957
|
+
shrinks and the scroller inside it does its job. */
|
|
3958
|
+
.stack > * { min-width: 0; }
|
|
3959
|
+
/* Numeric columns shrink to their contents, so the width goes to the two columns that
|
|
3960
|
+
carry text — the actor and what is known about it. Six counters of one or two digits
|
|
3961
|
+
were each taking about a hundred pixels while the State column was squeezed against the
|
|
3962
|
+
buttons. Same trick the feed's time column already uses. */
|
|
3963
|
+
#view-actors th.num, #view-actors td.num { width: 1%; white-space: nowrap; }
|
|
3662
3964
|
.two { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); align-items: start; }
|
|
3663
3965
|
.grid3 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
|
3664
3966
|
@container dash (max-width: 1080px) { .two { grid-template-columns: minmax(0, 1fr); } }
|
|
@@ -3738,7 +4040,7 @@ thead th {
|
|
|
3738
4040
|
text-align: left; font-size: 11px; font-weight: 600; color: var(--muted);
|
|
3739
4041
|
text-transform: uppercase; letter-spacing: .05em; padding: 9px 15px; border-bottom: 1px solid var(--line);
|
|
3740
4042
|
}
|
|
3741
|
-
tbody td { padding:
|
|
4043
|
+
tbody td { padding: 7px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
|
|
3742
4044
|
/* Narrow, quiet, and never the reason a row wraps: the time is for scanning down, not
|
|
3743
4045
|
for reading across. */
|
|
3744
4046
|
tbody td.when { color: var(--muted); font-size: 11.5px; white-space: nowrap; width: 1%; padding-right: 4px; }
|
|
@@ -4118,7 +4420,7 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4118
4420
|
|
|
4119
4421
|
<div class="two">
|
|
4120
4422
|
<section class="panel feed-panel">
|
|
4121
|
-
<h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span></h2>
|
|
4423
|
+
<h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span><button class="sub load-skipped" id="feed-load-skipped" type="button" hidden>Load them</button></h2>
|
|
4122
4424
|
<div class="toolbar">
|
|
4123
4425
|
<div class="filters" id="filters"></div>
|
|
4124
4426
|
<div class="search">
|
|
@@ -4126,6 +4428,7 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4126
4428
|
<kbd aria-hidden="true">/</kbd>
|
|
4127
4429
|
</div>
|
|
4128
4430
|
<button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
|
|
4431
|
+
<div class="pager pager-inline" id="feed-pager-top" hidden></div>
|
|
4129
4432
|
</div>
|
|
4130
4433
|
<div class="feed-scroll">
|
|
4131
4434
|
<table>
|
|
@@ -4137,6 +4440,7 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4137
4440
|
<tbody id="rows"></tbody>
|
|
4138
4441
|
</table>
|
|
4139
4442
|
</div>
|
|
4443
|
+
<div class="pager" id="feed-pager" hidden></div>
|
|
4140
4444
|
<div class="empty" id="empty">Nothing assessed yet. Send some traffic through the handler and it appears here within a moment.</div>
|
|
4141
4445
|
</section>
|
|
4142
4446
|
|
|
@@ -4185,6 +4489,7 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4185
4489
|
<div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
|
|
4186
4490
|
population than the feed's ring, which holds requests rather than clients. This is
|
|
4187
4491
|
what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
|
|
4492
|
+
<div class="pager pager-top" id="actors-pager-top" hidden></div>
|
|
4188
4493
|
<div class="feed-scroll">
|
|
4189
4494
|
<table>
|
|
4190
4495
|
<thead>
|
|
@@ -4196,6 +4501,7 @@ var DASHBOARD_MARKUP = String.raw`
|
|
|
4196
4501
|
<tbody id="actor-rows"></tbody>
|
|
4197
4502
|
</table>
|
|
4198
4503
|
</div>
|
|
4504
|
+
<div class="pager" id="actors-pager" hidden></div>
|
|
4199
4505
|
<div class="empty" id="actors-empty" hidden>Nothing in the registry yet.</div>
|
|
4200
4506
|
</section>
|
|
4201
4507
|
</div>
|