@thotischner/observability-mcp 3.0.0 → 3.0.1
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/dist/audit/sinks/s3.d.ts +61 -0
- package/dist/audit/sinks/s3.js +179 -0
- package/dist/audit/sinks/s3.test.d.ts +1 -0
- package/dist/audit/sinks/s3.test.js +175 -0
- package/dist/auth/policy/batch-dry-run.js +15 -0
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +49 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/federation/registry.d.ts +27 -5
- package/dist/federation/registry.js +49 -4
- package/dist/federation/registry.test.js +79 -3
- package/dist/federation/upstream.d.ts +32 -6
- package/dist/federation/upstream.js +60 -12
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +306 -65
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/policy/redact.js +1 -1
- package/dist/postmortem/store.d.ts +34 -0
- package/dist/postmortem/store.js +113 -0
- package/dist/postmortem/store.test.d.ts +1 -0
- package/dist/postmortem/store.test.js +118 -0
- package/dist/scim/compliance.test.d.ts +1 -0
- package/dist/scim/compliance.test.js +169 -0
- package/dist/scim/factory.test.d.ts +1 -0
- package/dist/scim/factory.test.js +54 -0
- package/dist/scim/patch-ops.test.d.ts +1 -0
- package/dist/scim/patch-ops.test.js +100 -0
- package/dist/scim/redis-store.d.ts +38 -0
- package/dist/scim/redis-store.js +178 -0
- package/dist/scim/redis-store.test.d.ts +1 -0
- package/dist/scim/redis-store.test.js +138 -0
- package/dist/scim/routes.d.ts +27 -2
- package/dist/scim/routes.js +161 -15
- package/dist/scim/store.d.ts +40 -1
- package/dist/scim/store.js +23 -5
- package/dist/sdk/hook-wrappers.d.ts +39 -0
- package/dist/sdk/hook-wrappers.js +113 -0
- package/dist/sdk/hook-wrappers.test.d.ts +1 -0
- package/dist/sdk/hook-wrappers.test.js +204 -0
- package/dist/sdk/index.d.ts +13 -0
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +22 -2
- package/dist/tools/topology.js +23 -5
- package/dist/tools/topology.test.js +45 -0
- package/dist/transport/transportSessionMap.d.ts +70 -0
- package/dist/transport/transportSessionMap.js +128 -0
- package/dist/transport/transportSessionMap.test.d.ts +1 -0
- package/dist/transport/transportSessionMap.test.js +111 -0
- package/dist/ui/index.html +856 -101
- package/package.json +1 -1
package/dist/ui/index.html
CHANGED
|
@@ -937,16 +937,23 @@
|
|
|
937
937
|
.view-toggle button.active { background: var(--accent-soft); color: var(--accent); }
|
|
938
938
|
.view-toggle button:hover { color: var(--text); }
|
|
939
939
|
|
|
940
|
-
/*
|
|
941
|
-
.
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
.
|
|
946
|
-
.
|
|
947
|
-
.
|
|
948
|
-
.
|
|
949
|
-
|
|
940
|
+
/* One helper line under a page H1. */
|
|
941
|
+
.ph-sub { margin: 4px 0 0; font-size: var(--fs-sm); color: var(--text-2, var(--text)); }
|
|
942
|
+
|
|
943
|
+
/* Disclosure (native <details>) — used for the in-card "bind a
|
|
944
|
+
credential" note and the demoted legacy catalog section. */
|
|
945
|
+
.pg-disclosure > summary { cursor: pointer; list-style: none; user-select: none; font-size: 13px; color: var(--text-2, var(--text)); padding: 8px 0; display: flex; align-items: center; gap: 8px; }
|
|
946
|
+
.pg-disclosure > summary::-webkit-details-marker { display: none; }
|
|
947
|
+
.pg-disclosure > summary::before { content: "▸"; font-size: 11px; color: var(--text-3, #8a93a5); transition: transform .12s ease; }
|
|
948
|
+
.pg-disclosure[open] > summary::before { transform: rotate(90deg); }
|
|
949
|
+
.pg-disclosure-body { padding: 4px 0 8px; }
|
|
950
|
+
/* The "bind a credential" note sits inside the primary card. */
|
|
951
|
+
.pg-bind { border-top: 1px solid var(--border); margin-top: 8px; }
|
|
952
|
+
.pg-bind pre { background: var(--surface-2); padding: 8px 10px; border-radius: 4px; font-size: 11px; overflow-x: auto; margin: 6px 0; }
|
|
953
|
+
/* The legacy catalog: a recessed, muted block, demoted by depth. */
|
|
954
|
+
.pg-legacy { border: 1px solid var(--border); border-radius: 6px; margin-top: 18px; padding: 0 14px; background: var(--surface-2); }
|
|
955
|
+
.pg-legacy > summary { color: var(--text-3, #8a93a5); }
|
|
956
|
+
.pg-legacy-note { color: var(--text-3, #8a93a5); margin: 0 0 12px; }
|
|
950
957
|
|
|
951
958
|
/* Products — card grid */
|
|
952
959
|
.pcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
|
|
@@ -1039,6 +1046,25 @@
|
|
|
1039
1046
|
.pol-probe-grid { grid-template-columns: 1fr 1fr; }
|
|
1040
1047
|
}
|
|
1041
1048
|
|
|
1049
|
+
/* Batch evaluate heat-map (P4) */
|
|
1050
|
+
.pol-batch { display: grid; grid-template-columns: 360px 1fr; gap: var(--sp-4); align-items: start; }
|
|
1051
|
+
.pol-batch .form-row { display: flex; flex-direction: column; gap: var(--sp-1); margin-bottom: var(--sp-3); }
|
|
1052
|
+
.pol-batch textarea { font-family: var(--mono); font-size: 12px; padding: var(--sp-2); border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); resize: vertical; }
|
|
1053
|
+
.pol-batch select[multiple] { font-family: var(--mono); font-size: 12px; padding: var(--sp-1); border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); }
|
|
1054
|
+
.pol-batch-result { min-height: 200px; }
|
|
1055
|
+
.pol-heat { border-collapse: collapse; font-size: 11px; }
|
|
1056
|
+
.pol-heat th, .pol-heat td { border: 1px solid var(--border); padding: 4px 8px; text-align: center; vertical-align: middle; }
|
|
1057
|
+
.pol-heat thead th { background: var(--surface-2); position: sticky; top: 0; }
|
|
1058
|
+
.pol-heat .row-head { background: var(--surface-2); font-weight: 600; text-align: left; padding-right: var(--sp-3); white-space: nowrap; }
|
|
1059
|
+
.pol-heat .resource-head { background: var(--surface-2); font-size: 10px; opacity: .8; font-weight: normal; }
|
|
1060
|
+
.pol-heat .cell-allow { background: var(--success-soft); color: var(--success); font-weight: 600; cursor: help; }
|
|
1061
|
+
.pol-heat .cell-deny { background: var(--danger-soft); color: var(--danger); font-weight: 600; cursor: help; }
|
|
1062
|
+
.pol-heat .cell-na { background: transparent; color: var(--text-3); }
|
|
1063
|
+
.pol-batch-dropped { margin-top: var(--sp-2); font-size: 11px; color: var(--warn); }
|
|
1064
|
+
@media (max-width: 1200px) {
|
|
1065
|
+
.pol-batch { grid-template-columns: 1fr; }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1042
1068
|
/* Author-controls are hidden when the active engine is read-only.
|
|
1043
1069
|
Anything with data-engine-required="file" needs the file engine. */
|
|
1044
1070
|
body[data-policy-engine="opa"] [data-engine-required="file"],
|
|
@@ -1365,6 +1391,48 @@
|
|
|
1365
1391
|
stroke: var(--accent); stroke-width: 3;
|
|
1366
1392
|
filter: drop-shadow(0 0 4px var(--accent-soft));
|
|
1367
1393
|
}
|
|
1394
|
+
|
|
1395
|
+
/* ===== Playground (Q13) — restrained, monochrome ===== */
|
|
1396
|
+
.pg-seg { display:inline-flex; gap:0; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
|
|
1397
|
+
.pg-seg button { border:none; background:transparent; color:var(--text-2,var(--text)); font:inherit; font-size:12px; padding:3px 11px; cursor:pointer; }
|
|
1398
|
+
.pg-seg button + button { border-left:1px solid var(--border); }
|
|
1399
|
+
.pg-seg button.active { background:var(--surface-3); color:var(--text); font-weight:600; }
|
|
1400
|
+
/* JSON: two tones only — keys at full strength, scalars muted. */
|
|
1401
|
+
.pg-json { white-space:pre-wrap; font-family:var(--mono); font-size:12px; background:var(--bg); padding:12px; border:1px solid var(--border); border-radius:4px; max-height:540px; overflow:auto; margin:0; color:var(--text-3,#8a93a5); }
|
|
1402
|
+
.pg-json .k { color:var(--text); }
|
|
1403
|
+
.pg-json .s { color:var(--text-2,var(--text)); }
|
|
1404
|
+
.pg-json .n, .pg-json .b { color:var(--text-2,var(--text)); }
|
|
1405
|
+
.pg-json .z { color:var(--text-3,#8a93a5); }
|
|
1406
|
+
.pg-tbl-wrap { max-height:540px; overflow:auto; border:1px solid var(--border); border-radius:4px; }
|
|
1407
|
+
table.pg-tbl { border-collapse:collapse; width:100%; font-size:12px; }
|
|
1408
|
+
table.pg-tbl th, table.pg-tbl td { text-align:left; padding:6px 10px; border-bottom:1px solid var(--border); white-space:nowrap; }
|
|
1409
|
+
table.pg-tbl th { position:sticky; top:0; background:var(--surface-2); color:var(--text-2,var(--text)); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.03em; }
|
|
1410
|
+
table.pg-tbl tr:last-child td { border-bottom:none; }
|
|
1411
|
+
table.pg-tbl tr:hover td { background:var(--surface-2); }
|
|
1412
|
+
/* Status: plain text by default; only failure states get a tint. */
|
|
1413
|
+
table.pg-tbl td .pill { font-weight:600; }
|
|
1414
|
+
table.pg-tbl td .pill.down, table.pg-tbl td .pill.error, table.pg-tbl td .pill.crit { color:var(--danger); }
|
|
1415
|
+
table.pg-tbl td .pill.warn, table.pg-tbl td .pill.degraded { color:var(--warning); }
|
|
1416
|
+
.pg-err-banner { border:1px solid var(--danger); color:var(--danger); border-radius:4px; padding:8px 12px; margin-bottom:10px; font-size:13px; }
|
|
1417
|
+
|
|
1418
|
+
/* Tool picker — type-ahead combobox, closed at rest (Carbon-style). */
|
|
1419
|
+
.pg-combo { position:relative; max-width:560px; }
|
|
1420
|
+
.pg-combo input { width:100%; padding-right:30px; }
|
|
1421
|
+
.pg-combo input:focus { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft); outline:none; }
|
|
1422
|
+
.pg-combo-chev { position:absolute; right:6px; top:50%; transform:translateY(-50%); background:none; border:none; color:var(--text-3,#8a93a5); cursor:pointer; font-size:11px; padding:4px; line-height:1; }
|
|
1423
|
+
.pg-combo-menu { position:absolute; z-index:30; left:0; right:0; top:calc(100% + 4px); background:var(--surface); border:1px solid var(--border); border-radius:4px; box-shadow:0 6px 18px rgba(0,0,0,0.28); max-height:340px; overflow:auto; }
|
|
1424
|
+
.pg-grp-hdr { font-size:11px; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-3,#8a93a5); padding:8px 14px 4px; pointer-events:none; }
|
|
1425
|
+
.pg-grp-hdr + .pg-grp-hdr { display:none; } /* no empty headers */
|
|
1426
|
+
.pg-opt { padding:7px 14px; cursor:pointer; border-left:2px solid transparent; }
|
|
1427
|
+
.pg-opt:hover, .pg-opt.hl { background:var(--surface-2); }
|
|
1428
|
+
.pg-opt.sel { background:var(--surface-3); border-left-color:var(--accent); }
|
|
1429
|
+
.pg-opt .nm { font-family:var(--mono); font-size:13px; color:var(--text); }
|
|
1430
|
+
.pg-opt .sm { font-size:12px; color:var(--text-3,#8a93a5); margin-top:1px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1431
|
+
.pg-opt .src { font-size:10px; text-transform:uppercase; letter-spacing:.04em; color:var(--text-3,#8a93a5); }
|
|
1432
|
+
.pg-sel-sum { font-size:12px; color:var(--text-3,#8a93a5); margin-top:6px; min-height:1em; max-width:560px; }
|
|
1433
|
+
.pg-args-tools { float:right; display:inline-flex; gap:4px; }
|
|
1434
|
+
.pg-args-box { font-family:var(--mono); font-size:12px; line-height:1.5; }
|
|
1435
|
+
.pg-args-err { color:var(--danger); font-size:12px; margin-top:6px; }
|
|
1368
1436
|
</style>
|
|
1369
1437
|
</head>
|
|
1370
1438
|
<body>
|
|
@@ -1383,6 +1451,8 @@
|
|
|
1383
1451
|
<button class="nav-btn" data-page="services" title="Services" onclick="showPage('services')"><span class="nav-ico">⊞</span><span class="nav-label">Services</span></button>
|
|
1384
1452
|
<button class="nav-btn" data-page="health" title="Health" onclick="showPage('health')"><span class="nav-ico">✚</span><span class="nav-label">Health</span></button>
|
|
1385
1453
|
<button class="nav-btn" data-page="topology" title="Topology" onclick="showPage('topology')"><span class="nav-ico">◇</span><span class="nav-label">Topology</span></button>
|
|
1454
|
+
<button class="nav-btn" data-page="postmortems" title="Postmortems" onclick="showPage('postmortems')"><span class="nav-ico">◷</span><span class="nav-label">Postmortems</span></button>
|
|
1455
|
+
<button class="nav-btn" data-page="playground" title="Playground" onclick="showPage('playground')"><span class="nav-ico">⏵</span><span class="nav-label">Playground</span></button>
|
|
1386
1456
|
</div>
|
|
1387
1457
|
</div>
|
|
1388
1458
|
<div class="rail-grp" data-grp="catalog">
|
|
@@ -1884,6 +1954,109 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1884
1954
|
</div>
|
|
1885
1955
|
</div>
|
|
1886
1956
|
|
|
1957
|
+
<!-- ===== Observability: Postmortems (P6 — persisted reports) ===== -->
|
|
1958
|
+
<div class="page" id="page-postmortems">
|
|
1959
|
+
<div class="page-head">
|
|
1960
|
+
<div class="ph-left">
|
|
1961
|
+
<div class="breadcrumb">Console / Observability / <b>Postmortems</b></div>
|
|
1962
|
+
<h1>Postmortems</h1>
|
|
1963
|
+
</div>
|
|
1964
|
+
<div class="ph-actions">
|
|
1965
|
+
<button class="btn btn-primary btn-sm" onclick="pmOpenNew()">+ Generate</button>
|
|
1966
|
+
</div>
|
|
1967
|
+
</div>
|
|
1968
|
+
|
|
1969
|
+
<div class="card">
|
|
1970
|
+
<div class="card-header"><h2>Generated reports
|
|
1971
|
+
<button class="info" aria-label="About postmortems"
|
|
1972
|
+
data-title="Postmortems"
|
|
1973
|
+
data-info="Persisted output of the generate_postmortem MCP tool. Each entry stitches anomaly history + traces + blast-radius + log highlights into one markdown report for a service. RBAC: viewers list, operators regenerate, admins delete."
|
|
1974
|
+
onclick="infoPop(this)">?</button>
|
|
1975
|
+
</h2></div>
|
|
1976
|
+
<div class="content">
|
|
1977
|
+
<div id="pm-list-body"><div class="empty">Loading…</div></div>
|
|
1978
|
+
</div>
|
|
1979
|
+
</div>
|
|
1980
|
+
|
|
1981
|
+
<div class="card" id="pm-detail-card" hidden>
|
|
1982
|
+
<div class="card-header"><h2 id="pm-detail-title">Report</h2>
|
|
1983
|
+
<span style="flex:1"></span>
|
|
1984
|
+
<button class="btn btn-ghost btn-sm" onclick="pmCloseDetail()">Close</button>
|
|
1985
|
+
<button class="btn btn-sm" id="pm-detail-regen" onclick="pmRegenerate()" hidden>Regenerate</button>
|
|
1986
|
+
<button class="btn btn-sm btn-danger" id="pm-detail-delete" onclick="pmDelete()" hidden>Delete</button>
|
|
1987
|
+
</div>
|
|
1988
|
+
<div class="content">
|
|
1989
|
+
<div id="pm-detail-meta" class="muted" style="margin-bottom:8px;font-size:12px"></div>
|
|
1990
|
+
<pre id="pm-detail-md" style="white-space:pre-wrap;font-family:var(--mono);font-size:12px;background:var(--bg);padding:12px;border:1px solid var(--border);border-radius:4px;max-height:540px;overflow:auto"></pre>
|
|
1991
|
+
</div>
|
|
1992
|
+
</div>
|
|
1993
|
+
</div>
|
|
1994
|
+
|
|
1995
|
+
<!-- ===== Playground (Q13 / v3.1) ===== -->
|
|
1996
|
+
<div class="page" id="page-playground">
|
|
1997
|
+
<div class="page-head">
|
|
1998
|
+
<div class="ph-left">
|
|
1999
|
+
<div class="breadcrumb">Console / Observability / <b>Playground</b></div>
|
|
2000
|
+
<h1>Tool Playground</h1>
|
|
2001
|
+
</div>
|
|
2002
|
+
</div>
|
|
2003
|
+
|
|
2004
|
+
<div class="card">
|
|
2005
|
+
<div class="card-header"><h2>Invoke a tool
|
|
2006
|
+
<button class="info" aria-label="About playground"
|
|
2007
|
+
data-title="Playground"
|
|
2008
|
+
data-info="Run any registered MCP tool against the live gateway. Uses your current credential's RBAC + rate-limit + entitlement + audit — identical to the dispatch path an MCP client would hit. Result is the raw CallToolResult."
|
|
2009
|
+
onclick="infoPop(this)">?</button>
|
|
2010
|
+
</h2></div>
|
|
2011
|
+
<div class="content">
|
|
2012
|
+
<!-- hidden field carries the selected tool name -->
|
|
2013
|
+
<input type="hidden" id="pg-tool" value="">
|
|
2014
|
+
<div class="form-row">
|
|
2015
|
+
<label for="pg-combo-input">Tool</label>
|
|
2016
|
+
<div class="pg-combo" id="pg-combo">
|
|
2017
|
+
<input type="text" id="pg-combo-input" class="input" autocomplete="off" spellcheck="false"
|
|
2018
|
+
placeholder="Select or filter tools…" role="combobox" aria-expanded="false" aria-controls="pg-combo-menu"
|
|
2019
|
+
oninput="pgComboFilter(this.value)" onfocus="pgComboOpen()" onkeydown="pgComboKey(event)">
|
|
2020
|
+
<button type="button" class="pg-combo-chev" id="pg-combo-chev" tabindex="-1" aria-label="Toggle tool list" onclick="pgComboToggle()">▾</button>
|
|
2021
|
+
<div class="pg-combo-menu" id="pg-combo-menu" role="listbox" hidden></div>
|
|
2022
|
+
</div>
|
|
2023
|
+
<div id="pg-sel-sum" class="pg-sel-sum"></div>
|
|
2024
|
+
</div>
|
|
2025
|
+
|
|
2026
|
+
<div class="form-row" style="margin-top:20px">
|
|
2027
|
+
<label for="pg-args">Arguments <span class="muted" style="font-weight:normal">(JSON)</span>
|
|
2028
|
+
<span class="pg-args-tools">
|
|
2029
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick="pgFormatArgs()">Format</button>
|
|
2030
|
+
<button type="button" class="btn btn-ghost btn-sm" onclick="document.getElementById('pg-args').value='{}'">Reset</button>
|
|
2031
|
+
</span>
|
|
2032
|
+
</label>
|
|
2033
|
+
<textarea id="pg-args" class="input pg-args-box" rows="8" spellcheck="false">{}</textarea>
|
|
2034
|
+
<div id="pg-args-err" class="pg-args-err" hidden></div>
|
|
2035
|
+
</div>
|
|
2036
|
+
<div class="row" style="gap:8px;margin-top:8px">
|
|
2037
|
+
<button class="btn btn-primary" onclick="pgRun()" id="pg-run-btn" disabled>Invoke</button>
|
|
2038
|
+
<button class="btn btn-ghost" onclick="pgClear()">Clear result</button>
|
|
2039
|
+
</div>
|
|
2040
|
+
</div>
|
|
2041
|
+
</div>
|
|
2042
|
+
|
|
2043
|
+
<div class="card" id="pg-result-card" hidden>
|
|
2044
|
+
<div class="card-header">
|
|
2045
|
+
<h2>Result <span id="pg-result-meta" class="muted" style="font-weight:normal;font-size:12px"></span></h2>
|
|
2046
|
+
<span style="flex:1"></span>
|
|
2047
|
+
<div class="pg-seg" id="pg-view-seg">
|
|
2048
|
+
<button data-view="pretty" class="active" onclick="pgSetView('pretty')">Pretty</button>
|
|
2049
|
+
<button data-view="table" onclick="pgSetView('table')">Table</button>
|
|
2050
|
+
<button data-view="raw" onclick="pgSetView('raw')">Raw</button>
|
|
2051
|
+
</div>
|
|
2052
|
+
<button class="btn btn-ghost btn-sm" style="margin-left:8px" onclick="pgCopy()">Copy</button>
|
|
2053
|
+
</div>
|
|
2054
|
+
<div class="content">
|
|
2055
|
+
<div id="pg-result-body"></div>
|
|
2056
|
+
</div>
|
|
2057
|
+
</div>
|
|
2058
|
+
</div>
|
|
2059
|
+
|
|
1887
2060
|
<!-- ===== Catalog: Context Products ===== -->
|
|
1888
2061
|
<div class="page" id="page-products">
|
|
1889
2062
|
<div class="page-head">
|
|
@@ -1892,55 +2065,14 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1892
2065
|
<h1>Context Products
|
|
1893
2066
|
<button class="info" aria-label="What is a context product"
|
|
1894
2067
|
data-title="Context products"
|
|
1895
|
-
data-info="A context product is a named, governed bundle of
|
|
2068
|
+
data-info="A context product is a named, governed bundle of MCP tools you expose to an agent or credential. The bundle's tools allow-list filters tools/list at the /mcp transport, so an agent bound to a product sees only that product's tools. Products compose with access control — a request must satisfy both RBAC and the product binding."
|
|
1896
2069
|
onclick="infoPop(this)">?</button>
|
|
1897
2070
|
</h1>
|
|
1898
|
-
|
|
1899
|
-
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="entEditNew('cat')">+ New product</button><button class="btn btn-sm" id="ent-cat-editbtn" onclick="entEditOpen('cat')">Edit catalog</button></div>
|
|
1900
|
-
</div>
|
|
1901
|
-
<div class="card" style="background:var(--accent-soft);border-color:transparent">
|
|
1902
|
-
<div style="font-size:var(--fs-sm);color:var(--text);line-height:1.6">
|
|
1903
|
-
<b>The catalog</b> publishes <b>context products</b> — reusable bundles of sources, services and tools —
|
|
1904
|
-
and the <b>grants</b> that decide which principals may consume each product. Browse below as cards or a
|
|
1905
|
-
table; an admin can create or change products via the editor or the API example.
|
|
1906
|
-
</div>
|
|
1907
|
-
</div>
|
|
1908
|
-
<!-- MCP Products — governed via /api/products (the new, RBAC-gated
|
|
1909
|
-
surface from the MCP Products + RBAC phase). Replaces the
|
|
1910
|
-
legacy enterprise-catalog block below for new deployments;
|
|
1911
|
-
the legacy block stays so existing /api/enterprise/catalog
|
|
1912
|
-
operators see their data until they migrate. -->
|
|
1913
|
-
<!-- Inline leitfaden — collapsed by default once the operator
|
|
1914
|
-
dismisses it; re-opens by clicking the chevron. -->
|
|
1915
|
-
<div class="card" id="mcp-products-leitfaden">
|
|
1916
|
-
<div class="card-header" style="cursor:pointer" onclick="mcpLeitfadenToggle()">
|
|
1917
|
-
<h2 style="display:flex;align-items:center;gap:.5rem">
|
|
1918
|
-
<span class="leitfaden-chev" id="mcp-leitfaden-chev">▾</span>
|
|
1919
|
-
About Products
|
|
1920
|
-
</h2>
|
|
1921
|
-
<span class="t-sm" style="opacity:.7">click to collapse</span>
|
|
1922
|
-
</div>
|
|
1923
|
-
<div id="mcp-leitfaden-body">
|
|
1924
|
-
<div class="leitfaden-grid">
|
|
1925
|
-
<div>
|
|
1926
|
-
<h3>What is a product?</h3>
|
|
1927
|
-
<p class="t-sm">A curated, named bundle of MCP tools you expose to one agent. The bundle's <code>tools</code> allow-list filters <code>tools/list</code> at the <code>/mcp</code> transport, so an agent bound to <em>Ops Bundle</em> sees only the operations tools and not the developer tools.</p>
|
|
1928
|
-
</div>
|
|
1929
|
-
<div>
|
|
1930
|
-
<h3>When do I need one?</h3>
|
|
1931
|
-
<p class="t-sm">Whenever more than one agent connects. Each team / use-case gets its own product — SRE on-call vs coding assistant vs compliance auditor. Without a product the credential sees every registered tool, which is fine for a single-operator demo but not for a production multi-agent deployment.</p>
|
|
1932
|
-
</div>
|
|
1933
|
-
<div>
|
|
1934
|
-
<h3>How do agents pick one up?</h3>
|
|
1935
|
-
<p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>:</p>
|
|
1936
|
-
<pre style="margin:.25rem 0"><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
|
|
1937
|
-
OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
|
|
1938
|
-
<p class="t-sm">The next <code>/mcp</code> session of <code>agent</code> sees only the tools in <code>ops-bundle</code>. <a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
|
|
1939
|
-
</div>
|
|
1940
|
-
</div>
|
|
2071
|
+
<p class="ph-sub">Curated, governed tool bundles you expose to an agent or credential.</p>
|
|
1941
2072
|
</div>
|
|
1942
2073
|
</div>
|
|
1943
2074
|
|
|
2075
|
+
<!-- Primary surface: MCP Products via /api/products (RBAC-gated). -->
|
|
1944
2076
|
<div class="card" id="mcp-products-card">
|
|
1945
2077
|
<div class="card-header">
|
|
1946
2078
|
<h2>MCP Products
|
|
@@ -1959,38 +2091,54 @@ OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
|
|
|
1959
2091
|
</div>
|
|
1960
2092
|
</div>
|
|
1961
2093
|
<div id="mcp-products-box"><div class="empty">Loading…</div></div>
|
|
2094
|
+
<details class="pg-disclosure pg-bind">
|
|
2095
|
+
<summary>Bind a credential to a product</summary>
|
|
2096
|
+
<div class="pg-disclosure-body">
|
|
2097
|
+
<p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>; the agent's next <code>/mcp</code> session then sees only that product's tools:</p>
|
|
2098
|
+
<pre><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
|
|
2099
|
+
OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
|
|
2100
|
+
<p class="t-sm"><a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
|
|
2101
|
+
</div>
|
|
2102
|
+
</details>
|
|
1962
2103
|
</div>
|
|
1963
2104
|
|
|
1964
|
-
<!-- Legacy enterprise
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
<
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
</span>
|
|
1976
|
-
<input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1977
|
-
</div>
|
|
1978
|
-
<div class="form-hint" style="margin-bottom:8px">Define products & grants below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
|
|
1979
|
-
<div id="cat-form"></div>
|
|
1980
|
-
<textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1981
|
-
<div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1982
|
-
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1983
|
-
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1984
|
-
<button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
|
|
2105
|
+
<!-- Legacy enterprise catalog — deprecated, demoted into a
|
|
2106
|
+
collapsed disclosure so it stays reachable for operators
|
|
2107
|
+
still wiring OMCP_ENTERPRISE_CATALOG_FILE without competing
|
|
2108
|
+
with the primary surface above. Open state persists. -->
|
|
2109
|
+
<details class="pg-disclosure pg-legacy" id="ent-legacy" ontoggle="pgLegacyPersist(this)">
|
|
2110
|
+
<summary>Legacy catalog (enterprise)</summary>
|
|
2111
|
+
<div class="pg-disclosure-body">
|
|
2112
|
+
<p class="t-sm pg-legacy-note">Deprecated. Backed by <code>OMCP_ENTERPRISE_CATALOG_FILE</code>. Use <b>MCP Products</b> above for new deployments.</p>
|
|
2113
|
+
<div class="row-inline" style="margin-bottom:12px">
|
|
2114
|
+
<button class="btn btn-sm" onclick="entEditNew('cat')">+ New product</button>
|
|
2115
|
+
<button class="btn btn-sm" id="ent-cat-editbtn" onclick="entEditOpen('cat')">Edit catalog</button>
|
|
1985
2116
|
</div>
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
2117
|
+
<div id="ent-catalog"><div class="empty">Loading…</div></div>
|
|
2118
|
+
<div id="ent-cat-editor" class="hidden" style="margin-top:12px">
|
|
2119
|
+
<div class="ed-bar">
|
|
2120
|
+
<span class="view-toggle" id="cat-views">
|
|
2121
|
+
<button data-v="form" class="active" onclick="catView('form')">Form</button>
|
|
2122
|
+
<button data-v="json" onclick="catView('json')">JSON</button>
|
|
2123
|
+
<button data-v="yaml" onclick="catView('yaml')">YAML</button>
|
|
2124
|
+
</span>
|
|
2125
|
+
<input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
2126
|
+
</div>
|
|
2127
|
+
<div class="form-hint" style="margin-bottom:8px">Define products & grants below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
|
|
2128
|
+
<div id="cat-form"></div>
|
|
2129
|
+
<textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
2130
|
+
<div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
|
|
2131
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
2132
|
+
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
2133
|
+
<button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
|
|
2134
|
+
</div>
|
|
1992
2135
|
</div>
|
|
1993
|
-
<
|
|
2136
|
+
<div class="codeblock collapsed" style="margin-top:14px">
|
|
2137
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
2138
|
+
<span class="cb-chev">▾</span><span class="cb-title">API · create a context product via curl</span>
|
|
2139
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
2140
|
+
</div>
|
|
2141
|
+
<pre><span class="tok-cmt"># publish a "payments-eu" product and grant it to a principal.</span>
|
|
1994
2142
|
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1995
2143
|
curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
1996
2144
|
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
@@ -2005,8 +2153,9 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
2005
2153
|
},
|
|
2006
2154
|
"grants": { "alice": ["payments-eu"] }
|
|
2007
2155
|
}'</pre>
|
|
2156
|
+
</div>
|
|
2008
2157
|
</div>
|
|
2009
|
-
</
|
|
2158
|
+
</details>
|
|
2010
2159
|
</div>
|
|
2011
2160
|
|
|
2012
2161
|
<!-- ===== Governance: Policies (PolicyEngine snapshot + dry-run) ===== -->
|
|
@@ -2077,6 +2226,7 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
2077
2226
|
<button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
|
|
2078
2227
|
<button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
|
|
2079
2228
|
<button class="pol-subtab" role="tab" aria-controls="pol-pane-subjects" data-pol-tab="subjects" onclick="polSetTab('subjects')">Subjects</button>
|
|
2229
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-batch" data-pol-tab="batch" onclick="polSetTab('batch')">Batch evaluate</button>
|
|
2080
2230
|
</nav>
|
|
2081
2231
|
|
|
2082
2232
|
<!-- Roles sub-tab — master/detail: role list on the left, the
|
|
@@ -2176,6 +2326,42 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
2176
2326
|
<div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
|
|
2177
2327
|
</div>
|
|
2178
2328
|
|
|
2329
|
+
<!-- Batch evaluate sub-tab (P4) — wraps POST /api/policy/dry-run-batch
|
|
2330
|
+
into a UI: subjects × resources × actions multi-select,
|
|
2331
|
+
one click renders a green/red heat-map matrix the user
|
|
2332
|
+
can hover for the per-cell deny reason; "Export CSV"
|
|
2333
|
+
re-hits the same endpoint with Accept: text/csv. -->
|
|
2334
|
+
<div class="card pol-pane" id="pol-pane-batch" role="tabpanel" hidden>
|
|
2335
|
+
<div class="card-header"><h2>Batch evaluate
|
|
2336
|
+
<button class="info" aria-label="About batch evaluate"
|
|
2337
|
+
data-title="Batch evaluate"
|
|
2338
|
+
data-info="Probe the active policy engine for every (subject × resource × action) cell at once. Useful before changing a role to see who would lose/gain access. Capped at 100 × 100 × 10 cells."
|
|
2339
|
+
onclick="infoPop(this)">?</button>
|
|
2340
|
+
</h2></div>
|
|
2341
|
+
<div class="content pol-batch">
|
|
2342
|
+
<div class="pol-batch-form">
|
|
2343
|
+
<div class="form-row">
|
|
2344
|
+
<label for="pol-batch-subjects">Subjects (one role per line; format: <code>label=role1,role2</code>; e.g. <code>alice=viewer</code>):</label>
|
|
2345
|
+
<textarea id="pol-batch-subjects" rows="4" placeholder="alice=viewer bob=operator,viewer admin@prod=admin"></textarea>
|
|
2346
|
+
</div>
|
|
2347
|
+
<div class="form-row">
|
|
2348
|
+
<label for="pol-batch-resources">Resources (multi-select):</label>
|
|
2349
|
+
<select id="pol-batch-resources" multiple size="6"></select>
|
|
2350
|
+
</div>
|
|
2351
|
+
<div class="form-row">
|
|
2352
|
+
<label for="pol-batch-actions">Actions (multi-select):</label>
|
|
2353
|
+
<select id="pol-batch-actions" multiple size="4"></select>
|
|
2354
|
+
</div>
|
|
2355
|
+
<div class="form-row" style="display:flex;gap:8px;align-items:center">
|
|
2356
|
+
<button class="btn btn-primary" id="pol-batch-eval-btn" onclick="polBatchEvaluate()">Evaluate</button>
|
|
2357
|
+
<button class="btn btn-sm" id="pol-batch-csv-btn" onclick="polBatchExportCsv()" disabled>Export CSV</button>
|
|
2358
|
+
<span id="pol-batch-totals" class="muted" style="margin-left:auto"></span>
|
|
2359
|
+
</div>
|
|
2360
|
+
</div>
|
|
2361
|
+
<div id="pol-batch-result" class="pol-batch-result"><div class="muted">Pick subjects + resources + actions, then click <b>Evaluate</b>.</div></div>
|
|
2362
|
+
</div>
|
|
2363
|
+
</div>
|
|
2364
|
+
|
|
2179
2365
|
<!-- Kept for back-compat — the legacy snapshot lives here but
|
|
2180
2366
|
the new Roles sub-tab supersedes it. JS hides it once
|
|
2181
2367
|
Roles renders successfully so we don't duplicate
|
|
@@ -2501,6 +2687,401 @@ function showPage(name) {
|
|
|
2501
2687
|
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
2502
2688
|
if(name==='products') loadMcpProducts();
|
|
2503
2689
|
if(name==='policies') loadPolicies();
|
|
2690
|
+
if(name==='postmortems') pmLoadList();
|
|
2691
|
+
if(name==='playground') pgInit();
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
// --- Playground tab (Q13 / v3.1) ---------------------------------------
|
|
2695
|
+
// In-product tool invocation. Picks a tool from /api/tools/registry,
|
|
2696
|
+
// POSTs {tool, args} to /api/playground/invoke, renders the raw
|
|
2697
|
+
// CallToolResult. RBAC + entitlement + audit live on the server side —
|
|
2698
|
+
// the UI just shows whatever comes back.
|
|
2699
|
+
|
|
2700
|
+
let PG_TOOLS = [];
|
|
2701
|
+
let PG_FILTERED = []; // flat list of tool names currently shown in the menu (keyboard nav)
|
|
2702
|
+
let PG_HL = -1; // highlighted index into PG_FILTERED
|
|
2703
|
+
let PG_COMBO_WIRED = false;
|
|
2704
|
+
// Category display order + label. Unknown categories fall into "other"
|
|
2705
|
+
// so nothing silently vanishes.
|
|
2706
|
+
const PG_CAT_ORDER = ['discovery', 'query', 'diagnose', 'topology', 'federated', 'other'];
|
|
2707
|
+
const PG_CAT_LABEL = { discovery:'Discovery', query:'Query', diagnose:'Diagnose', topology:'Topology', federated:'Federated', other:'Other' };
|
|
2708
|
+
|
|
2709
|
+
async function pgInit() {
|
|
2710
|
+
if (PG_TOOLS.length) return; // already loaded
|
|
2711
|
+
try {
|
|
2712
|
+
const res = await fetch('/api/tools/registry');
|
|
2713
|
+
PG_TOOLS = (await res.json()).tools || [];
|
|
2714
|
+
} catch (e) {
|
|
2715
|
+
document.getElementById('pg-sel-sum').textContent = 'Failed to load tool catalogue: ' + (e && e.message ? e.message : String(e));
|
|
2716
|
+
}
|
|
2717
|
+
if (!PG_COMBO_WIRED) {
|
|
2718
|
+
// One document-level outside-click closer.
|
|
2719
|
+
document.addEventListener('click', (ev) => {
|
|
2720
|
+
const combo = document.getElementById('pg-combo');
|
|
2721
|
+
if (combo && !combo.contains(ev.target)) pgComboClose();
|
|
2722
|
+
});
|
|
2723
|
+
PG_COMBO_WIRED = true;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
// A tool name with a dot is federated in from an upstream gateway —
|
|
2728
|
+
// the prefix is its source; route those into the "federated" group.
|
|
2729
|
+
function pgEnrich(t) {
|
|
2730
|
+
const dot = t.name.indexOf('.');
|
|
2731
|
+
return {
|
|
2732
|
+
name: t.name,
|
|
2733
|
+
summary: t.summary || '',
|
|
2734
|
+
category: dot > 0 ? 'federated' : (t.category || 'other'),
|
|
2735
|
+
source: dot > 0 ? t.name.slice(0, dot) : null,
|
|
2736
|
+
};
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
function pgComboRender(q) {
|
|
2740
|
+
const menu = document.getElementById('pg-combo-menu');
|
|
2741
|
+
const sel = document.getElementById('pg-tool').value;
|
|
2742
|
+
const norm = (q || '').trim().toLowerCase();
|
|
2743
|
+
const showAll = !norm || norm === sel.toLowerCase();
|
|
2744
|
+
const match = (t) => showAll || t.name.toLowerCase().includes(norm) || t.summary.toLowerCase().includes(norm);
|
|
2745
|
+
const byCat = {};
|
|
2746
|
+
PG_TOOLS.map(pgEnrich).filter(match).forEach((t) => { (byCat[t.category] = byCat[t.category] || []).push(t); });
|
|
2747
|
+
const cats = PG_CAT_ORDER.filter((c) => byCat[c]).concat(Object.keys(byCat).filter((c) => !PG_CAT_ORDER.includes(c)));
|
|
2748
|
+
PG_FILTERED = [];
|
|
2749
|
+
let html = '';
|
|
2750
|
+
cats.forEach((cat) => {
|
|
2751
|
+
html += '<div class="pg-grp-hdr">' + escHtml(PG_CAT_LABEL[cat] || cat) + '</div>';
|
|
2752
|
+
byCat[cat].forEach((t) => {
|
|
2753
|
+
const idx = PG_FILTERED.length;
|
|
2754
|
+
PG_FILTERED.push(t.name);
|
|
2755
|
+
html += '<div class="pg-opt' + (t.name === sel ? ' sel' : '') + '" role="option" data-idx="' + idx + '" data-name="' + escHtml(t.name) + '" onclick="pgComboPick(this.dataset.name)">' +
|
|
2756
|
+
'<div class="nm">' + escHtml(t.name) + (t.source ? ' <span class="src">via ' + escHtml(t.source) + '</span>' : '') + '</div>' +
|
|
2757
|
+
'<div class="sm">' + escHtml(t.summary) + '</div>' +
|
|
2758
|
+
'</div>';
|
|
2759
|
+
});
|
|
2760
|
+
});
|
|
2761
|
+
if (!PG_FILTERED.length) html = '<div class="pg-grp-hdr">No matches</div>';
|
|
2762
|
+
menu.innerHTML = html;
|
|
2763
|
+
PG_HL = -1;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
function pgComboOpen() {
|
|
2767
|
+
const menu = document.getElementById('pg-combo-menu');
|
|
2768
|
+
pgComboRender(document.getElementById('pg-combo-input').value);
|
|
2769
|
+
menu.hidden = false;
|
|
2770
|
+
document.getElementById('pg-combo-input').setAttribute('aria-expanded', 'true');
|
|
2771
|
+
}
|
|
2772
|
+
function pgComboClose() {
|
|
2773
|
+
const menu = document.getElementById('pg-combo-menu');
|
|
2774
|
+
if (menu) menu.hidden = true;
|
|
2775
|
+
const inp = document.getElementById('pg-combo-input');
|
|
2776
|
+
if (inp) inp.setAttribute('aria-expanded', 'false');
|
|
2777
|
+
}
|
|
2778
|
+
function pgComboToggle() {
|
|
2779
|
+
const menu = document.getElementById('pg-combo-menu');
|
|
2780
|
+
if (menu.hidden) { document.getElementById('pg-combo-input').focus(); pgComboOpen(); }
|
|
2781
|
+
else pgComboClose();
|
|
2782
|
+
}
|
|
2783
|
+
function pgComboFilter(v) { pgComboRender(v); document.getElementById('pg-combo-menu').hidden = false; }
|
|
2784
|
+
|
|
2785
|
+
function pgComboPick(name) {
|
|
2786
|
+
document.getElementById('pg-tool').value = name;
|
|
2787
|
+
document.getElementById('pg-combo-input').value = name;
|
|
2788
|
+
const t = PG_TOOLS.find((x) => x.name === name);
|
|
2789
|
+
document.getElementById('pg-sel-sum').textContent = t ? t.summary : '';
|
|
2790
|
+
document.getElementById('pg-run-btn').disabled = false;
|
|
2791
|
+
pgComboClose();
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function pgComboKey(e) {
|
|
2795
|
+
const menu = document.getElementById('pg-combo-menu');
|
|
2796
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
2797
|
+
e.preventDefault();
|
|
2798
|
+
if (menu.hidden) { pgComboOpen(); return; }
|
|
2799
|
+
if (!PG_FILTERED.length) return;
|
|
2800
|
+
PG_HL = e.key === 'ArrowDown'
|
|
2801
|
+
? (PG_HL + 1) % PG_FILTERED.length
|
|
2802
|
+
: (PG_HL - 1 + PG_FILTERED.length) % PG_FILTERED.length;
|
|
2803
|
+
const opts = menu.querySelectorAll('.pg-opt');
|
|
2804
|
+
opts.forEach((o) => o.classList.remove('hl'));
|
|
2805
|
+
const cur = menu.querySelector('.pg-opt[data-idx="' + PG_HL + '"]');
|
|
2806
|
+
if (cur) { cur.classList.add('hl'); cur.scrollIntoView({ block: 'nearest' }); }
|
|
2807
|
+
} else if (e.key === 'Enter') {
|
|
2808
|
+
if (!menu.hidden && PG_HL >= 0 && PG_FILTERED[PG_HL]) { e.preventDefault(); pgComboPick(PG_FILTERED[PG_HL]); }
|
|
2809
|
+
} else if (e.key === 'Escape') {
|
|
2810
|
+
pgComboClose();
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function pgFormatArgs() {
|
|
2815
|
+
const el = document.getElementById('pg-args');
|
|
2816
|
+
const err = document.getElementById('pg-args-err');
|
|
2817
|
+
try {
|
|
2818
|
+
el.value = JSON.stringify(JSON.parse(el.value || '{}'), null, 2);
|
|
2819
|
+
err.hidden = true;
|
|
2820
|
+
} catch (e) {
|
|
2821
|
+
err.hidden = false;
|
|
2822
|
+
err.textContent = 'Invalid JSON: ' + (e && e.message ? e.message : e);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// Last invocation state — drives the view toggle without re-running.
|
|
2827
|
+
let PG_LAST = null; // { raw: <full {tool,result} JSON>, data: <unwrapped payload>, error: <string|null> }
|
|
2828
|
+
let PG_VIEW = 'pretty';
|
|
2829
|
+
|
|
2830
|
+
async function pgRun() {
|
|
2831
|
+
const sel = document.getElementById('pg-tool');
|
|
2832
|
+
const argsEl = document.getElementById('pg-args');
|
|
2833
|
+
const btn = document.getElementById('pg-run-btn');
|
|
2834
|
+
const card = document.getElementById('pg-result-card');
|
|
2835
|
+
const body = document.getElementById('pg-result-body');
|
|
2836
|
+
const meta = document.getElementById('pg-result-meta');
|
|
2837
|
+
const tool = sel.value || '';
|
|
2838
|
+
if (!tool) { return; }
|
|
2839
|
+
const argErr = document.getElementById('pg-args-err');
|
|
2840
|
+
let args;
|
|
2841
|
+
try {
|
|
2842
|
+
args = argsEl.value.trim() ? JSON.parse(argsEl.value) : {};
|
|
2843
|
+
if (argErr) argErr.hidden = true;
|
|
2844
|
+
} catch (e) {
|
|
2845
|
+
if (argErr) { argErr.hidden = false; argErr.textContent = 'Arguments are not valid JSON: ' + (e && e.message ? e.message : e); }
|
|
2846
|
+
card.hidden = false;
|
|
2847
|
+
meta.textContent = ' · client-side error';
|
|
2848
|
+
PG_LAST = { raw: null, data: null, error: 'Arguments are not valid JSON: ' + (e && e.message ? e.message : e) };
|
|
2849
|
+
pgRenderResult();
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
btn.disabled = true;
|
|
2853
|
+
meta.textContent = ' · running…';
|
|
2854
|
+
card.hidden = false;
|
|
2855
|
+
body.textContent = '';
|
|
2856
|
+
const t0 = Date.now();
|
|
2857
|
+
try {
|
|
2858
|
+
const res = await fetch('/api/playground/invoke', {
|
|
2859
|
+
method: 'POST',
|
|
2860
|
+
headers: { 'content-type': 'application/json' },
|
|
2861
|
+
body: JSON.stringify({ tool, args }),
|
|
2862
|
+
});
|
|
2863
|
+
const json = await res.json();
|
|
2864
|
+
meta.textContent = ' · HTTP ' + res.status + ' · ' + (Date.now() - t0) + ' ms';
|
|
2865
|
+
PG_LAST = pgUnwrap(json, res.ok);
|
|
2866
|
+
pgRenderResult();
|
|
2867
|
+
} catch (e) {
|
|
2868
|
+
meta.textContent = ' · network error';
|
|
2869
|
+
PG_LAST = { raw: null, data: null, error: (e && e.message) ? e.message : String(e) };
|
|
2870
|
+
pgRenderResult();
|
|
2871
|
+
} finally {
|
|
2872
|
+
btn.disabled = false;
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// Pull the meaningful payload out of an MCP CallToolResult. Tools return
|
|
2877
|
+
// { tool, result:{ content:[{type:'text',text}], isError? } }. The text
|
|
2878
|
+
// is usually itself JSON — parse it so we can pretty/table-render it.
|
|
2879
|
+
function pgUnwrap(json, httpOk) {
|
|
2880
|
+
if (!httpOk || (json && json.error)) {
|
|
2881
|
+
return { raw: json, data: null, error: (json && (json.error || json.message)) || ('HTTP error') };
|
|
2882
|
+
}
|
|
2883
|
+
const result = json && json.result;
|
|
2884
|
+
let data = result;
|
|
2885
|
+
let error = (result && result.isError) ? 'Tool reported isError' : null;
|
|
2886
|
+
const content = result && result.content;
|
|
2887
|
+
if (Array.isArray(content)) {
|
|
2888
|
+
const text = content.filter(c => c && c.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
|
2889
|
+
if (text) {
|
|
2890
|
+
try { data = JSON.parse(text); }
|
|
2891
|
+
catch (e) { data = text; } // not JSON — show the raw text
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
return { raw: json, data, error };
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function pgSetView(v) {
|
|
2898
|
+
PG_VIEW = v;
|
|
2899
|
+
document.querySelectorAll('#pg-view-seg button').forEach(b => b.classList.toggle('active', b.dataset.view === v));
|
|
2900
|
+
pgRenderResult();
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
function pgRenderResult() {
|
|
2904
|
+
const body = document.getElementById('pg-result-body');
|
|
2905
|
+
if (!PG_LAST) { body.textContent = ''; return; }
|
|
2906
|
+
let html = '';
|
|
2907
|
+
if (PG_LAST.error) html += '<div class="pg-err-banner">⚠ ' + escHtml(PG_LAST.error) + '</div>';
|
|
2908
|
+
|
|
2909
|
+
if (PG_VIEW === 'raw' || PG_LAST.raw == null && PG_LAST.data == null) {
|
|
2910
|
+
html += '<pre class="pg-json">' + pgHighlight(PG_LAST.raw != null ? PG_LAST.raw : (PG_LAST.error || '')) + '</pre>';
|
|
2911
|
+
} else if (PG_VIEW === 'table') {
|
|
2912
|
+
const tbl = pgTryTable(PG_LAST.data);
|
|
2913
|
+
html += tbl || ('<div class="muted" style="font-size:13px;margin-bottom:8px">No tabular shape detected — showing Pretty.</div><pre class="pg-json">' + pgHighlight(PG_LAST.data) + '</pre>');
|
|
2914
|
+
} else { // pretty
|
|
2915
|
+
html += '<pre class="pg-json">' + pgHighlight(PG_LAST.data) + '</pre>';
|
|
2916
|
+
}
|
|
2917
|
+
body.innerHTML = html;
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// Syntax-highlight a value as pretty JSON. Escapes first (XSS-safe),
|
|
2921
|
+
// then wraps tokens in colour spans.
|
|
2922
|
+
function pgHighlight(value) {
|
|
2923
|
+
let s;
|
|
2924
|
+
if (typeof value === 'string') s = value;
|
|
2925
|
+
else s = JSON.stringify(value, null, 2);
|
|
2926
|
+
if (typeof s !== 'string') s = String(s);
|
|
2927
|
+
s = escHtml(s);
|
|
2928
|
+
// strings (incl. keys), then numbers / bools / null
|
|
2929
|
+
s = s.replace(/"(\\.|[^&]|&(?!quot;))*?"(\s*:)?/g, (m, _g, colon) =>
|
|
2930
|
+
'<span class="' + (colon ? 'k' : 's') + '">' + m.replace(/(\s*:)$/, '') + '</span>' + (colon || ''));
|
|
2931
|
+
s = s.replace(/\b(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '<span class="n">$1</span>');
|
|
2932
|
+
s = s.replace(/\b(true|false)\b/g, '<span class="b">$1</span>');
|
|
2933
|
+
s = s.replace(/\bnull\b/g, '<span class="z">null</span>');
|
|
2934
|
+
return s;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
// Render an array-of-objects (or an object whose first array value is one)
|
|
2938
|
+
// as a table. Returns null when nothing tabular is found.
|
|
2939
|
+
function pgTryTable(data) {
|
|
2940
|
+
let rows = null;
|
|
2941
|
+
if (Array.isArray(data) && data.length && data.every(r => r && typeof r === 'object' && !Array.isArray(r))) {
|
|
2942
|
+
rows = data;
|
|
2943
|
+
} else if (data && typeof data === 'object') {
|
|
2944
|
+
for (const k of Object.keys(data)) {
|
|
2945
|
+
const v = data[k];
|
|
2946
|
+
if (Array.isArray(v) && v.length && v.every(r => r && typeof r === 'object' && !Array.isArray(r))) { rows = v; break; }
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
if (!rows) return null;
|
|
2950
|
+
// Union of keys, preserving first-seen order.
|
|
2951
|
+
const cols = [];
|
|
2952
|
+
rows.forEach(r => Object.keys(r).forEach(k => { if (!cols.includes(k)) cols.push(k); }));
|
|
2953
|
+
const head = '<tr>' + cols.map(c => '<th>' + escHtml(c) + '</th>').join('') + '</tr>';
|
|
2954
|
+
const bodyRows = rows.map(r => '<tr>' + cols.map(c => '<td>' + pgCell(r[c]) + '</td>').join('') + '</tr>').join('');
|
|
2955
|
+
return '<div class="pg-tbl-wrap"><table class="pg-tbl"><thead>' + head + '</thead><tbody>' + bodyRows + '</tbody></table></div>';
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// One table cell. Status-like strings get a coloured pill; objects/arrays
|
|
2959
|
+
// collapse to compact JSON.
|
|
2960
|
+
function pgCell(v) {
|
|
2961
|
+
if (v == null) return '<span class="muted">—</span>';
|
|
2962
|
+
if (typeof v === 'object') return '<span class="muted" style="font-family:var(--mono);font-size:11px">' + escHtml(JSON.stringify(v)) + '</span>';
|
|
2963
|
+
const s = String(v);
|
|
2964
|
+
const cls = { up:'up', healthy:'healthy', ok:'ok', down:'down', error:'error', critical:'crit', crit:'crit', warn:'warn', warning:'warn', degraded:'degraded' }[s.toLowerCase()];
|
|
2965
|
+
if (cls) return '<span class="pill ' + cls + '">' + escHtml(s) + '</span>';
|
|
2966
|
+
return escHtml(s);
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
function pgCopy() {
|
|
2970
|
+
if (!PG_LAST) return;
|
|
2971
|
+
const text = PG_VIEW === 'raw'
|
|
2972
|
+
? JSON.stringify(PG_LAST.raw, null, 2)
|
|
2973
|
+
: (typeof PG_LAST.data === 'string' ? PG_LAST.data : JSON.stringify(PG_LAST.data, null, 2));
|
|
2974
|
+
navigator.clipboard.writeText(text).then(() => toast('Copied'), () => toast('Copy failed'));
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
function pgClear() {
|
|
2978
|
+
document.getElementById('pg-result-card').hidden = true;
|
|
2979
|
+
document.getElementById('pg-result-body').textContent = '';
|
|
2980
|
+
document.getElementById('pg-result-meta').textContent = '';
|
|
2981
|
+
PG_LAST = null;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// --- Postmortems tab (P6) -----------------------------------------------
|
|
2985
|
+
// Mirrors the generate_postmortem MCP tool into the UI: list persisted
|
|
2986
|
+
// entries newest-first, open a detail view rendering the markdown,
|
|
2987
|
+
// regenerate (POST /api/postmortems re-runs the tool with the same
|
|
2988
|
+
// service+window), delete (admin-only — the API gates).
|
|
2989
|
+
|
|
2990
|
+
let PM_LAST_DETAIL = null;
|
|
2991
|
+
|
|
2992
|
+
async function pmLoadList() {
|
|
2993
|
+
const body = document.getElementById('pm-list-body');
|
|
2994
|
+
if (!body) return;
|
|
2995
|
+
body.innerHTML = '<div class="empty">Loading…</div>';
|
|
2996
|
+
try {
|
|
2997
|
+
const r = await fetch('/api/postmortems');
|
|
2998
|
+
if (!r.ok) { body.innerHTML = '<div class="empty">HTTP ' + r.status + '</div>'; return; }
|
|
2999
|
+
const j = await r.json();
|
|
3000
|
+
if (!j.entries || j.entries.length === 0) {
|
|
3001
|
+
body.innerHTML = '<div class="empty">No postmortems yet. Click <b>+ Generate</b> to create one for a service.</div>';
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
let html = '<table class="data-table"><thead><tr><th>When</th><th>Service</th><th>Window</th><th>Synopsis</th><th>By</th><th></th></tr></thead><tbody>';
|
|
3005
|
+
for (const e of j.entries) {
|
|
3006
|
+
html += '<tr>'
|
|
3007
|
+
+ '<td>' + escHtml(e.ts) + '</td>'
|
|
3008
|
+
+ '<td><code>' + escHtml(e.service) + '</code></td>'
|
|
3009
|
+
+ '<td>' + escHtml(e.window) + '</td>'
|
|
3010
|
+
+ '<td>' + escHtml((e.synopsis || '').slice(0, 120)) + '</td>'
|
|
3011
|
+
+ '<td>' + escHtml(e.createdBy) + '</td>'
|
|
3012
|
+
+ '<td><button class="btn btn-sm" onclick="pmOpen(\'' + escHtml(e.id) + '\')">Open</button></td>'
|
|
3013
|
+
+ '</tr>';
|
|
3014
|
+
}
|
|
3015
|
+
html += '</tbody></table>';
|
|
3016
|
+
body.innerHTML = html;
|
|
3017
|
+
} catch (e) {
|
|
3018
|
+
body.innerHTML = '<div class="empty">' + escHtml('Load failed: ' + (e?.message || e)) + '</div>';
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
async function pmOpen(id) {
|
|
3023
|
+
try {
|
|
3024
|
+
const r = await fetch('/api/postmortems/' + encodeURIComponent(id));
|
|
3025
|
+
if (!r.ok) { alert('HTTP ' + r.status); return; }
|
|
3026
|
+
const j = await r.json();
|
|
3027
|
+
PM_LAST_DETAIL = j;
|
|
3028
|
+
const card = document.getElementById('pm-detail-card');
|
|
3029
|
+
const title = document.getElementById('pm-detail-title');
|
|
3030
|
+
const meta = document.getElementById('pm-detail-meta');
|
|
3031
|
+
const md = document.getElementById('pm-detail-md');
|
|
3032
|
+
title.textContent = j.report.service + ' — ' + j.report.window;
|
|
3033
|
+
meta.textContent = j.ts + ' · by ' + j.createdBy + ' · id ' + j.id;
|
|
3034
|
+
md.textContent = j.report.markdown || '';
|
|
3035
|
+
card.hidden = false;
|
|
3036
|
+
document.getElementById('pm-detail-regen').hidden = false;
|
|
3037
|
+
document.getElementById('pm-detail-delete').hidden = false;
|
|
3038
|
+
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
3039
|
+
} catch (e) { alert('Open failed: ' + (e?.message || e)); }
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
function pmCloseDetail() {
|
|
3043
|
+
PM_LAST_DETAIL = null;
|
|
3044
|
+
document.getElementById('pm-detail-card').hidden = true;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
async function pmOpenNew() {
|
|
3048
|
+
const service = prompt('Service name to generate a postmortem for:');
|
|
3049
|
+
if (!service) return;
|
|
3050
|
+
const duration = prompt('Window (e.g. 1h, 6h):', '1h') || '1h';
|
|
3051
|
+
await pmGenerate(service.trim(), duration.trim());
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
async function pmGenerate(service, duration) {
|
|
3055
|
+
try {
|
|
3056
|
+
const r = await fetch('/api/postmortems', {
|
|
3057
|
+
method: 'POST',
|
|
3058
|
+
headers: { 'content-type': 'application/json' },
|
|
3059
|
+
body: JSON.stringify({ service, duration }),
|
|
3060
|
+
});
|
|
3061
|
+
if (!r.ok) {
|
|
3062
|
+
const text = await r.text();
|
|
3063
|
+
alert('Generate failed: HTTP ' + r.status + (text ? '\n' + text : ''));
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
const stored = await r.json();
|
|
3067
|
+
await pmLoadList();
|
|
3068
|
+
await pmOpen(stored.id);
|
|
3069
|
+
} catch (e) { alert('Generate failed: ' + (e?.message || e)); }
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
async function pmRegenerate() {
|
|
3073
|
+
if (!PM_LAST_DETAIL) return;
|
|
3074
|
+
await pmGenerate(PM_LAST_DETAIL.report.service, PM_LAST_DETAIL.report.window);
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
async function pmDelete() {
|
|
3078
|
+
if (!PM_LAST_DETAIL) return;
|
|
3079
|
+
if (!confirm('Delete this postmortem? This cannot be undone.')) return;
|
|
3080
|
+
try {
|
|
3081
|
+
const r = await fetch('/api/postmortems/' + encodeURIComponent(PM_LAST_DETAIL.id), { method: 'DELETE' });
|
|
3082
|
+
if (r.status === 204) { pmCloseDetail(); await pmLoadList(); return; }
|
|
3083
|
+
alert('Delete failed: HTTP ' + r.status);
|
|
3084
|
+
} catch (e) { alert('Delete failed: ' + (e?.message || e)); }
|
|
2504
3085
|
}
|
|
2505
3086
|
|
|
2506
3087
|
// --- Policies tab (PolicyEngine snapshot + dry-run) ---
|
|
@@ -2568,6 +3149,150 @@ function polSetTab(name) {
|
|
|
2568
3149
|
// so deferring the fetch keeps the page-enter cost low.
|
|
2569
3150
|
if (name === 'subjects') polLoadSubjects();
|
|
2570
3151
|
if (name === 'bindings') polLoadBindings();
|
|
3152
|
+
if (name === 'batch') polBatchInit();
|
|
3153
|
+
}
|
|
3154
|
+
|
|
3155
|
+
// --- Batch evaluate sub-tab (P4) ---
|
|
3156
|
+
//
|
|
3157
|
+
// One round-trip to POST /api/policy/dry-run-batch with the
|
|
3158
|
+
// parsed subjects + selected resources + selected actions.
|
|
3159
|
+
// Renders the (subject × resource × action) matrix as a coloured
|
|
3160
|
+
// heat-map. CSV export re-hits the same endpoint with
|
|
3161
|
+
// `Accept: text/csv` so we never have to format CSV in the UI.
|
|
3162
|
+
|
|
3163
|
+
let POL_BATCH_INITED = false;
|
|
3164
|
+
let POL_BATCH_LAST = null;
|
|
3165
|
+
|
|
3166
|
+
function polBatchInit() {
|
|
3167
|
+
if (POL_BATCH_INITED) return;
|
|
3168
|
+
POL_BATCH_INITED = true;
|
|
3169
|
+
// Reuse POL_RESOURCES + POL_ACTIONS — the same constants the Roles
|
|
3170
|
+
// matrix renders from, kept in sync with VALID_RESOURCES /
|
|
3171
|
+
// VALID_ACTIONS server-side via a CI check.
|
|
3172
|
+
const resSel = document.getElementById('pol-batch-resources');
|
|
3173
|
+
const actSel = document.getElementById('pol-batch-actions');
|
|
3174
|
+
if (resSel) {
|
|
3175
|
+
resSel.innerHTML = POL_RESOURCES.map((r) => '<option value="' + escHtml(r) + '" selected>' + escHtml(r) + '</option>').join('');
|
|
3176
|
+
}
|
|
3177
|
+
if (actSel) {
|
|
3178
|
+
actSel.innerHTML = POL_ACTIONS.map((a) => '<option value="' + escHtml(a) + '" selected>' + escHtml(a) + '</option>').join('');
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
function polBatchParseSubjects(text) {
|
|
3183
|
+
const out = [];
|
|
3184
|
+
for (const line of String(text || '').split('\n')) {
|
|
3185
|
+
const t = line.trim();
|
|
3186
|
+
if (!t) continue;
|
|
3187
|
+
const eq = t.indexOf('=');
|
|
3188
|
+
if (eq < 0) continue;
|
|
3189
|
+
const key = t.slice(0, eq).trim();
|
|
3190
|
+
const roles = t.slice(eq + 1).split(',').map((r) => r.trim()).filter(Boolean);
|
|
3191
|
+
if (!key || roles.length === 0) continue;
|
|
3192
|
+
out.push({ key, roles });
|
|
3193
|
+
}
|
|
3194
|
+
return out;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
function polBatchBuildRequest() {
|
|
3198
|
+
const subjects = polBatchParseSubjects(document.getElementById('pol-batch-subjects')?.value);
|
|
3199
|
+
const resources = Array.from(document.getElementById('pol-batch-resources')?.selectedOptions || []).map((o) => o.value);
|
|
3200
|
+
const actions = Array.from(document.getElementById('pol-batch-actions')?.selectedOptions || []).map((o) => o.value);
|
|
3201
|
+
return { subjects, resources, actions };
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
async function polBatchEvaluate() {
|
|
3205
|
+
const body = polBatchBuildRequest();
|
|
3206
|
+
const out = document.getElementById('pol-batch-result');
|
|
3207
|
+
const totals = document.getElementById('pol-batch-totals');
|
|
3208
|
+
const csvBtn = document.getElementById('pol-batch-csv-btn');
|
|
3209
|
+
if (body.subjects.length === 0 || body.resources.length === 0 || body.actions.length === 0) {
|
|
3210
|
+
out.innerHTML = '<div class="empty">Need at least one subject, one resource, and one action.</div>';
|
|
3211
|
+
if (csvBtn) csvBtn.disabled = true;
|
|
3212
|
+
if (totals) totals.textContent = '';
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
out.innerHTML = '<div class="muted">Evaluating…</div>';
|
|
3216
|
+
try {
|
|
3217
|
+
const r = await fetch('/api/policy/dry-run-batch', {
|
|
3218
|
+
method: 'POST',
|
|
3219
|
+
headers: { 'content-type': 'application/json' },
|
|
3220
|
+
body: JSON.stringify(body),
|
|
3221
|
+
});
|
|
3222
|
+
if (!r.ok) {
|
|
3223
|
+
out.innerHTML = '<div class="empty">' + escHtml('Evaluate failed: HTTP ' + r.status) + '</div>';
|
|
3224
|
+
if (csvBtn) csvBtn.disabled = true;
|
|
3225
|
+
return;
|
|
3226
|
+
}
|
|
3227
|
+
const j = await r.json();
|
|
3228
|
+
POL_BATCH_LAST = body;
|
|
3229
|
+
polBatchRender(j, body);
|
|
3230
|
+
if (csvBtn) csvBtn.disabled = false;
|
|
3231
|
+
if (totals) totals.textContent = `${j.totals.cells} cells · ${j.totals.allow} allow · ${j.totals.deny} deny`;
|
|
3232
|
+
} catch (e) {
|
|
3233
|
+
out.innerHTML = '<div class="empty">' + escHtml('Evaluate failed: ' + (e?.message || e)) + '</div>';
|
|
3234
|
+
if (csvBtn) csvBtn.disabled = true;
|
|
3235
|
+
}
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
function polBatchRender(result, req) {
|
|
3239
|
+
const out = document.getElementById('pol-batch-result');
|
|
3240
|
+
if (!out) return;
|
|
3241
|
+
// Matrix layout: rows = subjects, columns = (resource, action) pairs.
|
|
3242
|
+
// Grouping resources visually keeps the table compact.
|
|
3243
|
+
const rows = req.subjects.map((s) => s.key);
|
|
3244
|
+
const colGroups = req.resources.map((res) => ({ res, actions: req.actions.slice() }));
|
|
3245
|
+
let html = '<div style="overflow:auto;max-height:520px"><table class="pol-heat">';
|
|
3246
|
+
// Resource header row
|
|
3247
|
+
html += '<thead><tr><th rowspan="2" class="row-head">Subject</th>';
|
|
3248
|
+
for (const g of colGroups) html += '<th class="resource-head" colspan="' + g.actions.length + '">' + escHtml(g.res) + '</th>';
|
|
3249
|
+
html += '</tr><tr>';
|
|
3250
|
+
for (const g of colGroups) for (const a of g.actions) html += '<th>' + escHtml(a) + '</th>';
|
|
3251
|
+
html += '</tr></thead><tbody>';
|
|
3252
|
+
for (const sk of rows) {
|
|
3253
|
+
html += '<tr><td class="row-head">' + escHtml(sk) + '</td>';
|
|
3254
|
+
for (const g of colGroups) {
|
|
3255
|
+
for (const a of g.actions) {
|
|
3256
|
+
const cell = result.matrix?.[sk]?.[g.res]?.[a];
|
|
3257
|
+
if (!cell) {
|
|
3258
|
+
html += '<td class="cell-na" title="not evaluated">·</td>';
|
|
3259
|
+
} else if (cell.allowed) {
|
|
3260
|
+
html += '<td class="cell-allow" title="' + escHtml(cell.reason || 'allow') + '">✓</td>';
|
|
3261
|
+
} else {
|
|
3262
|
+
html += '<td class="cell-deny" title="' + escHtml(cell.reason || 'deny') + '">✗</td>';
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
html += '</tr>';
|
|
3267
|
+
}
|
|
3268
|
+
html += '</tbody></table></div>';
|
|
3269
|
+
if (Array.isArray(result.dropped) && result.dropped.length) {
|
|
3270
|
+
html += '<div class="pol-batch-dropped">Dropped: ' +
|
|
3271
|
+
result.dropped.map((d) => escHtml(d.kind + ' ' + d.value + ' (' + d.reason + ')')).join('; ') + '</div>';
|
|
3272
|
+
}
|
|
3273
|
+
out.innerHTML = html;
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
async function polBatchExportCsv() {
|
|
3277
|
+
if (!POL_BATCH_LAST) return;
|
|
3278
|
+
try {
|
|
3279
|
+
const r = await fetch('/api/policy/dry-run-batch', {
|
|
3280
|
+
method: 'POST',
|
|
3281
|
+
headers: { 'content-type': 'application/json', 'accept': 'text/csv' },
|
|
3282
|
+
body: JSON.stringify(POL_BATCH_LAST),
|
|
3283
|
+
});
|
|
3284
|
+
if (!r.ok) { alert('CSV export failed: HTTP ' + r.status); return; }
|
|
3285
|
+
const csv = await r.text();
|
|
3286
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
3287
|
+
const url = URL.createObjectURL(blob);
|
|
3288
|
+
const a = document.createElement('a');
|
|
3289
|
+
a.href = url;
|
|
3290
|
+
a.download = 'policy-dry-run-' + new Date().toISOString().replace(/[:.]/g, '-') + '.csv';
|
|
3291
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
3292
|
+
URL.revokeObjectURL(url);
|
|
3293
|
+
} catch (e) {
|
|
3294
|
+
alert('CSV export failed: ' + (e?.message || e));
|
|
3295
|
+
}
|
|
2571
3296
|
}
|
|
2572
3297
|
|
|
2573
3298
|
// Bindings = the (subject → roles) view derived from /api/subjects.
|
|
@@ -3195,7 +3920,29 @@ function toggleTheme(){
|
|
|
3195
3920
|
const _omcpRawFetch = window.fetch.bind(window);
|
|
3196
3921
|
let _omcpLoginInflight = null;
|
|
3197
3922
|
let _omcpLoginResolve = null;
|
|
3923
|
+
// CSRF double-submit: the server issues a non-HttpOnly omcp-csrf
|
|
3924
|
+
// cookie and enforces that mutating /api requests echo it back in
|
|
3925
|
+
// X-CSRF-Token. The browser sends the cookie automatically but never
|
|
3926
|
+
// the header — so this wrapper reads the cookie and injects the
|
|
3927
|
+
// matching header on every state-changing request. Safe methods and
|
|
3928
|
+
// cross-origin requests are left untouched.
|
|
3929
|
+
function _omcpCsrfToken() {
|
|
3930
|
+
const m = document.cookie.match(/(?:^|;\s*)omcp-csrf=([^;]+)/);
|
|
3931
|
+
return m ? decodeURIComponent(m[1]) : null;
|
|
3932
|
+
}
|
|
3933
|
+
function _omcpWithCsrf(input, init) {
|
|
3934
|
+
const method = ((init && init.method) || (typeof input === 'object' && input.method) || 'GET').toUpperCase();
|
|
3935
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return init;
|
|
3936
|
+
const token = _omcpCsrfToken();
|
|
3937
|
+
if (!token) return init;
|
|
3938
|
+
const next = Object.assign({}, init);
|
|
3939
|
+
const headers = new Headers((init && init.headers) || (typeof input === 'object' && input.headers) || {});
|
|
3940
|
+
if (!headers.has('X-CSRF-Token')) headers.set('X-CSRF-Token', token);
|
|
3941
|
+
next.headers = headers;
|
|
3942
|
+
return next;
|
|
3943
|
+
}
|
|
3198
3944
|
window.fetch = async function omcpAuthedFetch(input, init) {
|
|
3945
|
+
init = _omcpWithCsrf(input, init);
|
|
3199
3946
|
const res = await _omcpRawFetch(input, init);
|
|
3200
3947
|
if (res.status !== 401) return res;
|
|
3201
3948
|
let body = null;
|
|
@@ -3382,10 +4129,21 @@ async function omcpCheckGovernance() {
|
|
|
3382
4129
|
const info = await r.json();
|
|
3383
4130
|
const g = info && info.governance;
|
|
3384
4131
|
if (!g) return;
|
|
4132
|
+
const warns = [];
|
|
3385
4133
|
if (g.authMode === 'basic' && g.authSecretEphemeral) {
|
|
4134
|
+
warns.push('OMCP_SESSION_SECRET is not set — every sign-in will be lost on server restart. Set a stable value in production.');
|
|
4135
|
+
}
|
|
4136
|
+
// P9: plugin signature verification is off — filesystem plugins
|
|
4137
|
+
// load without integrity checks. Production deployments should
|
|
4138
|
+
// turn this back on (VERIFY_PLUGINS=true is the default since
|
|
4139
|
+
// F1, so seeing this banner means an operator opted OUT).
|
|
4140
|
+
if (g.pluginsVerified === false) {
|
|
4141
|
+
warns.push('Plugin signature verification is OFF (VERIFY_PLUGINS=false). Any filesystem plugin can load unchecked. Re-enable for production.');
|
|
4142
|
+
}
|
|
4143
|
+
if (warns.length > 0) {
|
|
3386
4144
|
const bar = document.getElementById('omcp-warning-bar');
|
|
3387
4145
|
if (bar) {
|
|
3388
|
-
bar.textContent = '⚠
|
|
4146
|
+
bar.textContent = '⚠ ' + warns.join(' · ');
|
|
3389
4147
|
bar.style.display = '';
|
|
3390
4148
|
}
|
|
3391
4149
|
}
|
|
@@ -4167,22 +4925,16 @@ function mcpProductsSetView(v) {
|
|
|
4167
4925
|
loadMcpProducts();
|
|
4168
4926
|
}
|
|
4169
4927
|
|
|
4170
|
-
//
|
|
4171
|
-
//
|
|
4172
|
-
//
|
|
4173
|
-
function
|
|
4174
|
-
|
|
4175
|
-
if (!card) return;
|
|
4176
|
-
let collapsed = false;
|
|
4177
|
-
try { collapsed = localStorage.getItem('omcp-mcp-leitfaden-collapsed') === '1'; } catch (e) { /* noop */ }
|
|
4178
|
-
card.classList.toggle('collapsed', collapsed);
|
|
4928
|
+
// Legacy-catalog disclosure: persist open/closed so an operator who
|
|
4929
|
+
// actually uses the deprecated catalog isn't re-expanding it every
|
|
4930
|
+
// visit. Default closed (the <details> has no `open` attribute).
|
|
4931
|
+
function pgLegacyPersist(el) {
|
|
4932
|
+
try { localStorage.setItem('omcp-legacy-catalog-open', el.open ? '1' : '0'); } catch (e) { /* noop */ }
|
|
4179
4933
|
}
|
|
4180
|
-
function
|
|
4181
|
-
const
|
|
4182
|
-
if (!
|
|
4183
|
-
|
|
4184
|
-
card.classList.toggle('collapsed', nextCollapsed);
|
|
4185
|
-
try { localStorage.setItem('omcp-mcp-leitfaden-collapsed', nextCollapsed ? '1' : '0'); } catch (e) { /* noop */ }
|
|
4934
|
+
function pgLegacyRestore() {
|
|
4935
|
+
const el = document.getElementById('ent-legacy');
|
|
4936
|
+
if (!el) return;
|
|
4937
|
+
try { el.open = localStorage.getItem('omcp-legacy-catalog-open') === '1'; } catch (e) { /* noop */ }
|
|
4186
4938
|
}
|
|
4187
4939
|
|
|
4188
4940
|
// Cache the tool registry — fetched once on first modal open and
|
|
@@ -4404,7 +5156,7 @@ function mcpProductEmptyHtml(configured) {
|
|
|
4404
5156
|
}
|
|
4405
5157
|
|
|
4406
5158
|
async function loadMcpProducts() {
|
|
4407
|
-
|
|
5159
|
+
pgLegacyRestore();
|
|
4408
5160
|
const box = document.getElementById('mcp-products-box');
|
|
4409
5161
|
const scope = document.getElementById('mcp-products-scope');
|
|
4410
5162
|
if (!box) return;
|
|
@@ -5963,7 +6715,10 @@ function renderTopologyGraph(){
|
|
|
5963
6715
|
const r = idx.byId.get(id);
|
|
5964
6716
|
const base = topoKindColor(r ? r.kind : 'scope');
|
|
5965
6717
|
// soft alpha + slightly lighter
|
|
5966
|
-
|
|
6718
|
+
// `base` already starts with `#` from topoKindColor; just append
|
|
6719
|
+
// a 1-byte alpha to get an 8-digit hex CSS colour. The dead
|
|
6720
|
+
// `.replace(/^#/, '#')` step from an earlier refactor is gone.
|
|
6721
|
+
return base + '22';
|
|
5967
6722
|
};
|
|
5968
6723
|
const scopeBands = [];
|
|
5969
6724
|
for (const scopeId of pureScope){
|