@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.
@@ -403,19 +403,28 @@ function clearFeed() {
403
403
  state.actor = void 0;
404
404
  state.bufferedWhilePaused = 0;
405
405
  state.laggedDrops = 0;
406
+ resetPaging();
406
407
  }
407
408
  function setSearch(value) {
408
409
  state.search = value;
409
410
  state.terms = parseQuery(value);
411
+ resetPaging();
412
+ }
413
+ function resetPaging() {
414
+ state.feedPage = 0;
415
+ state.feedFrozen = void 0;
410
416
  }
411
417
  function textOf(row) {
412
418
  if (row.text === void 0) row.text = searchableText(row.entry);
413
419
  return row.text;
414
420
  }
421
+ function sortRows() {
422
+ state.rows.sort((a, b) => a.entry.at - b.entry.at);
423
+ }
415
424
  function matches(row) {
416
425
  return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));
417
426
  }
418
- function visibleRows(limit = FEED_LIMIT) {
427
+ function matchingRows(limit = Number.POSITIVE_INFINITY) {
419
428
  const shown = [];
420
429
  for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
421
430
  const row = state.rows[i];
@@ -423,6 +432,22 @@ function visibleRows(limit = FEED_LIMIT) {
423
432
  }
424
433
  return shown;
425
434
  }
435
+ function feedPage(size) {
436
+ const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;
437
+ const pages = Math.max(1, Math.ceil(all.length / size));
438
+ const page = Math.min(Math.max(0, state.feedPage), pages - 1);
439
+ if (page !== state.feedPage) state.feedPage = page;
440
+ return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };
441
+ }
442
+ function goToFeedPage(page) {
443
+ const next = Math.max(0, page);
444
+ if (next === 0) {
445
+ state.feedFrozen = void 0;
446
+ } else if (state.feedFrozen === void 0) {
447
+ state.feedFrozen = matchingRows();
448
+ }
449
+ state.feedPage = next;
450
+ }
426
451
  function matchingCount() {
427
452
  let count = 0;
428
453
  for (const row of state.rows) if (matches(row)) count++;
@@ -462,14 +487,13 @@ function aggregate(rows) {
462
487
  }
463
488
  return totals;
464
489
  }
465
- var MAX_ROWS, FEED_LIMIT, state;
490
+ var MAX_ROWS, state;
466
491
  var init_store = __esm({
467
492
  "src/dashboard/client/store.ts"() {
468
493
  "use strict";
469
494
  init_query();
470
495
  init_outcome();
471
496
  MAX_ROWS = 1e3;
472
- FEED_LIMIT = 300;
473
497
  state = {
474
498
  rows: [],
475
499
  byId: /* @__PURE__ */ new Map(),
@@ -491,7 +515,13 @@ var init_store = __esm({
491
515
  editorMode: "gui",
492
516
  guardDirty: false,
493
517
  bufferedWhilePaused: 0,
494
- laggedDrops: 0
518
+ laggedDrops: 0,
519
+ feedPage: 0,
520
+ feedFrozen: void 0,
521
+ actorsPage: 0,
522
+ feedPageSize: 50,
523
+ actorsPageSize: 25,
524
+ caughtUp: 0
495
525
  };
496
526
  }
497
527
  });
@@ -546,7 +576,18 @@ function rangeLabel(milliseconds) {
546
576
  return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;
547
577
  }
548
578
  function clockTime(at) {
549
- return new Date(at).toLocaleTimeString();
579
+ const when = new Date(at);
580
+ return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;
581
+ }
582
+ function clockDate(at) {
583
+ const when = new Date(at);
584
+ return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;
585
+ }
586
+ function clockStamp(at) {
587
+ return `${clockDate(at)} ${clockTime(at)}`;
588
+ }
589
+ function pad(value) {
590
+ return String(value).padStart(2, "0");
550
591
  }
551
592
  function windowLabel(count, oldestAt2, now) {
552
593
  if (count === 0) return "this window \xB7 empty";
@@ -561,6 +602,68 @@ var init_format = __esm({
561
602
  }
562
603
  });
563
604
 
605
+ // src/dashboard/client/pager.ts
606
+ function renderPager(host, model, options) {
607
+ const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);
608
+ if (painted.get(host) === signature && host.childElementCount > 0) return;
609
+ painted.set(host, signature);
610
+ clear(host);
611
+ const steps = [
612
+ { to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },
613
+ { to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }
614
+ ];
615
+ const [previous, next] = steps;
616
+ host.appendChild(stepButton(previous, model));
617
+ const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;
618
+ const where = el("span", "where", range);
619
+ where.setAttribute("aria-live", "polite");
620
+ host.appendChild(where);
621
+ host.appendChild(stepButton(next, model));
622
+ if (model.held !== void 0) {
623
+ const held = el("span", "held", model.held);
624
+ held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";
625
+ host.appendChild(held);
626
+ }
627
+ if (options.withSize && model.size !== void 0) {
628
+ const size = model.size;
629
+ const label2 = el("label", "size");
630
+ label2.appendChild(document.createTextNode("Per page"));
631
+ const select2 = document.createElement("select");
632
+ for (const choice of size.choices) {
633
+ const option = document.createElement("option");
634
+ option.value = String(choice);
635
+ option.textContent = String(choice);
636
+ option.selected = choice === size.current;
637
+ select2.appendChild(option);
638
+ }
639
+ select2.addEventListener("change", () => {
640
+ const chosen = Number(select2.value);
641
+ if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);
642
+ });
643
+ label2.appendChild(select2);
644
+ host.appendChild(label2);
645
+ }
646
+ }
647
+ function stepButton(step, model) {
648
+ const button = el("button", "step", step.glyph);
649
+ const element = button;
650
+ element.type = "button";
651
+ element.disabled = step.disabled;
652
+ button.setAttribute("aria-label", step.label);
653
+ button.title = step.label;
654
+ button.addEventListener("click", () => model.go(Math.max(0, step.to)));
655
+ return button;
656
+ }
657
+ var painted;
658
+ var init_pager = __esm({
659
+ "src/dashboard/client/pager.ts"() {
660
+ "use strict";
661
+ init_dom();
662
+ init_format();
663
+ painted = /* @__PURE__ */ new WeakMap();
664
+ }
665
+ });
666
+
564
667
  // src/dashboard/client/replay.ts
565
668
  function replayLine(entry) {
566
669
  const headers = {};
@@ -751,7 +854,7 @@ function drawActor() {
751
854
  ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],
752
855
  ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],
753
856
  ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],
754
- ["First seen", stats !== void 0 ? clockTime(stats.firstSeen) : "\u2014"],
857
+ ["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],
755
858
  ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]
756
859
  ];
757
860
  for (const [key, value] of rows) {
@@ -1830,6 +1933,13 @@ var init_policy = __esm({
1830
1933
 
1831
1934
  // src/dashboard/client/feed.ts
1832
1935
  function initFeed() {
1936
+ const loadThem = byId("feed-load-skipped");
1937
+ loadThem.addEventListener("click", () => {
1938
+ loadThem.disabled = true;
1939
+ void loadSkipped().finally(() => {
1940
+ loadThem.disabled = false;
1941
+ });
1942
+ });
1833
1943
  const filters = $("filters");
1834
1944
  for (const [name, label2] of FILTERS) {
1835
1945
  const button = el("button", null, label2);
@@ -1837,6 +1947,7 @@ function initFeed() {
1837
1947
  button.dataset["filter"] = name;
1838
1948
  button.addEventListener("click", () => {
1839
1949
  state.filter = name;
1950
+ resetPaging();
1840
1951
  for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");
1841
1952
  button.setAttribute("aria-pressed", "true");
1842
1953
  app.syncUrl();
@@ -1853,7 +1964,7 @@ function initFeed() {
1853
1964
  const exportShown = byId("feed-export");
1854
1965
  exportShown.hidden = !SECTIONS.evidence;
1855
1966
  exportShown.addEventListener("click", () => {
1856
- const rows = visibleRows(Number.POSITIVE_INFINITY);
1967
+ const rows = matchingRows();
1857
1968
  if (rows.length === 0) {
1858
1969
  toast("warn", "Nothing to export", "No request in the window matches this filter.");
1859
1970
  return;
@@ -1871,9 +1982,65 @@ function reflectFilterButtons() {
1871
1982
  const search = byId("search");
1872
1983
  if (search.value !== state.search) search.value = state.search;
1873
1984
  }
1985
+ async function loadSkipped() {
1986
+ const before = state.rows.length;
1987
+ try {
1988
+ const body = await getJson("/api/feed");
1989
+ for (const entry of body.entries) ingest(entry);
1990
+ sortRows();
1991
+ } catch {
1992
+ toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
1993
+ return;
1994
+ }
1995
+ state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
1996
+ const added = state.rows.length - before;
1997
+ resetPaging();
1998
+ app.drawNow();
1999
+ toast(
2000
+ added > 0 ? "ok" : "warn",
2001
+ added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",
2002
+ added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."
2003
+ );
2004
+ }
2005
+ function drawPager(paged) {
2006
+ const size = state.feedPageSize;
2007
+ const hidden = paged.pages <= 1;
2008
+ const model = {
2009
+ page: paged.page,
2010
+ from: paged.page * size + 1,
2011
+ to: Math.min(paged.total, (paged.page + 1) * size),
2012
+ total: paged.total,
2013
+ atStart: paged.page === 0,
2014
+ atEnd: paged.page >= paged.pages - 1,
2015
+ ...paged.page > 0 ? { held: "held while you read" } : {},
2016
+ go: (page) => {
2017
+ goToFeedPage(page);
2018
+ app.drawNow();
2019
+ },
2020
+ size: {
2021
+ current: size,
2022
+ choices: FEED_PAGE_SIZES,
2023
+ set: (next) => {
2024
+ state.feedPageSize = next;
2025
+ resetPaging();
2026
+ app.drawNow();
2027
+ }
2028
+ }
2029
+ };
2030
+ for (const [id, withSize] of [
2031
+ ["feed-pager-top", true],
2032
+ ["feed-pager", false]
2033
+ ]) {
2034
+ const host = $(id);
2035
+ host.hidden = hidden;
2036
+ if (hidden) clear(host);
2037
+ else renderPager(host, model, { withSize });
2038
+ }
2039
+ }
1874
2040
  function drawFeed() {
1875
2041
  const body = byId("rows");
1876
- const shown = visibleRows();
2042
+ const paged = feedPage(state.feedPageSize);
2043
+ const shown = paged.rows;
1877
2044
  let index = 0;
1878
2045
  const place = (node) => {
1879
2046
  const current = body.childNodes[index] ?? null;
@@ -1904,11 +2071,13 @@ function drawFeed() {
1904
2071
  const total = state.rows.length;
1905
2072
  const matching = matchingCount();
1906
2073
  $("empty").hidden = total > 0;
1907
- $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}${matching > FEED_LIMIT ? ` \xB7 showing ${n(FEED_LIMIT)}` : ""}`;
1908
- const skipped = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
2074
+ $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;
2075
+ drawPager(paged);
2076
+ const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);
1909
2077
  const note = $("feed-skipped");
1910
2078
  note.hidden = skipped === 0;
1911
2079
  note.textContent = `${n(skipped)} not streamed`;
2080
+ byId("feed-load-skipped").hidden = skipped === 0;
1912
2081
  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.";
1913
2082
  }
1914
2083
  function resetFeedCache() {
@@ -2115,12 +2284,14 @@ function showText(text) {
2115
2284
  selection.addRange(range);
2116
2285
  }
2117
2286
  }
2118
- var FILTERS, rendered;
2287
+ var FILTERS, rendered, FEED_PAGE_SIZES;
2119
2288
  var init_feed = __esm({
2120
2289
  "src/dashboard/client/feed.ts"() {
2121
2290
  "use strict";
2122
2291
  init_dom();
2123
2292
  init_store();
2293
+ init_api();
2294
+ init_pager();
2124
2295
  init_boot();
2125
2296
  init_app();
2126
2297
  init_format();
@@ -2139,6 +2310,7 @@ var init_feed = __esm({
2139
2310
  ["allow", "Served"]
2140
2311
  ];
2141
2312
  rendered = /* @__PURE__ */ new Map();
2313
+ FEED_PAGE_SIZES = [25, 50, 100, 200];
2142
2314
  }
2143
2315
  });
2144
2316
 
@@ -2243,27 +2415,34 @@ function drawTiles(force = false) {
2243
2415
  const total = metrics.requests;
2244
2416
  const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;
2245
2417
  const provenBotCount = provenBots(metrics.verdicts);
2418
+ const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;
2246
2419
  const tiles = [
2247
- ["", n(total), "Requests", "since start"],
2248
- ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`],
2249
- ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied on this alone"],
2250
- ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`],
2251
- ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "rules asking for more than the evidence" : "no rule overreached"],
2252
- ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`],
2420
+ ["", n(total), "Requests", "since start", void 0],
2421
+ ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],
2422
+ ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],
2423
+ ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],
2424
+ ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],
2425
+ ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],
2253
2426
  [
2254
2427
  "",
2255
2428
  n(mitigated),
2256
2429
  "Mitigated",
2257
- metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged, limited or delayed"
2430
+ metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",
2431
+ void 0
2258
2432
  ],
2259
- ["good", n(served), "Served", `${pct(served, total)} of traffic`],
2260
- ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now"]
2433
+ ["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],
2434
+ ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]
2261
2435
  ];
2262
- for (const [kind, value, key, sub] of tiles) {
2263
- const tile = el("div", `tile ${kind}`);
2436
+ for (const [kind, value, key, sub, goes] of tiles) {
2437
+ const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);
2264
2438
  tile.appendChild(el("div", "v tnum", value));
2265
2439
  tile.appendChild(el("div", "k", key));
2266
2440
  tile.appendChild(el("div", "s", sub));
2441
+ if (goes !== void 0) {
2442
+ tile.type = "button";
2443
+ tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));
2444
+ tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));
2445
+ }
2267
2446
  box.appendChild(tile);
2268
2447
  }
2269
2448
  }
@@ -2525,6 +2704,7 @@ var init_panels = __esm({
2525
2704
  init_bars();
2526
2705
  init_format();
2527
2706
  init_outcome();
2707
+ init_app();
2528
2708
  }
2529
2709
  });
2530
2710
 
@@ -2627,7 +2807,7 @@ function drawTraffic() {
2627
2807
  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 }));
2628
2808
  const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });
2629
2809
  const title = svgEl("title");
2630
- title.textContent = `${new Date(change.at).toLocaleTimeString()} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;
2810
+ title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;
2631
2811
  dot.appendChild(title);
2632
2812
  svg.appendChild(dot);
2633
2813
  }
@@ -2646,7 +2826,7 @@ function drawTraffic() {
2646
2826
  hover.setAttribute("x", String(index * step));
2647
2827
  hover.setAttribute("width", String(step));
2648
2828
  clear(tip);
2649
- tip.appendChild(el("div", "t", `${new Date(bucket.at).toLocaleTimeString()} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));
2829
+ tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));
2650
2830
  tip.appendChild(tipRow("Served", n(bucket.served), s1));
2651
2831
  tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));
2652
2832
  tip.appendChild(tipRow("Denied", n(bucket.denied), crit));
@@ -2662,7 +2842,7 @@ function drawTraffic() {
2662
2842
  const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);
2663
2843
  const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);
2664
2844
  const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });
2665
- $("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("; ")}.`);
2845
+ $("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("; ")}.`);
2666
2846
  const legend = $("traffic-legend");
2667
2847
  clear(legend);
2668
2848
  legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));
@@ -2865,7 +3045,7 @@ var init_charts = __esm({
2865
3045
  async function loadActors() {
2866
3046
  if (!SECTIONS.registry) return;
2867
3047
  try {
2868
- const body = await getJson("/api/actors?limit=100");
3048
+ const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);
2869
3049
  state.actors = body.actors;
2870
3050
  state.actorsTracked = body.tracked;
2871
3051
  drawActors();
@@ -2882,6 +3062,41 @@ function trackActors() {
2882
3062
  void loadActors();
2883
3063
  }, 4e3);
2884
3064
  }
3065
+ function drawActorsPager(full) {
3066
+ const page = state.actorsPage;
3067
+ const hidden = page === 0 && !full;
3068
+ const from = page * state.actorsPageSize + 1;
3069
+ const model = {
3070
+ page,
3071
+ from,
3072
+ to: from + state.actors.length - 1,
3073
+ total: state.actorsTracked,
3074
+ atStart: page === 0,
3075
+ atEnd: !full,
3076
+ go: (next) => {
3077
+ state.actorsPage = Math.max(0, next);
3078
+ void loadActors();
3079
+ },
3080
+ size: {
3081
+ current: state.actorsPageSize,
3082
+ choices: ACTORS_PAGE_SIZES,
3083
+ set: (next) => {
3084
+ state.actorsPageSize = next;
3085
+ state.actorsPage = 0;
3086
+ void loadActors();
3087
+ }
3088
+ }
3089
+ };
3090
+ for (const [id, withSize] of [
3091
+ ["actors-pager-top", true],
3092
+ ["actors-pager", false]
3093
+ ]) {
3094
+ const host = $(id);
3095
+ host.hidden = hidden;
3096
+ if (hidden) clear(host);
3097
+ else renderPager(host, model, { withSize });
3098
+ }
3099
+ }
2885
3100
  function drawActors() {
2886
3101
  if (!SECTIONS.registry) return;
2887
3102
  if (isConfirming()) return;
@@ -2889,6 +3104,7 @@ function drawActors() {
2889
3104
  clear(body);
2890
3105
  const actors = state.actors;
2891
3106
  $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
3107
+ drawActorsPager(actors.length === state.actorsPageSize);
2892
3108
  byId("actors-empty").hidden = actors.length > 0;
2893
3109
  for (const actor of actors) {
2894
3110
  const row = el("tr");
@@ -2907,7 +3123,7 @@ function drawActors() {
2907
3123
  const tags = [];
2908
3124
  if (actor.cleared) tags.push("cleared as human");
2909
3125
  if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);
2910
- tags.push(`first seen ${clockTime(actor.firstSeen)}`);
3126
+ tags.push(`first seen ${clockStamp(actor.firstSeen)}`);
2911
3127
  stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));
2912
3128
  row.appendChild(stateCell);
2913
3129
  const actions = el("td", "acts");
@@ -2926,7 +3142,7 @@ function drawActors() {
2926
3142
  body.appendChild(row);
2927
3143
  }
2928
3144
  }
2929
- var timer;
3145
+ var timer, ACTORS_PAGE_SIZES;
2930
3146
  var init_registry = __esm({
2931
3147
  "src/dashboard/client/registry.ts"() {
2932
3148
  "use strict";
@@ -2937,6 +3153,8 @@ var init_registry = __esm({
2937
3153
  init_format();
2938
3154
  init_api();
2939
3155
  init_store();
3156
+ init_pager();
3157
+ ACTORS_PAGE_SIZES = [25, 50, 100, 200];
2940
3158
  }
2941
3159
  });
2942
3160
 
@@ -3338,9 +3556,26 @@ function start() {
3338
3556
  setSearch(url.search);
3339
3557
  if (SECTIONS.feed) reflectFilterButtons();
3340
3558
  showTab(url.tab, { replace: true });
3341
- void loadInitialSnapshot();
3559
+ suspendScrollAnchoring();
3560
+ void loadInitialSnapshot().finally(settleScrollAnchoring);
3342
3561
  connectStream();
3343
3562
  }
3563
+ function htmlElement() {
3564
+ const root2 = rootNode();
3565
+ return root2 instanceof Document ? root2.documentElement : void 0;
3566
+ }
3567
+ function suspendScrollAnchoring() {
3568
+ htmlElement()?.classList.add("settling");
3569
+ }
3570
+ function settleScrollAnchoring() {
3571
+ const html = htmlElement();
3572
+ if (html === void 0) return;
3573
+ requestAnimationFrame(() => {
3574
+ requestAnimationFrame(() => {
3575
+ html.classList.remove("settling");
3576
+ });
3577
+ });
3578
+ }
3344
3579
  var TABS, available, FIRST, pending;
3345
3580
  var init_client = __esm({
3346
3581
  "src/dashboard/client/index.ts"() {
@@ -3551,6 +3786,8 @@ var DASHBOARD_CSS = String.raw`/* ----------------------------------------------
3551
3786
 
3552
3787
  * { box-sizing: border-box; }
3553
3788
  html, body { height: 100%; }
3789
+ /* Restored by the client once the first render has settled. See the note on .tiles. */
3790
+ html.settling { overflow-anchor: none; }
3554
3791
  body {
3555
3792
  margin: 0; background: var(--page); color: var(--ink);
3556
3793
  font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -3643,14 +3880,68 @@ button[disabled] { opacity: .5; cursor: default; }
3643
3880
  /* --- layout ------------------------------------------------------------- */
3644
3881
  main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
3645
3882
  .stack { display: grid; gap: 16px; }
3646
- .tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); }
3883
+ /* Everything above the feed is drawn by script once the first snapshot arrives, which
3884
+ inserts a block of content above what is already laid out. The browser's scroll
3885
+ anchoring compensates for that by scrolling down by its height — so on any window narrow
3886
+ enough for the counter row to wrap, the dashboard opened with its own counters already
3887
+ off the top of the screen, every time.
3888
+
3889
+ Anchoring is switched off for the first render and switched back on once it has settled
3890
+ (see settleScrollAnchoring in the client). It is not simply left off: the feed puts new requests at
3891
+ the top, and anchoring is exactly what keeps somebody's place while they read a screen
3892
+ that grows above them. */
3893
+ .tiles { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); }
3647
3894
  .tile {
3648
- background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
3649
- padding: 12px 14px 13px; box-shadow: var(--shadow);
3650
- }
3651
- .tile .v { font-size: 26px; font-weight: 620; letter-spacing: -.025em; line-height: 1.1; }
3652
- .tile .k { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 4px; font-weight: 560; }
3653
- .tile .s { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
3895
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
3896
+ padding: 8px 12px 9px; box-shadow: var(--shadow);
3897
+ }
3898
+ .tile .v { font-size: 21px; font-weight: 620; letter-spacing: -.025em; line-height: 1.15; }
3899
+ .tile .k { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 2px; font-weight: 560; }
3900
+ /* One line, always. These are grid items, so the tallest sets the height of all nine —
3901
+ two captions wrapping to a second line was costing every tile forty pixels of nothing.
3902
+ The full text stays available on hover rather than being cut from the page. */
3903
+ .tile .s { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
3904
+ /* A tile that navigates is a real button, so it arrives carrying the browser's own
3905
+ font, centring and chrome. Reset to match its inert neighbours exactly, then given
3906
+ back the one thing a div must not have: something that says it can be pressed. */
3907
+ button.tile {
3908
+ font: inherit; color: inherit; text-align: left; width: 100%; display: block;
3909
+ cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
3910
+ }
3911
+ button.tile:hover { border-color: var(--focus); }
3912
+ /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
3913
+ heading, so it is styled to read as part of the sentence rather than as a form control. */
3914
+ .load-skipped {
3915
+ font: inherit; margin-left: 6px; padding: 1px 8px; border-radius: 6px; cursor: pointer;
3916
+ border: 1px solid var(--warn-text); background: transparent; color: var(--warn-text);
3917
+ }
3918
+ .load-skipped:hover { background: color-mix(in srgb, var(--warn-text) 12%, transparent); }
3919
+ .load-skipped:disabled { opacity: .5; cursor: default; }
3920
+
3921
+ /* Prev/next under a table. Quiet: it is navigation for a list, not an action on it. */
3922
+ .pager {
3923
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
3924
+ padding: 9px 2px 2px; font-size: 12px; color: var(--muted);
3925
+ }
3926
+ .pager button {
3927
+ font: inherit; padding: 4px 11px; border-radius: 7px;
3928
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer;
3929
+ }
3930
+ .pager button:hover:not(:disabled) { border-color: var(--focus); }
3931
+ .pager button:disabled { opacity: .45; cursor: default; }
3932
+ .pager .where { font-variant-numeric: tabular-nums; }
3933
+ .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
3934
+ /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
3935
+ was thirty-six pixels of mostly empty rule above every screenful of requests. */
3936
+ .pager.pager-inline { padding: 0; margin-left: auto; gap: 8px; }
3937
+ .pager.pager-inline .size { margin-left: 0; }
3938
+ .pager .step { min-width: 30px; font-size: 15px; line-height: 1; padding: 3px 8px 5px; }
3939
+ .pager .size { display: flex; align-items: center; gap: 6px; margin-left: auto; }
3940
+ .pager .size select {
3941
+ font: inherit; padding: 3px 6px; border-radius: 6px;
3942
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
3943
+ }
3944
+ .pager .held { color: var(--warn-text); }
3654
3945
  .tile.good .v { color: var(--good-text); }
3655
3946
  .tile.warn .v { color: var(--warn-text); }
3656
3947
  .tile.crit .v { color: var(--crit-text); }
@@ -3665,7 +3956,7 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
3665
3956
  the page header, where they were always meant to. */
3666
3957
  .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: clip; }
3667
3958
  .panel > h2 {
3668
- margin: 0; padding: 12px 15px; font-size: 12px; font-weight: 620; color: var(--muted);
3959
+ margin: 0; padding: 9px 14px; font-size: 11.5px; font-weight: 620; color: var(--muted);
3669
3960
  text-transform: uppercase; letter-spacing: .055em; border-bottom: 1px solid var(--line);
3670
3961
  display: flex; align-items: center; gap: 10px;
3671
3962
  }
@@ -3677,6 +3968,17 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
3677
3968
  Embedded in a 320px sidebar on a 1280px screen the media query never fired, the second
3678
3969
  column held its 280px minimum, and the feed was squeezed to twenty-two pixels. */
3679
3970
  .stack { container: dash / inline-size; }
3971
+ /* A grid item's min-width is auto, so a panel wrapping a wide table refuses to shrink and
3972
+ pushes the whole document sideways instead — which is what the Actors screen did under
3973
+ about 600px, where the table is widest and the viewport narrowest. The .two grid already
3974
+ says minmax(0, ...) for this reason; .stack needs the same permission. With it the panel
3975
+ shrinks and the scroller inside it does its job. */
3976
+ .stack > * { min-width: 0; }
3977
+ /* Numeric columns shrink to their contents, so the width goes to the two columns that
3978
+ carry text — the actor and what is known about it. Six counters of one or two digits
3979
+ were each taking about a hundred pixels while the State column was squeezed against the
3980
+ buttons. Same trick the feed's time column already uses. */
3981
+ #view-actors th.num, #view-actors td.num { width: 1%; white-space: nowrap; }
3680
3982
  .two { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); align-items: start; }
3681
3983
  .grid3 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
3682
3984
  @container dash (max-width: 1080px) { .two { grid-template-columns: minmax(0, 1fr); } }
@@ -3756,7 +4058,7 @@ thead th {
3756
4058
  text-align: left; font-size: 11px; font-weight: 600; color: var(--muted);
3757
4059
  text-transform: uppercase; letter-spacing: .05em; padding: 9px 15px; border-bottom: 1px solid var(--line);
3758
4060
  }
3759
- tbody td { padding: 9px 15px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
4061
+ tbody td { padding: 7px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
3760
4062
  /* Narrow, quiet, and never the reason a row wraps: the time is for scanning down, not
3761
4063
  for reading across. */
3762
4064
  tbody td.when { color: var(--muted); font-size: 11.5px; white-space: nowrap; width: 1%; padding-right: 4px; }
@@ -4136,7 +4438,7 @@ var DASHBOARD_MARKUP = String.raw`
4136
4438
 
4137
4439
  <div class="two">
4138
4440
  <section class="panel feed-panel">
4139
- <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>
4441
+ <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>
4140
4442
  <div class="toolbar">
4141
4443
  <div class="filters" id="filters"></div>
4142
4444
  <div class="search">
@@ -4144,6 +4446,7 @@ var DASHBOARD_MARKUP = String.raw`
4144
4446
  <kbd aria-hidden="true">/</kbd>
4145
4447
  </div>
4146
4448
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
4449
+ <div class="pager pager-inline" id="feed-pager-top" hidden></div>
4147
4450
  </div>
4148
4451
  <div class="feed-scroll">
4149
4452
  <table>
@@ -4155,6 +4458,7 @@ var DASHBOARD_MARKUP = String.raw`
4155
4458
  <tbody id="rows"></tbody>
4156
4459
  </table>
4157
4460
  </div>
4461
+ <div class="pager" id="feed-pager" hidden></div>
4158
4462
  <div class="empty" id="empty">Nothing assessed yet. Send some traffic through the handler and it appears here within a moment.</div>
4159
4463
  </section>
4160
4464
 
@@ -4203,6 +4507,7 @@ var DASHBOARD_MARKUP = String.raw`
4203
4507
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
4204
4508
  population than the feed's ring, which holds requests rather than clients. This is
4205
4509
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
4510
+ <div class="pager pager-top" id="actors-pager-top" hidden></div>
4206
4511
  <div class="feed-scroll">
4207
4512
  <table>
4208
4513
  <thead>
@@ -4214,6 +4519,7 @@ var DASHBOARD_MARKUP = String.raw`
4214
4519
  <tbody id="actor-rows"></tbody>
4215
4520
  </table>
4216
4521
  </div>
4522
+ <div class="pager" id="actors-pager" hidden></div>
4217
4523
  <div class="empty" id="actors-empty" hidden>Nothing in the registry yet.</div>
4218
4524
  </section>
4219
4525
  </div>