@shield-agent/kya 0.16.0 → 0.19.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.
Files changed (64) hide show
  1. package/.mcp.json +1 -1
  2. package/README.md +6 -4
  3. package/amp/settings.example.json +1 -1
  4. package/assets/activity-receipt.png +0 -0
  5. package/assets/report-activity.png +0 -0
  6. package/assets/report-certify.png +0 -0
  7. package/assets/report-changes.png +0 -0
  8. package/assets/report-gateway-backends.png +0 -0
  9. package/assets/report-gateway-listeners.png +0 -0
  10. package/assets/report-gateway-playground.png +0 -0
  11. package/assets/report-gateway-policies.png +0 -0
  12. package/assets/report-gateway-routes.png +0 -0
  13. package/assets/report-gateway.png +0 -0
  14. package/assets/report-overview.png +0 -0
  15. package/claude/claude_desktop_config.example.json +1 -1
  16. package/cline/cline_mcp_settings.example.json +1 -1
  17. package/copilot/mcp-config.example.json +1 -1
  18. package/dist/cli.d.ts.map +1 -1
  19. package/dist/cli.js +5 -2
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/gate-serve.d.ts.map +1 -1
  22. package/dist/commands/gate-serve.js +35 -1
  23. package/dist/commands/gate-serve.js.map +1 -1
  24. package/dist/commands/gate.d.ts +10 -0
  25. package/dist/commands/gate.d.ts.map +1 -1
  26. package/dist/commands/gate.js +50 -4
  27. package/dist/commands/gate.js.map +1 -1
  28. package/dist/gate/config.d.ts +2 -0
  29. package/dist/gate/config.d.ts.map +1 -1
  30. package/dist/gate/config.js +26 -3
  31. package/dist/gate/config.js.map +1 -1
  32. package/dist/gate/hosted-sync.d.ts +74 -0
  33. package/dist/gate/hosted-sync.d.ts.map +1 -0
  34. package/dist/gate/hosted-sync.js +138 -0
  35. package/dist/gate/hosted-sync.js.map +1 -0
  36. package/dist/receipt/gate-page.d.ts +41 -2
  37. package/dist/receipt/gate-page.d.ts.map +1 -1
  38. package/dist/receipt/gate-page.js +59 -0
  39. package/dist/receipt/gate-page.js.map +1 -1
  40. package/dist/receipt/live-server.d.ts.map +1 -1
  41. package/dist/receipt/live-server.js +47 -2
  42. package/dist/receipt/live-server.js.map +1 -1
  43. package/dist/receipt/receipt-css.d.ts.map +1 -1
  44. package/dist/receipt/receipt-css.js +94 -44
  45. package/dist/receipt/receipt-css.js.map +1 -1
  46. package/dist/receipt/render-receipt.d.ts +17 -0
  47. package/dist/receipt/render-receipt.d.ts.map +1 -1
  48. package/dist/receipt/render-receipt.js +528 -270
  49. package/dist/receipt/render-receipt.js.map +1 -1
  50. package/droid/README.md +1 -1
  51. package/droid/mcp.example.json +1 -1
  52. package/gemini/settings.example.json +1 -1
  53. package/grok/README.md +1 -1
  54. package/kilo/kilo.example.json +1 -1
  55. package/kimi/mcp.example.json +1 -1
  56. package/kiro/mcp.example.json +1 -1
  57. package/manifest.json +1 -1
  58. package/mastracode/mcp.example.json +1 -1
  59. package/mcp.json +1 -1
  60. package/openai/codex.config.example.toml +1 -1
  61. package/opencode/opencode.example.json +1 -1
  62. package/package.json +1 -1
  63. package/qwen/settings.example.json +1 -1
  64. package/server.json +2 -2
@@ -10,8 +10,15 @@ import { loadCertifyCard, loadIdentity, loadOrrCard, loadSandboxes, loadShowback
10
10
  import { SHOWBACK_DISCLAIMER, } from "../showback/cost-per-task.js";
11
11
  import { computeDashboard } from "./dashboard.js";
12
12
  import { buildChangesModel } from "./changes.js";
13
- import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, LOOPBACK_RULE, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
13
+ import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
14
14
  import { receiptCss } from "./receipt-css.js";
15
+ export const PAGE_SIZE = 50;
16
+ export function clampPage(page, total) {
17
+ if (total === 0)
18
+ return 1;
19
+ const p = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
20
+ return Math.min(p, total);
21
+ }
15
22
  function esc(s) {
16
23
  return stripEscapes(s)
17
24
  .replace(/&/g, "&")
@@ -203,6 +210,7 @@ export function buildReceiptModel(sessionId, events, extras) {
203
210
  rangeLabel: `session ${sessionId}`,
204
211
  generatedAt: new Date().toISOString(),
205
212
  events: filtered,
213
+ page: extras?.page ?? 1,
206
214
  spend: extras?.spend,
207
215
  mcpSeen: extras?.mcpSeen,
208
216
  live: extras?.live,
@@ -223,6 +231,7 @@ export function buildWindowReceiptModel(events, days, extras) {
223
231
  rangeLabel: `last ${n} day${n === 1 ? "" : "s"}`,
224
232
  generatedAt: new Date().toISOString(),
225
233
  events,
234
+ page: extras?.page ?? 1,
226
235
  spend: extras?.spend,
227
236
  mcpSeen: extras?.mcpSeen,
228
237
  live: extras?.live,
@@ -244,6 +253,7 @@ export function loadReceiptModel(input) {
244
253
  ? readTrail(input.cwd)
245
254
  : readTrailSince(input.cwd, new Date(Date.now() - days * 24 * 60 * 60 * 1000));
246
255
  const extras = {
256
+ page: input.page ?? 1,
247
257
  live: input.live,
248
258
  liveToken: input.liveToken,
249
259
  identity: loadIdentity(input.cwd),
@@ -424,6 +434,19 @@ function navItem(href, label, count, runningDot) {
424
434
  ${countSpan}
425
435
  </a>`;
426
436
  }
437
+ function navGroup(label, runningDot) {
438
+ const dot = runningDot ? '<span class="status-dot" aria-hidden="true"></span>' : "";
439
+ return `<div class="nav-group">
440
+ <span class="nav-group-label">${dot}${esc(label)}</span>
441
+ </div>`;
442
+ }
443
+ function navSubItem(href, label, count) {
444
+ const countSpan = count !== undefined ? `<span class="nav-count">${count}</span>` : "";
445
+ return `<a class="nav-link nav-sub" id="tab-${href.slice(1)}" href="${href}">
446
+ <span class="nav-label">${esc(label)}</span>
447
+ ${countSpan}
448
+ </a>`;
449
+ }
427
450
  function statChips(agg) {
428
451
  // Chips are filter toggles: native buttons keep identical styling via .stat.
429
452
  const chip = (label, n, group, value, cls = "") => n > 0
@@ -599,11 +622,9 @@ function wiredHostsPanel(hosts) {
599
622
  </section>`;
600
623
  }
601
624
  /**
602
- * Gateway panel: always rendered (the gateway is a primary feature — an
603
- * absent panel read as "nothing to say", hiding the not-set-up state).
604
- * Per-server rows carry the Servers facet value as data-server so they line
605
- * up with the feed's data-server stamps; ids come from gateways.json and are
606
- * always escaped.
625
+ * System-tab gateway summary: a single status row that links to the dedicated
626
+ * Gateway section. Detail lives in the Gateway pages so the System tab stays
627
+ * focused on host/runtime state.
607
628
  */
608
629
  function gatePanel(card, nowMs) {
609
630
  if (!card)
@@ -613,60 +634,27 @@ function gatePanel(card, nowMs) {
613
634
  <h2>Gateway</h2>
614
635
  <div class="orrline">
615
636
  <span class="pill">not set up</span>
637
+ <span class="mute"><a href="#gateway-home">Open Gateway</a> to configure</span>
616
638
  </div>
617
- <p class="line">Gateway not set up — <code>kya start</code> puts a governed gateway in front of any MCP server automatically: every tool call proxied, evaluated, and on this trail.</p>
618
- <ol class="steps">
619
- <li>Add any MCP server to a host config (or <code>kya gate init</code> to pick from recipes)</li>
620
- <li><code>kya start</code> — imports it, installs the gateway, and routes the host through it (originals backed up under .kya/backups)</li>
621
- <li><code>kya gate doctor</code> — binary, config, listener health, loopback-only posture</li>
622
- </ol>
623
639
  </section>`;
624
640
  }
625
- const rows = card.servers
626
- .map((s) => {
627
- const dot = s.worst === "none"
628
- ? ""
629
- : `<span class="wdot ${s.worst}" title="worst verdict: ${s.worst}"></span>`;
630
- return ` <li data-server="${esc(s.serverFacet)}">${dot}<code>${esc(s.id)}</code> — ${esc(s.transport)} · ${s.events} event${s.events === 1 ? "" : "s"}</li>`;
631
- })
632
- .join("\n");
633
641
  const binary = card.binaryPresent
634
642
  ? `binary ${card.binaryVersion ? esc(clip(card.binaryVersion, 40)) : "installed"}`
635
643
  : "binary missing — kya gate setup";
636
- if (card.state === "configured-stopped") {
637
- return `<section class="panel gate" aria-label="Gateway">
638
- <h2>Gateway</h2>
639
- <div class="orrline">
640
- <span class="pill">stopped</span>
641
- <span class="mute">${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured · ${binary}</span>
642
- </div>
643
- <ul class="rows">
644
- ${rows}
645
- </ul>
646
- <p class="mute small">stopped — <code>kya gate run</code> to start the listener</p>
647
- </section>`;
648
- }
649
644
  const uptime = card.startedAt ? ` · up since ${esc(relativeTime(card.startedAt, nowMs))}` : "";
650
- if (card.servers.length === 0) {
651
- return `<section class="panel gate" aria-label="Gateway">
652
- <h2>Gateway</h2>
653
- <div class="orrline">
654
- <span class="pill ok">running</span>
655
- <span class="mute">${card.url ? `<code>${esc(clip(card.url, 60))}</code>` : ""}${uptime} · ${binary}</span>
656
- </div>
657
- <p class="line">running, no servers configured — add servers to .kya/gateways.json and restart (<code>kya gate stop</code>, then <code>kya gate run</code>)</p>
658
- </section>`;
659
- }
645
+ const pill = card.state === "running"
646
+ ? `<span class="pill ok">running</span>`
647
+ : `<span class="pill">stopped</span>`;
648
+ const detail = card.state === "running"
649
+ ? `${card.url ? `<code>${esc(clip(card.url, 60))}</code>` : ""}${uptime} · ${card.servers.length} server${card.servers.length === 1 ? "" : "s"} · ${card.events} event${card.events === 1 ? "" : "s"}`
650
+ : `${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured · ${binary}`;
660
651
  return `<section class="panel gate" aria-label="Gateway">
661
652
  <h2>Gateway</h2>
662
653
  <div class="orrline">
663
- <span class="pill ok">running</span>
664
- <span class="mute">${card.url ? `<code>${esc(clip(card.url, 60))}</code>` : ""}${uptime} · ${binary}</span>
654
+ ${pill}
655
+ <span class="mute">${detail}</span>
656
+ <span class="mute"><a href="#gateway-home">Open Gateway</a></span>
665
657
  </div>
666
- <ul class="rows">
667
- ${rows}
668
- </ul>
669
- <p class="mute small">${card.events} gateway event${card.events === 1 ? "" : "s"} in this window · <code>kya gate stop</code> stops everything</p>
670
658
  </section>`;
671
659
  }
672
660
  /**
@@ -675,40 +663,42 @@ ${rows}
675
663
  * playground, quick actions, and a gateway-native events table. All user-controlled
676
664
  * values (server ids, commands, urls, tool names) are escaped.
677
665
  */
678
- function gatePagePanel(page, nowMs) {
679
- if (!page) {
680
- return `<section class="zone" id="gateway" aria-labelledby="tab-gateway">
681
- <p class="mute small zone-empty">Gateway details will appear here after setup.</p>
682
- </section>`;
683
- }
684
- if (page.state === "not-set-up") {
685
- return `<section class="zone gate-zone" id="gateway" aria-labelledby="tab-gateway">
686
- <h2>Gateway</h2>
687
- <p class="sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
688
- <div class="card">
689
- <span class="pill">not set up</span>
690
- <p class="line">${esc(GATE_NOT_SETUP_QUICKSTART)}</p>
691
- <div class="quick-actions">
692
- <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>
693
- </div>
694
- <div id="gate-doctor-result"></div>
695
- </div>
696
- </section>`;
697
- }
698
- const denyCount = page.gateEvents.filter((e) => e.verdict.toUpperCase() === "DENY" || e.neverEvent || e.reasonCode === "NEVER_EVENT").length;
666
+ function gateStatusHeader(page, nowMs) {
699
667
  const listenerStatus = page.state === "running"
700
668
  ? `<span class="pill ok">running</span>`
701
669
  : `<span class="pill">stopped</span>`;
702
670
  const bindBadge = page.bindScope.loopbackOnly
703
671
  ? `<span class="pill ok" title="${esc(page.bindScope.detail)}">loopback-only</span>`
704
672
  : `<span class="pill warn" title="${esc(page.bindScope.detail)}">not loopback-only</span>`;
705
- const statCards = `<div class="stat-cards">
673
+ const uptime = page.startedAt
674
+ ? ` · up since <time datetime="${esc(page.startedAt)}">${esc(relativeTime(page.startedAt, nowMs))}</time>`
675
+ : "";
676
+ const urlLine = page.url ? ` <code>${esc(clip(page.url, 80))}</code>` : "";
677
+ return `<div class="gate-status-header">
678
+ <span class="gate-status-title">Gateway</span>
679
+ <span class="gate-status-meta">${listenerStatus}${urlLine}${uptime} · ${bindBadge}</span>
680
+ </div>`;
681
+ }
682
+ function gateQuickActions(page) {
683
+ const buttons = page.state === "not-set-up"
684
+ ? `<button type="button" class="stat" data-action="doctor">Doctor</button>`
685
+ : page.state === "configured-stopped"
686
+ ? `<button type="button" class="stat ok" data-action="start">Start</button>
687
+ <button type="button" class="stat" data-action="doctor">Doctor</button>`
688
+ : `<button type="button" class="stat bad" data-action="stop">Stop</button>
689
+ <button type="button" class="stat warn" data-action="restart">Restart</button>
690
+ <button type="button" class="stat" data-action="doctor">Doctor</button>`;
691
+ return `<div class="quick-actions">${buttons}</div><div id="gate-doctor-result"></div>`;
692
+ }
693
+ function gateStatCards(page) {
694
+ const denyCount = page.gateEvents.filter((e) => e.verdict.toUpperCase() === "DENY" || e.neverEvent || e.reasonCode === "NEVER_EVENT").length;
695
+ return `<div class="stat-cards">
706
696
  <div class="stat-card">
707
- <span class="lab">Listener</span>
708
- <span class="val">${listenerStatus} ${bindBadge}</span>
697
+ <span class="lab">Listeners</span>
698
+ <span class="num">${page.listeners.length}</span>
709
699
  </div>
710
700
  <div class="stat-card">
711
- <span class="lab">Targets</span>
701
+ <span class="lab">Backends</span>
712
702
  <span class="num">${page.servers.length}</span>
713
703
  </div>
714
704
  <div class="stat-card">
@@ -720,125 +710,31 @@ function gatePagePanel(page, nowMs) {
720
710
  <span class="num${denyCount > 0 ? " bad" : ""}">${denyCount}</span>
721
711
  </div>
722
712
  </div>`;
723
- const uptime = page.startedAt
724
- ? `<p class="line">Up since <time datetime="${esc(page.startedAt)}">${esc(relativeTime(page.startedAt, nowMs))}</time></p>`
725
- : "";
726
- const binaryLine = page.binaryPresent
727
- ? `<p class="line">Binary <code>${page.binaryVersion ? esc(clip(page.binaryVersion, 40)) : "installed"}</code> · <code>${esc(page.binaryPath)}</code></p>`
728
- : `<p class="line">Binary missing — <code>kya gate setup</code></p>`;
729
- const listenerUrl = page.url
730
- ? `<p class="line">Listener <code>${esc(clip(page.url, 80))}</code> · port ${page.port ?? page.otlpPort}</p>`
731
- : `<p class="line">Listener port ${page.port ?? 3930} (stopped)</p>`;
732
- const listenerCard = `<div class="card">
733
- <h3>Listener</h3>
734
- ${listenerUrl}
735
- ${uptime}
736
- ${binaryLine}
737
- <p class="line">OTLP receiver port ${page.otlpPort} · failure mode ${esc(page.failureMode)}</p>
738
- </div>`;
739
- const targetsTable = (() => {
740
- if (page.servers.length === 0) {
741
- return `<div class="card">
742
- <h3>Targets</h3>
743
- <p class="empty small">No servers configured — add one to .kya/gateways.json or run <code>kya gate init</code>.</p>
744
- </div>`;
745
- }
746
- const rows = page.servers
747
- .map((s) => {
748
- const transport = s.transport === "stdio"
749
- ? `<code title="${esc(s.cmd?.join(" ") ?? "")}">${esc(clip(s.cmd?.join(" ") ?? s.transport, 50))}</code>`
750
- : `<code title="${esc(s.url ?? "")}">${esc(clip(s.url ?? s.transport, 50))}</code>`;
751
- const provenance = s.importedFrom
752
- .map((h) => `<span class="chip">${esc(h)}</span>`)
753
- .join("");
754
- const policy = `<span class="pill">${esc(s.policy.defaultTier ?? "unknown")}</span> · ${s.policy.denyPatterns.length} pattern${s.policy.denyPatterns.length === 1 ? "" : "s"}`;
755
- const dot = s.worst === "none"
756
- ? ""
757
- : `<span class="wdot ${s.worst}" title="worst verdict: ${s.worst}"></span>`;
758
- const denied = s.deniedTools.length === 0
759
- ? '<span class="mute">—</span>'
760
- : s.deniedTools.map((t) => `<span class="chip bad">${esc(t)}</span>`).join("");
761
- return ` <tr data-server="${esc(s.serverFacet)}">
762
- <td><span class="wdot ${s.worst === "none" ? "mute" : s.worst}"></span> <code>${esc(s.id)}</code></td>
763
- <td>${transport}</td>
764
- <td>${provenance}</td>
765
- <td>${policy}</td>
766
- <td>${dot} ${s.events}</td>
767
- <td>${denied}</td>
768
- </tr>`;
769
- })
770
- .join("\n");
713
+ }
714
+ function gateEventsTable(events, nowMs) {
715
+ if (events.length === 0) {
771
716
  return `<div class="card">
772
- <h3>Targets</h3>
773
- <table class="tbl targets-table">
774
- <thead><tr><th>Server</th><th>Transport</th><th>Provenance</th><th>Policy</th><th>Events</th><th>Denied tools</th></tr></thead>
775
- <tbody>
776
- ${rows}
777
- </tbody>
778
- </table>
779
- </div>`;
780
- })();
781
- const policiesCard = `<div class="card">
782
- <h3>Policies</h3>
783
- <p class="sub">Network rule</p>
784
- <pre><code>${esc(LOOPBACK_RULE)}</code></pre>
785
- <p class="sub">Per-server deny patterns</p>
786
- ${page.servers.length === 0 ? '<p class="mute small">No servers configured.</p>' : page.servers
787
- .map((s) => `<details><summary><code>${esc(s.id)}</code> · ${s.policy.denyPatterns.length} pattern${s.policy.denyPatterns.length === 1 ? "" : "s"}</summary><pre><code>${esc(s.policy.denyPatterns.join("\n"))}</code></pre></details>`)
788
- .join("\n")}
789
- <p class="mute small">READ and WRITE tools are allowed; WRITE calls are observed/audited. Only destructive/admin patterns are denied.</p>
790
- </div>`;
791
- const serverOptions = page.servers
792
- .map((s) => `<option value="${esc(s.id)}">${esc(s.id)}</option>`)
793
- .join("");
794
- const playgroundCard = `<div class="card">
795
- <h3>Playground</h3>
796
- <p class="mute small">Dry-run — no tool is executed.</p>
797
- <form class="playground-form" id="gate-play-form">
798
- <label class="sr-only" for="gate-play-server">Server</label>
799
- <select id="gate-play-server" name="server" required>
800
- <option value="" disabled selected>Server</option>
801
- ${serverOptions}
802
- </select>
803
- <label class="sr-only" for="gate-play-tool">Tool</label>
804
- <input id="gate-play-tool" name="tool" type="text" placeholder="tool_name" required />
805
- <button type="submit" class="stat">Evaluate</button>
806
- </form>
807
- <div id="gate-play-result"></div>
808
- </div>`;
809
- const quickActions = (() => {
810
- if (page.state === "configured-stopped") {
811
- return `<button type="button" class="stat ok" data-action="start" id="gate-action-start">Start</button>
812
- <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>`;
813
- }
814
- return `<button type="button" class="stat bad" data-action="stop" id="gate-action-stop">Stop</button>
815
- <button type="button" class="stat warn" data-action="restart" id="gate-action-restart">Restart</button>
816
- <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>`;
817
- })();
818
- const eventsTable = (() => {
819
- if (page.gateEvents.length === 0) {
820
- return `<div class="card">
821
717
  <h3>Gateway events</h3>
822
718
  <p class="empty small">No gateway events in this window.</p>
823
719
  </div>`;
824
- }
825
- const rows = [...page.gateEvents]
826
- .sort((a, b) => b.ts.localeCompare(a.ts))
827
- .slice(0, 25)
828
- .map((e) => {
829
- const server = toolServerPrefix(e.toolId) ?? "";
830
- const tool = toolNameFromToolId(e.toolId) ?? e.toolId;
831
- const t = tone(e.verdict);
832
- const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
833
- return ` <tr>
720
+ }
721
+ const rows = [...events]
722
+ .sort((a, b) => b.ts.localeCompare(a.ts))
723
+ .slice(0, 25)
724
+ .map((e) => {
725
+ const server = toolServerPrefix(e.toolId) ?? "";
726
+ const tool = toolNameFromToolId(e.toolId) ?? e.toolId;
727
+ const t = tone(e.verdict);
728
+ const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
729
+ return ` <tr>
834
730
  <td><time datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time></td>
835
731
  <td><a href="?f=server:${esc(mcpServerLabel(server))}#activity">${esc(server)}</a></td>
836
732
  <td><code>${esc(tool)}</code></td>
837
733
  <td><span class="pill ${t}${never ? " orr-red" : ""}">${esc(verdictWord(e.verdict))}${never ? " · never" : ""}</span></td>
838
734
  </tr>`;
839
- })
840
- .join("\n");
841
- return `<div class="card">
735
+ })
736
+ .join("\n");
737
+ return `<div class="card">
842
738
  <h3>Gateway events <span class="mute">latest 25</span></h3>
843
739
  <table class="tbl">
844
740
  <thead><tr><th>Time</th><th>Server</th><th>Tool</th><th>Verdict</th></tr></thead>
@@ -847,29 +743,229 @@ ${rows}
847
743
  </tbody>
848
744
  </table>
849
745
  </div>`;
850
- })();
851
- return `<section class="zone gate-zone" id="gateway" aria-labelledby="tab-gateway">
852
- <h2>Gateway</h2>
853
- <p class="sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
854
- ${statCards}
855
- <div class="card-grid">
856
- ${listenerCard}
857
- ${policiesCard}
746
+ }
747
+ function gateHomePage(page, nowMs) {
748
+ if (page.state === "not-set-up") {
749
+ return `<section class="zone gate-zone" id="gateway-home" aria-labelledby="tab-gateway-home">
750
+ ${gateStatusHeader(page, nowMs)}
751
+ <p class="page-sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
752
+ <div class="card">
753
+ <span class="pill">not set up</span>
754
+ <p class="line">${esc(GATE_NOT_SETUP_QUICKSTART)}</p>
755
+ ${gateQuickActions(page)}
858
756
  </div>
859
- ${targetsTable}
757
+ </section>`;
758
+ }
759
+ return `<section class="zone gate-zone" id="gateway-home" aria-labelledby="tab-gateway-home">
760
+ ${gateStatusHeader(page, nowMs)}
761
+ <p class="page-sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
762
+ ${gateStatCards(page)}
860
763
  <div class="card-grid">
861
- ${playgroundCard}
764
+ <div class="card">
765
+ <h3>Listener</h3>
766
+ <p class="line">${page.url ? `<code>${esc(clip(page.url, 80))}</code>` : `Listener port ${page.port ?? 3930}`} · ${page.state}</p>
767
+ <p class="line">OTLP receiver port ${page.otlpPort}</p>
768
+ <p class="line">Failure mode <span class="pill">${esc(page.failureMode)}</span></p>
769
+ ${page.binaryPresent ? `<p class="line">Binary <code>${page.binaryVersion ? esc(clip(page.binaryVersion, 40)) : "installed"}</code></p>` : `<p class="line">Binary missing — <code>kya gate setup</code></p>`}
770
+ </div>
862
771
  <div class="card">
863
772
  <h3>Quick actions</h3>
864
- <div class="quick-actions">
865
- ${quickActions}
866
- </div>
867
- <div id="gate-doctor-result"></div>
773
+ ${gateQuickActions(page)}
774
+ </div>
775
+ </div>
776
+ ${gateEventsTable(page.gateEvents, nowMs)}
777
+ </section>`;
778
+ }
779
+ function gateListenersPage(page) {
780
+ const rows = page.listeners
781
+ .map((l) => ` <tr>
782
+ <td><span class="pill ${l.state === "running" ? "ok" : ""}">${esc(l.state)}</span></td>
783
+ <td>${esc(l.name)}</td>
784
+ <td>${esc(l.protocol)}</td>
785
+ <td><code>${esc(l.address)}</code></td>
786
+ <td>${l.uptime ? `<time datetime="${esc(l.uptime)}">${esc(relativeTime(l.uptime, Date.now()))}</time>` : '<span class="mute">—</span>'}</td>
787
+ <td>${esc(l.detail ?? "")}</td>
788
+ </tr>`)
789
+ .join("\n");
790
+ return `<section class="zone gate-zone" id="gateway-listeners" aria-labelledby="tab-gateway-listeners">
791
+ <h2 class="page-title">Listeners</h2>
792
+ <p class="page-sub">MCP listener and OTLP receiver configured by the gateway.</p>
793
+ <div class="card">
794
+ <table class="tbl">
795
+ <thead><tr><th>State</th><th>Name</th><th>Protocol</th><th>Address</th><th>Since</th><th>Detail</th></tr></thead>
796
+ <tbody>
797
+ ${rows}
798
+ </tbody>
799
+ </table>
800
+ </div>
801
+ </section>`;
802
+ }
803
+ function gateRoutesPage(page) {
804
+ if (page.routes.length === 0) {
805
+ return `<section class="zone gate-zone" id="gateway-routes" aria-labelledby="tab-gateway-routes">
806
+ <h2 class="page-title">Routes</h2>
807
+ <p class="page-sub">One route per configured backend server.</p>
808
+ <div class="card">
809
+ <p class="empty small">No servers configured — add one to .kya/gateways.json or run <code>kya gate init</code>.</p>
810
+ </div>
811
+ </section>`;
812
+ }
813
+ const rows = page.routes
814
+ .map((r) => {
815
+ const dot = r.worst === "none" ? "" : `<span class="wdot ${r.worst}"></span>`;
816
+ const denied = r.deniedTools.length === 0
817
+ ? '<span class="mute">—</span>'
818
+ : r.deniedTools.map((t) => `<span class="chip bad">${esc(t)}</span>`).join("");
819
+ return ` <tr data-server="${esc(r.backendLabel)}">
820
+ <td><code>${esc(r.pattern)}</code></td>
821
+ <td>${esc(r.backendLabel)}</td>
822
+ <td><span class="pill">${esc(r.tier ?? "unknown")}</span></td>
823
+ <td>${r.denyCount}</td>
824
+ <td>${dot} ${r.events}</td>
825
+ <td>${denied}</td>
826
+ </tr>`;
827
+ })
828
+ .join("\n");
829
+ return `<section class="zone gate-zone" id="gateway-routes" aria-labelledby="tab-gateway-routes">
830
+ <h2 class="page-title">Routes</h2>
831
+ <p class="page-sub">One route per configured backend server. Pattern matches proxied tool calls.</p>
832
+ <div class="card">
833
+ <table class="tbl">
834
+ <thead><tr><th>Pattern</th><th>Backend</th><th>Tier</th><th>Deny patterns</th><th>Events</th><th>Denied tools</th></tr></thead>
835
+ <tbody>
836
+ ${rows}
837
+ </tbody>
838
+ </table>
839
+ </div>
840
+ </section>`;
841
+ }
842
+ function gateBackendsPage(page) {
843
+ if (page.servers.length === 0) {
844
+ return `<section class="zone gate-zone" id="gateway-backends" aria-labelledby="tab-gateway-backends">
845
+ <h2 class="page-title">Backends</h2>
846
+ <p class="page-sub">Configured upstream MCP servers.</p>
847
+ <div class="card">
848
+ <p class="empty small">No servers configured — add one to .kya/gateways.json or run <code>kya gate init</code>.</p>
849
+ </div>
850
+ </section>`;
851
+ }
852
+ const rows = page.servers
853
+ .map((s) => {
854
+ const transport = s.transport === "stdio"
855
+ ? `<code title="${esc(s.cmd?.join(" ") ?? "")}">${esc(clip(s.cmd?.join(" ") ?? s.transport, 60))}</code>`
856
+ : `<code title="${esc(s.url ?? "")}">${esc(clip(s.url ?? s.transport, 60))}</code>`;
857
+ const provenance = s.importedFrom.map((h) => `<span class="chip">${esc(h)}</span>`).join("");
858
+ const dot = s.worst === "none" ? "" : `<span class="wdot ${s.worst}"></span>`;
859
+ const denied = s.deniedTools.length === 0
860
+ ? '<span class="mute">—</span>'
861
+ : s.deniedTools.map((t) => `<span class="chip bad">${esc(t)}</span>`).join("");
862
+ return ` <tr data-server="${esc(s.serverFacet)}">
863
+ <td><span class="wdot ${s.worst === "none" ? "mute" : s.worst}"></span> <code>${esc(s.id)}</code></td>
864
+ <td>${esc(s.transport)}</td>
865
+ <td>${transport}</td>
866
+ <td>${provenance}</td>
867
+ <td><span class="pill">${esc(s.policy.defaultTier ?? "unknown")}</span></td>
868
+ <td>${dot} ${s.events}</td>
869
+ <td>${denied}</td>
870
+ </tr>`;
871
+ })
872
+ .join("\n");
873
+ return `<section class="zone gate-zone" id="gateway-backends" aria-labelledby="tab-gateway-backends">
874
+ <h2 class="page-title">Backends</h2>
875
+ <p class="page-sub">Configured upstream MCP servers and their connection details.</p>
876
+ <div class="card">
877
+ <table class="tbl targets-table">
878
+ <thead><tr><th>Server</th><th>Transport</th><th>Target</th><th>Provenance</th><th>Policy</th><th>Events</th><th>Denied tools</th></tr></thead>
879
+ <tbody>
880
+ ${rows}
881
+ </tbody>
882
+ </table>
883
+ </div>
884
+ </section>`;
885
+ }
886
+ function gatePoliciesPage(page) {
887
+ const { policySummary } = page;
888
+ const mixTotal = policySummary.verdicts.allow +
889
+ policySummary.verdicts.deny +
890
+ policySummary.verdicts.hold +
891
+ policySummary.verdicts.never;
892
+ const pct = (n) => (mixTotal > 0 ? Math.round((n / mixTotal) * 100) : 0);
893
+ const mixRow = (label, n, fill) => n > 0
894
+ ? `<div class="mix-row"><span class="mix-lab">${esc(label)}</span><span class="mix-bar"><span class="mix-fill ${fill}" style="width:${pct(n)}%"></span></span><span class="mix-num">${n}</span></div>`
895
+ : "";
896
+ const verdictMix = `<div class="card">
897
+ <h3>Verdict mix</h3>
898
+ ${mixRow("Allow", policySummary.verdicts.allow, "ok")}
899
+ ${mixRow("Review", policySummary.verdicts.hold, "warn")}
900
+ ${mixRow("Deny", policySummary.verdicts.deny, "bad")}
901
+ ${mixRow("Never", policySummary.verdicts.never, "bad")}
902
+ <p class="mute small">${mixTotal} gateway event${mixTotal === 1 ? "" : "s"} in this window</p>
903
+ </div>`;
904
+ const denyCards = page.servers.length === 0
905
+ ? '<div class="card"><h3>Per-server deny patterns</h3><p class="mute small">No servers configured.</p></div>'
906
+ : page.servers
907
+ .map((s) => `<div class="card">
908
+ <h3><code>${esc(s.id)}</code> · ${s.policy.denyPatterns.length} pattern${s.policy.denyPatterns.length === 1 ? "" : "s"}</h3>
909
+ ${s.policy.denyPatterns.length === 0 ? '<p class="mute small">No deny patterns — all tools observed.</p>' : `<pre><code>${esc(s.policy.denyPatterns.join("\n"))}</code></pre>`}
910
+ </div>`)
911
+ .join("\n");
912
+ return `<section class="zone gate-zone" id="gateway-policies" aria-labelledby="tab-gateway-policies">
913
+ <h2 class="page-title">Policies</h2>
914
+ <p class="page-sub">Network rule, failure mode, and per-server deny patterns.</p>
915
+ <div class="card-grid">
916
+ <div class="card">
917
+ <h3>Network rule</h3>
918
+ <pre><code>${esc(policySummary.networkRule)}</code></pre>
919
+ <h3>Failure mode</h3>
920
+ <p class="line"><span class="pill">${esc(policySummary.failureMode)}</span></p>
921
+ <p class="mute small">Total policies evaluated: ${policySummary.totalPolicies}</p>
868
922
  </div>
923
+ ${verdictMix}
869
924
  </div>
870
- ${eventsTable}
925
+ ${denyCards}
871
926
  </section>`;
872
927
  }
928
+ function gatePlaygroundPage(page) {
929
+ const serverOptions = page.servers
930
+ .map((s) => `<option value="${esc(s.id)}">${esc(s.id)}</option>`)
931
+ .join("");
932
+ const samples = page.playgroundSamples.length === 0
933
+ ? ""
934
+ : `<p class="mute small">Recent tools: ${page.playgroundSamples.map((t) => `<code>${esc(t)}</code>`).join(" ")}</p>`;
935
+ return `<section class="zone gate-zone" id="gateway-playground" aria-labelledby="tab-gateway-playground">
936
+ <h2 class="page-title">Playground</h2>
937
+ <p class="page-sub">Dry-run a tool call against the generated policy — no tool is executed.</p>
938
+ <div class="card">
939
+ <form class="playground-form" id="gate-play-form">
940
+ <label class="sr-only" for="gate-play-server">Server</label>
941
+ <select id="gate-play-server" name="server" required>
942
+ <option value="" disabled selected>Server</option>
943
+ ${serverOptions}
944
+ </select>
945
+ <label class="sr-only" for="gate-play-tool">Tool</label>
946
+ <input id="gate-play-tool" name="tool" type="text" placeholder="tool_name" required />
947
+ <button type="submit" class="stat">Evaluate</button>
948
+ </form>
949
+ ${samples}
950
+ <div id="gate-play-result"></div>
951
+ </div>
952
+ </section>`;
953
+ }
954
+ function gatePagePanel(page, nowMs) {
955
+ if (!page) {
956
+ return `<section class="zone" id="gateway-home" aria-labelledby="tab-gateway-home">
957
+ <p class="mute small zone-empty">Gateway details will appear here after setup.</p>
958
+ </section>`;
959
+ }
960
+ return [
961
+ gateHomePage(page, nowMs),
962
+ page.state === "not-set-up" ? "" : gateListenersPage(page),
963
+ page.state === "not-set-up" ? "" : gateRoutesPage(page),
964
+ page.state === "not-set-up" ? "" : gateBackendsPage(page),
965
+ page.state === "not-set-up" ? "" : gatePoliciesPage(page),
966
+ page.state === "not-set-up" ? "" : gatePlaygroundPage(page),
967
+ ].join("\n");
968
+ }
873
969
  function sandboxesPanel(card, nowMs) {
874
970
  if (!card)
875
971
  return "";
@@ -1132,29 +1228,34 @@ ${topRuns
1132
1228
  <p class="mute small">${esc(SHOWBACK_DISCLAIMER)}</p>
1133
1229
  </section>`;
1134
1230
  }
1135
- /**
1136
- * Compact Overview status card for the gateway, rendered in every state so
1137
- * the feature is discoverable before setup; the System tab carries the full
1138
- * panel (server rows, quickstart).
1139
- */
1140
- function gateHeroCard(card) {
1141
- if (!card)
1231
+ export function gatewayHeroCard(page, _nowMs) {
1232
+ if (!page)
1142
1233
  return "";
1143
- const detail = card.state === "running"
1144
- ? `${card.url ? `<code>${esc(clip(card.url, 48))}</code> · ` : ""}${card.servers.length} server${card.servers.length === 1 ? "" : "s"} · ${card.events} event${card.events === 1 ? "" : "s"}`
1145
- : card.state === "configured-stopped"
1146
- ? `${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured — <code>kya gate run</code>`
1147
- : `add an MCP server to a host config — <code>kya start</code> governs it`;
1148
- const pill = card.state === "running"
1234
+ const statePill = page.state === "running"
1149
1235
  ? `<span class="pill ok">running</span>`
1150
- : `<span class="pill">${card.state === "configured-stopped" ? "stopped" : "not set up"}</span>`;
1151
- return `<section class="panel hero-gate" aria-label="Gateway">
1236
+ : page.state === "not-set-up"
1237
+ ? `<span class="pill">not set up</span>`
1238
+ : `<span class="pill">stopped</span>`;
1239
+ const binary = page.binaryPresent
1240
+ ? `binary ${page.binaryVersion ? esc(clip(page.binaryVersion, 40)) : "installed"}`
1241
+ : "binary missing — kya gate setup";
1242
+ const detail = page.state === "running"
1243
+ ? `${page.url ? `<code>${esc(clip(page.url, 60))}</code>` : `port ${page.port ?? 3930}`} · ${page.listeners.length} listener${page.listeners.length === 1 ? "" : "s"} · ${page.servers.length} backend${page.servers.length === 1 ? "" : "s"}`
1244
+ : `${page.servers.length} backend${page.servers.length === 1 ? "" : "s"} configured · ${binary}`;
1245
+ const denyCount = page.gateEvents.filter((e) => e.verdict.toUpperCase() === "DENY" || e.neverEvent || e.reasonCode === "NEVER_EVENT").length;
1246
+ return `<section class="panel" aria-label="Gateway">
1152
1247
  <h2>Gateway</h2>
1153
1248
  <div class="orrline">
1154
- ${pill}
1249
+ ${statePill}
1250
+ <span class="mute">${detail}</span>
1155
1251
  </div>
1156
- <p class="mute small">${detail}</p>
1157
- <p class="mute small">server rows + quickstart: <a href="#system">System tab</a></p>
1252
+ <div class="kpi-row">
1253
+ ${kpiTile("Listeners", page.listeners.length)}
1254
+ ${kpiTile("Backends", page.servers.length)}
1255
+ ${kpiTile("Events", page.gateEvents.length)}
1256
+ ${kpiTile("Denies", denyCount, denyCount > 0 ? "bad" : "")}
1257
+ </div>
1258
+ <p class="mute small"><a href="#gateway-home">Open Gateway</a></p>
1158
1259
  </section>`;
1159
1260
  }
1160
1261
  /**
@@ -1172,9 +1273,99 @@ function normalizeTrailEvents(events) {
1172
1273
  targetPath: e.targetPath ? clip(e.targetPath, 80) : undefined,
1173
1274
  }));
1174
1275
  }
1276
+ function eventMatchesFilter(e, group, value) {
1277
+ switch (group) {
1278
+ case "verdict":
1279
+ return e.verdict.toUpperCase() === value;
1280
+ case "never":
1281
+ return (e.neverEvent || e.reasonCode === "NEVER_EVENT") && value === "1";
1282
+ case "mode":
1283
+ return e.mode === value;
1284
+ case "plane":
1285
+ return (e.host?.trim() || "unknown") === value;
1286
+ case "product":
1287
+ return (e.product ?? "other") === value;
1288
+ case "project":
1289
+ if (value === "")
1290
+ return false;
1291
+ return (e.project?.trim() ?? "") === value;
1292
+ case "tool":
1293
+ return clip(e.toolId, 60) === value;
1294
+ case "session":
1295
+ return (e.sessionId || "unknown") === value;
1296
+ case "server": {
1297
+ const parsed = parseMcpToolId(e.toolId);
1298
+ return parsed != null && mcpServerLabel(parsed.server) === value;
1299
+ }
1300
+ default:
1301
+ return false;
1302
+ }
1303
+ }
1304
+ function applyFeedFilters(events, filters) {
1305
+ const groups = Object.entries(filters)
1306
+ .map(([group, vals]) => [group, vals.filter((v) => v !== "")])
1307
+ .filter(([, vals]) => vals.length > 0);
1308
+ if (groups.length === 0)
1309
+ return [...events];
1310
+ return events.filter((e) => groups.every(([group, vals]) => vals.some((v) => eventMatchesFilter(e, group, v))));
1311
+ }
1312
+ export function renderFeedPage(events, filters, page, nowMs, live) {
1313
+ const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
1314
+ const filtered = applyFeedFilters(sorted, filters);
1315
+ const total = filtered.length;
1316
+ const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
1317
+ const p = clampPage(page, pages);
1318
+ const start = (p - 1) * PAGE_SIZE;
1319
+ const pageEvents = filtered.slice(start, start + PAGE_SIZE);
1320
+ return {
1321
+ feedHtml: renderFeed(pageEvents, nowMs, live),
1322
+ paginationHtml: paginationBar(total, p, pages),
1323
+ total,
1324
+ page: p,
1325
+ pages,
1326
+ };
1327
+ }
1328
+ function paginationBar(total, page, pages) {
1329
+ if (pages <= 1 && total === 0) {
1330
+ return `<div class="pagination-bar"><span class="mute">${total} events</span></div>`;
1331
+ }
1332
+ if (pages <= 1) {
1333
+ return `<div class="pagination-bar"><span class="mute">Page ${page} of ${pages} · ${total} event${total === 1 ? "" : "s"}</span></div>`;
1334
+ }
1335
+ const prev = `<button type="button" class="stat" data-page="prev" ${page <= 1 ? "disabled" : ""}>Previous</button>`;
1336
+ const next = `<button type="button" class="stat" data-page="next" ${page >= pages ? "disabled" : ""}>Next</button>`;
1337
+ const maxWindow = 7;
1338
+ let startPage = Math.max(1, page - Math.floor(maxWindow / 2));
1339
+ let endPage = Math.min(pages, startPage + maxWindow - 1);
1340
+ if (endPage - startPage + 1 < maxWindow) {
1341
+ startPage = Math.max(1, endPage - maxWindow + 1);
1342
+ }
1343
+ const numbers = [];
1344
+ for (let i = startPage; i <= endPage; i++) {
1345
+ const active = i === page ? " active" : "";
1346
+ numbers.push(`<button type="button" class="stat${active}" data-page="${i}" ${i === page ? "disabled" : ""}>${i}</button>`);
1347
+ }
1348
+ return `<div class="pagination-bar">\n ${prev}\n ${numbers.join("\n ")}\n ${next}\n <span class="mute">Page ${page} of ${pages} · ${total} event${total === 1 ? "" : "s"}</span>\n</div>`;
1349
+ }
1175
1350
  export function renderReceiptHtml(model) {
1176
1351
  const nowMs = Date.now();
1177
1352
  const events = normalizeTrailEvents(model.events);
1353
+ // Pagination + live filtering are only meaningful when the report is served
1354
+ // by the loopback live server (liveToken present). Static on-disk artifacts
1355
+ // render the full event list so nothing is hidden behind inactive buttons.
1356
+ const livePaged = !!model.liveToken;
1357
+ const currentPage = livePaged
1358
+ ? clampPage(model.page ?? 1, Math.max(1, Math.ceil(events.length / PAGE_SIZE)))
1359
+ : 1;
1360
+ const initialFeed = livePaged
1361
+ ? renderFeedPage(events, {}, currentPage, nowMs, model.live ?? false)
1362
+ : {
1363
+ feedHtml: renderFeed(events, nowMs, model.live ?? false),
1364
+ paginationHtml: `<div class="pagination-bar"><span class="mute">${events.length} event${events.length === 1 ? "" : "s"}</span></div>`,
1365
+ total: events.length,
1366
+ page: 1,
1367
+ pages: 1,
1368
+ };
1178
1369
  const c = countVerdicts(events);
1179
1370
  const agg = aggregateEvents(events);
1180
1371
  const chips = statChips(agg);
@@ -1211,8 +1402,8 @@ export function renderReceiptHtml(model) {
1211
1402
  })();
1212
1403
  </script>`
1213
1404
  : "";
1214
- // Client-side chip filters: static and live renders both get this. It runs
1215
- // standalone (no live token needed) and never touches the EventSource block.
1405
+ // Client-side chip filters and pagination: only emitted for live reports
1406
+ // (liveToken present). Static receipts render page 1 without interactivity.
1216
1407
  // State maps are null-prototype: attacker-controlled values (project names)
1217
1408
  // must never resolve via Object.prototype. The id lets tests extract just
1218
1409
  // this script. KNOWN is space-delimited so `indexOf(' '+g+' ')` doubles as a
@@ -1223,23 +1414,43 @@ export function renderReceiptHtml(model) {
1223
1414
  // chip toggle and every tab click would wipe the filter. Legacy #f= hashes
1224
1415
  // are still parsed on load (read-only); the next save writes the ?f= form
1225
1416
  // and drops the legacy fragment.
1226
- const filterScript = `<script id="kya-filters">
1417
+ const filterScript = model.liveToken
1418
+ ? `<script id="kya-filters">
1227
1419
  (function(){
1228
1420
  var feed = document.getElementById('feed');
1229
1421
  if (!feed) return;
1230
1422
  var chg = document.getElementById('changes-list');
1423
+ var bar = document.getElementById('pagination-bar');
1231
1424
  var KNOWN = ' verdict never mode plane product project tool session server ';
1232
1425
  var state = Object.create(null);
1426
+ var page = 1;
1427
+ var LIVE = !!(document.querySelectorAll && document.querySelectorAll('main[data-live-token]').length);
1233
1428
  var clearBtn = document.getElementById('clear-filters');
1234
1429
  var chips = document.querySelectorAll('[data-fgroup]');
1430
+ function readToken(){
1431
+ var s = location.search;
1432
+ if (s.indexOf('?') !== 0) return '';
1433
+ var params = s.slice(1).split('&');
1434
+ for (var i = 0; i < params.length; i++) {
1435
+ if (params[i].indexOf('t=') === 0) {
1436
+ try { return decodeURIComponent(params[i].slice(2)); } catch(e){ return params[i].slice(2); }
1437
+ }
1438
+ }
1439
+ return '';
1440
+ }
1235
1441
  function parse(){
1236
1442
  state = Object.create(null);
1443
+ page = 1;
1237
1444
  var raw = null;
1238
1445
  var s = location.search;
1239
1446
  if (s.indexOf('?') === 0) {
1240
1447
  var params = s.slice(1).split('&');
1241
1448
  for (var i = 0; i < params.length; i++) {
1242
- if (params[i].indexOf('f=') === 0) { raw = params[i].slice(2); break; }
1449
+ if (raw === null && params[i].indexOf('f=') === 0) { raw = params[i].slice(2); }
1450
+ else if (params[i].indexOf('page=') === 0) {
1451
+ var p = parseInt(params[i].slice(5), 10);
1452
+ if (p > 0) page = p;
1453
+ }
1243
1454
  }
1244
1455
  }
1245
1456
  if (raw === null) {
@@ -1270,10 +1481,11 @@ export function renderReceiptHtml(model) {
1270
1481
  if (s.indexOf('?') === 0) {
1271
1482
  var params = s.slice(1).split('&');
1272
1483
  for (var i = 0; i < params.length; i++) {
1273
- if (params[i] && params[i].indexOf('f=') !== 0) kept.push(params[i]);
1484
+ if (params[i] && params[i].indexOf('f=') !== 0 && params[i].indexOf('page=') !== 0) kept.push(params[i]);
1274
1485
  }
1275
1486
  }
1276
1487
  if (parts.length) kept.push('f=' + parts.join(','));
1488
+ if (page > 1) kept.push('page=' + page);
1277
1489
  var q = kept.length ? '?' + kept.join('&') : '';
1278
1490
  // The tab hash rides along untouched; a legacy #f= fragment is not a tab
1279
1491
  // hash and is dropped now that the state lives in the query.
@@ -1294,17 +1506,21 @@ export function renderReceiptHtml(model) {
1294
1506
  }
1295
1507
  function apply(){
1296
1508
  var on = active();
1297
- feed.querySelectorAll('.ev').forEach(function(row){
1298
- row.classList.toggle('filtered-out', !rowVisible(row, on));
1299
- });
1300
- var day = null, dayHasRows = false;
1301
- function flush(){ if (day) day.classList.toggle('filtered-out', on && !dayHasRows); }
1302
- for (var i = 0; i < feed.children.length; i++) {
1303
- var el = feed.children[i];
1304
- if (el.classList.contains('day')) { flush(); day = el; dayHasRows = false; }
1305
- else if (el.classList.contains('ev') && !el.classList.contains('filtered-out')) dayHasRows = true;
1509
+ // Guard: the script is only emitted for live reports, so the feed is
1510
+ // always server-rendered; changes entries still filter client-side.
1511
+ if (!LIVE) {
1512
+ feed.querySelectorAll('.ev').forEach(function(row){
1513
+ row.classList.toggle('filtered-out', !rowVisible(row, on));
1514
+ });
1515
+ var day = null, dayHasRows = false;
1516
+ function flush(){ if (day) day.classList.toggle('filtered-out', on && !dayHasRows); }
1517
+ for (var i = 0; i < feed.children.length; i++) {
1518
+ var el = feed.children[i];
1519
+ if (el.classList.contains('day')) { flush(); day = el; dayHasRows = false; }
1520
+ else if (el.classList.contains('ev') && !el.classList.contains('filtered-out')) dayHasRows = true;
1521
+ }
1522
+ flush();
1306
1523
  }
1307
- flush();
1308
1524
  // Changes tab: entries filter individually; file/session groups collapse
1309
1525
  // when no entry inside survives.
1310
1526
  if (chg) {
@@ -1322,6 +1538,21 @@ export function renderReceiptHtml(model) {
1322
1538
  });
1323
1539
  if (clearBtn) clearBtn.hidden = !on;
1324
1540
  }
1541
+ function feedUpdate(){
1542
+ var T = readToken();
1543
+ if (!T) return;
1544
+ fetch('/feed?t=' + encodeURIComponent(T), {
1545
+ method: 'POST',
1546
+ headers: { 'Content-Type': 'application/json' },
1547
+ body: JSON.stringify({ filters: state, page: page })
1548
+ })
1549
+ .then(function(r){ return r.json(); })
1550
+ .then(function(data){
1551
+ if (feed && data.feedHtml) feed.innerHTML = data.feedHtml;
1552
+ if (bar && data.paginationHtml) bar.innerHTML = data.paginationHtml;
1553
+ })
1554
+ .catch(function(err){ console.error('KYA feed update failed', err); });
1555
+ }
1325
1556
  chips.forEach(function(chip){
1326
1557
  chip.addEventListener('click', function(){
1327
1558
  var g = chip.dataset.fgroup, v = chip.dataset.fvalue;
@@ -1331,13 +1562,32 @@ export function renderReceiptHtml(model) {
1331
1562
  } else {
1332
1563
  (state[g] || (state[g] = Object.create(null)))[v] = true;
1333
1564
  }
1565
+ page = 1;
1334
1566
  save(); apply();
1567
+ if (LIVE) feedUpdate();
1335
1568
  });
1336
1569
  });
1337
- if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); save(); apply(); });
1570
+ if (LIVE && bar) {
1571
+ bar.addEventListener('click', function(e){
1572
+ var btn = e.target.closest('button[data-page]');
1573
+ if (!btn) return;
1574
+ var dp = btn.getAttribute('data-page');
1575
+ if (dp === 'prev') page = Math.max(1, page - 1);
1576
+ else if (dp === 'next') page = page + 1;
1577
+ else {
1578
+ var np = parseInt(dp, 10);
1579
+ if (np > 0) page = np;
1580
+ }
1581
+ save(); apply();
1582
+ feedUpdate();
1583
+ });
1584
+ }
1585
+ if (clearBtn) clearBtn.addEventListener('click', function(){ state = Object.create(null); page = 1; save(); apply(); if (LIVE) feedUpdate(); });
1338
1586
  parse(); apply();
1587
+ if (LIVE && (active() || page !== 1)) feedUpdate();
1339
1588
  })();
1340
- </script>`;
1589
+ </script>`
1590
+ : "";
1341
1591
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
1342
1592
  const themeScript = `<script>
1343
1593
  (function(){
@@ -1423,36 +1673,33 @@ export function renderReceiptHtml(model) {
1423
1673
  });
1424
1674
  }
1425
1675
 
1426
- var gateway = document.getElementById('gateway');
1427
- if (gateway) {
1428
- gateway.querySelectorAll('[data-action]').forEach(function(btn){
1429
- btn.addEventListener('click', function(){
1430
- var action = btn.getAttribute('data-action');
1431
- var resultEl = document.getElementById('gate-doctor-result');
1432
- btn.disabled = true;
1433
- if (action === 'doctor') setResult(resultEl, 'Running doctor...', false);
1434
- post('/gate/action', { action: action })
1435
- .then(parseJson)
1436
- .then(function(data){
1437
- if (data && data.ok) {
1438
- if (action === 'doctor') {
1439
- setResult(resultEl, '<pre>' + esc(JSON.stringify(data.report, null, 2)) + '</pre>', false);
1440
- btn.disabled = false;
1441
- } else {
1442
- setTimeout(function(){ location.reload(); }, 800);
1443
- }
1444
- } else {
1445
- setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1676
+ document.querySelectorAll('.gate-zone [data-action]').forEach(function(btn){
1677
+ btn.addEventListener('click', function(){
1678
+ var action = btn.getAttribute('data-action');
1679
+ var resultEl = document.getElementById('gate-doctor-result');
1680
+ btn.disabled = true;
1681
+ if (action === 'doctor') setResult(resultEl, 'Running doctor...', false);
1682
+ post('/gate/action', { action: action })
1683
+ .then(parseJson)
1684
+ .then(function(data){
1685
+ if (data && data.ok) {
1686
+ if (action === 'doctor') {
1687
+ setResult(resultEl, '<pre>' + esc(JSON.stringify(data.report, null, 2)) + '</pre>', false);
1446
1688
  btn.disabled = false;
1689
+ } else {
1690
+ setTimeout(function(){ location.reload(); }, 800);
1447
1691
  }
1448
- })
1449
- .catch(function(err){
1450
- setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1692
+ } else {
1693
+ setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1451
1694
  btn.disabled = false;
1452
- });
1453
- });
1695
+ }
1696
+ })
1697
+ .catch(function(err){
1698
+ setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1699
+ btn.disabled = false;
1700
+ });
1454
1701
  });
1455
- }
1702
+ });
1456
1703
 
1457
1704
  var stopLink = document.getElementById('stop-report');
1458
1705
  if (stopLink) {
@@ -1515,7 +1762,13 @@ export function renderReceiptHtml(model) {
1515
1762
  ${navItem("#activity", "Activity", events.length)}
1516
1763
  ${navItem("#changes", "Changes", changes.fileCount)}
1517
1764
  ${navItem("#certify", "Certify", model.certify?.gap ?? 0)}
1518
- ${navItem("#gateway", "Gateway", model.gate?.servers.length ?? 0, model.gate?.state === "running")}
1765
+ ${navGroup("Gateway", model.gate?.state === "running")}
1766
+ ${navSubItem("#gateway-home", "Home", model.gate?.servers.length ?? 0)}
1767
+ ${navSubItem("#gateway-listeners", "Listeners", model.gate?.listeners?.length ?? 0)}
1768
+ ${navSubItem("#gateway-routes", "Routes", model.gate?.routes?.length ?? 0)}
1769
+ ${navSubItem("#gateway-backends", "Backends", model.gate?.servers.length ?? 0)}
1770
+ ${navSubItem("#gateway-policies", "Policies", model.gate?.policySummary?.totalPolicies ?? 0)}
1771
+ ${navSubItem("#gateway-playground", "Playground", model.gate?.playgroundSamples?.length ?? 0)}
1519
1772
  ${navItem("#system", "System", undefined)}
1520
1773
  </nav>
1521
1774
  <footer class="sidebar-footer">
@@ -1524,7 +1777,7 @@ export function renderReceiptHtml(model) {
1524
1777
  <a href="#" id="stop-report">Stop report</a>
1525
1778
  </footer>
1526
1779
  </aside>
1527
- <main>
1780
+ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
1528
1781
  <header class="page-header">
1529
1782
  <div class="title-row">
1530
1783
  <h1>${esc(model.title)}</h1>
@@ -1533,14 +1786,16 @@ export function renderReceiptHtml(model) {
1533
1786
  ${identityLine(model.identity)}
1534
1787
  </header>
1535
1788
  ${blockedBanner}
1536
- <div class="hero">
1537
- ${heroCertifyPanel(model.certify)}
1538
- ${verdictsHeroCard(c, events.length)}
1539
- ${activityHeroCard(agg, dashboard, events.length)}
1540
- ${gateHeroCard(model.gate)}
1541
- ${showbackHeroCard(model.showback)}
1542
- </div>
1543
1789
  <section class="zone" id="overview" aria-labelledby="tab-overview">
1790
+ <div class="hero">
1791
+ ${heroCertifyPanel(model.certify)}
1792
+ <div class="hero-tiles">
1793
+ ${verdictsHeroCard(c, events.length)}
1794
+ ${activityHeroCard(agg, dashboard, events.length)}
1795
+ ${gatewayHeroCard(model.gate, nowMs)}
1796
+ ${showbackHeroCard(model.showback)}
1797
+ </div>
1798
+ </div>
1544
1799
  ${overviewBody}
1545
1800
  </section>
1546
1801
  <section class="zone" id="activity" aria-labelledby="tab-activity">
@@ -1558,8 +1813,11 @@ export function renderReceiptHtml(model) {
1558
1813
  ${chips.servers}
1559
1814
  </div>
1560
1815
  <section class="feed" id="feed" aria-label="Activity feed">
1561
- ${renderFeed(events, nowMs, model.live)}
1816
+ ${initialFeed.feedHtml}
1562
1817
  </section>
1818
+ <div id="pagination-bar" aria-label="Activity pagination">
1819
+ ${initialFeed.paginationHtml}
1820
+ </div>
1563
1821
  </section>
1564
1822
  <section class="zone" id="changes" aria-labelledby="tab-changes">
1565
1823
  <section class="feed chg" id="changes-list" aria-label="Changes by session and file">