@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.
- package/.mcp.json +1 -1
- package/README.md +6 -4
- package/amp/settings.example.json +1 -1
- package/assets/activity-receipt.png +0 -0
- package/assets/report-activity.png +0 -0
- package/assets/report-certify.png +0 -0
- package/assets/report-changes.png +0 -0
- package/assets/report-gateway-backends.png +0 -0
- package/assets/report-gateway-listeners.png +0 -0
- package/assets/report-gateway-playground.png +0 -0
- package/assets/report-gateway-policies.png +0 -0
- package/assets/report-gateway-routes.png +0 -0
- package/assets/report-gateway.png +0 -0
- package/assets/report-overview.png +0 -0
- package/claude/claude_desktop_config.example.json +1 -1
- package/cline/cline_mcp_settings.example.json +1 -1
- package/copilot/mcp-config.example.json +1 -1
- package/dist/receipt/enrich.d.ts +1 -0
- package/dist/receipt/enrich.d.ts.map +1 -1
- package/dist/receipt/enrich.js +1 -0
- package/dist/receipt/enrich.js.map +1 -1
- package/dist/receipt/gate-card.d.ts +2 -0
- package/dist/receipt/gate-card.d.ts.map +1 -1
- package/dist/receipt/gate-card.js +1 -1
- package/dist/receipt/gate-card.js.map +1 -1
- package/dist/receipt/gate-page.d.ts +86 -0
- package/dist/receipt/gate-page.d.ts.map +1 -0
- package/dist/receipt/gate-page.js +175 -0
- package/dist/receipt/gate-page.js.map +1 -0
- package/dist/receipt/live-server.d.ts +8 -0
- package/dist/receipt/live-server.d.ts.map +1 -1
- package/dist/receipt/live-server.js +180 -2
- package/dist/receipt/live-server.js.map +1 -1
- package/dist/receipt/receipt-css.d.ts +6 -5
- package/dist/receipt/receipt-css.d.ts.map +1 -1
- package/dist/receipt/receipt-css.js +330 -80
- package/dist/receipt/receipt-css.js.map +1 -1
- package/dist/receipt/render-receipt.d.ts +19 -2
- package/dist/receipt/render-receipt.d.ts.map +1 -1
- package/dist/receipt/render-receipt.js +758 -135
- package/dist/receipt/render-receipt.js.map +1 -1
- package/droid/README.md +1 -1
- package/droid/mcp.example.json +1 -1
- package/gemini/settings.example.json +1 -1
- package/grok/README.md +1 -1
- package/kilo/kilo.example.json +1 -1
- package/kimi/mcp.example.json +1 -1
- package/kiro/mcp.example.json +1 -1
- package/manifest.json +1 -1
- package/mastracode/mcp.example.json +1 -1
- package/mcp.json +1 -1
- package/openai/codex.config.example.toml +1 -1
- package/opencode/opencode.example.json +1 -1
- package/package.json +1 -1
- package/qwen/settings.example.json +1 -1
- 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,
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
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
|
-
|
|
630
|
-
|
|
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
|
-
|
|
634
|
-
<span class="mute">${
|
|
654
|
+
${pill}
|
|
655
|
+
<span class="mute">${detail}</span>
|
|
656
|
+
<span class="mute"><a href="#gateway-home">Open Gateway</a></span>
|
|
635
657
|
</div>
|
|
636
|
-
|
|
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
|
-
|
|
639
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
<div class="
|
|
647
|
-
<
|
|
648
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
:
|
|
943
|
-
|
|
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
|
-
${
|
|
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"
|
|
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
|
|
1007
|
-
//
|
|
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 =
|
|
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);
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
var
|
|
1096
|
-
if (
|
|
1097
|
-
|
|
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 (
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
-
<
|
|
1157
|
-
<
|
|
1158
|
-
<
|
|
1159
|
-
<
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
${
|
|
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>`;
|