@shield-agent/kya 0.15.0 → 0.18.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 (56) 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/receipt/enrich.d.ts +1 -0
  19. package/dist/receipt/enrich.d.ts.map +1 -1
  20. package/dist/receipt/enrich.js +1 -0
  21. package/dist/receipt/enrich.js.map +1 -1
  22. package/dist/receipt/gate-card.d.ts +2 -0
  23. package/dist/receipt/gate-card.d.ts.map +1 -1
  24. package/dist/receipt/gate-card.js +1 -1
  25. package/dist/receipt/gate-card.js.map +1 -1
  26. package/dist/receipt/gate-page.d.ts +86 -0
  27. package/dist/receipt/gate-page.d.ts.map +1 -0
  28. package/dist/receipt/gate-page.js +175 -0
  29. package/dist/receipt/gate-page.js.map +1 -0
  30. package/dist/receipt/live-server.d.ts +8 -0
  31. package/dist/receipt/live-server.d.ts.map +1 -1
  32. package/dist/receipt/live-server.js +180 -2
  33. package/dist/receipt/live-server.js.map +1 -1
  34. package/dist/receipt/receipt-css.d.ts +6 -5
  35. package/dist/receipt/receipt-css.d.ts.map +1 -1
  36. package/dist/receipt/receipt-css.js +330 -80
  37. package/dist/receipt/receipt-css.js.map +1 -1
  38. package/dist/receipt/render-receipt.d.ts +19 -2
  39. package/dist/receipt/render-receipt.d.ts.map +1 -1
  40. package/dist/receipt/render-receipt.js +758 -135
  41. package/dist/receipt/render-receipt.js.map +1 -1
  42. package/droid/README.md +1 -1
  43. package/droid/mcp.example.json +1 -1
  44. package/gemini/settings.example.json +1 -1
  45. package/grok/README.md +1 -1
  46. package/kilo/kilo.example.json +1 -1
  47. package/kimi/mcp.example.json +1 -1
  48. package/kiro/mcp.example.json +1 -1
  49. package/manifest.json +1 -1
  50. package/mastracode/mcp.example.json +1 -1
  51. package/mcp.json +1 -1
  52. package/openai/codex.config.example.toml +1 -1
  53. package/opencode/opencode.example.json +1 -1
  54. package/package.json +1 -1
  55. package/qwen/settings.example.json +1 -1
  56. 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, loadGateCard } from "./gate-card.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,12 +253,13 @@ 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),
250
260
  sandboxes: loadSandboxes(input.cwd),
251
261
  wiredHosts: loadWiredHosts(input.cwd),
252
- gate: loadGateCard(events),
262
+ gate: loadGatePage(events),
253
263
  orr: loadOrrCard(input.cwd),
254
264
  certify: loadCertifyCard(input.cwd),
255
265
  showback,
@@ -265,6 +275,25 @@ export function loadReceiptModel(input) {
265
275
  }
266
276
  return buildWindowReceiptModel(events, days, extras);
267
277
  }
278
+ /**
279
+ * Build the data-* filter facet attribute string shared by feed articles and
280
+ * change entries. `sessionId` is passed explicitly because change entries roll
281
+ * up under their session heading.
282
+ */
283
+ function filterDataAttrs(e, sessionId) {
284
+ const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
285
+ const project = e.project?.trim();
286
+ const server = parseMcpToolId(e.toolId);
287
+ return (`data-verdict="${esc(e.verdict.toUpperCase())}"` +
288
+ (never ? ` data-never="1"` : "") +
289
+ ` data-mode="${esc(e.mode)}"` +
290
+ ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
291
+ ` data-product="${esc(e.product ?? "other")}"` +
292
+ ` data-tool="${esc(clip(e.toolId, 60))}"` +
293
+ ` data-session="${esc(sessionId)}"` +
294
+ (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
295
+ (project ? ` data-project="${esc(project)}"` : ""));
296
+ }
268
297
  function modeBadge(mode) {
269
298
  const letter = mode === "observe" ? "O" : mode === "hold" ? "H" : mode === "offline" ? "X" : "";
270
299
  if (!letter)
@@ -309,17 +338,7 @@ function renderFeed(events, nowMs, live) {
309
338
  // omitted when there is none. data-tool is clipped to 60 chars and must
310
339
  // match the tool filter values stamped on dashboard rows. data-server is
311
340
  // the recognized MCP server label, omitted for non-MCP or unknown servers.
312
- const project = e.project?.trim();
313
- const server = parseMcpToolId(e.toolId);
314
- const dataAttrs = `data-verdict="${esc(e.verdict.toUpperCase())}"` +
315
- (never ? ` data-never="1"` : "") +
316
- ` data-mode="${esc(e.mode)}"` +
317
- ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
318
- ` data-product="${esc(e.product ?? "other")}"` +
319
- ` data-tool="${esc(clip(e.toolId, 60))}"` +
320
- ` data-session="${esc(e.sessionId || "unknown")}"` +
321
- (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
322
- (project ? ` data-project="${esc(project)}"` : "");
341
+ const dataAttrs = filterDataAttrs(e, e.sessionId || "unknown");
323
342
  parts.push(`<article class="ev ${t}${never ? " never" : ""}" ${dataAttrs}>
324
343
  <div class="rail" aria-hidden="true"><span class="tick"></span></div>
325
344
  <div class="main">
@@ -363,17 +382,7 @@ function changesPanel(changes, nowMs) {
363
382
  const entries = f.entries
364
383
  .map((e) => {
365
384
  const t = tone(e.verdict);
366
- const project = e.project?.trim();
367
- const server = parseMcpToolId(e.toolId);
368
- const dataAttrs = `data-verdict="${esc(e.verdict.toUpperCase())}"` +
369
- (e.never ? ` data-never="1"` : "") +
370
- ` data-mode="${esc(e.mode)}"` +
371
- ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
372
- ` data-product="${esc(e.product ?? "other")}"` +
373
- ` data-tool="${esc(clip(e.toolId, 60))}"` +
374
- ` data-session="${esc(s.sessionId)}"` +
375
- (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
376
- (project ? ` data-project="${esc(project)}"` : "");
385
+ const dataAttrs = filterDataAttrs(e, s.sessionId);
377
386
  return ` <div class="chg-entry ${t}${e.never ? " never" : ""}" ${dataAttrs}>
378
387
  <div class="top">
379
388
  <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
@@ -417,6 +426,27 @@ function identityLine(identity) {
417
426
  // baseUrl is inert text here — never an anchor.
418
427
  return `<div class="identity">${esc(clip(bits.join(" · "), 160))}</div>`;
419
428
  }
429
+ function navItem(href, label, count, runningDot) {
430
+ const countSpan = count !== undefined ? `<span class="nav-count">${count}</span>` : "";
431
+ const dot = runningDot ? '<span class="status-dot" aria-hidden="true"></span>' : "";
432
+ return `<a class="nav-link" id="tab-${href.slice(1)}" href="${href}">
433
+ <span class="nav-label">${dot}${esc(label)}</span>
434
+ ${countSpan}
435
+ </a>`;
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
+ }
420
450
  function statChips(agg) {
421
451
  // Chips are filter toggles: native buttons keep identical styling via .stat.
422
452
  const chip = (label, n, group, value, cls = "") => n > 0
@@ -592,11 +622,9 @@ function wiredHostsPanel(hosts) {
592
622
  </section>`;
593
623
  }
594
624
  /**
595
- * Gateway panel: always rendered (the gateway is a primary feature — an
596
- * absent panel read as "nothing to say", hiding the not-set-up state).
597
- * Per-server rows carry the Servers facet value as data-server so they line
598
- * up with the feed's data-server stamps; ids come from gateways.json and are
599
- * 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.
600
628
  */
601
629
  function gatePanel(card, nowMs) {
602
630
  if (!card)
@@ -606,62 +634,338 @@ function gatePanel(card, nowMs) {
606
634
  <h2>Gateway</h2>
607
635
  <div class="orrline">
608
636
  <span class="pill">not set up</span>
637
+ <span class="mute"><a href="#gateway-home">Open Gateway</a> to configure</span>
609
638
  </div>
610
- <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>
611
- <ol class="steps">
612
- <li>Add any MCP server to a host config (or <code>kya gate init</code> to pick from recipes)</li>
613
- <li><code>kya start</code> — imports it, installs the gateway, and routes the host through it (originals backed up under .kya/backups)</li>
614
- <li><code>kya gate doctor</code> — binary, config, listener health, loopback-only posture</li>
615
- </ol>
616
639
  </section>`;
617
640
  }
618
- const rows = card.servers
619
- .map((s) => {
620
- const dot = s.worst === "none"
621
- ? ""
622
- : `<span class="wdot ${s.worst}" title="worst verdict: ${s.worst}"></span>`;
623
- return ` <li data-server="${esc(s.serverFacet)}">${dot}<code>${esc(s.id)}</code> — ${esc(s.transport)} · ${s.events} event${s.events === 1 ? "" : "s"}</li>`;
624
- })
625
- .join("\n");
626
641
  const binary = card.binaryPresent
627
642
  ? `binary ${card.binaryVersion ? esc(clip(card.binaryVersion, 40)) : "installed"}`
628
643
  : "binary missing — kya gate setup";
629
- if (card.state === "configured-stopped") {
630
- return `<section class="panel gate" aria-label="Gateway">
644
+ const uptime = card.startedAt ? ` · up since ${esc(relativeTime(card.startedAt, nowMs))}` : "";
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}`;
651
+ return `<section class="panel gate" aria-label="Gateway">
631
652
  <h2>Gateway</h2>
632
653
  <div class="orrline">
633
- <span class="pill">stopped</span>
634
- <span class="mute">${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured · ${binary}</span>
654
+ ${pill}
655
+ <span class="mute">${detail}</span>
656
+ <span class="mute"><a href="#gateway-home">Open Gateway</a></span>
635
657
  </div>
636
- <ul class="rows">
658
+ </section>`;
659
+ }
660
+ /**
661
+ * Full Gateway dashboard for the #gateway zone. Extends the compact System-tab
662
+ * panel with stat cards, listener details, targets table, policy cards, a dry-run
663
+ * playground, quick actions, and a gateway-native events table. All user-controlled
664
+ * values (server ids, commands, urls, tool names) are escaped.
665
+ */
666
+ function gateStatusHeader(page, nowMs) {
667
+ const listenerStatus = page.state === "running"
668
+ ? `<span class="pill ok">running</span>`
669
+ : `<span class="pill">stopped</span>`;
670
+ const bindBadge = page.bindScope.loopbackOnly
671
+ ? `<span class="pill ok" title="${esc(page.bindScope.detail)}">loopback-only</span>`
672
+ : `<span class="pill warn" title="${esc(page.bindScope.detail)}">not loopback-only</span>`;
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">
696
+ <div class="stat-card">
697
+ <span class="lab">Listeners</span>
698
+ <span class="num">${page.listeners.length}</span>
699
+ </div>
700
+ <div class="stat-card">
701
+ <span class="lab">Backends</span>
702
+ <span class="num">${page.servers.length}</span>
703
+ </div>
704
+ <div class="stat-card">
705
+ <span class="lab">Events</span>
706
+ <span class="num">${page.gateEvents.length}</span>
707
+ </div>
708
+ <div class="stat-card">
709
+ <span class="lab">Denies</span>
710
+ <span class="num${denyCount > 0 ? " bad" : ""}">${denyCount}</span>
711
+ </div>
712
+ </div>`;
713
+ }
714
+ function gateEventsTable(events, nowMs) {
715
+ if (events.length === 0) {
716
+ return `<div class="card">
717
+ <h3>Gateway events</h3>
718
+ <p class="empty small">No gateway events in this window.</p>
719
+ </div>`;
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>
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>
731
+ <td><a href="?f=server:${esc(mcpServerLabel(server))}#activity">${esc(server)}</a></td>
732
+ <td><code>${esc(tool)}</code></td>
733
+ <td><span class="pill ${t}${never ? " orr-red" : ""}">${esc(verdictWord(e.verdict))}${never ? " · never" : ""}</span></td>
734
+ </tr>`;
735
+ })
736
+ .join("\n");
737
+ return `<div class="card">
738
+ <h3>Gateway events <span class="mute">latest 25</span></h3>
739
+ <table class="tbl">
740
+ <thead><tr><th>Time</th><th>Server</th><th>Tool</th><th>Verdict</th></tr></thead>
741
+ <tbody>
637
742
  ${rows}
638
- </ul>
639
- <p class="mute small">stopped — <code>kya gate run</code> to start the listener</p>
743
+ </tbody>
744
+ </table>
745
+ </div>`;
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)}
756
+ </div>
640
757
  </section>`;
641
758
  }
642
- const uptime = card.startedAt ? ` · up since ${esc(relativeTime(card.startedAt, nowMs))}` : "";
643
- if (card.servers.length === 0) {
644
- return `<section class="panel gate" aria-label="Gateway">
645
- <h2>Gateway</h2>
646
- <div class="orrline">
647
- <span class="pill ok">running</span>
648
- <span class="mute">${card.url ? `<code>${esc(clip(card.url, 60))}</code>` : ""}${uptime} · ${binary}</span>
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)}
763
+ <div class="card-grid">
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>
771
+ <div class="card">
772
+ <h3>Quick actions</h3>
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>
649
810
  </div>
650
- <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>
651
811
  </section>`;
652
812
  }
653
- return `<section class="panel gate" aria-label="Gateway">
654
- <h2>Gateway</h2>
655
- <div class="orrline">
656
- <span class="pill ok">running</span>
657
- <span class="mute">${card.url ? `<code>${esc(clip(card.url, 60))}</code>` : ""}${uptime} · ${binary}</span>
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>
658
839
  </div>
659
- <ul class="rows">
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>
660
880
  ${rows}
661
- </ul>
662
- <p class="mute small">${card.events} gateway event${card.events === 1 ? "" : "s"} in this window · <code>kya gate stop</code> stops everything</p>
881
+ </tbody>
882
+ </table>
883
+ </div>
663
884
  </section>`;
664
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>
922
+ </div>
923
+ ${verdictMix}
924
+ </div>
925
+ ${denyCards}
926
+ </section>`;
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
+ }
665
969
  function sandboxesPanel(card, nowMs) {
666
970
  if (!card)
667
971
  return "";
@@ -924,29 +1228,34 @@ ${topRuns
924
1228
  <p class="mute small">${esc(SHOWBACK_DISCLAIMER)}</p>
925
1229
  </section>`;
926
1230
  }
927
- /**
928
- * Compact Overview status card for the gateway, rendered in every state so
929
- * the feature is discoverable before setup; the System tab carries the full
930
- * panel (server rows, quickstart).
931
- */
932
- function gateHeroCard(card) {
933
- if (!card)
1231
+ export function gatewayHeroCard(page, _nowMs) {
1232
+ if (!page)
934
1233
  return "";
935
- const detail = card.state === "running"
936
- ? `${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"}`
937
- : card.state === "configured-stopped"
938
- ? `${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured — <code>kya gate run</code>`
939
- : `add an MCP server to a host config — <code>kya start</code> governs it`;
940
- const pill = card.state === "running"
1234
+ const statePill = page.state === "running"
941
1235
  ? `<span class="pill ok">running</span>`
942
- : `<span class="pill">${card.state === "configured-stopped" ? "stopped" : "not set up"}</span>`;
943
- 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">
944
1247
  <h2>Gateway</h2>
945
1248
  <div class="orrline">
946
- ${pill}
1249
+ ${statePill}
1250
+ <span class="mute">${detail}</span>
1251
+ </div>
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" : "")}
947
1257
  </div>
948
- <p class="mute small">${detail}</p>
949
- <p class="mute small">server rows + quickstart: <a href="#system">System tab</a></p>
1258
+ <p class="mute small"><a href="#gateway-home">Open Gateway</a></p>
950
1259
  </section>`;
951
1260
  }
952
1261
  /**
@@ -964,9 +1273,99 @@ function normalizeTrailEvents(events) {
964
1273
  targetPath: e.targetPath ? clip(e.targetPath, 80) : undefined,
965
1274
  }));
966
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
+ }
967
1350
  export function renderReceiptHtml(model) {
968
1351
  const nowMs = Date.now();
969
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
+ };
970
1369
  const c = countVerdicts(events);
971
1370
  const agg = aggregateEvents(events);
972
1371
  const chips = statChips(agg);
@@ -990,7 +1389,7 @@ export function renderReceiptHtml(model) {
990
1389
  </footer>`;
991
1390
  // liveToken is minted base64url by the live server — safe in a JS string
992
1391
  // literal; static renders omit it. No post-hoc html.replace (spoofable).
993
- const eventsUrl = model.liveToken ? `/events?t=${model.liveToken}` : "/events";
1392
+ const eventsUrl = model.liveToken ? `/events?t=${encodeURIComponent(model.liveToken)}` : "/events";
994
1393
  const liveScript = model.live
995
1394
  ? `<script>
996
1395
  (function(){
@@ -1003,8 +1402,8 @@ export function renderReceiptHtml(model) {
1003
1402
  })();
1004
1403
  </script>`
1005
1404
  : "";
1006
- // Client-side chip filters: static and live renders both get this. It runs
1007
- // 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.
1008
1407
  // State maps are null-prototype: attacker-controlled values (project names)
1009
1408
  // must never resolve via Object.prototype. The id lets tests extract just
1010
1409
  // this script. KNOWN is space-delimited so `indexOf(' '+g+' ')` doubles as a
@@ -1015,23 +1414,43 @@ export function renderReceiptHtml(model) {
1015
1414
  // chip toggle and every tab click would wipe the filter. Legacy #f= hashes
1016
1415
  // are still parsed on load (read-only); the next save writes the ?f= form
1017
1416
  // and drops the legacy fragment.
1018
- const filterScript = `<script id="kya-filters">
1417
+ const filterScript = model.liveToken
1418
+ ? `<script id="kya-filters">
1019
1419
  (function(){
1020
1420
  var feed = document.getElementById('feed');
1021
1421
  if (!feed) return;
1022
1422
  var chg = document.getElementById('changes-list');
1423
+ var bar = document.getElementById('pagination-bar');
1023
1424
  var KNOWN = ' verdict never mode plane product project tool session server ';
1024
1425
  var state = Object.create(null);
1426
+ var page = 1;
1427
+ var LIVE = !!(document.querySelectorAll && document.querySelectorAll('main[data-live-token]').length);
1025
1428
  var clearBtn = document.getElementById('clear-filters');
1026
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
+ }
1027
1441
  function parse(){
1028
1442
  state = Object.create(null);
1443
+ page = 1;
1029
1444
  var raw = null;
1030
1445
  var s = location.search;
1031
1446
  if (s.indexOf('?') === 0) {
1032
1447
  var params = s.slice(1).split('&');
1033
1448
  for (var i = 0; i < params.length; i++) {
1034
- 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
+ }
1035
1454
  }
1036
1455
  }
1037
1456
  if (raw === null) {
@@ -1062,10 +1481,11 @@ export function renderReceiptHtml(model) {
1062
1481
  if (s.indexOf('?') === 0) {
1063
1482
  var params = s.slice(1).split('&');
1064
1483
  for (var i = 0; i < params.length; i++) {
1065
- 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]);
1066
1485
  }
1067
1486
  }
1068
1487
  if (parts.length) kept.push('f=' + parts.join(','));
1488
+ if (page > 1) kept.push('page=' + page);
1069
1489
  var q = kept.length ? '?' + kept.join('&') : '';
1070
1490
  // The tab hash rides along untouched; a legacy #f= fragment is not a tab
1071
1491
  // hash and is dropped now that the state lives in the query.
@@ -1086,17 +1506,21 @@ export function renderReceiptHtml(model) {
1086
1506
  }
1087
1507
  function apply(){
1088
1508
  var on = active();
1089
- feed.querySelectorAll('.ev').forEach(function(row){
1090
- row.classList.toggle('filtered-out', !rowVisible(row, on));
1091
- });
1092
- var day = null, dayHasRows = false;
1093
- function flush(){ if (day) day.classList.toggle('filtered-out', on && !dayHasRows); }
1094
- for (var i = 0; i < feed.children.length; i++) {
1095
- var el = feed.children[i];
1096
- if (el.classList.contains('day')) { flush(); day = el; dayHasRows = false; }
1097
- 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();
1098
1523
  }
1099
- flush();
1100
1524
  // Changes tab: entries filter individually; file/session groups collapse
1101
1525
  // when no entry inside survives.
1102
1526
  if (chg) {
@@ -1114,6 +1538,21 @@ export function renderReceiptHtml(model) {
1114
1538
  });
1115
1539
  if (clearBtn) clearBtn.hidden = !on;
1116
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
+ }
1117
1556
  chips.forEach(function(chip){
1118
1557
  chip.addEventListener('click', function(){
1119
1558
  var g = chip.dataset.fgroup, v = chip.dataset.fvalue;
@@ -1123,14 +1562,172 @@ export function renderReceiptHtml(model) {
1123
1562
  } else {
1124
1563
  (state[g] || (state[g] = Object.create(null)))[v] = true;
1125
1564
  }
1565
+ page = 1;
1126
1566
  save(); apply();
1567
+ if (LIVE) feedUpdate();
1127
1568
  });
1128
1569
  });
1129
- 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(); });
1130
1586
  parse(); apply();
1587
+ if (LIVE && (active() || page !== 1)) feedUpdate();
1131
1588
  })();
1132
- </script>`;
1589
+ </script>`
1590
+ : "";
1133
1591
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
1592
+ const themeScript = `<script>
1593
+ (function(){
1594
+ var root = document.documentElement;
1595
+ var btn = document.getElementById('theme-toggle');
1596
+ function apply(theme){ root.setAttribute('data-theme', theme); }
1597
+ var explicit = root.getAttribute('data-theme');
1598
+ var stored = null;
1599
+ try { stored = localStorage.getItem('kya-theme'); } catch(e){}
1600
+ if (explicit) {
1601
+ // honor an explicit theme set on the static artifact (e.g. screenshots)
1602
+ } else if (stored) apply(stored);
1603
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) apply('light');
1604
+ else apply('dark');
1605
+ if (btn) btn.addEventListener('click', function(){
1606
+ apply(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
1607
+ try { localStorage.setItem('kya-theme', root.getAttribute('data-theme')); } catch(e){}
1608
+ });
1609
+ })();
1610
+ </script>`;
1611
+ // Gateway interactive controls: playground evaluator, quick actions, and the
1612
+ // sidebar stop-report link. Only wired when the page is served by the live
1613
+ // loopback server (token present). Token is read from the query string so it
1614
+ // matches the same origin as the SSE endpoint; all user-controlled output is
1615
+ // escaped before DOM insertion.
1616
+ const gateScript = model.liveToken
1617
+ ? `<script>
1618
+ (function(){
1619
+ var T = '';
1620
+ var s = location.search;
1621
+ if (s.indexOf('?') === 0) {
1622
+ var params = s.slice(1).split('&');
1623
+ for (var i = 0; i < params.length; i++) {
1624
+ if (params[i].indexOf('t=') === 0) {
1625
+ try { T = decodeURIComponent(params[i].slice(2)); } catch(e){ T = params[i].slice(2); }
1626
+ break;
1627
+ }
1628
+ }
1629
+ }
1630
+ if (!T) return;
1631
+ function esc(str){
1632
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1633
+ }
1634
+ function setResult(el, html, isError){
1635
+ if (!el) return;
1636
+ el.innerHTML = '<div class="gate-result' + (isError ? ' error' : '') + '">' + html + '</div>';
1637
+ }
1638
+ function post(path, body){
1639
+ return fetch(path + '?t=' + encodeURIComponent(T), {
1640
+ method: 'POST',
1641
+ headers: { 'Content-Type': 'application/json' },
1642
+ body: JSON.stringify(body)
1643
+ });
1644
+ }
1645
+ function parseJson(res){ return res.json(); }
1646
+
1647
+ var form = document.getElementById('gate-play-form');
1648
+ if (form) {
1649
+ form.addEventListener('submit', function(e){
1650
+ e.preventDefault();
1651
+ var server = form.elements.server.value.trim();
1652
+ var tool = form.elements.tool.value.trim();
1653
+ var resultEl = document.getElementById('gate-play-result');
1654
+ var btn = form.querySelector('button[type="submit"]');
1655
+ if (btn) btn.disabled = true;
1656
+ setResult(resultEl, 'Evaluating...', false);
1657
+ post('/gate/playground', { server: server, tool: tool })
1658
+ .then(parseJson)
1659
+ .then(function(data){
1660
+ if (data && data.ok) {
1661
+ var pill = '<span class="pill ' + (data.verdict === 'allow' ? 'ok' : 'bad') + '">' + esc(data.verdict.toUpperCase()) + '</span>';
1662
+ setResult(resultEl, pill + '<p>' + esc(data.reason) + '</p>', false);
1663
+ } else {
1664
+ setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1665
+ }
1666
+ })
1667
+ .catch(function(err){
1668
+ setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1669
+ })
1670
+ .finally(function(){
1671
+ if (btn) btn.disabled = false;
1672
+ });
1673
+ });
1674
+ }
1675
+
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);
1688
+ btn.disabled = false;
1689
+ } else {
1690
+ setTimeout(function(){ location.reload(); }, 800);
1691
+ }
1692
+ } else {
1693
+ setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1694
+ btn.disabled = false;
1695
+ }
1696
+ })
1697
+ .catch(function(err){
1698
+ setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1699
+ btn.disabled = false;
1700
+ });
1701
+ });
1702
+ });
1703
+
1704
+ var stopLink = document.getElementById('stop-report');
1705
+ if (stopLink) {
1706
+ stopLink.addEventListener('click', function(e){
1707
+ e.preventDefault();
1708
+ stopLink.textContent = 'Stopping...';
1709
+ post('/stop', {})
1710
+ .then(parseJson)
1711
+ .then(function(data){
1712
+ if (data && data.ok) {
1713
+ stopLink.textContent = 'Report stopped';
1714
+ stopLink.setAttribute('aria-disabled', 'true');
1715
+ stopLink.style.pointerEvents = 'none';
1716
+ stopLink.style.opacity = '0.6';
1717
+ } else {
1718
+ stopLink.textContent = 'Stop failed';
1719
+ window.alert('Failed to stop report: ' + esc(data && data.error ? data.error : 'unknown'));
1720
+ }
1721
+ })
1722
+ .catch(function(err){
1723
+ stopLink.textContent = 'Stop failed';
1724
+ window.alert('Failed to stop report: ' + esc(err.message));
1725
+ });
1726
+ });
1727
+ }
1728
+ })();
1729
+ </script>`
1730
+ : "";
1134
1731
  // Zones never render as bare whitespace: with no panels they get a hint.
1135
1732
  const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1136
1733
  ? parts.filter(Boolean).join("\n ")
@@ -1153,42 +1750,54 @@ export function renderReceiptHtml(model) {
1153
1750
  <style>${receiptCss()}</style>
1154
1751
  </head>
1155
1752
  <body>
1156
- <main>
1157
- <div class="topstick">
1158
- <header class="bar">
1159
- <div class="title-row">
1160
- <h1>${esc(model.title)}</h1>
1161
- <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1162
- </div>
1163
- ${identityLine(model.identity)}
1164
- </header>
1165
- <nav class="tabs" aria-label="Report sections">
1166
- <a class="tab" id="tab-overview" href="#overview">Overview</a>
1167
- <a class="tab" id="tab-changes" href="#changes">Changes</a>
1168
- <a class="tab" id="tab-certify" href="#certify">Certify</a>
1169
- <a class="tab" id="tab-activity" href="#activity">Activity</a>
1170
- <a class="tab" id="tab-system" href="#system">System</a>
1171
- </nav>
1172
- </div>
1753
+ <aside class="sidebar">
1754
+ <header class="sidebar-header">
1755
+ <div class="brand">
1756
+ <span class="mark" aria-hidden="true">k</span>
1757
+ <span class="wordmark">kya</span>
1758
+ </div>
1759
+ </header>
1760
+ <nav class="sidebar-nav" aria-label="Report sections">
1761
+ ${navItem("#overview", "Overview", events.length)}
1762
+ ${navItem("#activity", "Activity", events.length)}
1763
+ ${navItem("#changes", "Changes", changes.fileCount)}
1764
+ ${navItem("#certify", "Certify", model.certify?.gap ?? 0)}
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)}
1772
+ ${navItem("#system", "System", undefined)}
1773
+ </nav>
1774
+ <footer class="sidebar-footer">
1775
+ <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">Theme</button>
1776
+ <!-- Stop-report action is wired by the live-server POST handler. -->
1777
+ <a href="#" id="stop-report">Stop report</a>
1778
+ </footer>
1779
+ </aside>
1780
+ ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
1781
+ <header class="page-header">
1782
+ <div class="title-row">
1783
+ <h1>${esc(model.title)}</h1>
1784
+ <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1785
+ </div>
1786
+ ${identityLine(model.identity)}
1787
+ </header>
1173
1788
  ${blockedBanner}
1174
- <div class="hero">
1175
- ${heroCertifyPanel(model.certify)}
1176
- ${verdictsHeroCard(c, events.length)}
1177
- ${activityHeroCard(agg, dashboard, events.length)}
1178
- ${gateHeroCard(model.gate)}
1179
- ${showbackHeroCard(model.showback)}
1180
- </div>
1181
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>
1182
1799
  ${overviewBody}
1183
1800
  </section>
1184
- <section class="zone" id="changes" aria-labelledby="tab-changes">
1185
- <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
1186
- ${changesPanel(changes, nowMs)}
1187
- </section>
1188
- </section>
1189
- <section class="zone" id="certify" aria-labelledby="tab-certify">
1190
- ${certifyBody}
1191
- </section>
1192
1801
  <section class="zone" id="activity" aria-labelledby="tab-activity">
1193
1802
  <div class="feed-head">
1194
1803
  <div class="stats" aria-label="Counts">
@@ -1204,14 +1813,28 @@ ${changesPanel(changes, nowMs)}
1204
1813
  ${chips.servers}
1205
1814
  </div>
1206
1815
  <section class="feed" id="feed" aria-label="Activity feed">
1207
- ${renderFeed(events, nowMs, model.live)}
1816
+ ${initialFeed.feedHtml}
1208
1817
  </section>
1818
+ <div id="pagination-bar" aria-label="Activity pagination">
1819
+ ${initialFeed.paginationHtml}
1820
+ </div>
1821
+ </section>
1822
+ <section class="zone" id="changes" aria-labelledby="tab-changes">
1823
+ <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
1824
+ ${changesPanel(changes, nowMs)}
1825
+ </section>
1826
+ </section>
1827
+ <section class="zone" id="certify" aria-labelledby="tab-certify">
1828
+ ${certifyBody}
1209
1829
  </section>
1830
+ ${gatePagePanel(model.gate, nowMs)}
1210
1831
  <section class="zone" id="system" aria-labelledby="tab-system">
1211
1832
  ${systemBody}
1212
1833
  </section>
1213
1834
  </main>
1214
1835
  ${filterScript}
1836
+ ${themeScript}
1837
+ ${gateScript}
1215
1838
  ${liveScript}
1216
1839
  </body>
1217
1840
  </html>`;