@thotischner/observability-mcp 1.8.1 → 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/analysis/history.d.ts +70 -0
- package/dist/analysis/history.js +170 -0
- package/dist/analysis/history.test.d.ts +1 -0
- package/dist/analysis/history.test.js +141 -0
- package/dist/audit/log.d.ts +9 -0
- package/dist/audit/log.js +20 -0
- package/dist/audit/redaction-bypass.d.ts +67 -0
- package/dist/audit/redaction-bypass.js +64 -0
- package/dist/audit/redaction-bypass.test.d.ts +1 -0
- package/dist/audit/redaction-bypass.test.js +72 -0
- 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/audit/sinks/types.d.ts +18 -0
- package/dist/audit/sinks/types.js +1 -0
- package/dist/audit/sinks/webhook.d.ts +45 -0
- package/dist/audit/sinks/webhook.js +111 -0
- package/dist/audit/sinks/webhook.test.d.ts +1 -0
- package/dist/audit/sinks/webhook.test.js +162 -0
- package/dist/auth/credentials.d.ts +11 -0
- package/dist/auth/credentials.js +27 -0
- package/dist/auth/credentials.test.js +21 -1
- package/dist/auth/csrf.d.ts +26 -0
- package/dist/auth/csrf.js +128 -0
- package/dist/auth/csrf.test.d.ts +1 -0
- package/dist/auth/csrf.test.js +143 -0
- package/dist/auth/local-users.d.ts +6 -0
- package/dist/auth/local-users.js +11 -0
- package/dist/auth/local-users.test.js +41 -0
- package/dist/auth/middleware.d.ts +7 -6
- package/dist/auth/oidc/dcr.d.ts +70 -0
- package/dist/auth/oidc/dcr.js +160 -0
- package/dist/auth/oidc/dcr.test.d.ts +1 -0
- package/dist/auth/oidc/dcr.test.js +109 -0
- package/dist/auth/oidc/endpoints.js +44 -0
- package/dist/auth/oidc/profiles.d.ts +22 -0
- package/dist/auth/oidc/profiles.js +95 -0
- package/dist/auth/oidc/profiles.test.d.ts +1 -0
- package/dist/auth/oidc/profiles.test.js +51 -0
- package/dist/auth/oidc/runtime.d.ts +3 -0
- package/dist/auth/oidc/runtime.js +16 -3
- package/dist/auth/oidc/runtime.test.js +1 -0
- package/dist/auth/policy/batch-dry-run.d.ts +56 -0
- package/dist/auth/policy/batch-dry-run.js +144 -0
- package/dist/auth/policy/batch-dry-run.test.d.ts +1 -0
- package/dist/auth/policy/batch-dry-run.test.js +140 -0
- package/dist/auth/policy/engine.d.ts +20 -4
- package/dist/auth/policy/engine.js +16 -2
- package/dist/auth/policy/loader.d.ts +11 -1
- package/dist/auth/policy/loader.js +37 -0
- package/dist/auth/policy/loader.test.d.ts +1 -0
- package/dist/auth/policy/loader.test.js +86 -0
- package/dist/auth/policy/opa.d.ts +5 -5
- package/dist/auth/policy/opa.js +25 -14
- package/dist/auth/policy/opa.test.js +48 -0
- package/dist/auth/rbac.d.ts +23 -1
- package/dist/auth/rbac.js +43 -1
- package/dist/auth/rbac.test.js +62 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/inspector-config.d.ts +9 -0
- package/dist/cli/inspector-config.js +28 -0
- package/dist/cli/inspector-config.test.d.ts +1 -0
- package/dist/cli/inspector-config.test.js +33 -0
- package/dist/cli/lib.d.ts +1 -1
- package/dist/cli/lib.js +1 -0
- package/dist/conformance/mcp-2025-11-25.test.d.ts +1 -0
- package/dist/conformance/mcp-2025-11-25.test.js +206 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loader.d.ts +8 -0
- package/dist/connectors/loader.js +55 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -0
- package/dist/connectors/manifest-hooks.test.d.ts +1 -0
- package/dist/connectors/manifest-hooks.test.js +206 -0
- package/dist/connectors/prometheus.test.js +31 -13
- package/dist/connectors/registry.d.ts +13 -0
- package/dist/connectors/registry.js +30 -0
- package/dist/connectors/registry.test.js +56 -2
- package/dist/context.d.ts +32 -0
- package/dist/context.js +35 -0
- package/dist/context.test.d.ts +1 -0
- package/dist/context.test.js +58 -0
- package/dist/federation/registry.d.ts +54 -0
- package/dist/federation/registry.js +122 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +206 -0
- package/dist/federation/upstream.d.ts +86 -0
- package/dist/federation/upstream.js +162 -0
- package/dist/federation/upstream.test.d.ts +1 -0
- package/dist/federation/upstream.test.js +118 -0
- package/dist/index.js +1435 -126
- package/dist/metrics/self.d.ts +1 -0
- package/dist/metrics/self.js +8 -0
- package/dist/middleware/ssrfGuard.d.ts +15 -0
- package/dist/middleware/ssrfGuard.js +103 -0
- package/dist/middleware/ssrfGuard.test.d.ts +1 -0
- package/dist/middleware/ssrfGuard.test.js +81 -0
- package/dist/observability/otel.d.ts +20 -0
- package/dist/observability/otel.js +118 -0
- package/dist/observability/otel.test.d.ts +1 -0
- package/dist/observability/otel.test.js +56 -0
- package/dist/openapi.js +215 -7
- package/dist/openapi.test.js +34 -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/postmortem/synthesizer.d.ts +83 -0
- package/dist/postmortem/synthesizer.js +205 -0
- package/dist/postmortem/synthesizer.test.d.ts +1 -0
- package/dist/postmortem/synthesizer.test.js +141 -0
- package/dist/products/loader.d.ts +31 -3
- package/dist/products/loader.js +77 -4
- package/dist/products/loader.test.js +90 -1
- package/dist/quota/charge.d.ts +28 -0
- package/dist/quota/charge.js +30 -0
- package/dist/quota/charge.test.d.ts +1 -0
- package/dist/quota/charge.test.js +83 -0
- package/dist/quota/limiter.d.ts +29 -4
- package/dist/quota/limiter.js +64 -8
- package/dist/quota/limiter.test.js +86 -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/group-role-map.d.ts +4 -0
- package/dist/scim/group-role-map.js +33 -0
- package/dist/scim/group-role-map.test.d.ts +1 -0
- package/dist/scim/group-role-map.test.js +33 -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 +40 -0
- package/dist/scim/routes.js +395 -0
- package/dist/scim/store.d.ts +76 -0
- package/dist/scim/store.js +196 -0
- package/dist/scim/store.test.d.ts +1 -0
- package/dist/scim/store.test.js +121 -0
- package/dist/scim/types.d.ts +73 -0
- package/dist/scim/types.js +29 -0
- 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/hooks.d.ts +77 -0
- package/dist/sdk/hooks.js +72 -0
- package/dist/sdk/hooks.test.d.ts +1 -0
- package/dist/sdk/hooks.test.js +159 -0
- package/dist/sdk/index.d.ts +15 -0
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/manifest-schema.d.ts +17 -0
- package/dist/sdk/manifest-schema.js +21 -0
- package/dist/tools/context-seam.test.js +6 -1
- package/dist/tools/detect-anomalies.d.ts +12 -1
- package/dist/tools/detect-anomalies.js +26 -5
- package/dist/tools/generate-postmortem.d.ts +35 -0
- package/dist/tools/generate-postmortem.js +191 -0
- package/dist/tools/get-anomaly-history.d.ts +35 -0
- package/dist/tools/get-anomaly-history.js +126 -0
- package/dist/tools/get-service-health.d.ts +1 -1
- package/dist/tools/get-service-health.js +4 -3
- package/dist/tools/list-services.d.ts +1 -1
- package/dist/tools/list-services.js +3 -2
- package/dist/tools/list-sources.d.ts +1 -1
- package/dist/tools/list-sources.js +6 -2
- package/dist/tools/query-logs.d.ts +1 -1
- package/dist/tools/query-logs.js +2 -2
- package/dist/tools/query-metrics.d.ts +1 -1
- package/dist/tools/query-metrics.js +19 -6
- package/dist/tools/query-traces.d.ts +47 -0
- package/dist/tools/query-traces.js +145 -0
- package/dist/tools/query-traces.test.d.ts +1 -0
- package/dist/tools/query-traces.test.js +110 -0
- package/dist/tools/registry-names.d.ts +35 -0
- package/dist/tools/registry-names.js +54 -0
- package/dist/tools/registry-names.test.d.ts +1 -0
- package/dist/tools/registry-names.test.js +61 -0
- package/dist/tools/topology.d.ts +3 -3
- package/dist/tools/topology.js +33 -11
- package/dist/tools/topology.test.js +45 -0
- package/dist/topology/merge.d.ts +22 -0
- package/dist/topology/merge.js +178 -0
- package/dist/topology/merge.test.d.ts +1 -0
- package/dist/topology/merge.test.js +110 -0
- package/dist/transport/sessionStore.d.ts +66 -0
- package/dist/transport/sessionStore.js +138 -0
- package/dist/transport/sessionStore.test.d.ts +1 -0
- package/dist/transport/sessionStore.test.js +118 -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/transport/websocket.d.ts +35 -0
- package/dist/transport/websocket.js +133 -0
- package/dist/transport/websocket.test.d.ts +1 -0
- package/dist/transport/websocket.test.js +124 -0
- package/dist/types.d.ts +51 -0
- package/dist/ui/index.html +2529 -145
- package/package.json +13 -3
package/dist/ui/index.html
CHANGED
|
@@ -936,6 +936,206 @@
|
|
|
936
936
|
.view-toggle button + button { border-left: 1px solid var(--border-strong); }
|
|
937
937
|
.view-toggle button.active { background: var(--accent-soft); color: var(--accent); }
|
|
938
938
|
.view-toggle button:hover { color: var(--text); }
|
|
939
|
+
|
|
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; }
|
|
957
|
+
|
|
958
|
+
/* Products — card grid */
|
|
959
|
+
.pcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
|
|
960
|
+
.pcard { position: relative; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: var(--sp-4); padding-left: calc(var(--sp-4) + 6px); display: flex; flex-direction: column; gap: var(--sp-2); transition: border-color .15s ease, box-shadow .15s ease; }
|
|
961
|
+
.pcard:hover { border-color: var(--border-strong); box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
|
962
|
+
.pcard-rail { position: absolute; left: 0; top: var(--sp-3); bottom: var(--sp-3); width: 4px; border-radius: 0 2px 2px 0; background: var(--accent); }
|
|
963
|
+
.pcard-hd { display: flex; align-items: flex-start; gap: var(--sp-2); }
|
|
964
|
+
.pcard-icon { width: 32px; height: 32px; flex: 0 0 32px; border-radius: var(--radius-sm); background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 18px; overflow: hidden; }
|
|
965
|
+
.pcard-icon img { width: 100%; height: 100%; object-fit: cover; }
|
|
966
|
+
.pcard-title { flex: 1; min-width: 0; }
|
|
967
|
+
.pcard-title h3 { font-size: 14px; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
968
|
+
.pcard-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
969
|
+
.pcard-status { flex: 0 0 auto; }
|
|
970
|
+
.pcard-desc { font-size: 12px; line-height: 1.45; opacity: .85; min-height: 2.9em; }
|
|
971
|
+
.pcard-tools-label { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); }
|
|
972
|
+
.pcard-tools { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
|
|
973
|
+
.pcard-tool { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; padding: 2px 6px; background: var(--surface-2); border-radius: var(--radius-sm); border: 1px solid var(--border); }
|
|
974
|
+
.pcard-tool-all { font-size: 11px; opacity: .55; font-style: italic; }
|
|
975
|
+
.pcard-footer { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); padding-top: var(--sp-2); border-top: 1px solid var(--border); }
|
|
976
|
+
.pcard-footer .pcard-spacer { flex: 1; }
|
|
977
|
+
|
|
978
|
+
/* Policies — engine banner + sticky dry-run */
|
|
979
|
+
.pol-engine-banner { display: flex; align-items: flex-start; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: var(--sp-3); }
|
|
980
|
+
.pol-engine-banner .pol-eb-dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--text-2); }
|
|
981
|
+
.pol-engine-banner .pol-eb-body { flex: 1; min-width: 0; }
|
|
982
|
+
.pol-engine-banner .pol-eb-title { font-weight: 600; font-size: 13px; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
|
|
983
|
+
.pol-engine-banner .pol-eb-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
984
|
+
.pol-engine-banner .pol-eb-body p { margin: var(--sp-2) 0 0; font-size: 12px; line-height: 1.5; opacity: .9; max-width: 720px; }
|
|
985
|
+
.pol-engine-banner.eng-builtin { background: var(--surface-2); border-color: var(--border); }
|
|
986
|
+
.pol-engine-banner.eng-builtin .pol-eb-dot { background: var(--text-2); }
|
|
987
|
+
.pol-engine-banner.eng-file { background: var(--success-soft); border-color: var(--success); }
|
|
988
|
+
.pol-engine-banner.eng-file .pol-eb-dot { background: var(--success); }
|
|
989
|
+
.pol-engine-banner.eng-opa { background: var(--warning-soft); border-color: var(--warning); }
|
|
990
|
+
.pol-engine-banner.eng-opa .pol-eb-dot { background: var(--warning); }
|
|
991
|
+
.pol-engine-banner .pol-eb-pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-weight: 500; }
|
|
992
|
+
|
|
993
|
+
/* Policies sub-tabs (Roles / Bindings / Subjects) */
|
|
994
|
+
.pol-subtabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-3); }
|
|
995
|
+
.pol-subtab { background: none; border: 0; padding: var(--sp-3) var(--sp-4); color: var(--text-2); cursor: pointer; font-size: 13px; position: relative; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
996
|
+
.pol-subtab:hover { color: var(--text); }
|
|
997
|
+
.pol-subtab[data-active="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 500; }
|
|
998
|
+
.pol-pane[hidden] { display: none; }
|
|
999
|
+
|
|
1000
|
+
/* Roles master/detail */
|
|
1001
|
+
.pol-roles-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 320px; }
|
|
1002
|
+
.pol-role-list { border-right: 1px solid var(--border); max-height: 60vh; overflow-y: auto; }
|
|
1003
|
+
.pol-role-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); cursor: pointer; border-bottom: 1px solid var(--border); }
|
|
1004
|
+
.pol-role-row:hover { background: var(--surface-2); }
|
|
1005
|
+
.pol-role-row[data-active="true"] { background: var(--accent-soft); }
|
|
1006
|
+
.pol-role-row .pol-role-name { font-weight: 500; font-size: 13px; }
|
|
1007
|
+
.pol-role-row .pol-role-count { font-size: 11px; opacity: .65; }
|
|
1008
|
+
.pol-role-detail { padding: var(--sp-4); overflow-x: auto; }
|
|
1009
|
+
.pol-role-detail h3 { margin: 0 0 var(--sp-3); font-size: 14px; font-weight: 600; }
|
|
1010
|
+
.pol-matrix { border-collapse: collapse; width: 100%; font-size: 12px; }
|
|
1011
|
+
.pol-matrix th, .pol-matrix td { padding: var(--sp-2); border: 1px solid var(--border); text-align: left; }
|
|
1012
|
+
.pol-matrix thead th { background: var(--surface-2); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .8; }
|
|
1013
|
+
.pol-matrix tbody th { font-weight: 500; background: var(--surface-2); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; }
|
|
1014
|
+
.pol-matrix td { text-align: center; }
|
|
1015
|
+
.pol-matrix .cell-grant { color: var(--success); font-weight: 600; }
|
|
1016
|
+
.pol-matrix .cell-empty { opacity: .25; }
|
|
1017
|
+
/* Effective-permissions overlay cells — drop the bold/success
|
|
1018
|
+
colour so "via <role>" reads as informational, not as a fresh
|
|
1019
|
+
grant; "denied" cells stay dimmed via cell-empty. */
|
|
1020
|
+
.pol-matrix td[data-effective="allowed"] { color: var(--text-1); font-weight: 400; }
|
|
1021
|
+
.pol-matrix td[data-effective="allowed"][data-via-selected="true"] { color: var(--success); font-weight: 500; }
|
|
1022
|
+
.pol-matrix td[data-effective="denied"] { color: var(--text-3); opacity: .55; font-style: italic; }
|
|
1023
|
+
@media (max-width: 900px) {
|
|
1024
|
+
.pol-roles-layout { grid-template-columns: 1fr; }
|
|
1025
|
+
.pol-role-list { border-right: 0; border-bottom: 1px solid var(--border); max-height: none; }
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/* Subjects sub-tab — three stacked sections */
|
|
1029
|
+
.pol-subjects-section { padding: var(--sp-3) var(--sp-4); }
|
|
1030
|
+
.pol-subjects-section + .pol-subjects-section { border-top: 1px solid var(--border); }
|
|
1031
|
+
.pol-subjects-section h3 { margin: 0 0 var(--sp-2); font-size: 13px; font-weight: 600; display: flex; align-items: baseline; gap: var(--sp-2); }
|
|
1032
|
+
.pol-subjects-section h3 .pol-subjects-count { font-size: 11px; opacity: .65; font-weight: 400; }
|
|
1033
|
+
.pol-subjects-section h3 .pol-subjects-source { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; opacity: .55; margin-left: auto; }
|
|
1034
|
+
.pol-subjects-empty { font-size: 12px; opacity: .65; padding: var(--sp-2) 0; }
|
|
1035
|
+
|
|
1036
|
+
/* Sticky dry-run bar — pinned at the top of the policies page */
|
|
1037
|
+
.pol-probe-bar { position: sticky; top: 0; z-index: 5; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); margin-bottom: var(--sp-3); box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
|
1038
|
+
.pol-probe-grid { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: var(--sp-3); align-items: end; }
|
|
1039
|
+
.pol-probe-grid .form-group { margin: 0; }
|
|
1040
|
+
.pol-probe-result { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0 0; }
|
|
1041
|
+
.pol-probe-result .pol-pv { font-weight: 600; padding: 2px 10px; border-radius: 4px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
|
|
1042
|
+
.pol-probe-result .pol-pv.allow { background: var(--success-soft); color: var(--success); }
|
|
1043
|
+
.pol-probe-result .pol-pv.deny { background: var(--danger-soft); color: var(--danger); }
|
|
1044
|
+
.pol-probe-result code { font-size: 11px; opacity: .8; }
|
|
1045
|
+
@media (max-width: 1000px) {
|
|
1046
|
+
.pol-probe-grid { grid-template-columns: 1fr 1fr; }
|
|
1047
|
+
}
|
|
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
|
+
|
|
1068
|
+
/* Author-controls are hidden when the active engine is read-only.
|
|
1069
|
+
Anything with data-engine-required="file" needs the file engine. */
|
|
1070
|
+
body[data-policy-engine="opa"] [data-engine-required="file"],
|
|
1071
|
+
body[data-policy-engine="builtin"] [data-engine-required="file"] {
|
|
1072
|
+
opacity: .35; pointer-events: none;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/* Products wizard — multi-step modal */
|
|
1076
|
+
.mcp-product-wizard { width: min(720px, 92vw); max-height: 86vh; display: flex; flex-direction: column; }
|
|
1077
|
+
.mcp-product-wizard .modal-body { flex: 1; overflow-y: auto; }
|
|
1078
|
+
.mcp-product-wizard .modal-footer { display: flex; align-items: center; gap: var(--sp-2); }
|
|
1079
|
+
.wiz-stepper { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); background: var(--surface-2); }
|
|
1080
|
+
.wiz-step-btn { display: flex; align-items: center; gap: var(--sp-2); background: none; border: 0; cursor: pointer; color: var(--text-2); padding: 4px 8px; border-radius: var(--radius-sm); font-size: 12px; }
|
|
1081
|
+
.wiz-step-btn:hover { color: var(--text); background: var(--surface); }
|
|
1082
|
+
.wiz-step-btn .wiz-step-num { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 1px solid var(--border); font-size: 11px; font-weight: 600; }
|
|
1083
|
+
.wiz-step-btn[data-active="true"] { color: var(--text); }
|
|
1084
|
+
.wiz-step-btn[data-active="true"] .wiz-step-num { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
1085
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num { background: var(--success); border-color: var(--success); color: #fff; }
|
|
1086
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num::after { content: "✓"; font-size: 10px; }
|
|
1087
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num > * { display: none; }
|
|
1088
|
+
.wiz-step-line { flex: 1; height: 1px; background: var(--border); min-width: 12px; }
|
|
1089
|
+
.wiz-step-help { padding: 0 0 var(--sp-3); opacity: .85; line-height: 1.5; }
|
|
1090
|
+
.wiz-pane[hidden] { display: none; }
|
|
1091
|
+
|
|
1092
|
+
/* Review pane */
|
|
1093
|
+
.wiz-review-grid { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-2) var(--sp-3); padding: var(--sp-2) var(--sp-3); background: var(--surface-2); border-radius: var(--radius-sm); border: 1px solid var(--border); }
|
|
1094
|
+
.wiz-review-grid dt { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .65; padding-top: 2px; }
|
|
1095
|
+
.wiz-review-grid dd { margin: 0; font-size: 13px; word-break: break-word; }
|
|
1096
|
+
.wiz-review-grid dd code { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
|
|
1097
|
+
.wiz-review-grid .wiz-review-empty { opacity: .5; font-style: italic; }
|
|
1098
|
+
.wiz-review-grid .wiz-review-swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: middle; margin-right: 6px; border: 1px solid var(--border); }
|
|
1099
|
+
.wiz-review-tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
1100
|
+
.wiz-review-tools .pcard-tool { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; padding: 2px 6px; background: var(--surface); border-radius: var(--radius-sm); border: 1px solid var(--border); }
|
|
1101
|
+
|
|
1102
|
+
/* Wizard Review — agent preview panel */
|
|
1103
|
+
.wiz-agent-preview-h { margin: var(--sp-4) 0 var(--sp-2); font-size: 13px; font-weight: 600; }
|
|
1104
|
+
.wiz-agent-preview { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); background: var(--surface-2); }
|
|
1105
|
+
.wiz-agent-banner { padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3); border-radius: var(--radius-sm); background: var(--warning-soft); color: var(--warning); font-size: 12px; }
|
|
1106
|
+
.wiz-agent-group { padding: var(--sp-1) 0; }
|
|
1107
|
+
.wiz-agent-group + .wiz-agent-group { border-top: 1px dashed var(--border); padding-top: var(--sp-2); margin-top: var(--sp-2); }
|
|
1108
|
+
.wiz-agent-tool { padding: 4px 0; }
|
|
1109
|
+
.wiz-agent-tool-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
|
|
1110
|
+
.wiz-agent-tool-summary { font-size: 11px; opacity: .75; line-height: 1.4; margin-top: 1px; }
|
|
1111
|
+
.mcp-agent-preview { width: min(640px, 92vw); }
|
|
1112
|
+
.mcp-agent-preview .modal-header { padding-left: var(--sp-3); }
|
|
1113
|
+
|
|
1114
|
+
/* Products modal — tools picker (multi-select grouped by category) */
|
|
1115
|
+
.tools-picker { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2); background: var(--surface-2); max-height: 280px; overflow-y: auto; }
|
|
1116
|
+
.tools-picker .tp-group { padding: var(--sp-1) 0; }
|
|
1117
|
+
.tools-picker .tp-group + .tp-group { margin-top: var(--sp-2); border-top: 1px dashed var(--border); padding-top: var(--sp-2); }
|
|
1118
|
+
.tools-picker .tp-cat { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); padding: 0 var(--sp-1); }
|
|
1119
|
+
.tools-picker .tp-item { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); cursor: pointer; }
|
|
1120
|
+
.tools-picker .tp-item:hover { background: var(--surface); }
|
|
1121
|
+
.tools-picker .tp-item input { margin-top: 3px; flex: 0 0 auto; }
|
|
1122
|
+
.tools-picker .tp-item .tp-text { flex: 1; min-width: 0; }
|
|
1123
|
+
.tools-picker .tp-item .tp-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
|
|
1124
|
+
.tools-picker .tp-item .tp-summary { font-size: 11px; opacity: .7; line-height: 1.4; margin-top: 1px; }
|
|
1125
|
+
.tools-picker .tp-actions { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-2); }
|
|
1126
|
+
.tools-picker .tp-actions button { font-size: 11px; padding: 2px 8px; }
|
|
1127
|
+
.tools-picker .tools-picker-hint { padding: var(--sp-2); opacity: .7; }
|
|
1128
|
+
|
|
1129
|
+
/* Products — empty state with templates */
|
|
1130
|
+
.pempty { padding: var(--sp-5); text-align: center; }
|
|
1131
|
+
.pempty h3 { margin: 0 0 var(--sp-2); font-size: 16px; }
|
|
1132
|
+
.pempty p { margin: 0 auto var(--sp-4); max-width: 540px; line-height: 1.5; opacity: .8; }
|
|
1133
|
+
.pempty-templates { display: flex; gap: var(--sp-2); justify-content: center; flex-wrap: wrap; }
|
|
1134
|
+
.pempty-tpl { padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); cursor: pointer; min-width: 140px; text-align: left; transition: border-color .15s ease, background .15s ease; }
|
|
1135
|
+
.pempty-tpl:hover { border-color: var(--accent); background: var(--accent-soft); }
|
|
1136
|
+
.pempty-tpl-title { font-weight: 600; font-size: 13px; margin-bottom: 2px; }
|
|
1137
|
+
.pempty-tpl-desc { font-size: 11px; opacity: .7; }
|
|
1138
|
+
|
|
939
1139
|
/* Form-first editor (Form / JSON / YAML — OpenShift-style) */
|
|
940
1140
|
.ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
|
|
941
1141
|
.ed-token { flex: 1; min-width: 200px; }
|
|
@@ -1191,6 +1391,48 @@
|
|
|
1191
1391
|
stroke: var(--accent); stroke-width: 3;
|
|
1192
1392
|
filter: drop-shadow(0 0 4px var(--accent-soft));
|
|
1193
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; }
|
|
1194
1436
|
</style>
|
|
1195
1437
|
</head>
|
|
1196
1438
|
<body>
|
|
@@ -1209,6 +1451,8 @@
|
|
|
1209
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>
|
|
1210
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>
|
|
1211
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>
|
|
1212
1456
|
</div>
|
|
1213
1457
|
</div>
|
|
1214
1458
|
<div class="rail-grp" data-grp="catalog">
|
|
@@ -1710,6 +1954,109 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1710
1954
|
</div>
|
|
1711
1955
|
</div>
|
|
1712
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
|
+
|
|
1713
2060
|
<!-- ===== Catalog: Context Products ===== -->
|
|
1714
2061
|
<div class="page" id="page-products">
|
|
1715
2062
|
<div class="page-head">
|
|
@@ -1718,70 +2065,80 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1718
2065
|
<h1>Context Products
|
|
1719
2066
|
<button class="info" aria-label="What is a context product"
|
|
1720
2067
|
data-title="Context products"
|
|
1721
|
-
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."
|
|
1722
2069
|
onclick="infoPop(this)">?</button>
|
|
1723
2070
|
</h1>
|
|
2071
|
+
<p class="ph-sub">Curated, governed tool bundles you expose to an agent or credential.</p>
|
|
1724
2072
|
</div>
|
|
1725
|
-
<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>
|
|
1726
2073
|
</div>
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
<b>The catalog</b> publishes <b>context products</b> — reusable bundles of sources, services and tools —
|
|
1730
|
-
and the <b>grants</b> that decide which principals may consume each product. Browse below as cards or a
|
|
1731
|
-
table; an admin can create or change products via the editor or the API example.
|
|
1732
|
-
</div>
|
|
1733
|
-
</div>
|
|
1734
|
-
<!-- MCP Products — governed via /api/products (the new, RBAC-gated
|
|
1735
|
-
surface from the MCP Products + RBAC phase). Replaces the
|
|
1736
|
-
legacy enterprise-catalog block below for new deployments;
|
|
1737
|
-
the legacy block stays so existing /api/enterprise/catalog
|
|
1738
|
-
operators see their data until they migrate. -->
|
|
2074
|
+
|
|
2075
|
+
<!-- Primary surface: MCP Products via /api/products (RBAC-gated). -->
|
|
1739
2076
|
<div class="card" id="mcp-products-card">
|
|
1740
2077
|
<div class="card-header">
|
|
1741
2078
|
<h2>MCP Products
|
|
1742
2079
|
<button class="info" aria-label="About the MCP Products API"
|
|
1743
2080
|
data-title="MCP Products"
|
|
1744
|
-
data-info="Curated tool bundles loaded from OMCP_PRODUCTS_FILE. Each product names an id, display name, optional tool allowlist (filters tools/list at the /mcp transport
|
|
2081
|
+
data-info="Curated tool bundles loaded from OMCP_PRODUCTS_FILE. Each product names an id, display name, optional tool allowlist (filters tools/list at the /mcp transport), branding metadata, and a status (published | staging). Non-admins see only their own tenant's published entries; admins see everything plus staging. Edits go through PUT /api/products/{id} with strict body validation."
|
|
1745
2082
|
onclick="infoPop(this)">?</button>
|
|
1746
2083
|
</h2>
|
|
1747
2084
|
<div class="row-inline">
|
|
1748
2085
|
<span id="mcp-products-scope" class="badge"></span>
|
|
2086
|
+
<span class="view-toggle" id="mcp-products-views">
|
|
2087
|
+
<button id="mcp-pv-cards" onclick="mcpProductsSetView('cards')">Cards</button>
|
|
2088
|
+
<button id="mcp-pv-table" onclick="mcpProductsSetView('table')">Table</button>
|
|
2089
|
+
</span>
|
|
1749
2090
|
<button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
|
|
1750
2091
|
</div>
|
|
1751
2092
|
</div>
|
|
1752
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>
|
|
1753
2103
|
</div>
|
|
1754
2104
|
|
|
1755
|
-
<!-- Legacy enterprise
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
<
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
</span>
|
|
1767
|
-
<input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1768
|
-
</div>
|
|
1769
|
-
<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>
|
|
1770
|
-
<div id="cat-form"></div>
|
|
1771
|
-
<textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1772
|
-
<div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1773
|
-
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1774
|
-
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1775
|
-
<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>
|
|
1776
2116
|
</div>
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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>
|
|
1783
2135
|
</div>
|
|
1784
|
-
<
|
|
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>
|
|
1785
2142
|
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1786
2143
|
curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
1787
2144
|
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
@@ -1796,8 +2153,9 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1796
2153
|
},
|
|
1797
2154
|
"grants": { "alice": ["payments-eu"] }
|
|
1798
2155
|
}'</pre>
|
|
2156
|
+
</div>
|
|
1799
2157
|
</div>
|
|
1800
|
-
</
|
|
2158
|
+
</details>
|
|
1801
2159
|
</div>
|
|
1802
2160
|
|
|
1803
2161
|
<!-- ===== Governance: Policies (PolicyEngine snapshot + dry-run) ===== -->
|
|
@@ -1809,38 +2167,208 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1809
2167
|
</div>
|
|
1810
2168
|
<div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
|
|
1811
2169
|
</div>
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
2170
|
+
|
|
2171
|
+
<!-- Engine banner — colour + copy distinguishes editable file
|
|
2172
|
+
vs read-only builtin / opa modes at a glance. -->
|
|
2173
|
+
<div id="pol-engine-banner" class="pol-engine-banner eng-builtin" hidden>
|
|
2174
|
+
<div class="pol-eb-dot"></div>
|
|
2175
|
+
<div class="pol-eb-body">
|
|
2176
|
+
<div class="pol-eb-title">
|
|
2177
|
+
<span id="pol-eb-title-text">Engine</span>
|
|
2178
|
+
<span id="pol-eb-kind" class="pol-eb-pill"></span>
|
|
2179
|
+
<span id="pol-eb-tenant-aware" class="pol-eb-pill" hidden>tenant-aware</span>
|
|
2180
|
+
</div>
|
|
2181
|
+
<div class="pol-eb-meta" id="pol-eb-meta"></div>
|
|
2182
|
+
<p id="pol-eb-copy"></p>
|
|
1822
2183
|
</div>
|
|
1823
2184
|
</div>
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2185
|
+
|
|
2186
|
+
<!-- Sticky dry-run probe bar — promoted to the top so it's the
|
|
2187
|
+
first affordance, not buried below the snapshot. Works
|
|
2188
|
+
across every engine including OPA (the only way to see what
|
|
2189
|
+
Rego actually grants without writing a unit test). -->
|
|
2190
|
+
<div class="pol-probe-bar">
|
|
2191
|
+
<h3 style="margin:0 0 var(--sp-2);display:flex;align-items:center;gap:var(--sp-2);font-size:13px">
|
|
2192
|
+
<span>Probe a permission</span>
|
|
2193
|
+
<button class="info" aria-label="About dry-run"
|
|
2194
|
+
data-title="Dry-run probe"
|
|
2195
|
+
data-info="Asks the active engine 'would these roles be allowed this resource:action under this tenant?' Works for every engine kind including OPA — the answer reflects the live Rego evaluation."
|
|
2196
|
+
onclick="infoPop(this)">?</button>
|
|
2197
|
+
</h3>
|
|
2198
|
+
<div class="pol-probe-grid">
|
|
2199
|
+
<div class="form-group">
|
|
1828
2200
|
<label>Roles <span class="form-hint">comma-separated</span></label>
|
|
1829
2201
|
<input id="pol-dry-roles" placeholder="admin, operator">
|
|
1830
2202
|
</div>
|
|
1831
|
-
<div class="form-group"
|
|
2203
|
+
<div class="form-group">
|
|
1832
2204
|
<label>Resource</label>
|
|
1833
2205
|
<input id="pol-dry-resource" placeholder="sources">
|
|
1834
2206
|
</div>
|
|
1835
|
-
<div class="form-group"
|
|
2207
|
+
<div class="form-group">
|
|
1836
2208
|
<label>Action</label>
|
|
1837
|
-
<input id="pol-dry-action" placeholder="
|
|
2209
|
+
<input id="pol-dry-action" placeholder="read">
|
|
2210
|
+
</div>
|
|
2211
|
+
<div class="form-group">
|
|
2212
|
+
<label>Tenant <span class="form-hint">optional</span></label>
|
|
2213
|
+
<input id="pol-dry-tenant" placeholder="default">
|
|
1838
2214
|
</div>
|
|
1839
|
-
<div class="form-group"
|
|
2215
|
+
<div class="form-group"><label> </label>
|
|
1840
2216
|
<button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
|
|
1841
2217
|
</div>
|
|
1842
2218
|
</div>
|
|
1843
|
-
<div id="pol-dry-out"
|
|
2219
|
+
<div id="pol-dry-out"></div>
|
|
2220
|
+
</div>
|
|
2221
|
+
|
|
2222
|
+
<!-- Sub-tab nav — k8s-style separation of Roles (the WHAT) vs
|
|
2223
|
+
Bindings (the WHO) vs Subjects (the principals). Slice F
|
|
2224
|
+
ships Roles; G + H fill in Bindings + Subjects. -->
|
|
2225
|
+
<nav class="pol-subtabs" role="tablist" aria-label="Policies sections">
|
|
2226
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
|
|
2227
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
|
|
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>
|
|
2230
|
+
</nav>
|
|
2231
|
+
|
|
2232
|
+
<!-- Roles sub-tab — master/detail: role list on the left, the
|
|
2233
|
+
selected role's permission matrix on the right. -->
|
|
2234
|
+
<div class="card pol-pane" id="pol-pane-roles" role="tabpanel">
|
|
2235
|
+
<div class="card-header"><h2>Roles
|
|
2236
|
+
<button class="info" aria-label="About roles"
|
|
2237
|
+
data-title="Roles"
|
|
2238
|
+
data-info="A role is the WHAT — a named bundle of resource:action grants. Bindings (next sub-tab) decide who carries the role. The matrix view shows the granted (✓) vs not-granted (—) cells for the selected role across every resource and action."
|
|
2239
|
+
onclick="infoPop(this)">?</button>
|
|
2240
|
+
<span style="flex:1"></span>
|
|
2241
|
+
<button class="btn btn-primary btn-sm" data-engine-required="file" data-rbac="users:delete" onclick="polRoleAuthorNew()">+ New role</button>
|
|
2242
|
+
</h2></div>
|
|
2243
|
+
<div class="pol-roles-layout">
|
|
2244
|
+
<div class="pol-role-list" id="pol-role-list" role="listbox" aria-label="Roles">
|
|
2245
|
+
<div class="empty">Loading…</div>
|
|
2246
|
+
</div>
|
|
2247
|
+
<div class="pol-role-detail" id="pol-role-detail">
|
|
2248
|
+
<div class="empty">Select a role on the left to see its permission matrix.</div>
|
|
2249
|
+
</div>
|
|
2250
|
+
</div>
|
|
2251
|
+
</div>
|
|
2252
|
+
|
|
2253
|
+
<!-- Bindings sub-tab — subject → roles table with inline edit. -->
|
|
2254
|
+
<div class="card pol-pane" id="pol-pane-bindings" role="tabpanel" hidden>
|
|
2255
|
+
<div class="card-header"><h2>Bindings
|
|
2256
|
+
<button class="info" aria-label="About bindings"
|
|
2257
|
+
data-title="Bindings"
|
|
2258
|
+
data-info="A binding maps a subject (user, API key, OIDC group) to one or more roles. Today only local-user role assignments can be edited at runtime (writes through OMCP_USERS_FILE). API-key roles aren't stored on the credential, and OIDC group → role mappings come from OMCP_OIDC_ROLE_MAP which is read once at boot — those rows show their env source instead of an edit button."
|
|
2259
|
+
onclick="infoPop(this)">?</button>
|
|
2260
|
+
</h2></div>
|
|
2261
|
+
<div id="pol-bindings-body" class="content"><div class="empty">Loading…</div></div>
|
|
2262
|
+
</div>
|
|
2263
|
+
|
|
2264
|
+
<!-- Role-author modal (file-engine only). Surfaces a name +
|
|
2265
|
+
permission-matrix checkbox grid; saves via
|
|
2266
|
+
PUT /api/policy/roles/:name. -->
|
|
2267
|
+
<div class="modal-overlay" id="pol-role-author-modal">
|
|
2268
|
+
<div class="modal" style="width:min(720px, 92vw)">
|
|
2269
|
+
<div class="modal-header">
|
|
2270
|
+
<h3>New role</h3>
|
|
2271
|
+
<button class="btn-icon" onclick="closeModal('pol-role-author-modal')">×</button>
|
|
2272
|
+
</div>
|
|
2273
|
+
<div class="modal-body">
|
|
2274
|
+
<div class="form-group">
|
|
2275
|
+
<label for="pol-role-author-name">Role name</label>
|
|
2276
|
+
<input type="text" id="pol-role-author-name" placeholder="e.g. on-call-sre" autocomplete="off">
|
|
2277
|
+
<div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. New roles append to the policy file; existing names overwrite.</div>
|
|
2278
|
+
</div>
|
|
2279
|
+
<div class="form-group">
|
|
2280
|
+
<label>Permissions</label>
|
|
2281
|
+
<div id="pol-role-author-grid" class="content"></div>
|
|
2282
|
+
<div class="form-hint">Tick cells to grant. Empty rows mean the role has no access to that resource.</div>
|
|
2283
|
+
</div>
|
|
2284
|
+
<div id="pol-role-author-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2285
|
+
</div>
|
|
2286
|
+
<div class="modal-footer">
|
|
2287
|
+
<button class="btn btn-ghost" onclick="closeModal('pol-role-author-modal')">Cancel</button>
|
|
2288
|
+
<span style="flex:1"></span>
|
|
2289
|
+
<button class="btn btn-primary" onclick="polRoleAuthorSave()">Save role</button>
|
|
2290
|
+
</div>
|
|
2291
|
+
</div>
|
|
2292
|
+
</div>
|
|
2293
|
+
|
|
2294
|
+
<!-- Binding-edit modal — only used for the local-user row. -->
|
|
2295
|
+
<div class="modal-overlay" id="pol-binding-modal">
|
|
2296
|
+
<div class="modal" style="width:min(520px, 92vw)">
|
|
2297
|
+
<div class="modal-header">
|
|
2298
|
+
<h3>Edit binding · <span id="pol-binding-subject"></span></h3>
|
|
2299
|
+
<button class="btn-icon" onclick="closeModal('pol-binding-modal')">×</button>
|
|
2300
|
+
</div>
|
|
2301
|
+
<div class="modal-body">
|
|
2302
|
+
<p class="t-sm" style="opacity:.85;margin:0 0 var(--sp-3)">
|
|
2303
|
+
Select the roles to grant <strong id="pol-binding-subject-2"></strong>.
|
|
2304
|
+
Unknown roles are rejected — the catalogue comes from the active engine.
|
|
2305
|
+
</p>
|
|
2306
|
+
<div id="pol-binding-roles" class="content"><div class="empty">Loading…</div></div>
|
|
2307
|
+
<div id="pol-binding-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2308
|
+
</div>
|
|
2309
|
+
<div class="modal-footer">
|
|
2310
|
+
<button class="btn btn-ghost" onclick="closeModal('pol-binding-modal')">Cancel</button>
|
|
2311
|
+
<span style="flex:1"></span>
|
|
2312
|
+
<button class="btn btn-primary" onclick="polBindingSave()">Save binding</button>
|
|
2313
|
+
</div>
|
|
2314
|
+
</div>
|
|
2315
|
+
</div>
|
|
2316
|
+
|
|
2317
|
+
<!-- Subjects sub-tab — three sections (Users / API keys /
|
|
2318
|
+
OIDC groups). Read-only this slice. -->
|
|
2319
|
+
<div class="card pol-pane" id="pol-pane-subjects" role="tabpanel" hidden>
|
|
2320
|
+
<div class="card-header"><h2>Subjects
|
|
2321
|
+
<button class="info" aria-label="About subjects"
|
|
2322
|
+
data-title="Subjects"
|
|
2323
|
+
data-info="The principals an OMCP deployment recognises: local users (basic auth via OMCP_USERS_FILE), bearer-token names from OMCP_API_KEYS, and OIDC groups the operator mapped to roles via OMCP_OIDC_ROLE_MAP. Slice H lands binding edits; this view is read-only."
|
|
2324
|
+
onclick="infoPop(this)">?</button>
|
|
2325
|
+
</h2></div>
|
|
2326
|
+
<div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
|
|
2327
|
+
</div>
|
|
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
|
+
|
|
2365
|
+
<!-- Kept for back-compat — the legacy snapshot lives here but
|
|
2366
|
+
the new Roles sub-tab supersedes it. JS hides it once
|
|
2367
|
+
Roles renders successfully so we don't duplicate
|
|
2368
|
+
information. -->
|
|
2369
|
+
<div class="card" id="pol-legacy-snapshot" hidden>
|
|
2370
|
+
<div class="card-header"><h2>Active policy (legacy view)</h2></div>
|
|
2371
|
+
<div id="pol-roles" class="content"></div>
|
|
1844
2372
|
</div>
|
|
1845
2373
|
</div>
|
|
1846
2374
|
|
|
@@ -1938,6 +2466,11 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1938
2466
|
<div class="form-group"><label>CA Certificate Path</label><input type="text" id="src-tls-ca" placeholder="/path/to/ca.pem"><div class="form-hint">Custom CA for self-signed certs (safer than skip verify)</div></div>
|
|
1939
2467
|
<div class="form-group"><label>Client Certificate Path</label><input type="text" id="src-tls-cert" placeholder="/path/to/client.pem"><div class="form-hint">For mutual TLS (mTLS)</div></div>
|
|
1940
2468
|
<div class="form-group"><label>Client Key Path</label><input type="text" id="src-tls-key" placeholder="/path/to/client-key.pem"></div>
|
|
2469
|
+
<div class="form-group">
|
|
2470
|
+
<label for="src-tenant">Tenant <span class="t-sm" style="opacity:.6">(optional, admin-only)</span></label>
|
|
2471
|
+
<input type="text" id="src-tenant" placeholder="(blank = global)" autocomplete="off">
|
|
2472
|
+
<div class="form-hint">Tagged source is visible only inside its tenant. Blank = global (every tenant). Non-admins can only see / create within their own tenant.</div>
|
|
2473
|
+
</div>
|
|
1941
2474
|
<div class="form-group" style="display:flex;align-items:center;gap:10px;"><label style="margin:0">Enabled</label><label class="toggle"><input type="checkbox" id="src-enabled" checked><span class="slider"></span></label></div>
|
|
1942
2475
|
<div class="test-result" id="test-result"></div>
|
|
1943
2476
|
</div>
|
|
@@ -1978,6 +2511,143 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1978
2511
|
</div>
|
|
1979
2512
|
</div>
|
|
1980
2513
|
|
|
2514
|
+
<!-- MCP Product Modal (Create + Edit) — 4-step wizard.
|
|
2515
|
+
The form fields keep their stable ids so save() and the
|
|
2516
|
+
existing field-by-field tests stay byte-identical; only the
|
|
2517
|
+
structural grouping + stepper navigation are new. -->
|
|
2518
|
+
<div class="modal-overlay" id="mcp-product-modal">
|
|
2519
|
+
<div class="modal mcp-product-wizard">
|
|
2520
|
+
<div class="modal-header">
|
|
2521
|
+
<h3 id="mcp-product-modal-title">New Product</h3>
|
|
2522
|
+
<button class="btn-icon" onclick="closeModal('mcp-product-modal')">×</button>
|
|
2523
|
+
</div>
|
|
2524
|
+
<!-- Stepper rail — bullets are clickable so an operator can
|
|
2525
|
+
jump back to any step they've already filled in. -->
|
|
2526
|
+
<div class="wiz-stepper" id="mcp-wiz-stepper" role="tablist" aria-label="Product wizard steps">
|
|
2527
|
+
<button class="wiz-step-btn" data-step="1" role="tab" aria-controls="wiz-pane-1" onclick="mcpWizGoto(1)">
|
|
2528
|
+
<span class="wiz-step-num">1</span><span class="wiz-step-lbl">Identity</span>
|
|
2529
|
+
</button>
|
|
2530
|
+
<span class="wiz-step-line"></span>
|
|
2531
|
+
<button class="wiz-step-btn" data-step="2" role="tab" aria-controls="wiz-pane-2" onclick="mcpWizGoto(2)">
|
|
2532
|
+
<span class="wiz-step-num">2</span><span class="wiz-step-lbl">Tools</span>
|
|
2533
|
+
</button>
|
|
2534
|
+
<span class="wiz-step-line"></span>
|
|
2535
|
+
<button class="wiz-step-btn" data-step="3" role="tab" aria-controls="wiz-pane-3" onclick="mcpWizGoto(3)">
|
|
2536
|
+
<span class="wiz-step-num">3</span><span class="wiz-step-lbl">Scope & branding</span>
|
|
2537
|
+
</button>
|
|
2538
|
+
<span class="wiz-step-line"></span>
|
|
2539
|
+
<button class="wiz-step-btn" data-step="4" role="tab" aria-controls="wiz-pane-4" onclick="mcpWizGoto(4)">
|
|
2540
|
+
<span class="wiz-step-num">4</span><span class="wiz-step-lbl">Review & publish</span>
|
|
2541
|
+
</button>
|
|
2542
|
+
</div>
|
|
2543
|
+
<div class="modal-body">
|
|
2544
|
+
<input type="hidden" id="mcp-product-mode" value="new">
|
|
2545
|
+
<input type="hidden" id="mcp-product-original-id" value="">
|
|
2546
|
+
|
|
2547
|
+
<!-- Step 1: Identity -->
|
|
2548
|
+
<div class="wiz-pane" id="wiz-pane-1" data-step="1" role="tabpanel">
|
|
2549
|
+
<p class="wiz-step-help t-sm">Give the product a stable id (URL-safe, immutable once saved) and a display name agents will see in <code>tools/list</code>.</p>
|
|
2550
|
+
<div class="form-group">
|
|
2551
|
+
<label for="mcp-product-id">Id</label>
|
|
2552
|
+
<input type="text" id="mcp-product-id" placeholder="e.g. ops-bundle" autocomplete="off">
|
|
2553
|
+
<div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. Immutable once saved.</div>
|
|
2554
|
+
</div>
|
|
2555
|
+
<div class="form-group">
|
|
2556
|
+
<label for="mcp-product-name">Display name</label>
|
|
2557
|
+
<input type="text" id="mcp-product-name" placeholder="e.g. Operations Bundle" autocomplete="off">
|
|
2558
|
+
</div>
|
|
2559
|
+
<div class="form-group">
|
|
2560
|
+
<label for="mcp-product-description">Description <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2561
|
+
<input type="text" id="mcp-product-description" placeholder="One-sentence summary" autocomplete="off">
|
|
2562
|
+
</div>
|
|
2563
|
+
</div>
|
|
2564
|
+
|
|
2565
|
+
<!-- Step 2: Tools -->
|
|
2566
|
+
<div class="wiz-pane" id="wiz-pane-2" data-step="2" role="tabpanel" hidden>
|
|
2567
|
+
<p class="wiz-step-help t-sm">Curate the tools an agent bound to this product can call. Leaving everything unchecked = no filter (every registered tool).</p>
|
|
2568
|
+
<div class="form-group">
|
|
2569
|
+
<div id="mcp-product-tools-picker" class="tools-picker">
|
|
2570
|
+
<div class="tools-picker-hint t-sm">Loading…</div>
|
|
2571
|
+
</div>
|
|
2572
|
+
<!-- Hidden textarea kept for back-compat — the picker syncs
|
|
2573
|
+
selections here, and save() still reads from it. -->
|
|
2574
|
+
<textarea id="mcp-product-tools" rows="2" hidden></textarea>
|
|
2575
|
+
</div>
|
|
2576
|
+
</div>
|
|
2577
|
+
|
|
2578
|
+
<!-- Step 3: Scope & branding -->
|
|
2579
|
+
<div class="wiz-pane" id="wiz-pane-3" data-step="3" role="tabpanel" hidden>
|
|
2580
|
+
<p class="wiz-step-help t-sm">Pick the lifecycle stage, the tenant scope, optional version, and the branding metadata that surfaces on the catalogue card.</p>
|
|
2581
|
+
<div class="form-row">
|
|
2582
|
+
<div class="form-group">
|
|
2583
|
+
<label for="mcp-product-status">Status</label>
|
|
2584
|
+
<select id="mcp-product-status">
|
|
2585
|
+
<option value="staging">staging (admin-only)</option>
|
|
2586
|
+
<option value="published">published (visible to agents)</option>
|
|
2587
|
+
</select>
|
|
2588
|
+
</div>
|
|
2589
|
+
<div class="form-group">
|
|
2590
|
+
<label for="mcp-product-tenant">Tenant <span class="t-sm" style="opacity:.6">(admin only)</span></label>
|
|
2591
|
+
<input type="text" id="mcp-product-tenant" placeholder="default" autocomplete="off">
|
|
2592
|
+
<div class="form-hint">Blank = same tenant as the calling user.</div>
|
|
2593
|
+
</div>
|
|
2594
|
+
</div>
|
|
2595
|
+
<div class="form-row">
|
|
2596
|
+
<div class="form-group">
|
|
2597
|
+
<label for="mcp-product-version">Version <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2598
|
+
<input type="text" id="mcp-product-version" placeholder="1.0.0" autocomplete="off">
|
|
2599
|
+
</div>
|
|
2600
|
+
<div class="form-group">
|
|
2601
|
+
<label for="mcp-product-color">Brand colour <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2602
|
+
<input type="text" id="mcp-product-color" placeholder="#3178c6" autocomplete="off">
|
|
2603
|
+
</div>
|
|
2604
|
+
</div>
|
|
2605
|
+
<div class="form-group">
|
|
2606
|
+
<label for="mcp-product-icon">Icon URL <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2607
|
+
<input type="text" id="mcp-product-icon" placeholder="https://example.com/icons/ops.svg" autocomplete="off">
|
|
2608
|
+
</div>
|
|
2609
|
+
</div>
|
|
2610
|
+
|
|
2611
|
+
<!-- Step 4: Review & publish -->
|
|
2612
|
+
<div class="wiz-pane" id="wiz-pane-4" data-step="4" role="tabpanel" hidden>
|
|
2613
|
+
<p class="wiz-step-help t-sm">Confirm everything below, then save. Staging products are admin-only; published products are visible to agents in the selected tenant.</p>
|
|
2614
|
+
<div id="mcp-wiz-review"></div>
|
|
2615
|
+
</div>
|
|
2616
|
+
|
|
2617
|
+
<div id="mcp-product-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2618
|
+
</div>
|
|
2619
|
+
<div class="modal-footer">
|
|
2620
|
+
<button class="btn btn-ghost" onclick="closeModal('mcp-product-modal')">Cancel</button>
|
|
2621
|
+
<span style="flex:1"></span>
|
|
2622
|
+
<button class="btn btn-ghost" id="mcp-wiz-back" onclick="mcpWizBack()" hidden>Back</button>
|
|
2623
|
+
<button class="btn btn-primary" id="mcp-wiz-next" onclick="mcpWizNext()">Next</button>
|
|
2624
|
+
<button class="btn btn-primary" id="mcp-wiz-save" onclick="mcpProductSave()" hidden>Save Product</button>
|
|
2625
|
+
</div>
|
|
2626
|
+
</div>
|
|
2627
|
+
</div>
|
|
2628
|
+
|
|
2629
|
+
<!-- Agent preview modal — invoked from per-card "Preview as agent"
|
|
2630
|
+
button. Same /api/products/:id/preview backend the wizard
|
|
2631
|
+
Review pane could call, but the wizard uses the live registry
|
|
2632
|
+
in-form because the draft hasn't been saved yet. -->
|
|
2633
|
+
<div class="modal-overlay" id="mcp-agent-preview-modal">
|
|
2634
|
+
<div class="modal mcp-agent-preview" style="--mcp-agent-accent: var(--accent)">
|
|
2635
|
+
<div class="modal-header" style="border-left: 4px solid var(--mcp-agent-accent)">
|
|
2636
|
+
<h3 id="mcp-agent-preview-title">Agent preview</h3>
|
|
2637
|
+
<button class="btn-icon" onclick="closeModal('mcp-agent-preview-modal')">×</button>
|
|
2638
|
+
</div>
|
|
2639
|
+
<div class="modal-body">
|
|
2640
|
+
<div id="mcp-agent-preview-meta" class="t-sm" style="opacity:.7;margin-bottom:var(--sp-3);font-family:'JetBrains Mono', ui-monospace, monospace;"></div>
|
|
2641
|
+
<p class="t-sm" style="opacity:.85">This is the <code>tools/list</code> set a credential bound to this product would receive on its <code>/mcp</code> session.</p>
|
|
2642
|
+
<div id="mcp-agent-preview-list" class="wiz-agent-preview"></div>
|
|
2643
|
+
</div>
|
|
2644
|
+
<div class="modal-footer">
|
|
2645
|
+
<span style="flex:1"></span>
|
|
2646
|
+
<button class="btn btn-primary" onclick="closeModal('mcp-agent-preview-modal')">Close</button>
|
|
2647
|
+
</div>
|
|
2648
|
+
</div>
|
|
2649
|
+
</div>
|
|
2650
|
+
|
|
1981
2651
|
<div class="toast" id="toast-el"></div>
|
|
1982
2652
|
|
|
1983
2653
|
<div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
|
|
@@ -2017,48 +2687,1182 @@ function showPage(name) {
|
|
|
2017
2687
|
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
2018
2688
|
if(name==='products') loadMcpProducts();
|
|
2019
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)); }
|
|
2020
3085
|
}
|
|
2021
3086
|
|
|
2022
3087
|
// --- Policies tab (PolicyEngine snapshot + dry-run) ---
|
|
2023
|
-
|
|
3088
|
+
// Classify the engine kind reported by /api/policy.engine into one of
|
|
3089
|
+
// the three banner styles. The kind is `builtin`, `file:<path>`, or
|
|
3090
|
+
// `opa:<url>`; the prefix is what we key on.
|
|
3091
|
+
function polEngineKind(engineStr) {
|
|
3092
|
+
const s = (engineStr || '').toLowerCase();
|
|
3093
|
+
if (s.startsWith('opa:')) return 'opa';
|
|
3094
|
+
if (s.startsWith('file:') || s.startsWith('file ')) return 'file';
|
|
3095
|
+
return 'builtin';
|
|
3096
|
+
}
|
|
3097
|
+
function polRenderEngineBanner(j) {
|
|
3098
|
+
const banner = document.getElementById('pol-engine-banner');
|
|
3099
|
+
if (!banner) return;
|
|
3100
|
+
const kind = polEngineKind(j.engine);
|
|
3101
|
+
banner.hidden = false;
|
|
3102
|
+
banner.className = 'pol-engine-banner eng-' + kind;
|
|
3103
|
+
// body[data-policy-engine="..."] drives CSS that disables every
|
|
3104
|
+
// [data-engine-required="file"] control when authoring isn't
|
|
3105
|
+
// supported by the active engine.
|
|
3106
|
+
document.body.setAttribute('data-policy-engine', kind);
|
|
3107
|
+
const titleEl = document.getElementById('pol-eb-title-text');
|
|
3108
|
+
const kindEl = document.getElementById('pol-eb-kind');
|
|
3109
|
+
const metaEl = document.getElementById('pol-eb-meta');
|
|
3110
|
+
const copyEl = document.getElementById('pol-eb-copy');
|
|
3111
|
+
const tenantPill = document.getElementById('pol-eb-tenant-aware');
|
|
3112
|
+
if (kindEl) kindEl.textContent = j.engine || 'unknown';
|
|
3113
|
+
if (tenantPill) tenantPill.hidden = !j.tenantAware;
|
|
3114
|
+
if (kind === 'file') {
|
|
3115
|
+
titleEl.textContent = 'Editable here';
|
|
3116
|
+
metaEl.textContent = 'source: ' + (j.engine || '');
|
|
3117
|
+
copyEl.textContent = 'Changes saved via the API are persisted to the file. A server restart re-reads the file from disk; until then the in-memory state is authoritative.';
|
|
3118
|
+
} else if (kind === 'opa') {
|
|
3119
|
+
titleEl.textContent = 'Read-only (OPA)';
|
|
3120
|
+
metaEl.textContent = 'evaluating Rego at ' + (j.engine || '').replace(/^opa:/i, '');
|
|
3121
|
+
copyEl.textContent = 'OPA is the source of truth. Define roles + bindings in Rego, deploy them through OPA’s bundle pipeline; this view reflects what OPA evaluates on every gate decision. Use the probe above to verify a specific verdict.';
|
|
3122
|
+
} else {
|
|
3123
|
+
titleEl.textContent = 'Built-in defaults (read-only)';
|
|
3124
|
+
metaEl.textContent = j.note || 'DEFAULT_POLICY shipped with the build';
|
|
3125
|
+
copyEl.textContent = 'Set OMCP_RBAC_POLICY_FILE to override with a YAML / JSON role catalogue (then the engine flips to file mode and authoring becomes available), or OMCP_OPA_URL to delegate evaluation to OPA.';
|
|
3126
|
+
}
|
|
3127
|
+
// Mirror the kind on the legacy badge in the header.
|
|
2024
3128
|
const engineEl = document.getElementById('pol-engine');
|
|
3129
|
+
if (engineEl) {
|
|
3130
|
+
engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
|
|
3131
|
+
engineEl.className = 'badge ' + (kind === 'opa' ? 'badge-warn' : kind === 'file' ? 'badge-ok' : '');
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
// Policy snapshot — fetched once on page enter, then reused by the
|
|
3135
|
+
// sub-tab renderers (Roles matrix today; Bindings + Subjects in
|
|
3136
|
+
// later slices). Reset on every loadPolicies() call.
|
|
3137
|
+
let POL_SNAPSHOT = null;
|
|
3138
|
+
let POL_SELECTED_ROLE = null;
|
|
3139
|
+
|
|
3140
|
+
function polSetTab(name) {
|
|
3141
|
+
document.querySelectorAll('.pol-subtab').forEach((btn) => {
|
|
3142
|
+
btn.setAttribute('data-active', String(btn.getAttribute('data-pol-tab') === name));
|
|
3143
|
+
btn.setAttribute('aria-selected', String(btn.getAttribute('data-pol-tab') === name));
|
|
3144
|
+
});
|
|
3145
|
+
document.querySelectorAll('.pol-pane').forEach((p) => {
|
|
3146
|
+
p.hidden = p.id !== 'pol-pane-' + name;
|
|
3147
|
+
});
|
|
3148
|
+
// Lazy-load Subjects on first visit — it has its own endpoint
|
|
3149
|
+
// so deferring the fetch keeps the page-enter cost low.
|
|
3150
|
+
if (name === 'subjects') polLoadSubjects();
|
|
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
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
// Bindings = the (subject → roles) view derived from /api/subjects.
|
|
3299
|
+
// We don't have a separate /api/bindings endpoint because the binding
|
|
3300
|
+
// data IS the subject data today (users carry roles; api-keys don't;
|
|
3301
|
+
// oidc groups map to a single role via the env catalog).
|
|
3302
|
+
let POL_BINDING_EDIT = null; // { kind, name } of the row being edited
|
|
3303
|
+
async function polLoadBindings() {
|
|
3304
|
+
const body = document.getElementById('pol-bindings-body');
|
|
3305
|
+
if (!body) return;
|
|
3306
|
+
// Reuse the subjects payload if already fetched — same data source.
|
|
3307
|
+
if (!POL_SUBJECTS) {
|
|
3308
|
+
try {
|
|
3309
|
+
const r = await fetch('/api/subjects');
|
|
3310
|
+
if (!r.ok) {
|
|
3311
|
+
body.innerHTML = '<div class="empty">Bindings view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
POL_SUBJECTS = await r.json();
|
|
3315
|
+
} catch (e) {
|
|
3316
|
+
body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
polRenderBindings(POL_SUBJECTS);
|
|
3321
|
+
}
|
|
3322
|
+
function polRenderBindings(s) {
|
|
3323
|
+
const body = document.getElementById('pol-bindings-body');
|
|
3324
|
+
if (!body) return;
|
|
3325
|
+
// Are user edits available? Only when OMCP_USERS_FILE is set
|
|
3326
|
+
// (server returns it under sources.users) — otherwise the PUT
|
|
3327
|
+
// endpoint 409s and the operator just gets a confusing failure.
|
|
3328
|
+
const userEditable = !!(s.sources && s.sources.users);
|
|
3329
|
+
const rows = [];
|
|
3330
|
+
for (const u of (s.users || [])) {
|
|
3331
|
+
const roles = (u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>';
|
|
3332
|
+
const action = userEditable
|
|
3333
|
+
? `<button class="btn btn-ghost btn-sm" data-bind-edit data-bind-kind="user" data-bind-name="${escHtml(u.username)}">Edit</button>`
|
|
3334
|
+
: '<span class="t-sm" style="opacity:.55" title="Set OMCP_USERS_FILE to enable user-role editing">read-only</span>';
|
|
3335
|
+
rows.push(`<tr>
|
|
3336
|
+
<td><code>${escHtml(u.username)}</code></td>
|
|
3337
|
+
<td><span class="tag">user</span></td>
|
|
3338
|
+
<td>${roles}</td>
|
|
3339
|
+
<td>${escHtml(u.tenant || 'default')}</td>
|
|
3340
|
+
<td style="text-align:right">${action}</td>
|
|
3341
|
+
</tr>`);
|
|
3342
|
+
}
|
|
3343
|
+
for (const k of (s.apiKeys || [])) {
|
|
3344
|
+
rows.push(`<tr>
|
|
3345
|
+
<td><code>${escHtml(k.name)}</code></td>
|
|
3346
|
+
<td><span class="tag">api key</span></td>
|
|
3347
|
+
<td><span class="t-sm" style="opacity:.5" title="Bearer credentials don't carry RBAC roles today — the management-plane RBAC gate uses session principals, not /mcp credentials">—</span></td>
|
|
3348
|
+
<td>${escHtml(k.tenant || 'default')}</td>
|
|
3349
|
+
<td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_API_KEYS</code></span></td>
|
|
3350
|
+
</tr>`);
|
|
3351
|
+
}
|
|
3352
|
+
for (const g of (s.oidcGroups || [])) {
|
|
3353
|
+
rows.push(`<tr>
|
|
3354
|
+
<td><code>${escHtml(g.claim)}</code></td>
|
|
3355
|
+
<td><span class="tag">oidc group</span></td>
|
|
3356
|
+
<td><span class="pill">${escHtml(g.role)}</span></td>
|
|
3357
|
+
<td><span class="t-sm" style="opacity:.55">—</span></td>
|
|
3358
|
+
<td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_OIDC_ROLE_MAP</code></span></td>
|
|
3359
|
+
</tr>`);
|
|
3360
|
+
}
|
|
3361
|
+
if (rows.length === 0) {
|
|
3362
|
+
body.innerHTML = `<div class="pol-subjects-empty" style="padding:var(--sp-4)">
|
|
3363
|
+
No subjects configured. Set one of <code>OMCP_USERS_FILE</code> /
|
|
3364
|
+
<code>OMCP_API_KEYS</code> / <code>OMCP_OIDC_ROLE_MAP</code> to populate this view.
|
|
3365
|
+
</div>`;
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
body.innerHTML = `<table class="data-table" style="width:100%">
|
|
3369
|
+
<thead><tr><th>Subject</th><th>Kind</th><th>Roles</th><th>Tenant</th><th></th></tr></thead>
|
|
3370
|
+
<tbody>${rows.join('')}</tbody>
|
|
3371
|
+
</table>`;
|
|
3372
|
+
// Wire the per-row Edit buttons via delegation so role-name strings
|
|
3373
|
+
// can't escape into onclick context (same defence-in-depth as the
|
|
3374
|
+
// role list in Slice F).
|
|
3375
|
+
if (!body.dataset.delegated) {
|
|
3376
|
+
body.addEventListener('click', (ev) => {
|
|
3377
|
+
const btn = ev.target.closest('[data-bind-edit]');
|
|
3378
|
+
if (!btn) return;
|
|
3379
|
+
const kind = btn.getAttribute('data-bind-kind');
|
|
3380
|
+
const name = btn.getAttribute('data-bind-name');
|
|
3381
|
+
if (kind && name) polBindingEdit(kind, name);
|
|
3382
|
+
});
|
|
3383
|
+
body.dataset.delegated = '1';
|
|
3384
|
+
}
|
|
3385
|
+
}
|
|
3386
|
+
function polBindingEdit(kind, name) {
|
|
3387
|
+
POL_BINDING_EDIT = { kind, name };
|
|
3388
|
+
const subj = document.getElementById('pol-binding-subject');
|
|
3389
|
+
const subj2 = document.getElementById('pol-binding-subject-2');
|
|
3390
|
+
if (subj) subj.textContent = name;
|
|
3391
|
+
if (subj2) subj2.textContent = name;
|
|
3392
|
+
const errEl = document.getElementById('pol-binding-error');
|
|
3393
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3394
|
+
// Build the checklist from the active engine's role catalogue.
|
|
3395
|
+
const rolesBox = document.getElementById('pol-binding-roles');
|
|
3396
|
+
const knownRoles = (POL_SNAPSHOT && POL_SNAPSHOT.roles) || [];
|
|
3397
|
+
const currentRoles = kind === 'user'
|
|
3398
|
+
? ((POL_SUBJECTS && POL_SUBJECTS.users.find((u) => u.username === name) || {}).roles || [])
|
|
3399
|
+
: [];
|
|
3400
|
+
if (rolesBox) {
|
|
3401
|
+
if (knownRoles.length === 0) {
|
|
3402
|
+
rolesBox.innerHTML = '<div class="empty">No roles declared in the active engine.</div>';
|
|
3403
|
+
} else {
|
|
3404
|
+
rolesBox.innerHTML = knownRoles.map((r) => {
|
|
3405
|
+
const checked = currentRoles.includes(r) ? 'checked' : '';
|
|
3406
|
+
return `<label class="tp-item" style="display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) 0;cursor:pointer">
|
|
3407
|
+
<input type="checkbox" data-pol-role value="${escHtml(r)}" ${checked}>
|
|
3408
|
+
<span style="font-family:'JetBrains Mono', ui-monospace, monospace;font-size:13px">${escHtml(r)}</span>
|
|
3409
|
+
</label>`;
|
|
3410
|
+
}).join('');
|
|
3411
|
+
}
|
|
3412
|
+
}
|
|
3413
|
+
document.getElementById('pol-binding-modal').classList.add('open');
|
|
3414
|
+
}
|
|
3415
|
+
async function polBindingSave() {
|
|
3416
|
+
if (!POL_BINDING_EDIT) return;
|
|
3417
|
+
const errEl = document.getElementById('pol-binding-error');
|
|
3418
|
+
const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
|
|
3419
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3420
|
+
if (POL_BINDING_EDIT.kind !== 'user') {
|
|
3421
|
+
setErr('Only local-user bindings are editable at runtime today.');
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
const checks = Array.from(document.querySelectorAll('#pol-binding-roles input[data-pol-role]:checked'))
|
|
3425
|
+
.map((el) => el.value);
|
|
3426
|
+
try {
|
|
3427
|
+
const r = await fetch('/api/users/' + encodeURIComponent(POL_BINDING_EDIT.name) + '/roles', {
|
|
3428
|
+
method: 'PUT',
|
|
3429
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3430
|
+
body: JSON.stringify({ roles: checks }),
|
|
3431
|
+
});
|
|
3432
|
+
if (!r.ok) {
|
|
3433
|
+
const j = await r.json().catch(() => ({}));
|
|
3434
|
+
setErr(j.error || ('HTTP ' + r.status));
|
|
3435
|
+
return;
|
|
3436
|
+
}
|
|
3437
|
+
closeModal('pol-binding-modal');
|
|
3438
|
+
toast('Roles updated for ' + POL_BINDING_EDIT.name);
|
|
3439
|
+
// Invalidate + refresh the bindings view.
|
|
3440
|
+
POL_SUBJECTS = null;
|
|
3441
|
+
await polLoadBindings();
|
|
3442
|
+
} catch (e) {
|
|
3443
|
+
setErr('Save failed: ' + (e && e.message || e));
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
// Cache the subjects payload so re-entering the tab doesn't refetch.
|
|
3448
|
+
let POL_SUBJECTS = null;
|
|
3449
|
+
async function polLoadSubjects() {
|
|
3450
|
+
const body = document.getElementById('pol-subjects-body');
|
|
3451
|
+
if (!body) return;
|
|
3452
|
+
if (POL_SUBJECTS) {
|
|
3453
|
+
polRenderSubjects(POL_SUBJECTS);
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
try {
|
|
3457
|
+
const r = await fetch('/api/subjects');
|
|
3458
|
+
if (!r.ok) {
|
|
3459
|
+
body.innerHTML = '<div class="empty">Subjects view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
3460
|
+
return;
|
|
3461
|
+
}
|
|
3462
|
+
POL_SUBJECTS = await r.json();
|
|
3463
|
+
polRenderSubjects(POL_SUBJECTS);
|
|
3464
|
+
} catch (e) {
|
|
3465
|
+
body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3466
|
+
}
|
|
3467
|
+
}
|
|
3468
|
+
function polRenderSubjects(j) {
|
|
3469
|
+
const body = document.getElementById('pol-subjects-body');
|
|
3470
|
+
if (!body) return;
|
|
3471
|
+
const sources = j.sources || {};
|
|
3472
|
+
const usersHtml = (j.users || []).map((u) => `
|
|
3473
|
+
<tr>
|
|
3474
|
+
<td><code>${escHtml(u.username)}</code></td>
|
|
3475
|
+
<td>${escHtml(u.name)}</td>
|
|
3476
|
+
<td>${(u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
|
|
3477
|
+
<td>${escHtml(u.tenant || 'default')}</td>
|
|
3478
|
+
</tr>`).join('');
|
|
3479
|
+
const apiKeysHtml = (j.apiKeys || []).map((k) => `
|
|
3480
|
+
<tr>
|
|
3481
|
+
<td><code>${escHtml(k.name)}</code></td>
|
|
3482
|
+
<td>${escHtml(k.tenant || 'default')}</td>
|
|
3483
|
+
<td>${k.productId ? `<code>${escHtml(k.productId)}</code>` : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
|
|
3484
|
+
<td>${k.bypassRedaction ? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">bypass</span>' : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
|
|
3485
|
+
<td>${k.allowedSources && k.allowedSources.length ? k.allowedSources.map((s) => `<code class="t-sm">${escHtml(s)}</code>`).join(' ') : '<span class="t-sm" style="opacity:.5">(all)</span>'}</td>
|
|
3486
|
+
</tr>`).join('');
|
|
3487
|
+
const groupsHtml = (j.oidcGroups || []).map((g) => `
|
|
3488
|
+
<tr>
|
|
3489
|
+
<td><code>${escHtml(g.claim)}</code></td>
|
|
3490
|
+
<td><span class="pill">${escHtml(g.role)}</span></td>
|
|
3491
|
+
</tr>`).join('');
|
|
3492
|
+
const usersTbl = usersHtml
|
|
3493
|
+
? `<table class="data-table" style="width:100%"><thead><tr><th>Username</th><th>Name</th><th>Roles</th><th>Tenant</th></tr></thead><tbody>${usersHtml}</tbody></table>`
|
|
3494
|
+
: `<div class="pol-subjects-empty">${sources.users ? 'No users in <code>' + escHtml(sources.users) + '</code>.' : 'No local-user file configured — set <code>OMCP_USERS_FILE</code> to enable basic-mode logins.'}</div>`;
|
|
3495
|
+
const apiKeysTbl = apiKeysHtml
|
|
3496
|
+
? `<table class="data-table" style="width:100%"><thead><tr><th>Name</th><th>Tenant</th><th>Product</th><th>Bypass</th><th>Sources</th></tr></thead><tbody>${apiKeysHtml}</tbody></table>`
|
|
3497
|
+
: `<div class="pol-subjects-empty">${sources.apiKeys ? 'No credentials in <code>OMCP_API_KEYS</code>.' : 'No API-key credentials configured — set <code>OMCP_API_KEYS</code> to gate the <code>/mcp</code> transport with bearer tokens.'}</div>`;
|
|
3498
|
+
const groupsTbl = groupsHtml
|
|
3499
|
+
? `<table class="data-table" style="width:100%"><thead><tr><th>Group / claim</th><th>Maps to role</th></tr></thead><tbody>${groupsHtml}</tbody></table>`
|
|
3500
|
+
: `<div class="pol-subjects-empty">${sources.oidcGroups ? 'No mappings in <code>OMCP_OIDC_ROLE_MAP</code>.' : 'No OIDC group mapping configured — set <code>OMCP_OIDC_ROLE_MAP</code> when running under <code>OMCP_AUTH=oidc</code>.'}</div>`;
|
|
3501
|
+
body.innerHTML = `
|
|
3502
|
+
<div class="pol-subjects-section">
|
|
3503
|
+
<h3>Users
|
|
3504
|
+
<span class="pol-subjects-count">${(j.users || []).length}</span>
|
|
3505
|
+
<span class="pol-subjects-source">${sources.users ? escHtml(sources.users) : 'OMCP_USERS_FILE'}</span>
|
|
3506
|
+
</h3>
|
|
3507
|
+
${usersTbl}
|
|
3508
|
+
</div>
|
|
3509
|
+
<div class="pol-subjects-section">
|
|
3510
|
+
<h3>API keys
|
|
3511
|
+
<span class="pol-subjects-count">${(j.apiKeys || []).length}</span>
|
|
3512
|
+
<span class="pol-subjects-source">OMCP_API_KEYS</span>
|
|
3513
|
+
</h3>
|
|
3514
|
+
${apiKeysTbl}
|
|
3515
|
+
</div>
|
|
3516
|
+
<div class="pol-subjects-section">
|
|
3517
|
+
<h3>OIDC groups
|
|
3518
|
+
<span class="pol-subjects-count">${(j.oidcGroups || []).length}</span>
|
|
3519
|
+
<span class="pol-subjects-source">OMCP_OIDC_ROLE_MAP</span>
|
|
3520
|
+
</h3>
|
|
3521
|
+
${groupsTbl}
|
|
3522
|
+
</div>`;
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
// Resources × actions catalogue, kept in sync with VALID_RESOURCES +
|
|
3526
|
+
// VALID_ACTIONS in src/auth/policy/loader.ts. Pinned client-side so
|
|
3527
|
+
// the matrix shows the FULL grid (granted vs not-granted) even when
|
|
3528
|
+
// a role has zero grants for a given resource — the empty cells are
|
|
3529
|
+
// information too. If the server adds a new resource / action,
|
|
3530
|
+
// extend this list; the policy.engine snapshot guards against
|
|
3531
|
+
// silent drift through its declared-roles catalogue.
|
|
3532
|
+
const POL_RESOURCES = ["sources","services","health","topology","settings","connectors","audit","catalog","users","redaction","products"];
|
|
3533
|
+
const POL_ACTIONS = ["read","write","delete","bypass"];
|
|
3534
|
+
|
|
3535
|
+
// Effective-permissions overlay: when a subject is selected the
|
|
3536
|
+
// matrix flips from "what does THIS role grant" to "what can THIS
|
|
3537
|
+
// subject do, and via which role". The overlay is pure client-side
|
|
3538
|
+
// composition over the existing /api/policy + /api/subjects snapshots
|
|
3539
|
+
// — no new endpoint. Null means overlay off (default = role-centric).
|
|
3540
|
+
let POL_EFFECTIVE_SUBJECT = null; // { kind: 'user'|'oidc', id: string, roles: string[] } | null
|
|
3541
|
+
|
|
3542
|
+
function polEffectiveSubjectsList() {
|
|
3543
|
+
// Build the dropdown options from the cached subjects payload.
|
|
3544
|
+
// Users carry an explicit roles[] array. OIDC groups map to a
|
|
3545
|
+
// single role via OMCP_OIDC_ROLE_MAP. API keys don't carry RBAC
|
|
3546
|
+
// roles in the current model, so they're omitted — selecting one
|
|
3547
|
+
// would always show "denied" everywhere, which is misleading.
|
|
3548
|
+
const out = [];
|
|
3549
|
+
if (!POL_SUBJECTS) return out;
|
|
3550
|
+
for (const u of (POL_SUBJECTS.users || [])) {
|
|
3551
|
+
out.push({ kind: 'user', id: u.username, label: u.username + ' (user)', roles: u.roles || [] });
|
|
3552
|
+
}
|
|
3553
|
+
for (const g of (POL_SUBJECTS.oidcGroups || [])) {
|
|
3554
|
+
out.push({ kind: 'oidc', id: g.claim, label: g.claim + ' (oidc group)', roles: [g.role] });
|
|
3555
|
+
}
|
|
3556
|
+
return out;
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
async function polEffectiveEnsureSubjects() {
|
|
3560
|
+
// Lazy-load /api/subjects the first time the operator opens the
|
|
3561
|
+
// selector. Reuse the same cache the Bindings/Subjects tabs use.
|
|
3562
|
+
if (POL_SUBJECTS) return;
|
|
3563
|
+
try {
|
|
3564
|
+
const r = await fetch('/api/subjects');
|
|
3565
|
+
if (!r.ok) return;
|
|
3566
|
+
POL_SUBJECTS = await r.json();
|
|
3567
|
+
} catch (e) {
|
|
3568
|
+
// Silent — the selector will just show "no subjects".
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function polEffectiveOnChange(ev) {
|
|
3573
|
+
const val = ev.target.value;
|
|
3574
|
+
if (!val) {
|
|
3575
|
+
POL_EFFECTIVE_SUBJECT = null;
|
|
3576
|
+
polRolesRender();
|
|
3577
|
+
return;
|
|
3578
|
+
}
|
|
3579
|
+
const list = polEffectiveSubjectsList();
|
|
3580
|
+
const found = list.find((s) => (s.kind + ':' + s.id) === val);
|
|
3581
|
+
POL_EFFECTIVE_SUBJECT = found || null;
|
|
3582
|
+
polRolesRender();
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
async function polEffectiveOpen() {
|
|
3586
|
+
// Operator clicked the "Show effective permissions" affordance —
|
|
3587
|
+
// make sure the subjects cache is populated, then re-render so the
|
|
3588
|
+
// selector has options.
|
|
3589
|
+
await polEffectiveEnsureSubjects();
|
|
3590
|
+
polRolesRender();
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
function polRolesRender() {
|
|
3594
|
+
const listEl = document.getElementById('pol-role-list');
|
|
3595
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
3596
|
+
if (!listEl || !detailEl || !POL_SNAPSHOT) return;
|
|
3597
|
+
const roles = POL_SNAPSHOT.roles || [];
|
|
3598
|
+
if (roles.length === 0) {
|
|
3599
|
+
listEl.innerHTML = '<div class="empty">No roles defined.</div>';
|
|
3600
|
+
detailEl.innerHTML = '<div class="empty">Define a role to see its permission matrix.</div>';
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
if (!POL_SELECTED_ROLE || !roles.includes(POL_SELECTED_ROLE)) {
|
|
3604
|
+
POL_SELECTED_ROLE = roles[0];
|
|
3605
|
+
}
|
|
3606
|
+
// Render the role list with grant counts. Selection is handled
|
|
3607
|
+
// via event delegation on the list container — keeps untrusted
|
|
3608
|
+
// role names (file-loaded policies can use any string) out of the
|
|
3609
|
+
// onclick attribute, where escHtml's HTML-special set doesn't
|
|
3610
|
+
// sanitise JS-string-literal characters like the single quote.
|
|
3611
|
+
listEl.innerHTML = roles.map((role) => {
|
|
3612
|
+
const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
|
|
3613
|
+
const active = role === POL_SELECTED_ROLE;
|
|
3614
|
+
return `<div class="pol-role-row" role="option" data-role="${escHtml(role)}" data-active="${active}">
|
|
3615
|
+
<span class="pol-role-name">${escHtml(role)}</span>
|
|
3616
|
+
<span class="pol-role-count">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>
|
|
3617
|
+
</div>`;
|
|
3618
|
+
}).join('');
|
|
3619
|
+
// Wire delegation once. Idempotent — re-rendering the inner HTML
|
|
3620
|
+
// doesn't lose the listener because it's bound to the outer list
|
|
3621
|
+
// element which persists. Guarded with a marker attribute so a
|
|
3622
|
+
// second loadPolicies call doesn't double-bind.
|
|
3623
|
+
if (!listEl.dataset.delegated) {
|
|
3624
|
+
listEl.addEventListener('click', (ev) => {
|
|
3625
|
+
const row = ev.target.closest('.pol-role-row');
|
|
3626
|
+
if (!row) return;
|
|
3627
|
+
const role = row.getAttribute('data-role');
|
|
3628
|
+
if (role) polSelectRole(role);
|
|
3629
|
+
});
|
|
3630
|
+
listEl.dataset.delegated = '1';
|
|
3631
|
+
}
|
|
3632
|
+
// Render the matrix for the selected role.
|
|
3633
|
+
const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[POL_SELECTED_ROLE]) || [];
|
|
3634
|
+
// Build a Set of "resource:action" pairs for O(1) lookup.
|
|
3635
|
+
const granted = new Set();
|
|
3636
|
+
for (const g of grants) granted.add(g.resource + ':' + g.action);
|
|
3637
|
+
const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
|
|
3638
|
+
|
|
3639
|
+
// Effective-overlay precompute. For each (resource, action) build a
|
|
3640
|
+
// list of roles (from the subject's role bundle) that grant it — so
|
|
3641
|
+
// the cell can show "via <role>" or "via <role> +N". Empty = denied.
|
|
3642
|
+
const effective = POL_EFFECTIVE_SUBJECT
|
|
3643
|
+
? (() => {
|
|
3644
|
+
const m = new Map();
|
|
3645
|
+
for (const role of (POL_EFFECTIVE_SUBJECT.roles || [])) {
|
|
3646
|
+
const gs = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
|
|
3647
|
+
for (const g of gs) {
|
|
3648
|
+
const k = g.resource + ':' + g.action;
|
|
3649
|
+
if (!m.has(k)) m.set(k, []);
|
|
3650
|
+
m.get(k).push(role);
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
return m;
|
|
3654
|
+
})()
|
|
3655
|
+
: null;
|
|
3656
|
+
|
|
3657
|
+
const bodyRows = POL_RESOURCES.map((res) => {
|
|
3658
|
+
const cells = POL_ACTIONS.map((act) => {
|
|
3659
|
+
const key = res + ':' + act;
|
|
3660
|
+
if (effective) {
|
|
3661
|
+
const viaRoles = effective.get(key) || [];
|
|
3662
|
+
const ownGrant = granted.has(key);
|
|
3663
|
+
if (viaRoles.length === 0) {
|
|
3664
|
+
return `<td class="cell-empty" data-grant="false" data-effective="denied" title="denied — subject has no role granting ${escHtml(res)}:${escHtml(act)}">denied</td>`;
|
|
3665
|
+
}
|
|
3666
|
+
const first = viaRoles[0];
|
|
3667
|
+
const extra = viaRoles.length > 1 ? ` <span class="t-sm" style="opacity:.55">+${viaRoles.length - 1}</span>` : '';
|
|
3668
|
+
const ownHint = ownGrant ? ' data-via-selected="true"' : '';
|
|
3669
|
+
// Show all granting roles in the title for an audit trail.
|
|
3670
|
+
const titleRoles = viaRoles.map((r) => r).join(', ');
|
|
3671
|
+
return `<td class="cell-grant" data-grant="true" data-effective="allowed"${ownHint} title="via ${escHtml(titleRoles)}"><span class="t-sm" style="font-family:'JetBrains Mono', ui-monospace, monospace">via ${escHtml(first)}</span>${extra}</td>`;
|
|
3672
|
+
}
|
|
3673
|
+
const has = granted.has(key);
|
|
3674
|
+
return has
|
|
3675
|
+
? `<td class="cell-grant" data-grant="true" title="${escHtml(res)}:${escHtml(act)} granted">✓</td>`
|
|
3676
|
+
: `<td class="cell-empty" data-grant="false">—</td>`;
|
|
3677
|
+
}).join('');
|
|
3678
|
+
return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
|
|
3679
|
+
}).join('');
|
|
3680
|
+
|
|
3681
|
+
// Effective-overlay bar — subject selector + clear control. Rendered
|
|
3682
|
+
// inside the detail panel so it survives polRolesRender re-renders
|
|
3683
|
+
// and stays visually attached to the matrix it modifies.
|
|
3684
|
+
const subjOpts = polEffectiveSubjectsList();
|
|
3685
|
+
const selectedKey = POL_EFFECTIVE_SUBJECT ? (POL_EFFECTIVE_SUBJECT.kind + ':' + POL_EFFECTIVE_SUBJECT.id) : '';
|
|
3686
|
+
const optEls = subjOpts.map((s) => {
|
|
3687
|
+
const v = s.kind + ':' + s.id;
|
|
3688
|
+
const sel = v === selectedKey ? ' selected' : '';
|
|
3689
|
+
return `<option value="${escHtml(v)}"${sel}>${escHtml(s.label)}</option>`;
|
|
3690
|
+
}).join('');
|
|
3691
|
+
const subjEmpty = subjOpts.length === 0;
|
|
3692
|
+
const overlayBar = `
|
|
3693
|
+
<div class="pol-effective-bar" style="display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);flex-wrap:wrap">
|
|
3694
|
+
<label class="t-sm" style="opacity:.7" for="pol-effective-subject">Show effective permissions for</label>
|
|
3695
|
+
<select id="pol-effective-subject" class="input input-sm" style="min-width:240px" onchange="polEffectiveOnChange(event)" onfocus="polEffectiveOpen()" aria-label="Subject for effective-permissions overlay">
|
|
3696
|
+
<option value="">(none — show role grants)</option>
|
|
3697
|
+
${optEls}
|
|
3698
|
+
</select>
|
|
3699
|
+
${POL_EFFECTIVE_SUBJECT
|
|
3700
|
+
? `<button class="btn btn-ghost btn-sm" onclick="polEffectiveOnChange({target:{value:''}})">Clear</button>
|
|
3701
|
+
<span class="t-sm" style="opacity:.65">via roles: ${(POL_EFFECTIVE_SUBJECT.roles || []).map((r) => `<code>${escHtml(r)}</code>`).join(', ') || '<em>none</em>'}</span>`
|
|
3702
|
+
: (subjEmpty ? '<span class="t-sm" style="opacity:.55">No subjects configured — set OMCP_USERS_FILE or OMCP_OIDC_ROLE_MAP to populate.</span>' : '')
|
|
3703
|
+
}
|
|
3704
|
+
</div>`;
|
|
3705
|
+
|
|
3706
|
+
const titleSuffix = POL_EFFECTIVE_SUBJECT
|
|
3707
|
+
? ` <span class="t-sm" style="opacity:.65;font-weight:400">— effective view for ${escHtml(POL_EFFECTIVE_SUBJECT.id)}</span>`
|
|
3708
|
+
: ` <span class="t-sm" style="opacity:.65;font-weight:400">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>`;
|
|
3709
|
+
|
|
3710
|
+
const legend = POL_EFFECTIVE_SUBJECT
|
|
3711
|
+
? `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
|
|
3712
|
+
<em>via <role></em> = the subject inherits this grant through that role. <em>denied</em> = no role in the subject's bundle grants it. +N = additional roles also grant it; hover the cell for the full list.
|
|
3713
|
+
</p>`
|
|
3714
|
+
: `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
|
|
3715
|
+
✓ = the role grants this resource:action combination. — = no grant. The
|
|
3716
|
+
<em>redaction:bypass</em> column is the operator-side gate for the per-call
|
|
3717
|
+
bypass; the credential must also be allow-listed via OMCP_KEY_BYPASS_REDACTION.
|
|
3718
|
+
</p>`;
|
|
3719
|
+
|
|
3720
|
+
detailEl.innerHTML = `
|
|
3721
|
+
<h3>${escHtml(POL_SELECTED_ROLE)}${titleSuffix}</h3>
|
|
3722
|
+
${overlayBar}
|
|
3723
|
+
<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>
|
|
3724
|
+
${legend}
|
|
3725
|
+
`;
|
|
3726
|
+
}
|
|
3727
|
+
function polSelectRole(role) {
|
|
3728
|
+
POL_SELECTED_ROLE = role;
|
|
3729
|
+
polRolesRender();
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
function polRoleAuthorNew() {
|
|
3733
|
+
// Author modal — render a permission-matrix as a checkbox grid
|
|
3734
|
+
// (rows = resources × cols = actions) plus a role-name input.
|
|
3735
|
+
const nameEl = document.getElementById('pol-role-author-name');
|
|
3736
|
+
const gridEl = document.getElementById('pol-role-author-grid');
|
|
3737
|
+
const errEl = document.getElementById('pol-role-author-error');
|
|
3738
|
+
if (nameEl) nameEl.value = '';
|
|
3739
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3740
|
+
if (gridEl) {
|
|
3741
|
+
const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
|
|
3742
|
+
const bodyRows = POL_RESOURCES.map((res) => {
|
|
3743
|
+
const cells = POL_ACTIONS.map((act) => `<td style="text-align:center">
|
|
3744
|
+
<input type="checkbox" data-pol-resource="${escHtml(res)}" data-pol-action="${escHtml(act)}" aria-label="${escHtml(res)}:${escHtml(act)}">
|
|
3745
|
+
</td>`).join('');
|
|
3746
|
+
return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
|
|
3747
|
+
}).join('');
|
|
3748
|
+
gridEl.innerHTML = `<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
|
3749
|
+
}
|
|
3750
|
+
document.getElementById('pol-role-author-modal').classList.add('open');
|
|
3751
|
+
setTimeout(() => nameEl && nameEl.focus(), 50);
|
|
3752
|
+
}
|
|
3753
|
+
async function polRoleAuthorSave() {
|
|
3754
|
+
const nameEl = document.getElementById('pol-role-author-name');
|
|
3755
|
+
const errEl = document.getElementById('pol-role-author-error');
|
|
3756
|
+
const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
|
|
3757
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3758
|
+
const name = (nameEl && nameEl.value || '').trim();
|
|
3759
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
|
|
3760
|
+
setErr('Role name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
|
|
3761
|
+
nameEl && nameEl.focus();
|
|
3762
|
+
return;
|
|
3763
|
+
}
|
|
3764
|
+
const checks = Array.from(document.querySelectorAll('#pol-role-author-grid input[type=checkbox]:checked'));
|
|
3765
|
+
const perms = checks.map((el) => ({
|
|
3766
|
+
resource: el.getAttribute('data-pol-resource'),
|
|
3767
|
+
action: el.getAttribute('data-pol-action'),
|
|
3768
|
+
})).filter((p) => p.resource && p.action);
|
|
3769
|
+
try {
|
|
3770
|
+
const r = await fetch('/api/policy/roles/' + encodeURIComponent(name), {
|
|
3771
|
+
method: 'PUT',
|
|
3772
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3773
|
+
body: JSON.stringify({ permissions: perms }),
|
|
3774
|
+
});
|
|
3775
|
+
if (!r.ok) {
|
|
3776
|
+
const j = await r.json().catch(() => ({}));
|
|
3777
|
+
setErr(j.error || ('HTTP ' + r.status));
|
|
3778
|
+
return;
|
|
3779
|
+
}
|
|
3780
|
+
closeModal('pol-role-author-modal');
|
|
3781
|
+
toast('Saved role ' + name);
|
|
3782
|
+
// Re-fetch the policy snapshot so the Roles list + matrix
|
|
3783
|
+
// reflect the change without a page reload.
|
|
3784
|
+
POL_SUBJECTS = null;
|
|
3785
|
+
POL_SELECTED_ROLE = name;
|
|
3786
|
+
await loadPolicies();
|
|
3787
|
+
} catch (e) {
|
|
3788
|
+
setErr('Save failed: ' + (e && e.message || e));
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
async function loadPolicies() {
|
|
2025
3793
|
const rolesEl = document.getElementById('pol-roles');
|
|
2026
3794
|
try {
|
|
2027
3795
|
const r = await fetch('/api/policy');
|
|
2028
3796
|
if (!r.ok) {
|
|
2029
|
-
rolesEl.innerHTML = '
|
|
2030
|
-
|
|
3797
|
+
if (rolesEl) rolesEl.innerHTML = '';
|
|
3798
|
+
const listEl = document.getElementById('pol-role-list');
|
|
3799
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
3800
|
+
const msg = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
3801
|
+
if (listEl) listEl.innerHTML = msg;
|
|
3802
|
+
if (detailEl) detailEl.innerHTML = '';
|
|
3803
|
+
const engineEl = document.getElementById('pol-engine');
|
|
3804
|
+
if (engineEl) engineEl.textContent = '';
|
|
3805
|
+
const banner = document.getElementById('pol-engine-banner');
|
|
3806
|
+
if (banner) banner.hidden = true;
|
|
3807
|
+
// Clear the body attribute so a stale value from a prior session
|
|
3808
|
+
// (e.g. logged-out tab refresh) doesn't keep authoring controls
|
|
3809
|
+
// dimmed under the wrong engine assumption.
|
|
3810
|
+
document.body.removeAttribute('data-policy-engine');
|
|
2031
3811
|
return;
|
|
2032
3812
|
}
|
|
2033
3813
|
const j = await r.json();
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
3814
|
+
polRenderEngineBanner(j);
|
|
3815
|
+
POL_SNAPSHOT = j;
|
|
3816
|
+
// Invalidate subjects cache on each policy reload so the next
|
|
3817
|
+
// Subjects tab visit fetches fresh data (env vars / users file
|
|
3818
|
+
// may have changed since last visit).
|
|
3819
|
+
POL_SUBJECTS = null;
|
|
3820
|
+
// Stale overlay selection points at a subject that may no longer
|
|
3821
|
+
// exist (subjects cache will be repopulated on next visit) —
|
|
3822
|
+
// safest to drop it on every policy reload.
|
|
3823
|
+
POL_EFFECTIVE_SUBJECT = null;
|
|
3824
|
+
// Default sub-tab = Roles.
|
|
3825
|
+
if (!document.querySelector('.pol-subtab[data-active="true"]')) {
|
|
3826
|
+
polSetTab('roles');
|
|
3827
|
+
}
|
|
3828
|
+
polRolesRender();
|
|
3829
|
+
// Warm the subjects cache in the background so the effective-
|
|
3830
|
+
// permissions selector has options the first time an operator
|
|
3831
|
+
// opens it (no fetch-on-focus jank). Failure is silent — the
|
|
3832
|
+
// selector falls back to its empty-state copy.
|
|
3833
|
+
polEffectiveEnsureSubjects().then(() => {
|
|
3834
|
+
if (POL_SUBJECTS && document.getElementById('pol-effective-subject')) polRolesRender();
|
|
3835
|
+
});
|
|
3836
|
+
// Legacy snapshot card kept hidden — the matrix supersedes it.
|
|
3837
|
+
if (rolesEl) {
|
|
3838
|
+
const blocks = [];
|
|
3839
|
+
for (const role of (j.roles || [])) {
|
|
3840
|
+
const grants = (j.policy && j.policy[role]) || [];
|
|
3841
|
+
const byRes = {};
|
|
3842
|
+
for (const g of grants) {
|
|
3843
|
+
(byRes[g.resource] = byRes[g.resource] || []).push(g.action);
|
|
3844
|
+
}
|
|
3845
|
+
const rows = Object.entries(byRes).map(([res, actions]) =>
|
|
3846
|
+
`<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
|
|
3847
|
+
).join('');
|
|
3848
|
+
blocks.push(`
|
|
3849
|
+
<div style="padding: var(--sp-3) var(--sp-4)">
|
|
3850
|
+
<h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
|
|
3851
|
+
<span>${escHtml(role)}</span>
|
|
3852
|
+
<span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
|
|
3853
|
+
</h3>
|
|
3854
|
+
<table class="data-table" style="width:100%"><thead><tr><th>Resource</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan="2" class="empty">no grants</td></tr>'}</tbody></table>
|
|
3855
|
+
</div>`);
|
|
2043
3856
|
}
|
|
2044
|
-
|
|
2045
|
-
`<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
|
|
2046
|
-
).join('');
|
|
2047
|
-
blocks.push(`
|
|
2048
|
-
<div style="padding: var(--sp-3) var(--sp-4)">
|
|
2049
|
-
<h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
|
|
2050
|
-
<span>${escHtml(role)}</span>
|
|
2051
|
-
<span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
|
|
2052
|
-
</h3>
|
|
2053
|
-
<table class="data-table" style="width:100%"><thead><tr><th>Resource</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan="2" class="empty">no grants</td></tr>'}</tbody></table>
|
|
2054
|
-
</div>`);
|
|
2055
|
-
}
|
|
2056
|
-
rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
|
|
2057
|
-
if (j.note) {
|
|
2058
|
-
rolesEl.insertAdjacentHTML('beforeend', `<div class="form-hint" style="padding: var(--sp-2) var(--sp-4) var(--sp-3)">${escHtml(j.note)}</div>`);
|
|
3857
|
+
rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
|
|
2059
3858
|
}
|
|
2060
3859
|
} catch (e) {
|
|
2061
|
-
|
|
3860
|
+
const msg = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3861
|
+
if (rolesEl) rolesEl.innerHTML = msg;
|
|
3862
|
+
const listEl = document.getElementById('pol-role-list');
|
|
3863
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
3864
|
+
if (listEl) listEl.innerHTML = msg;
|
|
3865
|
+
if (detailEl) detailEl.innerHTML = '';
|
|
2062
3866
|
}
|
|
2063
3867
|
}
|
|
2064
3868
|
async function polDryRun() {
|
|
@@ -2066,24 +3870,28 @@ async function polDryRun() {
|
|
|
2066
3870
|
const roles = document.getElementById('pol-dry-roles').value.trim();
|
|
2067
3871
|
const resource = document.getElementById('pol-dry-resource').value.trim();
|
|
2068
3872
|
const action = document.getElementById('pol-dry-action').value.trim();
|
|
3873
|
+
const tenant = document.getElementById('pol-dry-tenant').value.trim();
|
|
2069
3874
|
if (!resource || !action) {
|
|
2070
3875
|
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
|
|
2071
3876
|
return;
|
|
2072
3877
|
}
|
|
2073
3878
|
const params = new URLSearchParams({ resource, action });
|
|
2074
3879
|
if (roles) params.set('roles', roles);
|
|
3880
|
+
if (tenant) params.set('tenant', tenant);
|
|
2075
3881
|
try {
|
|
2076
3882
|
const r = await fetch('/api/policy?' + params.toString());
|
|
2077
3883
|
const j = await r.json();
|
|
2078
3884
|
const d = j.dryRun || {};
|
|
2079
|
-
const cls = d.allowed ? 'badge-ok' : 'badge-err';
|
|
2080
3885
|
const verdict = d.allowed ? 'allowed' : 'denied';
|
|
3886
|
+
const cls = d.allowed ? 'allow' : 'deny';
|
|
3887
|
+
const tenantTag = d.tenant ? ` <span class="tag t-sm">tenant: ${escHtml(d.tenant)}</span>` : '';
|
|
2081
3888
|
out.innerHTML = `
|
|
2082
|
-
<div
|
|
2083
|
-
<span class="
|
|
3889
|
+
<div class="pol-probe-result">
|
|
3890
|
+
<span class="pol-pv ${cls}" data-verdict="${verdict}">${verdict}</span>
|
|
2084
3891
|
<code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
|
|
3892
|
+
${tenantTag}
|
|
2085
3893
|
</div>
|
|
2086
|
-
<div class="form-hint" style="margin-top: var(--sp-
|
|
3894
|
+
<div class="form-hint" style="margin-top: var(--sp-1)">${escHtml(d.reason || '')}</div>`;
|
|
2087
3895
|
} catch (e) {
|
|
2088
3896
|
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2089
3897
|
}
|
|
@@ -2112,7 +3920,29 @@ function toggleTheme(){
|
|
|
2112
3920
|
const _omcpRawFetch = window.fetch.bind(window);
|
|
2113
3921
|
let _omcpLoginInflight = null;
|
|
2114
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
|
+
}
|
|
2115
3944
|
window.fetch = async function omcpAuthedFetch(input, init) {
|
|
3945
|
+
init = _omcpWithCsrf(input, init);
|
|
2116
3946
|
const res = await _omcpRawFetch(input, init);
|
|
2117
3947
|
if (res.status !== 401) return res;
|
|
2118
3948
|
let body = null;
|
|
@@ -2299,10 +4129,21 @@ async function omcpCheckGovernance() {
|
|
|
2299
4129
|
const info = await r.json();
|
|
2300
4130
|
const g = info && info.governance;
|
|
2301
4131
|
if (!g) return;
|
|
4132
|
+
const warns = [];
|
|
2302
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) {
|
|
2303
4144
|
const bar = document.getElementById('omcp-warning-bar');
|
|
2304
4145
|
if (bar) {
|
|
2305
|
-
bar.textContent = '⚠
|
|
4146
|
+
bar.textContent = '⚠ ' + warns.join(' · ');
|
|
2306
4147
|
bar.style.display = '';
|
|
2307
4148
|
}
|
|
2308
4149
|
}
|
|
@@ -3073,14 +4914,261 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
|
|
|
3073
4914
|
// --- Data Loading ---
|
|
3074
4915
|
async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
|
|
3075
4916
|
// --- MCP Products (new /api/products surface) ---
|
|
4917
|
+
// View mode (cards | table) for the Products catalog. Persisted in
|
|
4918
|
+
// localStorage so an operator's preference survives reloads.
|
|
4919
|
+
function mcpProductsView() {
|
|
4920
|
+
try { return localStorage.getItem('omcp-mcp-products-view') === 'table' ? 'table' : 'cards'; }
|
|
4921
|
+
catch (e) { return 'cards'; }
|
|
4922
|
+
}
|
|
4923
|
+
function mcpProductsSetView(v) {
|
|
4924
|
+
try { localStorage.setItem('omcp-mcp-products-view', v); } catch (e) { /* noop */ }
|
|
4925
|
+
loadMcpProducts();
|
|
4926
|
+
}
|
|
4927
|
+
|
|
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 */ }
|
|
4933
|
+
}
|
|
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 */ }
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
// Cache the tool registry — fetched once on first modal open and
|
|
4941
|
+
// reused thereafter. Falsy means not-yet-loaded; an empty array
|
|
4942
|
+
// means the endpoint replied but with no tools (server bug — we
|
|
4943
|
+
// fall back to a textarea-style entry in that case).
|
|
4944
|
+
let MCP_TOOLS_REGISTRY = null;
|
|
4945
|
+
async function mcpLoadToolsRegistry() {
|
|
4946
|
+
if (MCP_TOOLS_REGISTRY) return MCP_TOOLS_REGISTRY;
|
|
4947
|
+
try {
|
|
4948
|
+
const r = await fetch('/api/tools/registry');
|
|
4949
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
4950
|
+
const j = await r.json();
|
|
4951
|
+
MCP_TOOLS_REGISTRY = Array.isArray(j.tools) ? j.tools : [];
|
|
4952
|
+
return MCP_TOOLS_REGISTRY;
|
|
4953
|
+
} catch (e) {
|
|
4954
|
+
// Surface but don't break the modal — fall back to []
|
|
4955
|
+
// (operator can still hand-edit via the textarea fallback).
|
|
4956
|
+
MCP_TOOLS_REGISTRY = [];
|
|
4957
|
+
return MCP_TOOLS_REGISTRY;
|
|
4958
|
+
}
|
|
4959
|
+
}
|
|
4960
|
+
function mcpRenderToolsPicker(selected) {
|
|
4961
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
4962
|
+
const textarea = document.getElementById('mcp-product-tools');
|
|
4963
|
+
if (!picker || !textarea) return;
|
|
4964
|
+
const tools = MCP_TOOLS_REGISTRY || [];
|
|
4965
|
+
if (tools.length === 0) {
|
|
4966
|
+
// Fallback: expose the textarea so the operator can still author
|
|
4967
|
+
// by hand. The server-side typo guard catches mistakes either way.
|
|
4968
|
+
textarea.hidden = false;
|
|
4969
|
+
textarea.value = (selected || []).join('\n');
|
|
4970
|
+
picker.innerHTML = '<div class="tools-picker-hint">Tool registry unavailable — type names one per line.</div>';
|
|
4971
|
+
return;
|
|
4972
|
+
}
|
|
4973
|
+
const selSet = new Set(selected || []);
|
|
4974
|
+
// Group by category, ordered. Keep an "(other)" bucket for any
|
|
4975
|
+
// future category we forgot in the CSS.
|
|
4976
|
+
const order = ['discovery', 'query', 'diagnose', 'topology'];
|
|
4977
|
+
const groups = {};
|
|
4978
|
+
for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
|
|
4979
|
+
const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
|
|
4980
|
+
const html = order.filter((c) => groups[c]).map((cat) => {
|
|
4981
|
+
const items = groups[cat].map((t) => {
|
|
4982
|
+
const checked = selSet.has(t.name) ? 'checked' : '';
|
|
4983
|
+
return `<label class="tp-item">
|
|
4984
|
+
<input type="checkbox" data-tool="${escHtml(t.name)}" ${checked} onchange="mcpToolsPickerSync()">
|
|
4985
|
+
<span class="tp-text">
|
|
4986
|
+
<span class="tp-name">${escHtml(t.name)}</span>
|
|
4987
|
+
<span class="tp-summary">${escHtml(t.summary)}</span>
|
|
4988
|
+
</span>
|
|
4989
|
+
</label>`;
|
|
4990
|
+
}).join('');
|
|
4991
|
+
return `<div class="tp-group">
|
|
4992
|
+
<div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
|
|
4993
|
+
${items}
|
|
4994
|
+
</div>`;
|
|
4995
|
+
}).join('');
|
|
4996
|
+
picker.innerHTML = `
|
|
4997
|
+
<div class="tp-actions">
|
|
4998
|
+
<button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(true)">Select all</button>
|
|
4999
|
+
<button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(false)">Clear</button>
|
|
5000
|
+
</div>
|
|
5001
|
+
${html}
|
|
5002
|
+
`;
|
|
5003
|
+
// Keep the hidden textarea in sync with the initial selection.
|
|
5004
|
+
textarea.value = (selected || []).join('\n');
|
|
5005
|
+
}
|
|
5006
|
+
function mcpToolsPickerSync() {
|
|
5007
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
5008
|
+
const textarea = document.getElementById('mcp-product-tools');
|
|
5009
|
+
if (!picker || !textarea) return;
|
|
5010
|
+
const checked = Array.from(picker.querySelectorAll('input[type=checkbox][data-tool]:checked'))
|
|
5011
|
+
.map((el) => el.getAttribute('data-tool'))
|
|
5012
|
+
.filter(Boolean);
|
|
5013
|
+
textarea.value = checked.join('\n');
|
|
5014
|
+
}
|
|
5015
|
+
function mcpToolsPickerAll(on) {
|
|
5016
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
5017
|
+
if (!picker) return;
|
|
5018
|
+
picker.querySelectorAll('input[type=checkbox][data-tool]').forEach((el) => { el.checked = on; });
|
|
5019
|
+
mcpToolsPickerSync();
|
|
5020
|
+
}
|
|
5021
|
+
|
|
5022
|
+
// Empty-state product templates. Cloning a template prefills the
|
|
5023
|
+
// modal — the operator only has to pick an id + tweak before save.
|
|
5024
|
+
// Templates are pure UI; the server doesn't know about them.
|
|
5025
|
+
const MCP_PRODUCT_TEMPLATES = {
|
|
5026
|
+
ops: {
|
|
5027
|
+
title: 'Ops Bundle',
|
|
5028
|
+
desc: 'Incident-response tools for an on-call SRE agent.',
|
|
5029
|
+
product: {
|
|
5030
|
+
id: '', name: 'Ops Bundle',
|
|
5031
|
+
description: 'Incident-response tools for the on-call SRE agent.',
|
|
5032
|
+
status: 'staging',
|
|
5033
|
+
tools: ['query_logs', 'query_metrics', 'get_service_health', 'detect_anomalies'],
|
|
5034
|
+
},
|
|
5035
|
+
},
|
|
5036
|
+
dev: {
|
|
5037
|
+
title: 'Dev Bundle',
|
|
5038
|
+
desc: 'Discovery + topology tools for a coding agent. Read-only.',
|
|
5039
|
+
product: {
|
|
5040
|
+
id: '', name: 'Dev Bundle',
|
|
5041
|
+
description: 'Discovery + topology tools for a coding agent. Read-only.',
|
|
5042
|
+
status: 'staging',
|
|
5043
|
+
tools: ['list_sources', 'list_services', 'get_topology'],
|
|
5044
|
+
},
|
|
5045
|
+
},
|
|
5046
|
+
compliance: {
|
|
5047
|
+
title: 'Compliance Bundle',
|
|
5048
|
+
desc: 'Logs query only — for an audit agent.',
|
|
5049
|
+
product: {
|
|
5050
|
+
id: '', name: 'Compliance Bundle',
|
|
5051
|
+
description: 'Logs query only — for an audit agent.',
|
|
5052
|
+
status: 'staging',
|
|
5053
|
+
tools: ['query_logs'],
|
|
5054
|
+
},
|
|
5055
|
+
},
|
|
5056
|
+
blank: {
|
|
5057
|
+
title: 'Blank',
|
|
5058
|
+
desc: 'Start from scratch.',
|
|
5059
|
+
product: { id: '', name: '', status: 'staging' },
|
|
5060
|
+
},
|
|
5061
|
+
};
|
|
5062
|
+
function mcpProductTemplate(key) {
|
|
5063
|
+
const t = MCP_PRODUCT_TEMPLATES[key];
|
|
5064
|
+
if (!t) return;
|
|
5065
|
+
mcpProductOpen('new', t.product);
|
|
5066
|
+
}
|
|
5067
|
+
|
|
5068
|
+
function mcpProductCardHtml(p) {
|
|
5069
|
+
const accent = (p.branding && typeof p.branding.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(p.branding.color))
|
|
5070
|
+
? p.branding.color : 'var(--accent)';
|
|
5071
|
+
const icon = (p.branding && typeof p.branding.iconUrl === 'string' && /^https?:\/\//.test(p.branding.iconUrl))
|
|
5072
|
+
? `<img src="${escHtml(p.branding.iconUrl)}" alt="" onerror="this.style.display='none'">`
|
|
5073
|
+
: '◫';
|
|
5074
|
+
const status = p.status === 'staging'
|
|
5075
|
+
? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)" title="Hidden from non-admin agents">staging</span>'
|
|
5076
|
+
: '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
|
|
5077
|
+
const meta = [p.id, p.version ? 'v' + p.version : null, 'tenant: ' + (p.tenant || 'default')]
|
|
5078
|
+
.filter(Boolean).map(escHtml).join(' · ');
|
|
5079
|
+
const tools = (p.tools || []);
|
|
5080
|
+
const toolsLabel = tools.length === 0
|
|
5081
|
+
? '<span class="pcard-tool-all">no filter — every registered tool</span>'
|
|
5082
|
+
: tools.slice(0, 8).map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('') +
|
|
5083
|
+
(tools.length > 8 ? `<span class="pcard-tool">+${tools.length - 8} more</span>` : '');
|
|
5084
|
+
const desc = p.description
|
|
5085
|
+
? escHtml(p.description)
|
|
5086
|
+
: '<span style="opacity:.5">No description.</span>';
|
|
5087
|
+
const idAttr = encodeURIComponent(p.id);
|
|
5088
|
+
return `<div class="pcard">
|
|
5089
|
+
<div class="pcard-rail" style="background:${accent}"></div>
|
|
5090
|
+
<div class="pcard-hd">
|
|
5091
|
+
<div class="pcard-icon" style="color:${accent}">${icon}</div>
|
|
5092
|
+
<div class="pcard-title">
|
|
5093
|
+
<h3>${escHtml(p.name)}</h3>
|
|
5094
|
+
<div class="pcard-meta">${meta}</div>
|
|
5095
|
+
</div>
|
|
5096
|
+
<div class="pcard-status">${status}</div>
|
|
5097
|
+
</div>
|
|
5098
|
+
<div class="pcard-desc">${desc}</div>
|
|
5099
|
+
<div>
|
|
5100
|
+
<div class="pcard-tools-label">Tools (${tools.length || 'unrestricted'})</div>
|
|
5101
|
+
<div class="pcard-tools">${toolsLabel}</div>
|
|
5102
|
+
</div>
|
|
5103
|
+
<div class="pcard-footer">
|
|
5104
|
+
<button class="btn btn-ghost btn-sm" title="Preview as agent" aria-label="Preview ${escHtml(p.id)} as agent" onclick="mcpProductPreviewAgent('${idAttr}')">Preview as agent</button>
|
|
5105
|
+
<div class="pcard-spacer"></div>
|
|
5106
|
+
<button class="btn-icon" data-rbac="products:write" title="Edit" aria-label="Edit ${escHtml(p.id)}" onclick="mcpProductsEdit('${idAttr}')">✎</button>
|
|
5107
|
+
<button class="btn-icon" data-rbac="products:delete" title="Delete" aria-label="Delete ${escHtml(p.id)}" onclick="mcpProductsDelete('${idAttr}')">🗑</button>
|
|
5108
|
+
</div>
|
|
5109
|
+
</div>`;
|
|
5110
|
+
}
|
|
5111
|
+
|
|
5112
|
+
function mcpProductTableHtml(products) {
|
|
5113
|
+
const rows = products.map((p) => {
|
|
5114
|
+
const status = p.status === 'staging'
|
|
5115
|
+
? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
|
|
5116
|
+
: '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
|
|
5117
|
+
const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
|
|
5118
|
+
const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
|
|
5119
|
+
const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
|
|
5120
|
+
return `<tr>
|
|
5121
|
+
<td><code>${escHtml(p.id)}</code></td>
|
|
5122
|
+
<td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
|
|
5123
|
+
<td>${tenantCell}</td>
|
|
5124
|
+
<td>${status}</td>
|
|
5125
|
+
<td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
|
|
5126
|
+
<td style="text-align:right">
|
|
5127
|
+
<button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">✎</button>
|
|
5128
|
+
<button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">🗑</button>
|
|
5129
|
+
</td>
|
|
5130
|
+
</tr>`;
|
|
5131
|
+
}).join('');
|
|
5132
|
+
return `<table class="data-table" style="width:100%">
|
|
5133
|
+
<thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
|
|
5134
|
+
<tbody>${rows}</tbody>
|
|
5135
|
+
</table>`;
|
|
5136
|
+
}
|
|
5137
|
+
|
|
5138
|
+
function mcpProductEmptyHtml(configured) {
|
|
5139
|
+
const tpls = ['ops', 'dev', 'compliance', 'blank']
|
|
5140
|
+
.map((k) => {
|
|
5141
|
+
const t = MCP_PRODUCT_TEMPLATES[k];
|
|
5142
|
+
return `<button class="pempty-tpl" data-rbac="products:write" onclick="mcpProductTemplate('${k}')">
|
|
5143
|
+
<div class="pempty-tpl-title">${escHtml(t.title)}</div>
|
|
5144
|
+
<div class="pempty-tpl-desc">${escHtml(t.desc)}</div>
|
|
5145
|
+
</button>`;
|
|
5146
|
+
}).join('');
|
|
5147
|
+
const persistedHint = configured
|
|
5148
|
+
? 'Changes here persist to <code>OMCP_PRODUCTS_FILE</code>.'
|
|
5149
|
+
: '<code>OMCP_PRODUCTS_FILE</code> is unset — changes live in memory only and won\'t survive a restart.';
|
|
5150
|
+
return `<div class="pempty">
|
|
5151
|
+
<h3>No products yet</h3>
|
|
5152
|
+
<p>A product is a curated tool bundle an agent receives when it connects with a bound credential. Pick a template below or start blank — you can rename and tweak everything before saving.</p>
|
|
5153
|
+
<div class="pempty-templates" data-rbac="products:write">${tpls}</div>
|
|
5154
|
+
<p class="t-sm" style="margin-top:var(--sp-4);opacity:.7">${persistedHint}</p>
|
|
5155
|
+
</div>`;
|
|
5156
|
+
}
|
|
5157
|
+
|
|
3076
5158
|
async function loadMcpProducts() {
|
|
5159
|
+
pgLegacyRestore();
|
|
3077
5160
|
const box = document.getElementById('mcp-products-box');
|
|
3078
5161
|
const scope = document.getElementById('mcp-products-scope');
|
|
3079
5162
|
if (!box) return;
|
|
5163
|
+
// Sync view-toggle active state.
|
|
5164
|
+
const v = mcpProductsView();
|
|
5165
|
+
const btnC = document.getElementById('mcp-pv-cards');
|
|
5166
|
+
const btnT = document.getElementById('mcp-pv-table');
|
|
5167
|
+
if (btnC) btnC.classList.toggle('active', v === 'cards');
|
|
5168
|
+
if (btnT) btnT.classList.toggle('active', v === 'table');
|
|
3080
5169
|
try {
|
|
3081
5170
|
const r = await fetch('/api/products');
|
|
3082
5171
|
if (!r.ok) {
|
|
3083
|
-
// 403 = no products:read; render a friendly hint instead of an empty list
|
|
3084
5172
|
if (r.status === 403) {
|
|
3085
5173
|
box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
|
|
3086
5174
|
} else {
|
|
@@ -3094,59 +5182,312 @@ async function loadMcpProducts() {
|
|
|
3094
5182
|
scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
|
|
3095
5183
|
}
|
|
3096
5184
|
if (!j.products || j.products.length === 0) {
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
: 'Set <code>OMCP_PRODUCTS_FILE=<path></code> on the server (or use <b>+ New product</b> to start an in-memory catalog).';
|
|
3100
|
-
box.innerHTML = '<div class="empty">' + hint + '</div>';
|
|
5185
|
+
box.innerHTML = mcpProductEmptyHtml(j.configured);
|
|
5186
|
+
omcpApplyRbacToDom();
|
|
3101
5187
|
return;
|
|
3102
5188
|
}
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
|
|
3109
|
-
const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
|
|
3110
|
-
return `<tr>
|
|
3111
|
-
<td><code>${escHtml(p.id)}</code></td>
|
|
3112
|
-
<td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
|
|
3113
|
-
<td>${tenantCell}</td>
|
|
3114
|
-
<td>${status}</td>
|
|
3115
|
-
<td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
|
|
3116
|
-
<td style="text-align:right">
|
|
3117
|
-
<button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">✎</button>
|
|
3118
|
-
<button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">🗑</button>
|
|
3119
|
-
</td>
|
|
3120
|
-
</tr>`;
|
|
3121
|
-
}).join('');
|
|
3122
|
-
box.innerHTML = `<table class="data-table" style="width:100%">
|
|
3123
|
-
<thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
|
|
3124
|
-
<tbody>${rows}</tbody>
|
|
3125
|
-
</table>`;
|
|
5189
|
+
if (v === 'table') {
|
|
5190
|
+
box.innerHTML = mcpProductTableHtml(j.products);
|
|
5191
|
+
} else {
|
|
5192
|
+
box.innerHTML = `<div class="pcard-grid">${j.products.map(mcpProductCardHtml).join('')}</div>`;
|
|
5193
|
+
}
|
|
3126
5194
|
omcpApplyRbacToDom();
|
|
3127
5195
|
} catch (e) {
|
|
3128
5196
|
box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3129
5197
|
}
|
|
3130
5198
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
5199
|
+
// Form-driven Product modal — replaces the old chain of window.prompt
|
|
5200
|
+
// dialogs. Exposes id + name + description + status + tenant + tools
|
|
5201
|
+
// + version + branding in one place, validated client-side before
|
|
5202
|
+
// the server's strict parser sees it.
|
|
5203
|
+
// Wizard navigation state — current step (1..4) for the active
|
|
5204
|
+
// modal session. Reset on every mcpProductOpen call.
|
|
5205
|
+
let MCP_WIZ_STEP = 1;
|
|
5206
|
+
const MCP_WIZ_LAST = 4;
|
|
5207
|
+
|
|
5208
|
+
function mcpWizGoto(step) {
|
|
5209
|
+
if (step < 1 || step > MCP_WIZ_LAST) return;
|
|
5210
|
+
// Validate the current step before allowing forward navigation —
|
|
5211
|
+
// backwards / sidewards navigation always allowed (the operator
|
|
5212
|
+
// may want to revise).
|
|
5213
|
+
if (step > MCP_WIZ_STEP && !mcpWizValidateStep(MCP_WIZ_STEP)) return;
|
|
5214
|
+
MCP_WIZ_STEP = step;
|
|
5215
|
+
mcpWizRender();
|
|
5216
|
+
}
|
|
5217
|
+
function mcpWizNext() {
|
|
5218
|
+
if (MCP_WIZ_STEP < MCP_WIZ_LAST) mcpWizGoto(MCP_WIZ_STEP + 1);
|
|
5219
|
+
}
|
|
5220
|
+
function mcpWizBack() {
|
|
5221
|
+
if (MCP_WIZ_STEP > 1) mcpWizGoto(MCP_WIZ_STEP - 1);
|
|
5222
|
+
}
|
|
5223
|
+
function mcpWizValidateStep(step) {
|
|
5224
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
5225
|
+
errEl.style.display = 'none';
|
|
5226
|
+
errEl.textContent = '';
|
|
5227
|
+
if (step === 1) {
|
|
5228
|
+
const id = document.getElementById('mcp-product-id').value.trim();
|
|
5229
|
+
const name = document.getElementById('mcp-product-name').value.trim();
|
|
5230
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
|
|
5231
|
+
errEl.textContent = 'Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.';
|
|
5232
|
+
errEl.style.display = '';
|
|
5233
|
+
document.getElementById('mcp-product-id').focus();
|
|
5234
|
+
return false;
|
|
5235
|
+
}
|
|
5236
|
+
if (!name) {
|
|
5237
|
+
errEl.textContent = 'Display name is required.';
|
|
5238
|
+
errEl.style.display = '';
|
|
5239
|
+
document.getElementById('mcp-product-name').focus();
|
|
5240
|
+
return false;
|
|
5241
|
+
}
|
|
5242
|
+
}
|
|
5243
|
+
// Step 2 (tools) + step 3 (scope/branding) have no required fields.
|
|
5244
|
+
return true;
|
|
5245
|
+
}
|
|
5246
|
+
function mcpWizRender() {
|
|
5247
|
+
// Toggle pane visibility.
|
|
5248
|
+
for (let i = 1; i <= MCP_WIZ_LAST; i++) {
|
|
5249
|
+
const pane = document.getElementById('wiz-pane-' + i);
|
|
5250
|
+
if (pane) pane.hidden = i !== MCP_WIZ_STEP;
|
|
5251
|
+
}
|
|
5252
|
+
// Update stepper bullets — active = current, done = all earlier.
|
|
5253
|
+
const stepper = document.getElementById('mcp-wiz-stepper');
|
|
5254
|
+
if (stepper) {
|
|
5255
|
+
stepper.querySelectorAll('.wiz-step-btn').forEach((btn) => {
|
|
5256
|
+
const n = parseInt(btn.getAttribute('data-step') || '0', 10);
|
|
5257
|
+
btn.setAttribute('data-active', String(n === MCP_WIZ_STEP));
|
|
5258
|
+
btn.setAttribute('data-done', String(n < MCP_WIZ_STEP));
|
|
5259
|
+
btn.setAttribute('aria-selected', String(n === MCP_WIZ_STEP));
|
|
5260
|
+
});
|
|
5261
|
+
}
|
|
5262
|
+
// Footer button visibility.
|
|
5263
|
+
const back = document.getElementById('mcp-wiz-back');
|
|
5264
|
+
const next = document.getElementById('mcp-wiz-next');
|
|
5265
|
+
const save = document.getElementById('mcp-wiz-save');
|
|
5266
|
+
if (back) back.hidden = MCP_WIZ_STEP === 1;
|
|
5267
|
+
if (next) next.hidden = MCP_WIZ_STEP === MCP_WIZ_LAST;
|
|
5268
|
+
if (save) save.hidden = MCP_WIZ_STEP !== MCP_WIZ_LAST;
|
|
5269
|
+
// Render the review summary lazily when entering step 4.
|
|
5270
|
+
if (MCP_WIZ_STEP === MCP_WIZ_LAST) mcpWizRenderReview();
|
|
5271
|
+
}
|
|
5272
|
+
function mcpWizRenderReview() {
|
|
5273
|
+
const out = document.getElementById('mcp-wiz-review');
|
|
5274
|
+
if (!out) return;
|
|
5275
|
+
const v = (id) => (document.getElementById(id).value || '').trim();
|
|
5276
|
+
const id = v('mcp-product-id');
|
|
5277
|
+
const name = v('mcp-product-name');
|
|
5278
|
+
const desc = v('mcp-product-description');
|
|
5279
|
+
const status = v('mcp-product-status');
|
|
5280
|
+
const tenant = v('mcp-product-tenant');
|
|
5281
|
+
const version = v('mcp-product-version');
|
|
5282
|
+
const color = v('mcp-product-color');
|
|
5283
|
+
const icon = v('mcp-product-icon');
|
|
5284
|
+
const toolsRaw = (document.getElementById('mcp-product-tools').value || '');
|
|
5285
|
+
const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
5286
|
+
const empty = (s) => s ? escHtml(s) : '<span class="wiz-review-empty">—</span>';
|
|
5287
|
+
// Mirror mcpProductCardHtml's colour-validation regex so the
|
|
5288
|
+
// inline style attribute can't carry an arbitrary CSS value. Even
|
|
5289
|
+
// though escHtml prevents attribute breakout, restricting the
|
|
5290
|
+
// value to a hex literal makes a CSS-context injection
|
|
5291
|
+
// structurally impossible (defence-in-depth — reviewer-agent flag).
|
|
5292
|
+
const safeColor = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : null;
|
|
5293
|
+
const colorCell = !color
|
|
5294
|
+
? '<span class="wiz-review-empty">— (no brand colour)</span>'
|
|
5295
|
+
: safeColor
|
|
5296
|
+
? `<span class="wiz-review-swatch" style="background:${safeColor}"></span><code>${escHtml(color)}</code>`
|
|
5297
|
+
: `<code>${escHtml(color)}</code> <span class="t-sm" style="opacity:.6">(not a hex value)</span>`;
|
|
5298
|
+
const toolsCell = tools.length === 0
|
|
5299
|
+
? '<span class="wiz-review-empty">no filter — every registered tool</span>'
|
|
5300
|
+
: `<div class="wiz-review-tools">${tools.map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('')}</div>`;
|
|
5301
|
+
out.innerHTML = `<dl class="wiz-review-grid">
|
|
5302
|
+
<dt>Id</dt><dd><code>${empty(id)}</code></dd>
|
|
5303
|
+
<dt>Name</dt><dd>${empty(name)}</dd>
|
|
5304
|
+
<dt>Description</dt><dd>${empty(desc)}</dd>
|
|
5305
|
+
<dt>Status</dt><dd><span class="pill">${empty(status)}</span></dd>
|
|
5306
|
+
<dt>Tenant</dt><dd>${tenant ? escHtml(tenant) : '<span class="wiz-review-empty">(caller\'s tenant)</span>'}</dd>
|
|
5307
|
+
<dt>Tools</dt><dd>${toolsCell}</dd>
|
|
5308
|
+
<dt>Version</dt><dd>${empty(version)}</dd>
|
|
5309
|
+
<dt>Colour</dt><dd>${colorCell}</dd>
|
|
5310
|
+
<dt>Icon URL</dt><dd>${icon ? `<code>${escHtml(icon)}</code>` : '<span class="wiz-review-empty">—</span>'}</dd>
|
|
5311
|
+
</dl>
|
|
5312
|
+
<h4 class="wiz-agent-preview-h">Agent preview <span class="t-sm" style="opacity:.6;font-weight:400">— what the bound agent will see in <code>tools/list</code></span></h4>
|
|
5313
|
+
<div id="mcp-wiz-agent-preview" class="wiz-agent-preview"><div class="t-sm" style="opacity:.7">${tools.length === 0
|
|
5314
|
+
? 'No filter set. Agent would see every registered tool — load the live registry to confirm.'
|
|
5315
|
+
: `Filter active. Agent would see ${tools.length} tool${tools.length===1?'':'s'} from the allow-list.`}</div></div>`;
|
|
5316
|
+
// Resolve the preview from the live tool registry — same source the
|
|
5317
|
+
// server uses to filter /mcp tools/list. We don't hit
|
|
5318
|
+
// /api/products/:id/preview here because the wizard's form-state
|
|
5319
|
+
// hasn't been saved yet (the id may not exist on the server). The
|
|
5320
|
+
// resolution mirrors allowsTool's semantics exactly.
|
|
5321
|
+
mcpLoadToolsRegistry().then(() => {
|
|
5322
|
+
const previewEl = document.getElementById('mcp-wiz-agent-preview');
|
|
5323
|
+
if (!previewEl) return;
|
|
5324
|
+
const allow = new Set(tools);
|
|
5325
|
+
const filtered = (MCP_TOOLS_REGISTRY || []).filter((t) => tools.length === 0 || allow.has(t.name));
|
|
5326
|
+
previewEl.innerHTML = mcpAgentPreviewHtml(filtered, tools.length === 0);
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
|
|
5330
|
+
// Shared renderer for the agent-preview list — used by the wizard
|
|
5331
|
+
// Review pane and by the per-card "Preview as agent" action.
|
|
5332
|
+
function mcpAgentPreviewHtml(tools, unrestricted) {
|
|
5333
|
+
if (!tools || tools.length === 0) {
|
|
5334
|
+
return '<div class="t-sm" style="opacity:.7">No tools available.</div>';
|
|
5335
|
+
}
|
|
5336
|
+
const order = ['discovery', 'query', 'diagnose', 'topology'];
|
|
5337
|
+
const groups = {};
|
|
5338
|
+
for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
|
|
5339
|
+
const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
|
|
5340
|
+
const banner = unrestricted
|
|
5341
|
+
? '<div class="wiz-agent-banner" data-unrestricted="true">Unrestricted — the bound agent sees every registered tool below.</div>'
|
|
5342
|
+
: '';
|
|
5343
|
+
const html = order.filter((c) => groups[c]).map((cat) => {
|
|
5344
|
+
const items = groups[cat].map((t) => `
|
|
5345
|
+
<div class="wiz-agent-tool">
|
|
5346
|
+
<div class="wiz-agent-tool-name">${escHtml(t.name)}</div>
|
|
5347
|
+
<div class="wiz-agent-tool-summary">${escHtml(t.summary)}</div>
|
|
5348
|
+
</div>`).join('');
|
|
5349
|
+
return `<div class="wiz-agent-group">
|
|
5350
|
+
<div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
|
|
5351
|
+
${items}
|
|
5352
|
+
</div>`;
|
|
5353
|
+
}).join('');
|
|
5354
|
+
return banner + html;
|
|
3137
5355
|
}
|
|
5356
|
+
|
|
5357
|
+
// "Preview as agent" — called from the per-card button. Hits the
|
|
5358
|
+
// authoritative server endpoint so the operator sees what the bound
|
|
5359
|
+
// agent actually receives in production, not a client-side guess.
|
|
5360
|
+
async function mcpProductPreviewAgent(idEnc) {
|
|
5361
|
+
const id = decodeURIComponent(idEnc);
|
|
5362
|
+
let body;
|
|
5363
|
+
try {
|
|
5364
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id) + '/preview');
|
|
5365
|
+
if (!r.ok) { toast('Preview unavailable: HTTP ' + r.status); return; }
|
|
5366
|
+
body = await r.json();
|
|
5367
|
+
} catch (e) { toast('Preview failed: ' + (e && e.message || e)); return; }
|
|
5368
|
+
const accent = (body.product && body.product.branding && /^#[0-9a-fA-F]{3,8}$/.test(body.product.branding.color || ''))
|
|
5369
|
+
? body.product.branding.color : 'var(--accent)';
|
|
5370
|
+
const dlg = document.getElementById('mcp-agent-preview-modal');
|
|
5371
|
+
const title = document.getElementById('mcp-agent-preview-title');
|
|
5372
|
+
const meta = document.getElementById('mcp-agent-preview-meta');
|
|
5373
|
+
const list = document.getElementById('mcp-agent-preview-list');
|
|
5374
|
+
if (!dlg || !title || !meta || !list) return;
|
|
5375
|
+
title.textContent = body.product.name || body.product.id;
|
|
5376
|
+
meta.innerHTML = `<code>${escHtml(body.product.id)}</code>${body.product.version ? ' · v' + escHtml(body.product.version) : ''} · tenant: ${escHtml(body.product.tenant || 'default')} · status: ${escHtml(body.product.status || 'published')}`;
|
|
5377
|
+
list.innerHTML = mcpAgentPreviewHtml(body.tools || [], !!body.unrestricted);
|
|
5378
|
+
// Apply branding to the modal's left rail for a quick at-a-glance
|
|
5379
|
+
// identity confirmation that the right product was probed.
|
|
5380
|
+
dlg.style.setProperty('--mcp-agent-accent', accent);
|
|
5381
|
+
dlg.classList.add('open');
|
|
5382
|
+
}
|
|
5383
|
+
|
|
5384
|
+
function mcpProductOpen(mode, current) {
|
|
5385
|
+
const idEl = document.getElementById('mcp-product-id');
|
|
5386
|
+
const nameEl = document.getElementById('mcp-product-name');
|
|
5387
|
+
const descEl = document.getElementById('mcp-product-description');
|
|
5388
|
+
const statusEl = document.getElementById('mcp-product-status');
|
|
5389
|
+
const tenantEl = document.getElementById('mcp-product-tenant');
|
|
5390
|
+
const toolsEl = document.getElementById('mcp-product-tools');
|
|
5391
|
+
const verEl = document.getElementById('mcp-product-version');
|
|
5392
|
+
const colorEl = document.getElementById('mcp-product-color');
|
|
5393
|
+
const iconEl = document.getElementById('mcp-product-icon');
|
|
5394
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
5395
|
+
const titleEl = document.getElementById('mcp-product-modal-title');
|
|
5396
|
+
document.getElementById('mcp-product-mode').value = mode;
|
|
5397
|
+
document.getElementById('mcp-product-original-id').value = (current && current.id) || '';
|
|
5398
|
+
errEl.style.display = 'none';
|
|
5399
|
+
errEl.textContent = '';
|
|
5400
|
+
if (mode === 'edit' && current) {
|
|
5401
|
+
titleEl.textContent = 'Edit Product · ' + current.id;
|
|
5402
|
+
idEl.value = current.id;
|
|
5403
|
+
idEl.disabled = true;
|
|
5404
|
+
nameEl.value = current.name || '';
|
|
5405
|
+
descEl.value = current.description || '';
|
|
5406
|
+
statusEl.value = current.status || 'published';
|
|
5407
|
+
tenantEl.value = current.tenant || '';
|
|
5408
|
+
toolsEl.value = (current.tools || []).join('\n');
|
|
5409
|
+
verEl.value = current.version || '';
|
|
5410
|
+
colorEl.value = (current.branding && current.branding.color) || '';
|
|
5411
|
+
iconEl.value = (current.branding && current.branding.iconUrl) || '';
|
|
5412
|
+
} else {
|
|
5413
|
+
// 'new' mode. When the caller hands us a partial `current`
|
|
5414
|
+
// (the empty-state templates do this), prefill every field
|
|
5415
|
+
// they supplied — the operator only has to pick an id + tweak.
|
|
5416
|
+
// Falsy `current` zeroes everything (legacy "fresh modal" path).
|
|
5417
|
+
const c = current || {};
|
|
5418
|
+
titleEl.textContent = 'New Product';
|
|
5419
|
+
idEl.value = c.id || '';
|
|
5420
|
+
idEl.disabled = false;
|
|
5421
|
+
nameEl.value = c.name || '';
|
|
5422
|
+
descEl.value = c.description || '';
|
|
5423
|
+
statusEl.value = c.status || 'staging';
|
|
5424
|
+
tenantEl.value = c.tenant || '';
|
|
5425
|
+
toolsEl.value = (c.tools || []).join('\n');
|
|
5426
|
+
verEl.value = c.version || '';
|
|
5427
|
+
colorEl.value = (c.branding && c.branding.color) || '';
|
|
5428
|
+
iconEl.value = (c.branding && c.branding.iconUrl) || '';
|
|
5429
|
+
}
|
|
5430
|
+
document.getElementById('mcp-product-modal').classList.add('open');
|
|
5431
|
+
// Reset wizard state to step 1 on every open. Edit mode could
|
|
5432
|
+
// start on step 4 (Review), but starting at step 1 keeps the UX
|
|
5433
|
+
// uniform — an editor stepping through their own config catches
|
|
5434
|
+
// surprises.
|
|
5435
|
+
MCP_WIZ_STEP = 1;
|
|
5436
|
+
mcpWizRender();
|
|
5437
|
+
// Build the tools picker from the registry — fetched once on the
|
|
5438
|
+
// first modal open and cached client-side. Selected = whatever was
|
|
5439
|
+
// already in the (potentially template-prefilled) textarea.
|
|
5440
|
+
mcpLoadToolsRegistry().then(() => {
|
|
5441
|
+
const seeded = (toolsEl.value || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
5442
|
+
mcpRenderToolsPicker(seeded);
|
|
5443
|
+
});
|
|
5444
|
+
// When opening from a template the id is the one thing the operator
|
|
5445
|
+
// hasn't picked yet; in every other case (edit, blank new) the name
|
|
5446
|
+
// is the most natural focus target.
|
|
5447
|
+
const focusEl = (mode === 'edit') ? nameEl
|
|
5448
|
+
: (current && (current.name || (current.tools && current.tools.length))) ? idEl
|
|
5449
|
+
: idEl;
|
|
5450
|
+
setTimeout(() => focusEl.focus(), 50);
|
|
5451
|
+
}
|
|
5452
|
+
async function mcpProductsNew() { mcpProductOpen('new'); }
|
|
3138
5453
|
async function mcpProductsEdit(idEnc) {
|
|
3139
5454
|
const id = decodeURIComponent(idEnc);
|
|
3140
5455
|
const r = await fetch('/api/products/' + encodeURIComponent(id));
|
|
3141
5456
|
if (!r.ok) { toast('Could not fetch ' + id); return; }
|
|
3142
5457
|
const current = await r.json();
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
5458
|
+
mcpProductOpen('edit', current);
|
|
5459
|
+
}
|
|
5460
|
+
async function mcpProductSave() {
|
|
5461
|
+
const mode = document.getElementById('mcp-product-mode').value;
|
|
5462
|
+
const id = document.getElementById('mcp-product-id').value.trim();
|
|
5463
|
+
const name = document.getElementById('mcp-product-name').value.trim();
|
|
5464
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
5465
|
+
function fail(msg) { errEl.textContent = msg; errEl.style.display = ''; }
|
|
5466
|
+
// Mirror the server-side ID_RE so the user sees the rule before the
|
|
5467
|
+
// round-trip — server is still the source of truth for rejection.
|
|
5468
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
|
|
5469
|
+
fail('Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
|
|
5470
|
+
return;
|
|
5471
|
+
}
|
|
5472
|
+
if (!name) { fail('Display name is required.'); return; }
|
|
5473
|
+
const body = { id, name, status: document.getElementById('mcp-product-status').value };
|
|
5474
|
+
const desc = document.getElementById('mcp-product-description').value.trim();
|
|
5475
|
+
if (desc) body.description = desc;
|
|
5476
|
+
const tenant = document.getElementById('mcp-product-tenant').value.trim();
|
|
5477
|
+
if (tenant) body.tenant = tenant;
|
|
5478
|
+
// Split on newlines or commas so paste-from-a-list works either way.
|
|
5479
|
+
const toolsRaw = document.getElementById('mcp-product-tools').value || '';
|
|
5480
|
+
const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
5481
|
+
if (tools.length > 0) body.tools = tools;
|
|
5482
|
+
const version = document.getElementById('mcp-product-version').value.trim();
|
|
5483
|
+
if (version) body.version = version;
|
|
5484
|
+
const color = document.getElementById('mcp-product-color').value.trim();
|
|
5485
|
+
const icon = document.getElementById('mcp-product-icon').value.trim();
|
|
5486
|
+
if (color || icon) {
|
|
5487
|
+
body.branding = {};
|
|
5488
|
+
if (icon) body.branding.iconUrl = icon;
|
|
5489
|
+
if (color) body.branding.color = color;
|
|
5490
|
+
}
|
|
3150
5491
|
try {
|
|
3151
5492
|
const r = await fetch('/api/products/' + encodeURIComponent(id), {
|
|
3152
5493
|
method: 'PUT',
|
|
@@ -3155,12 +5496,13 @@ async function mcpProductsUpsert(id, body) {
|
|
|
3155
5496
|
});
|
|
3156
5497
|
if (!r.ok) {
|
|
3157
5498
|
const j = await r.json().catch(() => ({}));
|
|
3158
|
-
|
|
5499
|
+
fail(j.error || ('Save failed: HTTP ' + r.status));
|
|
3159
5500
|
return;
|
|
3160
5501
|
}
|
|
3161
|
-
|
|
5502
|
+
closeModal('mcp-product-modal');
|
|
5503
|
+
toast((mode === 'edit' ? 'Updated ' : 'Created ') + id);
|
|
3162
5504
|
await loadMcpProducts();
|
|
3163
|
-
} catch (e) {
|
|
5505
|
+
} catch (e) { fail('Save failed: ' + (e && e.message || e)); }
|
|
3164
5506
|
}
|
|
3165
5507
|
async function mcpProductsDelete(idEnc) {
|
|
3166
5508
|
const id = decodeURIComponent(idEnc);
|
|
@@ -3378,7 +5720,10 @@ function sortIndClass(key){
|
|
|
3378
5720
|
|
|
3379
5721
|
function srcRow(s){
|
|
3380
5722
|
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
3381
|
-
|
|
5723
|
+
// Show a tenant tag only when set — single-tenant deployments
|
|
5724
|
+
// stay visually identical to the pre-tenant rows.
|
|
5725
|
+
const tenantTag = s.tenant ? `<span class="tag" title="Tenant ${esc(s.tenant)}">${esc(s.tenant)}</span>` : '';
|
|
5726
|
+
return `<div class="source-row" data-src="${esc(s.name)}"><div class="dot ${sc}"></div><div class="source-info"><div class="name">${esc(s.name)}<span class="tag tag-type">${esc(s.type)}</span>${s.signalType?`<span class="tag tag-${s.signalType}">${s.signalType}</span>`:''}${tenantTag}</div><div class="url">${esc(s.url)}</div></div>${s.latencyMs?`<span class="tag-latency">${s.latencyMs}ms</span>`:''}<div class="source-actions" onclick="event.stopPropagation()"><label class="toggle" data-rbac="sources:write"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" data-rbac="sources:write" title="Edit source" aria-label="Edit source" onclick="openEditModal('${esc(s.name)}')">✎</button><button class="btn-icon" data-rbac="sources:delete" title="Delete source" aria-label="Delete source" onclick="openDeleteConfirm('source','${esc(s.name)}')">🗑</button></div></div>`;
|
|
3382
5727
|
}
|
|
3383
5728
|
function renderSources() {
|
|
3384
5729
|
const emptyDash = richEmpty({
|
|
@@ -3407,13 +5752,22 @@ function renderSources() {
|
|
|
3407
5752
|
const filterBar=`<div class="list-filter">
|
|
3408
5753
|
<input id="src-filter" type="search" placeholder="Filter sources by name, type or URL…" value="${esc(srcFilter)}" oninput="setSrcFilter(this.value)" aria-label="Filter sources">
|
|
3409
5754
|
<span class="count">${visible.length} of ${sourcesData.length}</span>
|
|
3410
|
-
<span class="view-toggle" style="margin-left:auto"><button class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span>
|
|
5755
|
+
<span class="view-toggle" id="src-views" style="margin-left:auto"><button id="src-view-list" class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button id="src-view-table" class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span>
|
|
3411
5756
|
</div>`;
|
|
3412
5757
|
let body;
|
|
3413
5758
|
if(view==='table'){
|
|
3414
5759
|
const h = (key, label) => `<th class="sortable ${sortIndClass(key)}" data-sort-key="${key}" onclick="setSrcSort('${key}')">${label}<span class="sort-ind"></span></th>`;
|
|
3415
|
-
|
|
3416
|
-
|
|
5760
|
+
// Show the tenant column only when at least one source is
|
|
5761
|
+
// tagged — single-tenant deployments stay uncluttered.
|
|
5762
|
+
const anyTenant = visible.some(s => s.tenant);
|
|
5763
|
+
const tenantHead = anyTenant ? h('tenant','Tenant') : '';
|
|
5764
|
+
body=`<table class="dtable"><thead><tr>${h('name','Name')}${h('type','Type')}${h('signal','Signal')}${tenantHead}${h('url','URL')}${h('status','Status')}${h('latency','Latency')}</tr></thead><tbody>`+
|
|
5765
|
+
visible.map(s=>{
|
|
5766
|
+
const tenantCell = anyTenant
|
|
5767
|
+
? `<td>${s.tenant ? `<span class="badge">${esc(s.tenant)}</span>` : '<span class="t-muted">global</span>'}</td>`
|
|
5768
|
+
: '';
|
|
5769
|
+
return `<tr data-src="${esc(s.name)}"><td class="mono">${esc(s.name)}</td><td>${esc(s.type)}</td><td>${s.signalType?esc(s.signalType):'—'}</td>${tenantCell}<td class="mono t-muted">${esc(s.url)}</td><td>${!s.enabled?'disabled':s.status==='up'?'<span class="pill-yes">up</span>':'<span class="pill-no">down</span>'}</td><td class="mono">${s.latencyMs?s.latencyMs+'ms':'—'}</td></tr>`;
|
|
5770
|
+
}).join('')+
|
|
3417
5771
|
`</tbody></table>`;
|
|
3418
5772
|
} else {
|
|
3419
5773
|
body = visible.length === 0
|
|
@@ -3603,14 +5957,37 @@ function setAuthInForm(auth) {
|
|
|
3603
5957
|
if(auth.type==='bearer') { document.getElementById('src-auth-token').value=auth.token||''; }
|
|
3604
5958
|
toggleAuthFields();
|
|
3605
5959
|
}
|
|
5960
|
+
// Admins (users:delete) can pick any tenant or leave blank (global);
|
|
5961
|
+
// non-admins are silently scoped to their own tenant by the server.
|
|
5962
|
+
// Pre-fill + disable for non-admins so the UX matches the server
|
|
5963
|
+
// posture — they SEE the constraint instead of typing into a field
|
|
5964
|
+
// that the server will then quietly rewrite.
|
|
5965
|
+
function _omcpApplyTenantFieldMode() {
|
|
5966
|
+
const inp = document.getElementById('src-tenant');
|
|
5967
|
+
if (!inp) return;
|
|
5968
|
+
const isAdmin = (typeof omcpCan === 'function') && omcpCan('users', 'delete');
|
|
5969
|
+
const sess = window.__omcpMe;
|
|
5970
|
+
const isAnonymous = !sess || sess.mode === 'anonymous';
|
|
5971
|
+
if (isAdmin || isAnonymous) {
|
|
5972
|
+
inp.disabled = false;
|
|
5973
|
+
inp.placeholder = '(blank = global)';
|
|
5974
|
+
} else {
|
|
5975
|
+
const own = (sess && sess.user && sess.user.tenant) || 'default';
|
|
5976
|
+
inp.value = own;
|
|
5977
|
+
inp.disabled = true;
|
|
5978
|
+
inp.placeholder = own;
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
3606
5981
|
function openAddModal() {
|
|
3607
5982
|
document.getElementById('modal-title').textContent='Add Source'; document.getElementById('modal-mode').value='add';
|
|
3608
5983
|
document.getElementById('modal-original-name').value=''; document.getElementById('src-name').value='';
|
|
3609
5984
|
document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
|
|
5985
|
+
document.getElementById('src-tenant').value='';
|
|
3610
5986
|
resetTlsFields();
|
|
3611
5987
|
document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
|
|
3612
5988
|
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
3613
5989
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
|
|
5990
|
+
_omcpApplyTenantFieldMode();
|
|
3614
5991
|
document.getElementById('source-modal').classList.add('open');
|
|
3615
5992
|
}
|
|
3616
5993
|
function openEditModal(name) {
|
|
@@ -3619,8 +5996,10 @@ function openEditModal(name) {
|
|
|
3619
5996
|
document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
|
|
3620
5997
|
document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
|
|
3621
5998
|
document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
|
|
5999
|
+
document.getElementById('src-tenant').value = s.tenant || '';
|
|
3622
6000
|
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
3623
6001
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
|
|
6002
|
+
_omcpApplyTenantFieldMode();
|
|
3624
6003
|
document.getElementById('source-modal').classList.add('open');
|
|
3625
6004
|
}
|
|
3626
6005
|
// --- Source modal form validation + banner ------------------------------
|
|
@@ -3680,6 +6059,8 @@ async function saveSource() {
|
|
|
3680
6059
|
if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
|
|
3681
6060
|
const mode=document.getElementById('modal-mode').value;
|
|
3682
6061
|
const src={name:document.getElementById('src-name').value.trim(),type:document.getElementById('src-type').value,url:document.getElementById('src-url').value.trim(),enabled:document.getElementById('src-enabled').checked,auth:getAuthFromForm(),tls:getTlsFromForm()};
|
|
6062
|
+
const tenant = document.getElementById('src-tenant').value.trim();
|
|
6063
|
+
if (tenant) src.tenant = tenant;
|
|
3683
6064
|
const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
|
|
3684
6065
|
try {
|
|
3685
6066
|
let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
|
|
@@ -4334,7 +6715,10 @@ function renderTopologyGraph(){
|
|
|
4334
6715
|
const r = idx.byId.get(id);
|
|
4335
6716
|
const base = topoKindColor(r ? r.kind : 'scope');
|
|
4336
6717
|
// soft alpha + slightly lighter
|
|
4337
|
-
|
|
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';
|
|
4338
6722
|
};
|
|
4339
6723
|
const scopeBands = [];
|
|
4340
6724
|
for (const scopeId of pureScope){
|