@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.
@@ -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 visibleRows(limit = FEED_LIMIT) {
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, FEED_LIMIT, state;
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
- return new Date(at).toLocaleTimeString();
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 ? clockTime(stats.firstSeen) : "\u2014"],
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 = visibleRows(Number.POSITIVE_INFINITY);
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 shown = visibleRows();
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)}${matching > FEED_LIMIT ? ` \xB7 showing ${n(FEED_LIMIT)}` : ""}`;
1896
- const skipped = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
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 on this alone"],
2238
- ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`],
2239
- ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "rules asking for more than the evidence" : "no rule overreached"],
2240
- ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`],
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, limited or delayed"
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 = `${new Date(change.at).toLocaleTimeString()} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;
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", `${new Date(bucket.at).toLocaleTimeString()} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));
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 ${new Date(busiest.at).toLocaleTimeString()}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);
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("/api/actors?limit=100");
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 ${clockTime(actor.firstSeen)}`);
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
- void loadInitialSnapshot();
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
- .tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); }
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: 12px;
3631
- padding: 12px 14px 13px; box-shadow: var(--shadow);
3632
- }
3633
- .tile .v { font-size: 26px; font-weight: 620; letter-spacing: -.025em; line-height: 1.1; }
3634
- .tile .k { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 4px; font-weight: 560; }
3635
- .tile .s { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
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: 12px 15px; font-size: 12px; font-weight: 620; color: var(--muted);
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: 9px 15px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
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>