@thotischner/observability-mcp 1.8.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/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 +129 -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.js +6 -4
- package/dist/connectors/loader.test.d.ts +1 -0
- package/dist/connectors/loader.test.js +78 -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 +32 -0
- package/dist/federation/registry.js +77 -0
- package/dist/federation/registry.test.d.ts +1 -0
- package/dist/federation/registry.test.js +130 -0
- package/dist/federation/upstream.d.ts +60 -0
- package/dist/federation/upstream.js +114 -0
- package/dist/index.js +1188 -120
- 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/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/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/routes.d.ts +15 -0
- package/dist/scim/routes.js +249 -0
- package/dist/scim/store.d.ts +37 -0
- package/dist/scim/store.js +178 -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/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 +2 -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 +1 -1
- package/dist/tools/detect-anomalies.js +5 -4
- 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 +10 -6
- 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/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 +1729 -100
- package/package.json +13 -3
package/dist/ui/index.html
CHANGED
|
@@ -936,6 +936,180 @@
|
|
|
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
|
+
/* Products — leitfaden card */
|
|
941
|
+
.leitfaden-chev { display: inline-block; transition: transform .15s ease; font-size: .85em; opacity: .7; }
|
|
942
|
+
#mcp-products-leitfaden.collapsed .leitfaden-chev { transform: rotate(-90deg); }
|
|
943
|
+
#mcp-products-leitfaden.collapsed #mcp-leitfaden-body { display: none; }
|
|
944
|
+
.leitfaden-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); padding: 0 var(--sp-4) var(--sp-4); }
|
|
945
|
+
.leitfaden-grid h3 { font-size: 13px; font-weight: 600; margin: 0 0 var(--sp-2); }
|
|
946
|
+
.leitfaden-grid p { margin: 0 0 var(--sp-2); line-height: 1.5; }
|
|
947
|
+
.leitfaden-grid pre { background: var(--surface-2); padding: var(--sp-2); border-radius: var(--radius-sm); font-size: 11px; overflow-x: auto; }
|
|
948
|
+
.leitfaden-grid code { font-size: .92em; }
|
|
949
|
+
@media (max-width: 900px) { .leitfaden-grid { grid-template-columns: 1fr; } }
|
|
950
|
+
|
|
951
|
+
/* Products — card grid */
|
|
952
|
+
.pcard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--sp-3); padding: var(--sp-3); }
|
|
953
|
+
.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; }
|
|
954
|
+
.pcard:hover { border-color: var(--border-strong); box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
|
955
|
+
.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); }
|
|
956
|
+
.pcard-hd { display: flex; align-items: flex-start; gap: var(--sp-2); }
|
|
957
|
+
.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; }
|
|
958
|
+
.pcard-icon img { width: 100%; height: 100%; object-fit: cover; }
|
|
959
|
+
.pcard-title { flex: 1; min-width: 0; }
|
|
960
|
+
.pcard-title h3 { font-size: 14px; font-weight: 600; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
961
|
+
.pcard-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
962
|
+
.pcard-status { flex: 0 0 auto; }
|
|
963
|
+
.pcard-desc { font-size: 12px; line-height: 1.45; opacity: .85; min-height: 2.9em; }
|
|
964
|
+
.pcard-tools-label { font-size: 11px; font-weight: 600; opacity: .65; text-transform: uppercase; letter-spacing: .04em; margin-bottom: var(--sp-1); }
|
|
965
|
+
.pcard-tools { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
|
|
966
|
+
.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); }
|
|
967
|
+
.pcard-tool-all { font-size: 11px; opacity: .55; font-style: italic; }
|
|
968
|
+
.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); }
|
|
969
|
+
.pcard-footer .pcard-spacer { flex: 1; }
|
|
970
|
+
|
|
971
|
+
/* Policies — engine banner + sticky dry-run */
|
|
972
|
+
.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); }
|
|
973
|
+
.pol-engine-banner .pol-eb-dot { flex: 0 0 8px; width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--text-2); }
|
|
974
|
+
.pol-engine-banner .pol-eb-body { flex: 1; min-width: 0; }
|
|
975
|
+
.pol-engine-banner .pol-eb-title { font-weight: 600; font-size: 13px; display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
|
|
976
|
+
.pol-engine-banner .pol-eb-meta { font-size: 11px; opacity: .7; margin-top: 2px; font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
977
|
+
.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; }
|
|
978
|
+
.pol-engine-banner.eng-builtin { background: var(--surface-2); border-color: var(--border); }
|
|
979
|
+
.pol-engine-banner.eng-builtin .pol-eb-dot { background: var(--text-2); }
|
|
980
|
+
.pol-engine-banner.eng-file { background: var(--success-soft); border-color: var(--success); }
|
|
981
|
+
.pol-engine-banner.eng-file .pol-eb-dot { background: var(--success); }
|
|
982
|
+
.pol-engine-banner.eng-opa { background: var(--warning-soft); border-color: var(--warning); }
|
|
983
|
+
.pol-engine-banner.eng-opa .pol-eb-dot { background: var(--warning); }
|
|
984
|
+
.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; }
|
|
985
|
+
|
|
986
|
+
/* Policies sub-tabs (Roles / Bindings / Subjects) */
|
|
987
|
+
.pol-subtabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-3); }
|
|
988
|
+
.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; }
|
|
989
|
+
.pol-subtab:hover { color: var(--text); }
|
|
990
|
+
.pol-subtab[data-active="true"] { color: var(--text); border-bottom-color: var(--accent); font-weight: 500; }
|
|
991
|
+
.pol-pane[hidden] { display: none; }
|
|
992
|
+
|
|
993
|
+
/* Roles master/detail */
|
|
994
|
+
.pol-roles-layout { display: grid; grid-template-columns: 260px 1fr; min-height: 320px; }
|
|
995
|
+
.pol-role-list { border-right: 1px solid var(--border); max-height: 60vh; overflow-y: auto; }
|
|
996
|
+
.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); }
|
|
997
|
+
.pol-role-row:hover { background: var(--surface-2); }
|
|
998
|
+
.pol-role-row[data-active="true"] { background: var(--accent-soft); }
|
|
999
|
+
.pol-role-row .pol-role-name { font-weight: 500; font-size: 13px; }
|
|
1000
|
+
.pol-role-row .pol-role-count { font-size: 11px; opacity: .65; }
|
|
1001
|
+
.pol-role-detail { padding: var(--sp-4); overflow-x: auto; }
|
|
1002
|
+
.pol-role-detail h3 { margin: 0 0 var(--sp-3); font-size: 14px; font-weight: 600; }
|
|
1003
|
+
.pol-matrix { border-collapse: collapse; width: 100%; font-size: 12px; }
|
|
1004
|
+
.pol-matrix th, .pol-matrix td { padding: var(--sp-2); border: 1px solid var(--border); text-align: left; }
|
|
1005
|
+
.pol-matrix thead th { background: var(--surface-2); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .8; }
|
|
1006
|
+
.pol-matrix tbody th { font-weight: 500; background: var(--surface-2); font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11.5px; }
|
|
1007
|
+
.pol-matrix td { text-align: center; }
|
|
1008
|
+
.pol-matrix .cell-grant { color: var(--success); font-weight: 600; }
|
|
1009
|
+
.pol-matrix .cell-empty { opacity: .25; }
|
|
1010
|
+
/* Effective-permissions overlay cells — drop the bold/success
|
|
1011
|
+
colour so "via <role>" reads as informational, not as a fresh
|
|
1012
|
+
grant; "denied" cells stay dimmed via cell-empty. */
|
|
1013
|
+
.pol-matrix td[data-effective="allowed"] { color: var(--text-1); font-weight: 400; }
|
|
1014
|
+
.pol-matrix td[data-effective="allowed"][data-via-selected="true"] { color: var(--success); font-weight: 500; }
|
|
1015
|
+
.pol-matrix td[data-effective="denied"] { color: var(--text-3); opacity: .55; font-style: italic; }
|
|
1016
|
+
@media (max-width: 900px) {
|
|
1017
|
+
.pol-roles-layout { grid-template-columns: 1fr; }
|
|
1018
|
+
.pol-role-list { border-right: 0; border-bottom: 1px solid var(--border); max-height: none; }
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/* Subjects sub-tab — three stacked sections */
|
|
1022
|
+
.pol-subjects-section { padding: var(--sp-3) var(--sp-4); }
|
|
1023
|
+
.pol-subjects-section + .pol-subjects-section { border-top: 1px solid var(--border); }
|
|
1024
|
+
.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); }
|
|
1025
|
+
.pol-subjects-section h3 .pol-subjects-count { font-size: 11px; opacity: .65; font-weight: 400; }
|
|
1026
|
+
.pol-subjects-section h3 .pol-subjects-source { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10.5px; opacity: .55; margin-left: auto; }
|
|
1027
|
+
.pol-subjects-empty { font-size: 12px; opacity: .65; padding: var(--sp-2) 0; }
|
|
1028
|
+
|
|
1029
|
+
/* Sticky dry-run bar — pinned at the top of the policies page */
|
|
1030
|
+
.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); }
|
|
1031
|
+
.pol-probe-grid { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: var(--sp-3); align-items: end; }
|
|
1032
|
+
.pol-probe-grid .form-group { margin: 0; }
|
|
1033
|
+
.pol-probe-result { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0 0; }
|
|
1034
|
+
.pol-probe-result .pol-pv { font-weight: 600; padding: 2px 10px; border-radius: 4px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; }
|
|
1035
|
+
.pol-probe-result .pol-pv.allow { background: var(--success-soft); color: var(--success); }
|
|
1036
|
+
.pol-probe-result .pol-pv.deny { background: var(--danger-soft); color: var(--danger); }
|
|
1037
|
+
.pol-probe-result code { font-size: 11px; opacity: .8; }
|
|
1038
|
+
@media (max-width: 1000px) {
|
|
1039
|
+
.pol-probe-grid { grid-template-columns: 1fr 1fr; }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/* Author-controls are hidden when the active engine is read-only.
|
|
1043
|
+
Anything with data-engine-required="file" needs the file engine. */
|
|
1044
|
+
body[data-policy-engine="opa"] [data-engine-required="file"],
|
|
1045
|
+
body[data-policy-engine="builtin"] [data-engine-required="file"] {
|
|
1046
|
+
opacity: .35; pointer-events: none;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/* Products wizard — multi-step modal */
|
|
1050
|
+
.mcp-product-wizard { width: min(720px, 92vw); max-height: 86vh; display: flex; flex-direction: column; }
|
|
1051
|
+
.mcp-product-wizard .modal-body { flex: 1; overflow-y: auto; }
|
|
1052
|
+
.mcp-product-wizard .modal-footer { display: flex; align-items: center; gap: var(--sp-2); }
|
|
1053
|
+
.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); }
|
|
1054
|
+
.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; }
|
|
1055
|
+
.wiz-step-btn:hover { color: var(--text); background: var(--surface); }
|
|
1056
|
+
.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; }
|
|
1057
|
+
.wiz-step-btn[data-active="true"] { color: var(--text); }
|
|
1058
|
+
.wiz-step-btn[data-active="true"] .wiz-step-num { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
1059
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num { background: var(--success); border-color: var(--success); color: #fff; }
|
|
1060
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num::after { content: "✓"; font-size: 10px; }
|
|
1061
|
+
.wiz-step-btn[data-done="true"] .wiz-step-num > * { display: none; }
|
|
1062
|
+
.wiz-step-line { flex: 1; height: 1px; background: var(--border); min-width: 12px; }
|
|
1063
|
+
.wiz-step-help { padding: 0 0 var(--sp-3); opacity: .85; line-height: 1.5; }
|
|
1064
|
+
.wiz-pane[hidden] { display: none; }
|
|
1065
|
+
|
|
1066
|
+
/* Review pane */
|
|
1067
|
+
.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); }
|
|
1068
|
+
.wiz-review-grid dt { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; font-weight: 600; opacity: .65; padding-top: 2px; }
|
|
1069
|
+
.wiz-review-grid dd { margin: 0; font-size: 13px; word-break: break-word; }
|
|
1070
|
+
.wiz-review-grid dd code { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
|
|
1071
|
+
.wiz-review-grid .wiz-review-empty { opacity: .5; font-style: italic; }
|
|
1072
|
+
.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); }
|
|
1073
|
+
.wiz-review-tools { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
1074
|
+
.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); }
|
|
1075
|
+
|
|
1076
|
+
/* Wizard Review — agent preview panel */
|
|
1077
|
+
.wiz-agent-preview-h { margin: var(--sp-4) 0 var(--sp-2); font-size: 13px; font-weight: 600; }
|
|
1078
|
+
.wiz-agent-preview { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); background: var(--surface-2); }
|
|
1079
|
+
.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; }
|
|
1080
|
+
.wiz-agent-group { padding: var(--sp-1) 0; }
|
|
1081
|
+
.wiz-agent-group + .wiz-agent-group { border-top: 1px dashed var(--border); padding-top: var(--sp-2); margin-top: var(--sp-2); }
|
|
1082
|
+
.wiz-agent-tool { padding: 4px 0; }
|
|
1083
|
+
.wiz-agent-tool-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
|
|
1084
|
+
.wiz-agent-tool-summary { font-size: 11px; opacity: .75; line-height: 1.4; margin-top: 1px; }
|
|
1085
|
+
.mcp-agent-preview { width: min(640px, 92vw); }
|
|
1086
|
+
.mcp-agent-preview .modal-header { padding-left: var(--sp-3); }
|
|
1087
|
+
|
|
1088
|
+
/* Products modal — tools picker (multi-select grouped by category) */
|
|
1089
|
+
.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; }
|
|
1090
|
+
.tools-picker .tp-group { padding: var(--sp-1) 0; }
|
|
1091
|
+
.tools-picker .tp-group + .tp-group { margin-top: var(--sp-2); border-top: 1px dashed var(--border); padding-top: var(--sp-2); }
|
|
1092
|
+
.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); }
|
|
1093
|
+
.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; }
|
|
1094
|
+
.tools-picker .tp-item:hover { background: var(--surface); }
|
|
1095
|
+
.tools-picker .tp-item input { margin-top: 3px; flex: 0 0 auto; }
|
|
1096
|
+
.tools-picker .tp-item .tp-text { flex: 1; min-width: 0; }
|
|
1097
|
+
.tools-picker .tp-item .tp-name { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; font-weight: 500; }
|
|
1098
|
+
.tools-picker .tp-item .tp-summary { font-size: 11px; opacity: .7; line-height: 1.4; margin-top: 1px; }
|
|
1099
|
+
.tools-picker .tp-actions { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-2); }
|
|
1100
|
+
.tools-picker .tp-actions button { font-size: 11px; padding: 2px 8px; }
|
|
1101
|
+
.tools-picker .tools-picker-hint { padding: var(--sp-2); opacity: .7; }
|
|
1102
|
+
|
|
1103
|
+
/* Products — empty state with templates */
|
|
1104
|
+
.pempty { padding: var(--sp-5); text-align: center; }
|
|
1105
|
+
.pempty h3 { margin: 0 0 var(--sp-2); font-size: 16px; }
|
|
1106
|
+
.pempty p { margin: 0 auto var(--sp-4); max-width: 540px; line-height: 1.5; opacity: .8; }
|
|
1107
|
+
.pempty-templates { display: flex; gap: var(--sp-2); justify-content: center; flex-wrap: wrap; }
|
|
1108
|
+
.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; }
|
|
1109
|
+
.pempty-tpl:hover { border-color: var(--accent); background: var(--accent-soft); }
|
|
1110
|
+
.pempty-tpl-title { font-weight: 600; font-size: 13px; margin-bottom: 2px; }
|
|
1111
|
+
.pempty-tpl-desc { font-size: 11px; opacity: .7; }
|
|
1112
|
+
|
|
939
1113
|
/* Form-first editor (Form / JSON / YAML — OpenShift-style) */
|
|
940
1114
|
.ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
|
|
941
1115
|
.ed-token { flex: 1; min-width: 200px; }
|
|
@@ -1736,16 +1910,51 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1736
1910
|
legacy enterprise-catalog block below for new deployments;
|
|
1737
1911
|
the legacy block stays so existing /api/enterprise/catalog
|
|
1738
1912
|
operators see their data until they migrate. -->
|
|
1913
|
+
<!-- Inline leitfaden — collapsed by default once the operator
|
|
1914
|
+
dismisses it; re-opens by clicking the chevron. -->
|
|
1915
|
+
<div class="card" id="mcp-products-leitfaden">
|
|
1916
|
+
<div class="card-header" style="cursor:pointer" onclick="mcpLeitfadenToggle()">
|
|
1917
|
+
<h2 style="display:flex;align-items:center;gap:.5rem">
|
|
1918
|
+
<span class="leitfaden-chev" id="mcp-leitfaden-chev">▾</span>
|
|
1919
|
+
About Products
|
|
1920
|
+
</h2>
|
|
1921
|
+
<span class="t-sm" style="opacity:.7">click to collapse</span>
|
|
1922
|
+
</div>
|
|
1923
|
+
<div id="mcp-leitfaden-body">
|
|
1924
|
+
<div class="leitfaden-grid">
|
|
1925
|
+
<div>
|
|
1926
|
+
<h3>What is a product?</h3>
|
|
1927
|
+
<p class="t-sm">A curated, named bundle of MCP tools you expose to one agent. The bundle's <code>tools</code> allow-list filters <code>tools/list</code> at the <code>/mcp</code> transport, so an agent bound to <em>Ops Bundle</em> sees only the operations tools and not the developer tools.</p>
|
|
1928
|
+
</div>
|
|
1929
|
+
<div>
|
|
1930
|
+
<h3>When do I need one?</h3>
|
|
1931
|
+
<p class="t-sm">Whenever more than one agent connects. Each team / use-case gets its own product — SRE on-call vs coding assistant vs compliance auditor. Without a product the credential sees every registered tool, which is fine for a single-operator demo but not for a production multi-agent deployment.</p>
|
|
1932
|
+
</div>
|
|
1933
|
+
<div>
|
|
1934
|
+
<h3>How do agents pick one up?</h3>
|
|
1935
|
+
<p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>:</p>
|
|
1936
|
+
<pre style="margin:.25rem 0"><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
|
|
1937
|
+
OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
|
|
1938
|
+
<p class="t-sm">The next <code>/mcp</code> session of <code>agent</code> sees only the tools in <code>ops-bundle</code>. <a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
|
|
1939
|
+
</div>
|
|
1940
|
+
</div>
|
|
1941
|
+
</div>
|
|
1942
|
+
</div>
|
|
1943
|
+
|
|
1739
1944
|
<div class="card" id="mcp-products-card">
|
|
1740
1945
|
<div class="card-header">
|
|
1741
1946
|
<h2>MCP Products
|
|
1742
1947
|
<button class="info" aria-label="About the MCP Products API"
|
|
1743
1948
|
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
|
|
1949
|
+
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
1950
|
onclick="infoPop(this)">?</button>
|
|
1746
1951
|
</h2>
|
|
1747
1952
|
<div class="row-inline">
|
|
1748
1953
|
<span id="mcp-products-scope" class="badge"></span>
|
|
1954
|
+
<span class="view-toggle" id="mcp-products-views">
|
|
1955
|
+
<button id="mcp-pv-cards" onclick="mcpProductsSetView('cards')">Cards</button>
|
|
1956
|
+
<button id="mcp-pv-table" onclick="mcpProductsSetView('table')">Table</button>
|
|
1957
|
+
</span>
|
|
1749
1958
|
<button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
|
|
1750
1959
|
</div>
|
|
1751
1960
|
</div>
|
|
@@ -1809,38 +2018,171 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1809
2018
|
</div>
|
|
1810
2019
|
<div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
|
|
1811
2020
|
</div>
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
2021
|
+
|
|
2022
|
+
<!-- Engine banner — colour + copy distinguishes editable file
|
|
2023
|
+
vs read-only builtin / opa modes at a glance. -->
|
|
2024
|
+
<div id="pol-engine-banner" class="pol-engine-banner eng-builtin" hidden>
|
|
2025
|
+
<div class="pol-eb-dot"></div>
|
|
2026
|
+
<div class="pol-eb-body">
|
|
2027
|
+
<div class="pol-eb-title">
|
|
2028
|
+
<span id="pol-eb-title-text">Engine</span>
|
|
2029
|
+
<span id="pol-eb-kind" class="pol-eb-pill"></span>
|
|
2030
|
+
<span id="pol-eb-tenant-aware" class="pol-eb-pill" hidden>tenant-aware</span>
|
|
2031
|
+
</div>
|
|
2032
|
+
<div class="pol-eb-meta" id="pol-eb-meta"></div>
|
|
2033
|
+
<p id="pol-eb-copy"></p>
|
|
1822
2034
|
</div>
|
|
1823
2035
|
</div>
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2036
|
+
|
|
2037
|
+
<!-- Sticky dry-run probe bar — promoted to the top so it's the
|
|
2038
|
+
first affordance, not buried below the snapshot. Works
|
|
2039
|
+
across every engine including OPA (the only way to see what
|
|
2040
|
+
Rego actually grants without writing a unit test). -->
|
|
2041
|
+
<div class="pol-probe-bar">
|
|
2042
|
+
<h3 style="margin:0 0 var(--sp-2);display:flex;align-items:center;gap:var(--sp-2);font-size:13px">
|
|
2043
|
+
<span>Probe a permission</span>
|
|
2044
|
+
<button class="info" aria-label="About dry-run"
|
|
2045
|
+
data-title="Dry-run probe"
|
|
2046
|
+
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."
|
|
2047
|
+
onclick="infoPop(this)">?</button>
|
|
2048
|
+
</h3>
|
|
2049
|
+
<div class="pol-probe-grid">
|
|
2050
|
+
<div class="form-group">
|
|
1828
2051
|
<label>Roles <span class="form-hint">comma-separated</span></label>
|
|
1829
2052
|
<input id="pol-dry-roles" placeholder="admin, operator">
|
|
1830
2053
|
</div>
|
|
1831
|
-
<div class="form-group"
|
|
2054
|
+
<div class="form-group">
|
|
1832
2055
|
<label>Resource</label>
|
|
1833
2056
|
<input id="pol-dry-resource" placeholder="sources">
|
|
1834
2057
|
</div>
|
|
1835
|
-
<div class="form-group"
|
|
2058
|
+
<div class="form-group">
|
|
1836
2059
|
<label>Action</label>
|
|
1837
|
-
<input id="pol-dry-action" placeholder="
|
|
2060
|
+
<input id="pol-dry-action" placeholder="read">
|
|
1838
2061
|
</div>
|
|
1839
|
-
<div class="form-group"
|
|
2062
|
+
<div class="form-group">
|
|
2063
|
+
<label>Tenant <span class="form-hint">optional</span></label>
|
|
2064
|
+
<input id="pol-dry-tenant" placeholder="default">
|
|
2065
|
+
</div>
|
|
2066
|
+
<div class="form-group"><label> </label>
|
|
1840
2067
|
<button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
|
|
1841
2068
|
</div>
|
|
1842
2069
|
</div>
|
|
1843
|
-
<div id="pol-dry-out"
|
|
2070
|
+
<div id="pol-dry-out"></div>
|
|
2071
|
+
</div>
|
|
2072
|
+
|
|
2073
|
+
<!-- Sub-tab nav — k8s-style separation of Roles (the WHAT) vs
|
|
2074
|
+
Bindings (the WHO) vs Subjects (the principals). Slice F
|
|
2075
|
+
ships Roles; G + H fill in Bindings + Subjects. -->
|
|
2076
|
+
<nav class="pol-subtabs" role="tablist" aria-label="Policies sections">
|
|
2077
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-roles" data-pol-tab="roles" onclick="polSetTab('roles')">Roles</button>
|
|
2078
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-bindings" data-pol-tab="bindings" onclick="polSetTab('bindings')">Bindings</button>
|
|
2079
|
+
<button class="pol-subtab" role="tab" aria-controls="pol-pane-subjects" data-pol-tab="subjects" onclick="polSetTab('subjects')">Subjects</button>
|
|
2080
|
+
</nav>
|
|
2081
|
+
|
|
2082
|
+
<!-- Roles sub-tab — master/detail: role list on the left, the
|
|
2083
|
+
selected role's permission matrix on the right. -->
|
|
2084
|
+
<div class="card pol-pane" id="pol-pane-roles" role="tabpanel">
|
|
2085
|
+
<div class="card-header"><h2>Roles
|
|
2086
|
+
<button class="info" aria-label="About roles"
|
|
2087
|
+
data-title="Roles"
|
|
2088
|
+
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."
|
|
2089
|
+
onclick="infoPop(this)">?</button>
|
|
2090
|
+
<span style="flex:1"></span>
|
|
2091
|
+
<button class="btn btn-primary btn-sm" data-engine-required="file" data-rbac="users:delete" onclick="polRoleAuthorNew()">+ New role</button>
|
|
2092
|
+
</h2></div>
|
|
2093
|
+
<div class="pol-roles-layout">
|
|
2094
|
+
<div class="pol-role-list" id="pol-role-list" role="listbox" aria-label="Roles">
|
|
2095
|
+
<div class="empty">Loading…</div>
|
|
2096
|
+
</div>
|
|
2097
|
+
<div class="pol-role-detail" id="pol-role-detail">
|
|
2098
|
+
<div class="empty">Select a role on the left to see its permission matrix.</div>
|
|
2099
|
+
</div>
|
|
2100
|
+
</div>
|
|
2101
|
+
</div>
|
|
2102
|
+
|
|
2103
|
+
<!-- Bindings sub-tab — subject → roles table with inline edit. -->
|
|
2104
|
+
<div class="card pol-pane" id="pol-pane-bindings" role="tabpanel" hidden>
|
|
2105
|
+
<div class="card-header"><h2>Bindings
|
|
2106
|
+
<button class="info" aria-label="About bindings"
|
|
2107
|
+
data-title="Bindings"
|
|
2108
|
+
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."
|
|
2109
|
+
onclick="infoPop(this)">?</button>
|
|
2110
|
+
</h2></div>
|
|
2111
|
+
<div id="pol-bindings-body" class="content"><div class="empty">Loading…</div></div>
|
|
2112
|
+
</div>
|
|
2113
|
+
|
|
2114
|
+
<!-- Role-author modal (file-engine only). Surfaces a name +
|
|
2115
|
+
permission-matrix checkbox grid; saves via
|
|
2116
|
+
PUT /api/policy/roles/:name. -->
|
|
2117
|
+
<div class="modal-overlay" id="pol-role-author-modal">
|
|
2118
|
+
<div class="modal" style="width:min(720px, 92vw)">
|
|
2119
|
+
<div class="modal-header">
|
|
2120
|
+
<h3>New role</h3>
|
|
2121
|
+
<button class="btn-icon" onclick="closeModal('pol-role-author-modal')">×</button>
|
|
2122
|
+
</div>
|
|
2123
|
+
<div class="modal-body">
|
|
2124
|
+
<div class="form-group">
|
|
2125
|
+
<label for="pol-role-author-name">Role name</label>
|
|
2126
|
+
<input type="text" id="pol-role-author-name" placeholder="e.g. on-call-sre" autocomplete="off">
|
|
2127
|
+
<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>
|
|
2128
|
+
</div>
|
|
2129
|
+
<div class="form-group">
|
|
2130
|
+
<label>Permissions</label>
|
|
2131
|
+
<div id="pol-role-author-grid" class="content"></div>
|
|
2132
|
+
<div class="form-hint">Tick cells to grant. Empty rows mean the role has no access to that resource.</div>
|
|
2133
|
+
</div>
|
|
2134
|
+
<div id="pol-role-author-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2135
|
+
</div>
|
|
2136
|
+
<div class="modal-footer">
|
|
2137
|
+
<button class="btn btn-ghost" onclick="closeModal('pol-role-author-modal')">Cancel</button>
|
|
2138
|
+
<span style="flex:1"></span>
|
|
2139
|
+
<button class="btn btn-primary" onclick="polRoleAuthorSave()">Save role</button>
|
|
2140
|
+
</div>
|
|
2141
|
+
</div>
|
|
2142
|
+
</div>
|
|
2143
|
+
|
|
2144
|
+
<!-- Binding-edit modal — only used for the local-user row. -->
|
|
2145
|
+
<div class="modal-overlay" id="pol-binding-modal">
|
|
2146
|
+
<div class="modal" style="width:min(520px, 92vw)">
|
|
2147
|
+
<div class="modal-header">
|
|
2148
|
+
<h3>Edit binding · <span id="pol-binding-subject"></span></h3>
|
|
2149
|
+
<button class="btn-icon" onclick="closeModal('pol-binding-modal')">×</button>
|
|
2150
|
+
</div>
|
|
2151
|
+
<div class="modal-body">
|
|
2152
|
+
<p class="t-sm" style="opacity:.85;margin:0 0 var(--sp-3)">
|
|
2153
|
+
Select the roles to grant <strong id="pol-binding-subject-2"></strong>.
|
|
2154
|
+
Unknown roles are rejected — the catalogue comes from the active engine.
|
|
2155
|
+
</p>
|
|
2156
|
+
<div id="pol-binding-roles" class="content"><div class="empty">Loading…</div></div>
|
|
2157
|
+
<div id="pol-binding-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2158
|
+
</div>
|
|
2159
|
+
<div class="modal-footer">
|
|
2160
|
+
<button class="btn btn-ghost" onclick="closeModal('pol-binding-modal')">Cancel</button>
|
|
2161
|
+
<span style="flex:1"></span>
|
|
2162
|
+
<button class="btn btn-primary" onclick="polBindingSave()">Save binding</button>
|
|
2163
|
+
</div>
|
|
2164
|
+
</div>
|
|
2165
|
+
</div>
|
|
2166
|
+
|
|
2167
|
+
<!-- Subjects sub-tab — three sections (Users / API keys /
|
|
2168
|
+
OIDC groups). Read-only this slice. -->
|
|
2169
|
+
<div class="card pol-pane" id="pol-pane-subjects" role="tabpanel" hidden>
|
|
2170
|
+
<div class="card-header"><h2>Subjects
|
|
2171
|
+
<button class="info" aria-label="About subjects"
|
|
2172
|
+
data-title="Subjects"
|
|
2173
|
+
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."
|
|
2174
|
+
onclick="infoPop(this)">?</button>
|
|
2175
|
+
</h2></div>
|
|
2176
|
+
<div id="pol-subjects-body" class="content"><div class="empty">Loading…</div></div>
|
|
2177
|
+
</div>
|
|
2178
|
+
|
|
2179
|
+
<!-- Kept for back-compat — the legacy snapshot lives here but
|
|
2180
|
+
the new Roles sub-tab supersedes it. JS hides it once
|
|
2181
|
+
Roles renders successfully so we don't duplicate
|
|
2182
|
+
information. -->
|
|
2183
|
+
<div class="card" id="pol-legacy-snapshot" hidden>
|
|
2184
|
+
<div class="card-header"><h2>Active policy (legacy view)</h2></div>
|
|
2185
|
+
<div id="pol-roles" class="content"></div>
|
|
1844
2186
|
</div>
|
|
1845
2187
|
</div>
|
|
1846
2188
|
|
|
@@ -1938,6 +2280,11 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1938
2280
|
<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
2281
|
<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
2282
|
<div class="form-group"><label>Client Key Path</label><input type="text" id="src-tls-key" placeholder="/path/to/client-key.pem"></div>
|
|
2283
|
+
<div class="form-group">
|
|
2284
|
+
<label for="src-tenant">Tenant <span class="t-sm" style="opacity:.6">(optional, admin-only)</span></label>
|
|
2285
|
+
<input type="text" id="src-tenant" placeholder="(blank = global)" autocomplete="off">
|
|
2286
|
+
<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>
|
|
2287
|
+
</div>
|
|
1941
2288
|
<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
2289
|
<div class="test-result" id="test-result"></div>
|
|
1943
2290
|
</div>
|
|
@@ -1978,6 +2325,143 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1978
2325
|
</div>
|
|
1979
2326
|
</div>
|
|
1980
2327
|
|
|
2328
|
+
<!-- MCP Product Modal (Create + Edit) — 4-step wizard.
|
|
2329
|
+
The form fields keep their stable ids so save() and the
|
|
2330
|
+
existing field-by-field tests stay byte-identical; only the
|
|
2331
|
+
structural grouping + stepper navigation are new. -->
|
|
2332
|
+
<div class="modal-overlay" id="mcp-product-modal">
|
|
2333
|
+
<div class="modal mcp-product-wizard">
|
|
2334
|
+
<div class="modal-header">
|
|
2335
|
+
<h3 id="mcp-product-modal-title">New Product</h3>
|
|
2336
|
+
<button class="btn-icon" onclick="closeModal('mcp-product-modal')">×</button>
|
|
2337
|
+
</div>
|
|
2338
|
+
<!-- Stepper rail — bullets are clickable so an operator can
|
|
2339
|
+
jump back to any step they've already filled in. -->
|
|
2340
|
+
<div class="wiz-stepper" id="mcp-wiz-stepper" role="tablist" aria-label="Product wizard steps">
|
|
2341
|
+
<button class="wiz-step-btn" data-step="1" role="tab" aria-controls="wiz-pane-1" onclick="mcpWizGoto(1)">
|
|
2342
|
+
<span class="wiz-step-num">1</span><span class="wiz-step-lbl">Identity</span>
|
|
2343
|
+
</button>
|
|
2344
|
+
<span class="wiz-step-line"></span>
|
|
2345
|
+
<button class="wiz-step-btn" data-step="2" role="tab" aria-controls="wiz-pane-2" onclick="mcpWizGoto(2)">
|
|
2346
|
+
<span class="wiz-step-num">2</span><span class="wiz-step-lbl">Tools</span>
|
|
2347
|
+
</button>
|
|
2348
|
+
<span class="wiz-step-line"></span>
|
|
2349
|
+
<button class="wiz-step-btn" data-step="3" role="tab" aria-controls="wiz-pane-3" onclick="mcpWizGoto(3)">
|
|
2350
|
+
<span class="wiz-step-num">3</span><span class="wiz-step-lbl">Scope & branding</span>
|
|
2351
|
+
</button>
|
|
2352
|
+
<span class="wiz-step-line"></span>
|
|
2353
|
+
<button class="wiz-step-btn" data-step="4" role="tab" aria-controls="wiz-pane-4" onclick="mcpWizGoto(4)">
|
|
2354
|
+
<span class="wiz-step-num">4</span><span class="wiz-step-lbl">Review & publish</span>
|
|
2355
|
+
</button>
|
|
2356
|
+
</div>
|
|
2357
|
+
<div class="modal-body">
|
|
2358
|
+
<input type="hidden" id="mcp-product-mode" value="new">
|
|
2359
|
+
<input type="hidden" id="mcp-product-original-id" value="">
|
|
2360
|
+
|
|
2361
|
+
<!-- Step 1: Identity -->
|
|
2362
|
+
<div class="wiz-pane" id="wiz-pane-1" data-step="1" role="tabpanel">
|
|
2363
|
+
<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>
|
|
2364
|
+
<div class="form-group">
|
|
2365
|
+
<label for="mcp-product-id">Id</label>
|
|
2366
|
+
<input type="text" id="mcp-product-id" placeholder="e.g. ops-bundle" autocomplete="off">
|
|
2367
|
+
<div class="form-hint">Pattern: <code>[A-Za-z0-9][A-Za-z0-9._-]{0,63}</code>. Immutable once saved.</div>
|
|
2368
|
+
</div>
|
|
2369
|
+
<div class="form-group">
|
|
2370
|
+
<label for="mcp-product-name">Display name</label>
|
|
2371
|
+
<input type="text" id="mcp-product-name" placeholder="e.g. Operations Bundle" autocomplete="off">
|
|
2372
|
+
</div>
|
|
2373
|
+
<div class="form-group">
|
|
2374
|
+
<label for="mcp-product-description">Description <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2375
|
+
<input type="text" id="mcp-product-description" placeholder="One-sentence summary" autocomplete="off">
|
|
2376
|
+
</div>
|
|
2377
|
+
</div>
|
|
2378
|
+
|
|
2379
|
+
<!-- Step 2: Tools -->
|
|
2380
|
+
<div class="wiz-pane" id="wiz-pane-2" data-step="2" role="tabpanel" hidden>
|
|
2381
|
+
<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>
|
|
2382
|
+
<div class="form-group">
|
|
2383
|
+
<div id="mcp-product-tools-picker" class="tools-picker">
|
|
2384
|
+
<div class="tools-picker-hint t-sm">Loading…</div>
|
|
2385
|
+
</div>
|
|
2386
|
+
<!-- Hidden textarea kept for back-compat — the picker syncs
|
|
2387
|
+
selections here, and save() still reads from it. -->
|
|
2388
|
+
<textarea id="mcp-product-tools" rows="2" hidden></textarea>
|
|
2389
|
+
</div>
|
|
2390
|
+
</div>
|
|
2391
|
+
|
|
2392
|
+
<!-- Step 3: Scope & branding -->
|
|
2393
|
+
<div class="wiz-pane" id="wiz-pane-3" data-step="3" role="tabpanel" hidden>
|
|
2394
|
+
<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>
|
|
2395
|
+
<div class="form-row">
|
|
2396
|
+
<div class="form-group">
|
|
2397
|
+
<label for="mcp-product-status">Status</label>
|
|
2398
|
+
<select id="mcp-product-status">
|
|
2399
|
+
<option value="staging">staging (admin-only)</option>
|
|
2400
|
+
<option value="published">published (visible to agents)</option>
|
|
2401
|
+
</select>
|
|
2402
|
+
</div>
|
|
2403
|
+
<div class="form-group">
|
|
2404
|
+
<label for="mcp-product-tenant">Tenant <span class="t-sm" style="opacity:.6">(admin only)</span></label>
|
|
2405
|
+
<input type="text" id="mcp-product-tenant" placeholder="default" autocomplete="off">
|
|
2406
|
+
<div class="form-hint">Blank = same tenant as the calling user.</div>
|
|
2407
|
+
</div>
|
|
2408
|
+
</div>
|
|
2409
|
+
<div class="form-row">
|
|
2410
|
+
<div class="form-group">
|
|
2411
|
+
<label for="mcp-product-version">Version <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2412
|
+
<input type="text" id="mcp-product-version" placeholder="1.0.0" autocomplete="off">
|
|
2413
|
+
</div>
|
|
2414
|
+
<div class="form-group">
|
|
2415
|
+
<label for="mcp-product-color">Brand colour <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2416
|
+
<input type="text" id="mcp-product-color" placeholder="#3178c6" autocomplete="off">
|
|
2417
|
+
</div>
|
|
2418
|
+
</div>
|
|
2419
|
+
<div class="form-group">
|
|
2420
|
+
<label for="mcp-product-icon">Icon URL <span class="t-sm" style="opacity:.6">(optional)</span></label>
|
|
2421
|
+
<input type="text" id="mcp-product-icon" placeholder="https://example.com/icons/ops.svg" autocomplete="off">
|
|
2422
|
+
</div>
|
|
2423
|
+
</div>
|
|
2424
|
+
|
|
2425
|
+
<!-- Step 4: Review & publish -->
|
|
2426
|
+
<div class="wiz-pane" id="wiz-pane-4" data-step="4" role="tabpanel" hidden>
|
|
2427
|
+
<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>
|
|
2428
|
+
<div id="mcp-wiz-review"></div>
|
|
2429
|
+
</div>
|
|
2430
|
+
|
|
2431
|
+
<div id="mcp-product-error" class="form-hint" role="alert" aria-live="polite" style="color: var(--danger); display: none;"></div>
|
|
2432
|
+
</div>
|
|
2433
|
+
<div class="modal-footer">
|
|
2434
|
+
<button class="btn btn-ghost" onclick="closeModal('mcp-product-modal')">Cancel</button>
|
|
2435
|
+
<span style="flex:1"></span>
|
|
2436
|
+
<button class="btn btn-ghost" id="mcp-wiz-back" onclick="mcpWizBack()" hidden>Back</button>
|
|
2437
|
+
<button class="btn btn-primary" id="mcp-wiz-next" onclick="mcpWizNext()">Next</button>
|
|
2438
|
+
<button class="btn btn-primary" id="mcp-wiz-save" onclick="mcpProductSave()" hidden>Save Product</button>
|
|
2439
|
+
</div>
|
|
2440
|
+
</div>
|
|
2441
|
+
</div>
|
|
2442
|
+
|
|
2443
|
+
<!-- Agent preview modal — invoked from per-card "Preview as agent"
|
|
2444
|
+
button. Same /api/products/:id/preview backend the wizard
|
|
2445
|
+
Review pane could call, but the wizard uses the live registry
|
|
2446
|
+
in-form because the draft hasn't been saved yet. -->
|
|
2447
|
+
<div class="modal-overlay" id="mcp-agent-preview-modal">
|
|
2448
|
+
<div class="modal mcp-agent-preview" style="--mcp-agent-accent: var(--accent)">
|
|
2449
|
+
<div class="modal-header" style="border-left: 4px solid var(--mcp-agent-accent)">
|
|
2450
|
+
<h3 id="mcp-agent-preview-title">Agent preview</h3>
|
|
2451
|
+
<button class="btn-icon" onclick="closeModal('mcp-agent-preview-modal')">×</button>
|
|
2452
|
+
</div>
|
|
2453
|
+
<div class="modal-body">
|
|
2454
|
+
<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>
|
|
2455
|
+
<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>
|
|
2456
|
+
<div id="mcp-agent-preview-list" class="wiz-agent-preview"></div>
|
|
2457
|
+
</div>
|
|
2458
|
+
<div class="modal-footer">
|
|
2459
|
+
<span style="flex:1"></span>
|
|
2460
|
+
<button class="btn btn-primary" onclick="closeModal('mcp-agent-preview-modal')">Close</button>
|
|
2461
|
+
</div>
|
|
2462
|
+
</div>
|
|
2463
|
+
</div>
|
|
2464
|
+
|
|
1981
2465
|
<div class="toast" id="toast-el"></div>
|
|
1982
2466
|
|
|
1983
2467
|
<div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
|
|
@@ -2020,45 +2504,640 @@ function showPage(name) {
|
|
|
2020
2504
|
}
|
|
2021
2505
|
|
|
2022
2506
|
// --- Policies tab (PolicyEngine snapshot + dry-run) ---
|
|
2023
|
-
|
|
2507
|
+
// Classify the engine kind reported by /api/policy.engine into one of
|
|
2508
|
+
// the three banner styles. The kind is `builtin`, `file:<path>`, or
|
|
2509
|
+
// `opa:<url>`; the prefix is what we key on.
|
|
2510
|
+
function polEngineKind(engineStr) {
|
|
2511
|
+
const s = (engineStr || '').toLowerCase();
|
|
2512
|
+
if (s.startsWith('opa:')) return 'opa';
|
|
2513
|
+
if (s.startsWith('file:') || s.startsWith('file ')) return 'file';
|
|
2514
|
+
return 'builtin';
|
|
2515
|
+
}
|
|
2516
|
+
function polRenderEngineBanner(j) {
|
|
2517
|
+
const banner = document.getElementById('pol-engine-banner');
|
|
2518
|
+
if (!banner) return;
|
|
2519
|
+
const kind = polEngineKind(j.engine);
|
|
2520
|
+
banner.hidden = false;
|
|
2521
|
+
banner.className = 'pol-engine-banner eng-' + kind;
|
|
2522
|
+
// body[data-policy-engine="..."] drives CSS that disables every
|
|
2523
|
+
// [data-engine-required="file"] control when authoring isn't
|
|
2524
|
+
// supported by the active engine.
|
|
2525
|
+
document.body.setAttribute('data-policy-engine', kind);
|
|
2526
|
+
const titleEl = document.getElementById('pol-eb-title-text');
|
|
2527
|
+
const kindEl = document.getElementById('pol-eb-kind');
|
|
2528
|
+
const metaEl = document.getElementById('pol-eb-meta');
|
|
2529
|
+
const copyEl = document.getElementById('pol-eb-copy');
|
|
2530
|
+
const tenantPill = document.getElementById('pol-eb-tenant-aware');
|
|
2531
|
+
if (kindEl) kindEl.textContent = j.engine || 'unknown';
|
|
2532
|
+
if (tenantPill) tenantPill.hidden = !j.tenantAware;
|
|
2533
|
+
if (kind === 'file') {
|
|
2534
|
+
titleEl.textContent = 'Editable here';
|
|
2535
|
+
metaEl.textContent = 'source: ' + (j.engine || '');
|
|
2536
|
+
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.';
|
|
2537
|
+
} else if (kind === 'opa') {
|
|
2538
|
+
titleEl.textContent = 'Read-only (OPA)';
|
|
2539
|
+
metaEl.textContent = 'evaluating Rego at ' + (j.engine || '').replace(/^opa:/i, '');
|
|
2540
|
+
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.';
|
|
2541
|
+
} else {
|
|
2542
|
+
titleEl.textContent = 'Built-in defaults (read-only)';
|
|
2543
|
+
metaEl.textContent = j.note || 'DEFAULT_POLICY shipped with the build';
|
|
2544
|
+
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.';
|
|
2545
|
+
}
|
|
2546
|
+
// Mirror the kind on the legacy badge in the header.
|
|
2024
2547
|
const engineEl = document.getElementById('pol-engine');
|
|
2548
|
+
if (engineEl) {
|
|
2549
|
+
engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
|
|
2550
|
+
engineEl.className = 'badge ' + (kind === 'opa' ? 'badge-warn' : kind === 'file' ? 'badge-ok' : '');
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
// Policy snapshot — fetched once on page enter, then reused by the
|
|
2554
|
+
// sub-tab renderers (Roles matrix today; Bindings + Subjects in
|
|
2555
|
+
// later slices). Reset on every loadPolicies() call.
|
|
2556
|
+
let POL_SNAPSHOT = null;
|
|
2557
|
+
let POL_SELECTED_ROLE = null;
|
|
2558
|
+
|
|
2559
|
+
function polSetTab(name) {
|
|
2560
|
+
document.querySelectorAll('.pol-subtab').forEach((btn) => {
|
|
2561
|
+
btn.setAttribute('data-active', String(btn.getAttribute('data-pol-tab') === name));
|
|
2562
|
+
btn.setAttribute('aria-selected', String(btn.getAttribute('data-pol-tab') === name));
|
|
2563
|
+
});
|
|
2564
|
+
document.querySelectorAll('.pol-pane').forEach((p) => {
|
|
2565
|
+
p.hidden = p.id !== 'pol-pane-' + name;
|
|
2566
|
+
});
|
|
2567
|
+
// Lazy-load Subjects on first visit — it has its own endpoint
|
|
2568
|
+
// so deferring the fetch keeps the page-enter cost low.
|
|
2569
|
+
if (name === 'subjects') polLoadSubjects();
|
|
2570
|
+
if (name === 'bindings') polLoadBindings();
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// Bindings = the (subject → roles) view derived from /api/subjects.
|
|
2574
|
+
// We don't have a separate /api/bindings endpoint because the binding
|
|
2575
|
+
// data IS the subject data today (users carry roles; api-keys don't;
|
|
2576
|
+
// oidc groups map to a single role via the env catalog).
|
|
2577
|
+
let POL_BINDING_EDIT = null; // { kind, name } of the row being edited
|
|
2578
|
+
async function polLoadBindings() {
|
|
2579
|
+
const body = document.getElementById('pol-bindings-body');
|
|
2580
|
+
if (!body) return;
|
|
2581
|
+
// Reuse the subjects payload if already fetched — same data source.
|
|
2582
|
+
if (!POL_SUBJECTS) {
|
|
2583
|
+
try {
|
|
2584
|
+
const r = await fetch('/api/subjects');
|
|
2585
|
+
if (!r.ok) {
|
|
2586
|
+
body.innerHTML = '<div class="empty">Bindings view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
POL_SUBJECTS = await r.json();
|
|
2590
|
+
} catch (e) {
|
|
2591
|
+
body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
polRenderBindings(POL_SUBJECTS);
|
|
2596
|
+
}
|
|
2597
|
+
function polRenderBindings(s) {
|
|
2598
|
+
const body = document.getElementById('pol-bindings-body');
|
|
2599
|
+
if (!body) return;
|
|
2600
|
+
// Are user edits available? Only when OMCP_USERS_FILE is set
|
|
2601
|
+
// (server returns it under sources.users) — otherwise the PUT
|
|
2602
|
+
// endpoint 409s and the operator just gets a confusing failure.
|
|
2603
|
+
const userEditable = !!(s.sources && s.sources.users);
|
|
2604
|
+
const rows = [];
|
|
2605
|
+
for (const u of (s.users || [])) {
|
|
2606
|
+
const roles = (u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>';
|
|
2607
|
+
const action = userEditable
|
|
2608
|
+
? `<button class="btn btn-ghost btn-sm" data-bind-edit data-bind-kind="user" data-bind-name="${escHtml(u.username)}">Edit</button>`
|
|
2609
|
+
: '<span class="t-sm" style="opacity:.55" title="Set OMCP_USERS_FILE to enable user-role editing">read-only</span>';
|
|
2610
|
+
rows.push(`<tr>
|
|
2611
|
+
<td><code>${escHtml(u.username)}</code></td>
|
|
2612
|
+
<td><span class="tag">user</span></td>
|
|
2613
|
+
<td>${roles}</td>
|
|
2614
|
+
<td>${escHtml(u.tenant || 'default')}</td>
|
|
2615
|
+
<td style="text-align:right">${action}</td>
|
|
2616
|
+
</tr>`);
|
|
2617
|
+
}
|
|
2618
|
+
for (const k of (s.apiKeys || [])) {
|
|
2619
|
+
rows.push(`<tr>
|
|
2620
|
+
<td><code>${escHtml(k.name)}</code></td>
|
|
2621
|
+
<td><span class="tag">api key</span></td>
|
|
2622
|
+
<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>
|
|
2623
|
+
<td>${escHtml(k.tenant || 'default')}</td>
|
|
2624
|
+
<td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_API_KEYS</code></span></td>
|
|
2625
|
+
</tr>`);
|
|
2626
|
+
}
|
|
2627
|
+
for (const g of (s.oidcGroups || [])) {
|
|
2628
|
+
rows.push(`<tr>
|
|
2629
|
+
<td><code>${escHtml(g.claim)}</code></td>
|
|
2630
|
+
<td><span class="tag">oidc group</span></td>
|
|
2631
|
+
<td><span class="pill">${escHtml(g.role)}</span></td>
|
|
2632
|
+
<td><span class="t-sm" style="opacity:.55">—</span></td>
|
|
2633
|
+
<td style="text-align:right"><span class="t-sm" style="opacity:.55">via <code>OMCP_OIDC_ROLE_MAP</code></span></td>
|
|
2634
|
+
</tr>`);
|
|
2635
|
+
}
|
|
2636
|
+
if (rows.length === 0) {
|
|
2637
|
+
body.innerHTML = `<div class="pol-subjects-empty" style="padding:var(--sp-4)">
|
|
2638
|
+
No subjects configured. Set one of <code>OMCP_USERS_FILE</code> /
|
|
2639
|
+
<code>OMCP_API_KEYS</code> / <code>OMCP_OIDC_ROLE_MAP</code> to populate this view.
|
|
2640
|
+
</div>`;
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
body.innerHTML = `<table class="data-table" style="width:100%">
|
|
2644
|
+
<thead><tr><th>Subject</th><th>Kind</th><th>Roles</th><th>Tenant</th><th></th></tr></thead>
|
|
2645
|
+
<tbody>${rows.join('')}</tbody>
|
|
2646
|
+
</table>`;
|
|
2647
|
+
// Wire the per-row Edit buttons via delegation so role-name strings
|
|
2648
|
+
// can't escape into onclick context (same defence-in-depth as the
|
|
2649
|
+
// role list in Slice F).
|
|
2650
|
+
if (!body.dataset.delegated) {
|
|
2651
|
+
body.addEventListener('click', (ev) => {
|
|
2652
|
+
const btn = ev.target.closest('[data-bind-edit]');
|
|
2653
|
+
if (!btn) return;
|
|
2654
|
+
const kind = btn.getAttribute('data-bind-kind');
|
|
2655
|
+
const name = btn.getAttribute('data-bind-name');
|
|
2656
|
+
if (kind && name) polBindingEdit(kind, name);
|
|
2657
|
+
});
|
|
2658
|
+
body.dataset.delegated = '1';
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
function polBindingEdit(kind, name) {
|
|
2662
|
+
POL_BINDING_EDIT = { kind, name };
|
|
2663
|
+
const subj = document.getElementById('pol-binding-subject');
|
|
2664
|
+
const subj2 = document.getElementById('pol-binding-subject-2');
|
|
2665
|
+
if (subj) subj.textContent = name;
|
|
2666
|
+
if (subj2) subj2.textContent = name;
|
|
2667
|
+
const errEl = document.getElementById('pol-binding-error');
|
|
2668
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
2669
|
+
// Build the checklist from the active engine's role catalogue.
|
|
2670
|
+
const rolesBox = document.getElementById('pol-binding-roles');
|
|
2671
|
+
const knownRoles = (POL_SNAPSHOT && POL_SNAPSHOT.roles) || [];
|
|
2672
|
+
const currentRoles = kind === 'user'
|
|
2673
|
+
? ((POL_SUBJECTS && POL_SUBJECTS.users.find((u) => u.username === name) || {}).roles || [])
|
|
2674
|
+
: [];
|
|
2675
|
+
if (rolesBox) {
|
|
2676
|
+
if (knownRoles.length === 0) {
|
|
2677
|
+
rolesBox.innerHTML = '<div class="empty">No roles declared in the active engine.</div>';
|
|
2678
|
+
} else {
|
|
2679
|
+
rolesBox.innerHTML = knownRoles.map((r) => {
|
|
2680
|
+
const checked = currentRoles.includes(r) ? 'checked' : '';
|
|
2681
|
+
return `<label class="tp-item" style="display:flex;align-items:center;gap:var(--sp-2);padding:var(--sp-1) 0;cursor:pointer">
|
|
2682
|
+
<input type="checkbox" data-pol-role value="${escHtml(r)}" ${checked}>
|
|
2683
|
+
<span style="font-family:'JetBrains Mono', ui-monospace, monospace;font-size:13px">${escHtml(r)}</span>
|
|
2684
|
+
</label>`;
|
|
2685
|
+
}).join('');
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
document.getElementById('pol-binding-modal').classList.add('open');
|
|
2689
|
+
}
|
|
2690
|
+
async function polBindingSave() {
|
|
2691
|
+
if (!POL_BINDING_EDIT) return;
|
|
2692
|
+
const errEl = document.getElementById('pol-binding-error');
|
|
2693
|
+
const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
|
|
2694
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
2695
|
+
if (POL_BINDING_EDIT.kind !== 'user') {
|
|
2696
|
+
setErr('Only local-user bindings are editable at runtime today.');
|
|
2697
|
+
return;
|
|
2698
|
+
}
|
|
2699
|
+
const checks = Array.from(document.querySelectorAll('#pol-binding-roles input[data-pol-role]:checked'))
|
|
2700
|
+
.map((el) => el.value);
|
|
2701
|
+
try {
|
|
2702
|
+
const r = await fetch('/api/users/' + encodeURIComponent(POL_BINDING_EDIT.name) + '/roles', {
|
|
2703
|
+
method: 'PUT',
|
|
2704
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2705
|
+
body: JSON.stringify({ roles: checks }),
|
|
2706
|
+
});
|
|
2707
|
+
if (!r.ok) {
|
|
2708
|
+
const j = await r.json().catch(() => ({}));
|
|
2709
|
+
setErr(j.error || ('HTTP ' + r.status));
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
closeModal('pol-binding-modal');
|
|
2713
|
+
toast('Roles updated for ' + POL_BINDING_EDIT.name);
|
|
2714
|
+
// Invalidate + refresh the bindings view.
|
|
2715
|
+
POL_SUBJECTS = null;
|
|
2716
|
+
await polLoadBindings();
|
|
2717
|
+
} catch (e) {
|
|
2718
|
+
setErr('Save failed: ' + (e && e.message || e));
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Cache the subjects payload so re-entering the tab doesn't refetch.
|
|
2723
|
+
let POL_SUBJECTS = null;
|
|
2724
|
+
async function polLoadSubjects() {
|
|
2725
|
+
const body = document.getElementById('pol-subjects-body');
|
|
2726
|
+
if (!body) return;
|
|
2727
|
+
if (POL_SUBJECTS) {
|
|
2728
|
+
polRenderSubjects(POL_SUBJECTS);
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
try {
|
|
2732
|
+
const r = await fetch('/api/subjects');
|
|
2733
|
+
if (!r.ok) {
|
|
2734
|
+
body.innerHTML = '<div class="empty">Subjects view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
POL_SUBJECTS = await r.json();
|
|
2738
|
+
polRenderSubjects(POL_SUBJECTS);
|
|
2739
|
+
} catch (e) {
|
|
2740
|
+
body.innerHTML = '<div class="empty">Subjects unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
function polRenderSubjects(j) {
|
|
2744
|
+
const body = document.getElementById('pol-subjects-body');
|
|
2745
|
+
if (!body) return;
|
|
2746
|
+
const sources = j.sources || {};
|
|
2747
|
+
const usersHtml = (j.users || []).map((u) => `
|
|
2748
|
+
<tr>
|
|
2749
|
+
<td><code>${escHtml(u.username)}</code></td>
|
|
2750
|
+
<td>${escHtml(u.name)}</td>
|
|
2751
|
+
<td>${(u.roles || []).map((r) => `<span class="pill">${escHtml(r)}</span>`).join(' ') || '<span class="t-sm" style="opacity:.5">—</span>'}</td>
|
|
2752
|
+
<td>${escHtml(u.tenant || 'default')}</td>
|
|
2753
|
+
</tr>`).join('');
|
|
2754
|
+
const apiKeysHtml = (j.apiKeys || []).map((k) => `
|
|
2755
|
+
<tr>
|
|
2756
|
+
<td><code>${escHtml(k.name)}</code></td>
|
|
2757
|
+
<td>${escHtml(k.tenant || 'default')}</td>
|
|
2758
|
+
<td>${k.productId ? `<code>${escHtml(k.productId)}</code>` : '<span class="t-sm" style="opacity:.5">—</span>'}</td>
|
|
2759
|
+
<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>
|
|
2760
|
+
<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>
|
|
2761
|
+
</tr>`).join('');
|
|
2762
|
+
const groupsHtml = (j.oidcGroups || []).map((g) => `
|
|
2763
|
+
<tr>
|
|
2764
|
+
<td><code>${escHtml(g.claim)}</code></td>
|
|
2765
|
+
<td><span class="pill">${escHtml(g.role)}</span></td>
|
|
2766
|
+
</tr>`).join('');
|
|
2767
|
+
const usersTbl = usersHtml
|
|
2768
|
+
? `<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>`
|
|
2769
|
+
: `<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>`;
|
|
2770
|
+
const apiKeysTbl = apiKeysHtml
|
|
2771
|
+
? `<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>`
|
|
2772
|
+
: `<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>`;
|
|
2773
|
+
const groupsTbl = groupsHtml
|
|
2774
|
+
? `<table class="data-table" style="width:100%"><thead><tr><th>Group / claim</th><th>Maps to role</th></tr></thead><tbody>${groupsHtml}</tbody></table>`
|
|
2775
|
+
: `<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>`;
|
|
2776
|
+
body.innerHTML = `
|
|
2777
|
+
<div class="pol-subjects-section">
|
|
2778
|
+
<h3>Users
|
|
2779
|
+
<span class="pol-subjects-count">${(j.users || []).length}</span>
|
|
2780
|
+
<span class="pol-subjects-source">${sources.users ? escHtml(sources.users) : 'OMCP_USERS_FILE'}</span>
|
|
2781
|
+
</h3>
|
|
2782
|
+
${usersTbl}
|
|
2783
|
+
</div>
|
|
2784
|
+
<div class="pol-subjects-section">
|
|
2785
|
+
<h3>API keys
|
|
2786
|
+
<span class="pol-subjects-count">${(j.apiKeys || []).length}</span>
|
|
2787
|
+
<span class="pol-subjects-source">OMCP_API_KEYS</span>
|
|
2788
|
+
</h3>
|
|
2789
|
+
${apiKeysTbl}
|
|
2790
|
+
</div>
|
|
2791
|
+
<div class="pol-subjects-section">
|
|
2792
|
+
<h3>OIDC groups
|
|
2793
|
+
<span class="pol-subjects-count">${(j.oidcGroups || []).length}</span>
|
|
2794
|
+
<span class="pol-subjects-source">OMCP_OIDC_ROLE_MAP</span>
|
|
2795
|
+
</h3>
|
|
2796
|
+
${groupsTbl}
|
|
2797
|
+
</div>`;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// Resources × actions catalogue, kept in sync with VALID_RESOURCES +
|
|
2801
|
+
// VALID_ACTIONS in src/auth/policy/loader.ts. Pinned client-side so
|
|
2802
|
+
// the matrix shows the FULL grid (granted vs not-granted) even when
|
|
2803
|
+
// a role has zero grants for a given resource — the empty cells are
|
|
2804
|
+
// information too. If the server adds a new resource / action,
|
|
2805
|
+
// extend this list; the policy.engine snapshot guards against
|
|
2806
|
+
// silent drift through its declared-roles catalogue.
|
|
2807
|
+
const POL_RESOURCES = ["sources","services","health","topology","settings","connectors","audit","catalog","users","redaction","products"];
|
|
2808
|
+
const POL_ACTIONS = ["read","write","delete","bypass"];
|
|
2809
|
+
|
|
2810
|
+
// Effective-permissions overlay: when a subject is selected the
|
|
2811
|
+
// matrix flips from "what does THIS role grant" to "what can THIS
|
|
2812
|
+
// subject do, and via which role". The overlay is pure client-side
|
|
2813
|
+
// composition over the existing /api/policy + /api/subjects snapshots
|
|
2814
|
+
// — no new endpoint. Null means overlay off (default = role-centric).
|
|
2815
|
+
let POL_EFFECTIVE_SUBJECT = null; // { kind: 'user'|'oidc', id: string, roles: string[] } | null
|
|
2816
|
+
|
|
2817
|
+
function polEffectiveSubjectsList() {
|
|
2818
|
+
// Build the dropdown options from the cached subjects payload.
|
|
2819
|
+
// Users carry an explicit roles[] array. OIDC groups map to a
|
|
2820
|
+
// single role via OMCP_OIDC_ROLE_MAP. API keys don't carry RBAC
|
|
2821
|
+
// roles in the current model, so they're omitted — selecting one
|
|
2822
|
+
// would always show "denied" everywhere, which is misleading.
|
|
2823
|
+
const out = [];
|
|
2824
|
+
if (!POL_SUBJECTS) return out;
|
|
2825
|
+
for (const u of (POL_SUBJECTS.users || [])) {
|
|
2826
|
+
out.push({ kind: 'user', id: u.username, label: u.username + ' (user)', roles: u.roles || [] });
|
|
2827
|
+
}
|
|
2828
|
+
for (const g of (POL_SUBJECTS.oidcGroups || [])) {
|
|
2829
|
+
out.push({ kind: 'oidc', id: g.claim, label: g.claim + ' (oidc group)', roles: [g.role] });
|
|
2830
|
+
}
|
|
2831
|
+
return out;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
async function polEffectiveEnsureSubjects() {
|
|
2835
|
+
// Lazy-load /api/subjects the first time the operator opens the
|
|
2836
|
+
// selector. Reuse the same cache the Bindings/Subjects tabs use.
|
|
2837
|
+
if (POL_SUBJECTS) return;
|
|
2838
|
+
try {
|
|
2839
|
+
const r = await fetch('/api/subjects');
|
|
2840
|
+
if (!r.ok) return;
|
|
2841
|
+
POL_SUBJECTS = await r.json();
|
|
2842
|
+
} catch (e) {
|
|
2843
|
+
// Silent — the selector will just show "no subjects".
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
function polEffectiveOnChange(ev) {
|
|
2848
|
+
const val = ev.target.value;
|
|
2849
|
+
if (!val) {
|
|
2850
|
+
POL_EFFECTIVE_SUBJECT = null;
|
|
2851
|
+
polRolesRender();
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const list = polEffectiveSubjectsList();
|
|
2855
|
+
const found = list.find((s) => (s.kind + ':' + s.id) === val);
|
|
2856
|
+
POL_EFFECTIVE_SUBJECT = found || null;
|
|
2857
|
+
polRolesRender();
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
async function polEffectiveOpen() {
|
|
2861
|
+
// Operator clicked the "Show effective permissions" affordance —
|
|
2862
|
+
// make sure the subjects cache is populated, then re-render so the
|
|
2863
|
+
// selector has options.
|
|
2864
|
+
await polEffectiveEnsureSubjects();
|
|
2865
|
+
polRolesRender();
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
function polRolesRender() {
|
|
2869
|
+
const listEl = document.getElementById('pol-role-list');
|
|
2870
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
2871
|
+
if (!listEl || !detailEl || !POL_SNAPSHOT) return;
|
|
2872
|
+
const roles = POL_SNAPSHOT.roles || [];
|
|
2873
|
+
if (roles.length === 0) {
|
|
2874
|
+
listEl.innerHTML = '<div class="empty">No roles defined.</div>';
|
|
2875
|
+
detailEl.innerHTML = '<div class="empty">Define a role to see its permission matrix.</div>';
|
|
2876
|
+
return;
|
|
2877
|
+
}
|
|
2878
|
+
if (!POL_SELECTED_ROLE || !roles.includes(POL_SELECTED_ROLE)) {
|
|
2879
|
+
POL_SELECTED_ROLE = roles[0];
|
|
2880
|
+
}
|
|
2881
|
+
// Render the role list with grant counts. Selection is handled
|
|
2882
|
+
// via event delegation on the list container — keeps untrusted
|
|
2883
|
+
// role names (file-loaded policies can use any string) out of the
|
|
2884
|
+
// onclick attribute, where escHtml's HTML-special set doesn't
|
|
2885
|
+
// sanitise JS-string-literal characters like the single quote.
|
|
2886
|
+
listEl.innerHTML = roles.map((role) => {
|
|
2887
|
+
const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
|
|
2888
|
+
const active = role === POL_SELECTED_ROLE;
|
|
2889
|
+
return `<div class="pol-role-row" role="option" data-role="${escHtml(role)}" data-active="${active}">
|
|
2890
|
+
<span class="pol-role-name">${escHtml(role)}</span>
|
|
2891
|
+
<span class="pol-role-count">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>
|
|
2892
|
+
</div>`;
|
|
2893
|
+
}).join('');
|
|
2894
|
+
// Wire delegation once. Idempotent — re-rendering the inner HTML
|
|
2895
|
+
// doesn't lose the listener because it's bound to the outer list
|
|
2896
|
+
// element which persists. Guarded with a marker attribute so a
|
|
2897
|
+
// second loadPolicies call doesn't double-bind.
|
|
2898
|
+
if (!listEl.dataset.delegated) {
|
|
2899
|
+
listEl.addEventListener('click', (ev) => {
|
|
2900
|
+
const row = ev.target.closest('.pol-role-row');
|
|
2901
|
+
if (!row) return;
|
|
2902
|
+
const role = row.getAttribute('data-role');
|
|
2903
|
+
if (role) polSelectRole(role);
|
|
2904
|
+
});
|
|
2905
|
+
listEl.dataset.delegated = '1';
|
|
2906
|
+
}
|
|
2907
|
+
// Render the matrix for the selected role.
|
|
2908
|
+
const grants = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[POL_SELECTED_ROLE]) || [];
|
|
2909
|
+
// Build a Set of "resource:action" pairs for O(1) lookup.
|
|
2910
|
+
const granted = new Set();
|
|
2911
|
+
for (const g of grants) granted.add(g.resource + ':' + g.action);
|
|
2912
|
+
const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
|
|
2913
|
+
|
|
2914
|
+
// Effective-overlay precompute. For each (resource, action) build a
|
|
2915
|
+
// list of roles (from the subject's role bundle) that grant it — so
|
|
2916
|
+
// the cell can show "via <role>" or "via <role> +N". Empty = denied.
|
|
2917
|
+
const effective = POL_EFFECTIVE_SUBJECT
|
|
2918
|
+
? (() => {
|
|
2919
|
+
const m = new Map();
|
|
2920
|
+
for (const role of (POL_EFFECTIVE_SUBJECT.roles || [])) {
|
|
2921
|
+
const gs = (POL_SNAPSHOT.policy && POL_SNAPSHOT.policy[role]) || [];
|
|
2922
|
+
for (const g of gs) {
|
|
2923
|
+
const k = g.resource + ':' + g.action;
|
|
2924
|
+
if (!m.has(k)) m.set(k, []);
|
|
2925
|
+
m.get(k).push(role);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
return m;
|
|
2929
|
+
})()
|
|
2930
|
+
: null;
|
|
2931
|
+
|
|
2932
|
+
const bodyRows = POL_RESOURCES.map((res) => {
|
|
2933
|
+
const cells = POL_ACTIONS.map((act) => {
|
|
2934
|
+
const key = res + ':' + act;
|
|
2935
|
+
if (effective) {
|
|
2936
|
+
const viaRoles = effective.get(key) || [];
|
|
2937
|
+
const ownGrant = granted.has(key);
|
|
2938
|
+
if (viaRoles.length === 0) {
|
|
2939
|
+
return `<td class="cell-empty" data-grant="false" data-effective="denied" title="denied — subject has no role granting ${escHtml(res)}:${escHtml(act)}">denied</td>`;
|
|
2940
|
+
}
|
|
2941
|
+
const first = viaRoles[0];
|
|
2942
|
+
const extra = viaRoles.length > 1 ? ` <span class="t-sm" style="opacity:.55">+${viaRoles.length - 1}</span>` : '';
|
|
2943
|
+
const ownHint = ownGrant ? ' data-via-selected="true"' : '';
|
|
2944
|
+
// Show all granting roles in the title for an audit trail.
|
|
2945
|
+
const titleRoles = viaRoles.map((r) => r).join(', ');
|
|
2946
|
+
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>`;
|
|
2947
|
+
}
|
|
2948
|
+
const has = granted.has(key);
|
|
2949
|
+
return has
|
|
2950
|
+
? `<td class="cell-grant" data-grant="true" title="${escHtml(res)}:${escHtml(act)} granted">✓</td>`
|
|
2951
|
+
: `<td class="cell-empty" data-grant="false">—</td>`;
|
|
2952
|
+
}).join('');
|
|
2953
|
+
return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
|
|
2954
|
+
}).join('');
|
|
2955
|
+
|
|
2956
|
+
// Effective-overlay bar — subject selector + clear control. Rendered
|
|
2957
|
+
// inside the detail panel so it survives polRolesRender re-renders
|
|
2958
|
+
// and stays visually attached to the matrix it modifies.
|
|
2959
|
+
const subjOpts = polEffectiveSubjectsList();
|
|
2960
|
+
const selectedKey = POL_EFFECTIVE_SUBJECT ? (POL_EFFECTIVE_SUBJECT.kind + ':' + POL_EFFECTIVE_SUBJECT.id) : '';
|
|
2961
|
+
const optEls = subjOpts.map((s) => {
|
|
2962
|
+
const v = s.kind + ':' + s.id;
|
|
2963
|
+
const sel = v === selectedKey ? ' selected' : '';
|
|
2964
|
+
return `<option value="${escHtml(v)}"${sel}>${escHtml(s.label)}</option>`;
|
|
2965
|
+
}).join('');
|
|
2966
|
+
const subjEmpty = subjOpts.length === 0;
|
|
2967
|
+
const overlayBar = `
|
|
2968
|
+
<div class="pol-effective-bar" style="display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);flex-wrap:wrap">
|
|
2969
|
+
<label class="t-sm" style="opacity:.7" for="pol-effective-subject">Show effective permissions for</label>
|
|
2970
|
+
<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">
|
|
2971
|
+
<option value="">(none — show role grants)</option>
|
|
2972
|
+
${optEls}
|
|
2973
|
+
</select>
|
|
2974
|
+
${POL_EFFECTIVE_SUBJECT
|
|
2975
|
+
? `<button class="btn btn-ghost btn-sm" onclick="polEffectiveOnChange({target:{value:''}})">Clear</button>
|
|
2976
|
+
<span class="t-sm" style="opacity:.65">via roles: ${(POL_EFFECTIVE_SUBJECT.roles || []).map((r) => `<code>${escHtml(r)}</code>`).join(', ') || '<em>none</em>'}</span>`
|
|
2977
|
+
: (subjEmpty ? '<span class="t-sm" style="opacity:.55">No subjects configured — set OMCP_USERS_FILE or OMCP_OIDC_ROLE_MAP to populate.</span>' : '')
|
|
2978
|
+
}
|
|
2979
|
+
</div>`;
|
|
2980
|
+
|
|
2981
|
+
const titleSuffix = POL_EFFECTIVE_SUBJECT
|
|
2982
|
+
? ` <span class="t-sm" style="opacity:.65;font-weight:400">— effective view for ${escHtml(POL_EFFECTIVE_SUBJECT.id)}</span>`
|
|
2983
|
+
: ` <span class="t-sm" style="opacity:.65;font-weight:400">${grants.length} grant${grants.length === 1 ? '' : 's'}</span>`;
|
|
2984
|
+
|
|
2985
|
+
const legend = POL_EFFECTIVE_SUBJECT
|
|
2986
|
+
? `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
|
|
2987
|
+
<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.
|
|
2988
|
+
</p>`
|
|
2989
|
+
: `<p class="t-sm" style="opacity:.65;margin-top:var(--sp-3)">
|
|
2990
|
+
✓ = the role grants this resource:action combination. — = no grant. The
|
|
2991
|
+
<em>redaction:bypass</em> column is the operator-side gate for the per-call
|
|
2992
|
+
bypass; the credential must also be allow-listed via OMCP_KEY_BYPASS_REDACTION.
|
|
2993
|
+
</p>`;
|
|
2994
|
+
|
|
2995
|
+
detailEl.innerHTML = `
|
|
2996
|
+
<h3>${escHtml(POL_SELECTED_ROLE)}${titleSuffix}</h3>
|
|
2997
|
+
${overlayBar}
|
|
2998
|
+
<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>
|
|
2999
|
+
${legend}
|
|
3000
|
+
`;
|
|
3001
|
+
}
|
|
3002
|
+
function polSelectRole(role) {
|
|
3003
|
+
POL_SELECTED_ROLE = role;
|
|
3004
|
+
polRolesRender();
|
|
3005
|
+
}
|
|
3006
|
+
|
|
3007
|
+
function polRoleAuthorNew() {
|
|
3008
|
+
// Author modal — render a permission-matrix as a checkbox grid
|
|
3009
|
+
// (rows = resources × cols = actions) plus a role-name input.
|
|
3010
|
+
const nameEl = document.getElementById('pol-role-author-name');
|
|
3011
|
+
const gridEl = document.getElementById('pol-role-author-grid');
|
|
3012
|
+
const errEl = document.getElementById('pol-role-author-error');
|
|
3013
|
+
if (nameEl) nameEl.value = '';
|
|
3014
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3015
|
+
if (gridEl) {
|
|
3016
|
+
const headerCells = POL_ACTIONS.map((a) => `<th>${escHtml(a)}</th>`).join('');
|
|
3017
|
+
const bodyRows = POL_RESOURCES.map((res) => {
|
|
3018
|
+
const cells = POL_ACTIONS.map((act) => `<td style="text-align:center">
|
|
3019
|
+
<input type="checkbox" data-pol-resource="${escHtml(res)}" data-pol-action="${escHtml(act)}" aria-label="${escHtml(res)}:${escHtml(act)}">
|
|
3020
|
+
</td>`).join('');
|
|
3021
|
+
return `<tr><th scope="row"><code>${escHtml(res)}</code></th>${cells}</tr>`;
|
|
3022
|
+
}).join('');
|
|
3023
|
+
gridEl.innerHTML = `<table class="pol-matrix"><thead><tr><th>Resource</th>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`;
|
|
3024
|
+
}
|
|
3025
|
+
document.getElementById('pol-role-author-modal').classList.add('open');
|
|
3026
|
+
setTimeout(() => nameEl && nameEl.focus(), 50);
|
|
3027
|
+
}
|
|
3028
|
+
async function polRoleAuthorSave() {
|
|
3029
|
+
const nameEl = document.getElementById('pol-role-author-name');
|
|
3030
|
+
const errEl = document.getElementById('pol-role-author-error');
|
|
3031
|
+
const setErr = (msg) => { if (errEl) { errEl.textContent = msg; errEl.style.display = ''; } };
|
|
3032
|
+
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
|
|
3033
|
+
const name = (nameEl && nameEl.value || '').trim();
|
|
3034
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name)) {
|
|
3035
|
+
setErr('Role name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
|
|
3036
|
+
nameEl && nameEl.focus();
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
const checks = Array.from(document.querySelectorAll('#pol-role-author-grid input[type=checkbox]:checked'));
|
|
3040
|
+
const perms = checks.map((el) => ({
|
|
3041
|
+
resource: el.getAttribute('data-pol-resource'),
|
|
3042
|
+
action: el.getAttribute('data-pol-action'),
|
|
3043
|
+
})).filter((p) => p.resource && p.action);
|
|
3044
|
+
try {
|
|
3045
|
+
const r = await fetch('/api/policy/roles/' + encodeURIComponent(name), {
|
|
3046
|
+
method: 'PUT',
|
|
3047
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3048
|
+
body: JSON.stringify({ permissions: perms }),
|
|
3049
|
+
});
|
|
3050
|
+
if (!r.ok) {
|
|
3051
|
+
const j = await r.json().catch(() => ({}));
|
|
3052
|
+
setErr(j.error || ('HTTP ' + r.status));
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
closeModal('pol-role-author-modal');
|
|
3056
|
+
toast('Saved role ' + name);
|
|
3057
|
+
// Re-fetch the policy snapshot so the Roles list + matrix
|
|
3058
|
+
// reflect the change without a page reload.
|
|
3059
|
+
POL_SUBJECTS = null;
|
|
3060
|
+
POL_SELECTED_ROLE = name;
|
|
3061
|
+
await loadPolicies();
|
|
3062
|
+
} catch (e) {
|
|
3063
|
+
setErr('Save failed: ' + (e && e.message || e));
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
async function loadPolicies() {
|
|
2025
3068
|
const rolesEl = document.getElementById('pol-roles');
|
|
2026
3069
|
try {
|
|
2027
3070
|
const r = await fetch('/api/policy');
|
|
2028
3071
|
if (!r.ok) {
|
|
2029
|
-
rolesEl.innerHTML = '
|
|
2030
|
-
|
|
3072
|
+
if (rolesEl) rolesEl.innerHTML = '';
|
|
3073
|
+
const listEl = document.getElementById('pol-role-list');
|
|
3074
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
3075
|
+
const msg = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
3076
|
+
if (listEl) listEl.innerHTML = msg;
|
|
3077
|
+
if (detailEl) detailEl.innerHTML = '';
|
|
3078
|
+
const engineEl = document.getElementById('pol-engine');
|
|
3079
|
+
if (engineEl) engineEl.textContent = '';
|
|
3080
|
+
const banner = document.getElementById('pol-engine-banner');
|
|
3081
|
+
if (banner) banner.hidden = true;
|
|
3082
|
+
// Clear the body attribute so a stale value from a prior session
|
|
3083
|
+
// (e.g. logged-out tab refresh) doesn't keep authoring controls
|
|
3084
|
+
// dimmed under the wrong engine assumption.
|
|
3085
|
+
document.body.removeAttribute('data-policy-engine');
|
|
2031
3086
|
return;
|
|
2032
3087
|
}
|
|
2033
3088
|
const j = await r.json();
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
3089
|
+
polRenderEngineBanner(j);
|
|
3090
|
+
POL_SNAPSHOT = j;
|
|
3091
|
+
// Invalidate subjects cache on each policy reload so the next
|
|
3092
|
+
// Subjects tab visit fetches fresh data (env vars / users file
|
|
3093
|
+
// may have changed since last visit).
|
|
3094
|
+
POL_SUBJECTS = null;
|
|
3095
|
+
// Stale overlay selection points at a subject that may no longer
|
|
3096
|
+
// exist (subjects cache will be repopulated on next visit) —
|
|
3097
|
+
// safest to drop it on every policy reload.
|
|
3098
|
+
POL_EFFECTIVE_SUBJECT = null;
|
|
3099
|
+
// Default sub-tab = Roles.
|
|
3100
|
+
if (!document.querySelector('.pol-subtab[data-active="true"]')) {
|
|
3101
|
+
polSetTab('roles');
|
|
3102
|
+
}
|
|
3103
|
+
polRolesRender();
|
|
3104
|
+
// Warm the subjects cache in the background so the effective-
|
|
3105
|
+
// permissions selector has options the first time an operator
|
|
3106
|
+
// opens it (no fetch-on-focus jank). Failure is silent — the
|
|
3107
|
+
// selector falls back to its empty-state copy.
|
|
3108
|
+
polEffectiveEnsureSubjects().then(() => {
|
|
3109
|
+
if (POL_SUBJECTS && document.getElementById('pol-effective-subject')) polRolesRender();
|
|
3110
|
+
});
|
|
3111
|
+
// Legacy snapshot card kept hidden — the matrix supersedes it.
|
|
3112
|
+
if (rolesEl) {
|
|
3113
|
+
const blocks = [];
|
|
3114
|
+
for (const role of (j.roles || [])) {
|
|
3115
|
+
const grants = (j.policy && j.policy[role]) || [];
|
|
3116
|
+
const byRes = {};
|
|
3117
|
+
for (const g of grants) {
|
|
3118
|
+
(byRes[g.resource] = byRes[g.resource] || []).push(g.action);
|
|
3119
|
+
}
|
|
3120
|
+
const rows = Object.entries(byRes).map(([res, actions]) =>
|
|
3121
|
+
`<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
|
|
3122
|
+
).join('');
|
|
3123
|
+
blocks.push(`
|
|
3124
|
+
<div style="padding: var(--sp-3) var(--sp-4)">
|
|
3125
|
+
<h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
|
|
3126
|
+
<span>${escHtml(role)}</span>
|
|
3127
|
+
<span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
|
|
3128
|
+
</h3>
|
|
3129
|
+
<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>
|
|
3130
|
+
</div>`);
|
|
2043
3131
|
}
|
|
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>`);
|
|
3132
|
+
rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
|
|
2059
3133
|
}
|
|
2060
3134
|
} catch (e) {
|
|
2061
|
-
|
|
3135
|
+
const msg = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3136
|
+
if (rolesEl) rolesEl.innerHTML = msg;
|
|
3137
|
+
const listEl = document.getElementById('pol-role-list');
|
|
3138
|
+
const detailEl = document.getElementById('pol-role-detail');
|
|
3139
|
+
if (listEl) listEl.innerHTML = msg;
|
|
3140
|
+
if (detailEl) detailEl.innerHTML = '';
|
|
2062
3141
|
}
|
|
2063
3142
|
}
|
|
2064
3143
|
async function polDryRun() {
|
|
@@ -2066,24 +3145,28 @@ async function polDryRun() {
|
|
|
2066
3145
|
const roles = document.getElementById('pol-dry-roles').value.trim();
|
|
2067
3146
|
const resource = document.getElementById('pol-dry-resource').value.trim();
|
|
2068
3147
|
const action = document.getElementById('pol-dry-action').value.trim();
|
|
3148
|
+
const tenant = document.getElementById('pol-dry-tenant').value.trim();
|
|
2069
3149
|
if (!resource || !action) {
|
|
2070
3150
|
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
|
|
2071
3151
|
return;
|
|
2072
3152
|
}
|
|
2073
3153
|
const params = new URLSearchParams({ resource, action });
|
|
2074
3154
|
if (roles) params.set('roles', roles);
|
|
3155
|
+
if (tenant) params.set('tenant', tenant);
|
|
2075
3156
|
try {
|
|
2076
3157
|
const r = await fetch('/api/policy?' + params.toString());
|
|
2077
3158
|
const j = await r.json();
|
|
2078
3159
|
const d = j.dryRun || {};
|
|
2079
|
-
const cls = d.allowed ? 'badge-ok' : 'badge-err';
|
|
2080
3160
|
const verdict = d.allowed ? 'allowed' : 'denied';
|
|
3161
|
+
const cls = d.allowed ? 'allow' : 'deny';
|
|
3162
|
+
const tenantTag = d.tenant ? ` <span class="tag t-sm">tenant: ${escHtml(d.tenant)}</span>` : '';
|
|
2081
3163
|
out.innerHTML = `
|
|
2082
|
-
<div
|
|
2083
|
-
<span class="
|
|
3164
|
+
<div class="pol-probe-result">
|
|
3165
|
+
<span class="pol-pv ${cls}" data-verdict="${verdict}">${verdict}</span>
|
|
2084
3166
|
<code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
|
|
3167
|
+
${tenantTag}
|
|
2085
3168
|
</div>
|
|
2086
|
-
<div class="form-hint" style="margin-top: var(--sp-
|
|
3169
|
+
<div class="form-hint" style="margin-top: var(--sp-1)">${escHtml(d.reason || '')}</div>`;
|
|
2087
3170
|
} catch (e) {
|
|
2088
3171
|
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2089
3172
|
}
|
|
@@ -3073,14 +4156,267 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
|
|
|
3073
4156
|
// --- Data Loading ---
|
|
3074
4157
|
async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
|
|
3075
4158
|
// --- MCP Products (new /api/products surface) ---
|
|
4159
|
+
// View mode (cards | table) for the Products catalog. Persisted in
|
|
4160
|
+
// localStorage so an operator's preference survives reloads.
|
|
4161
|
+
function mcpProductsView() {
|
|
4162
|
+
try { return localStorage.getItem('omcp-mcp-products-view') === 'table' ? 'table' : 'cards'; }
|
|
4163
|
+
catch (e) { return 'cards'; }
|
|
4164
|
+
}
|
|
4165
|
+
function mcpProductsSetView(v) {
|
|
4166
|
+
try { localStorage.setItem('omcp-mcp-products-view', v); } catch (e) { /* noop */ }
|
|
4167
|
+
loadMcpProducts();
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
// Leitfaden collapse state. Default expanded on first visit; once
|
|
4171
|
+
// the user collapses it the choice sticks. Idempotent — safe to
|
|
4172
|
+
// call before the DOM is ready (no-ops if the card isn't present).
|
|
4173
|
+
function mcpLeitfadenSync() {
|
|
4174
|
+
const card = document.getElementById('mcp-products-leitfaden');
|
|
4175
|
+
if (!card) return;
|
|
4176
|
+
let collapsed = false;
|
|
4177
|
+
try { collapsed = localStorage.getItem('omcp-mcp-leitfaden-collapsed') === '1'; } catch (e) { /* noop */ }
|
|
4178
|
+
card.classList.toggle('collapsed', collapsed);
|
|
4179
|
+
}
|
|
4180
|
+
function mcpLeitfadenToggle() {
|
|
4181
|
+
const card = document.getElementById('mcp-products-leitfaden');
|
|
4182
|
+
if (!card) return;
|
|
4183
|
+
const nextCollapsed = !card.classList.contains('collapsed');
|
|
4184
|
+
card.classList.toggle('collapsed', nextCollapsed);
|
|
4185
|
+
try { localStorage.setItem('omcp-mcp-leitfaden-collapsed', nextCollapsed ? '1' : '0'); } catch (e) { /* noop */ }
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// Cache the tool registry — fetched once on first modal open and
|
|
4189
|
+
// reused thereafter. Falsy means not-yet-loaded; an empty array
|
|
4190
|
+
// means the endpoint replied but with no tools (server bug — we
|
|
4191
|
+
// fall back to a textarea-style entry in that case).
|
|
4192
|
+
let MCP_TOOLS_REGISTRY = null;
|
|
4193
|
+
async function mcpLoadToolsRegistry() {
|
|
4194
|
+
if (MCP_TOOLS_REGISTRY) return MCP_TOOLS_REGISTRY;
|
|
4195
|
+
try {
|
|
4196
|
+
const r = await fetch('/api/tools/registry');
|
|
4197
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
4198
|
+
const j = await r.json();
|
|
4199
|
+
MCP_TOOLS_REGISTRY = Array.isArray(j.tools) ? j.tools : [];
|
|
4200
|
+
return MCP_TOOLS_REGISTRY;
|
|
4201
|
+
} catch (e) {
|
|
4202
|
+
// Surface but don't break the modal — fall back to []
|
|
4203
|
+
// (operator can still hand-edit via the textarea fallback).
|
|
4204
|
+
MCP_TOOLS_REGISTRY = [];
|
|
4205
|
+
return MCP_TOOLS_REGISTRY;
|
|
4206
|
+
}
|
|
4207
|
+
}
|
|
4208
|
+
function mcpRenderToolsPicker(selected) {
|
|
4209
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
4210
|
+
const textarea = document.getElementById('mcp-product-tools');
|
|
4211
|
+
if (!picker || !textarea) return;
|
|
4212
|
+
const tools = MCP_TOOLS_REGISTRY || [];
|
|
4213
|
+
if (tools.length === 0) {
|
|
4214
|
+
// Fallback: expose the textarea so the operator can still author
|
|
4215
|
+
// by hand. The server-side typo guard catches mistakes either way.
|
|
4216
|
+
textarea.hidden = false;
|
|
4217
|
+
textarea.value = (selected || []).join('\n');
|
|
4218
|
+
picker.innerHTML = '<div class="tools-picker-hint">Tool registry unavailable — type names one per line.</div>';
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
const selSet = new Set(selected || []);
|
|
4222
|
+
// Group by category, ordered. Keep an "(other)" bucket for any
|
|
4223
|
+
// future category we forgot in the CSS.
|
|
4224
|
+
const order = ['discovery', 'query', 'diagnose', 'topology'];
|
|
4225
|
+
const groups = {};
|
|
4226
|
+
for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
|
|
4227
|
+
const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
|
|
4228
|
+
const html = order.filter((c) => groups[c]).map((cat) => {
|
|
4229
|
+
const items = groups[cat].map((t) => {
|
|
4230
|
+
const checked = selSet.has(t.name) ? 'checked' : '';
|
|
4231
|
+
return `<label class="tp-item">
|
|
4232
|
+
<input type="checkbox" data-tool="${escHtml(t.name)}" ${checked} onchange="mcpToolsPickerSync()">
|
|
4233
|
+
<span class="tp-text">
|
|
4234
|
+
<span class="tp-name">${escHtml(t.name)}</span>
|
|
4235
|
+
<span class="tp-summary">${escHtml(t.summary)}</span>
|
|
4236
|
+
</span>
|
|
4237
|
+
</label>`;
|
|
4238
|
+
}).join('');
|
|
4239
|
+
return `<div class="tp-group">
|
|
4240
|
+
<div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
|
|
4241
|
+
${items}
|
|
4242
|
+
</div>`;
|
|
4243
|
+
}).join('');
|
|
4244
|
+
picker.innerHTML = `
|
|
4245
|
+
<div class="tp-actions">
|
|
4246
|
+
<button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(true)">Select all</button>
|
|
4247
|
+
<button type="button" class="btn btn-ghost" onclick="mcpToolsPickerAll(false)">Clear</button>
|
|
4248
|
+
</div>
|
|
4249
|
+
${html}
|
|
4250
|
+
`;
|
|
4251
|
+
// Keep the hidden textarea in sync with the initial selection.
|
|
4252
|
+
textarea.value = (selected || []).join('\n');
|
|
4253
|
+
}
|
|
4254
|
+
function mcpToolsPickerSync() {
|
|
4255
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
4256
|
+
const textarea = document.getElementById('mcp-product-tools');
|
|
4257
|
+
if (!picker || !textarea) return;
|
|
4258
|
+
const checked = Array.from(picker.querySelectorAll('input[type=checkbox][data-tool]:checked'))
|
|
4259
|
+
.map((el) => el.getAttribute('data-tool'))
|
|
4260
|
+
.filter(Boolean);
|
|
4261
|
+
textarea.value = checked.join('\n');
|
|
4262
|
+
}
|
|
4263
|
+
function mcpToolsPickerAll(on) {
|
|
4264
|
+
const picker = document.getElementById('mcp-product-tools-picker');
|
|
4265
|
+
if (!picker) return;
|
|
4266
|
+
picker.querySelectorAll('input[type=checkbox][data-tool]').forEach((el) => { el.checked = on; });
|
|
4267
|
+
mcpToolsPickerSync();
|
|
4268
|
+
}
|
|
4269
|
+
|
|
4270
|
+
// Empty-state product templates. Cloning a template prefills the
|
|
4271
|
+
// modal — the operator only has to pick an id + tweak before save.
|
|
4272
|
+
// Templates are pure UI; the server doesn't know about them.
|
|
4273
|
+
const MCP_PRODUCT_TEMPLATES = {
|
|
4274
|
+
ops: {
|
|
4275
|
+
title: 'Ops Bundle',
|
|
4276
|
+
desc: 'Incident-response tools for an on-call SRE agent.',
|
|
4277
|
+
product: {
|
|
4278
|
+
id: '', name: 'Ops Bundle',
|
|
4279
|
+
description: 'Incident-response tools for the on-call SRE agent.',
|
|
4280
|
+
status: 'staging',
|
|
4281
|
+
tools: ['query_logs', 'query_metrics', 'get_service_health', 'detect_anomalies'],
|
|
4282
|
+
},
|
|
4283
|
+
},
|
|
4284
|
+
dev: {
|
|
4285
|
+
title: 'Dev Bundle',
|
|
4286
|
+
desc: 'Discovery + topology tools for a coding agent. Read-only.',
|
|
4287
|
+
product: {
|
|
4288
|
+
id: '', name: 'Dev Bundle',
|
|
4289
|
+
description: 'Discovery + topology tools for a coding agent. Read-only.',
|
|
4290
|
+
status: 'staging',
|
|
4291
|
+
tools: ['list_sources', 'list_services', 'get_topology'],
|
|
4292
|
+
},
|
|
4293
|
+
},
|
|
4294
|
+
compliance: {
|
|
4295
|
+
title: 'Compliance Bundle',
|
|
4296
|
+
desc: 'Logs query only — for an audit agent.',
|
|
4297
|
+
product: {
|
|
4298
|
+
id: '', name: 'Compliance Bundle',
|
|
4299
|
+
description: 'Logs query only — for an audit agent.',
|
|
4300
|
+
status: 'staging',
|
|
4301
|
+
tools: ['query_logs'],
|
|
4302
|
+
},
|
|
4303
|
+
},
|
|
4304
|
+
blank: {
|
|
4305
|
+
title: 'Blank',
|
|
4306
|
+
desc: 'Start from scratch.',
|
|
4307
|
+
product: { id: '', name: '', status: 'staging' },
|
|
4308
|
+
},
|
|
4309
|
+
};
|
|
4310
|
+
function mcpProductTemplate(key) {
|
|
4311
|
+
const t = MCP_PRODUCT_TEMPLATES[key];
|
|
4312
|
+
if (!t) return;
|
|
4313
|
+
mcpProductOpen('new', t.product);
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4316
|
+
function mcpProductCardHtml(p) {
|
|
4317
|
+
const accent = (p.branding && typeof p.branding.color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(p.branding.color))
|
|
4318
|
+
? p.branding.color : 'var(--accent)';
|
|
4319
|
+
const icon = (p.branding && typeof p.branding.iconUrl === 'string' && /^https?:\/\//.test(p.branding.iconUrl))
|
|
4320
|
+
? `<img src="${escHtml(p.branding.iconUrl)}" alt="" onerror="this.style.display='none'">`
|
|
4321
|
+
: '◫';
|
|
4322
|
+
const status = p.status === 'staging'
|
|
4323
|
+
? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)" title="Hidden from non-admin agents">staging</span>'
|
|
4324
|
+
: '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
|
|
4325
|
+
const meta = [p.id, p.version ? 'v' + p.version : null, 'tenant: ' + (p.tenant || 'default')]
|
|
4326
|
+
.filter(Boolean).map(escHtml).join(' · ');
|
|
4327
|
+
const tools = (p.tools || []);
|
|
4328
|
+
const toolsLabel = tools.length === 0
|
|
4329
|
+
? '<span class="pcard-tool-all">no filter — every registered tool</span>'
|
|
4330
|
+
: tools.slice(0, 8).map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('') +
|
|
4331
|
+
(tools.length > 8 ? `<span class="pcard-tool">+${tools.length - 8} more</span>` : '');
|
|
4332
|
+
const desc = p.description
|
|
4333
|
+
? escHtml(p.description)
|
|
4334
|
+
: '<span style="opacity:.5">No description.</span>';
|
|
4335
|
+
const idAttr = encodeURIComponent(p.id);
|
|
4336
|
+
return `<div class="pcard">
|
|
4337
|
+
<div class="pcard-rail" style="background:${accent}"></div>
|
|
4338
|
+
<div class="pcard-hd">
|
|
4339
|
+
<div class="pcard-icon" style="color:${accent}">${icon}</div>
|
|
4340
|
+
<div class="pcard-title">
|
|
4341
|
+
<h3>${escHtml(p.name)}</h3>
|
|
4342
|
+
<div class="pcard-meta">${meta}</div>
|
|
4343
|
+
</div>
|
|
4344
|
+
<div class="pcard-status">${status}</div>
|
|
4345
|
+
</div>
|
|
4346
|
+
<div class="pcard-desc">${desc}</div>
|
|
4347
|
+
<div>
|
|
4348
|
+
<div class="pcard-tools-label">Tools (${tools.length || 'unrestricted'})</div>
|
|
4349
|
+
<div class="pcard-tools">${toolsLabel}</div>
|
|
4350
|
+
</div>
|
|
4351
|
+
<div class="pcard-footer">
|
|
4352
|
+
<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>
|
|
4353
|
+
<div class="pcard-spacer"></div>
|
|
4354
|
+
<button class="btn-icon" data-rbac="products:write" title="Edit" aria-label="Edit ${escHtml(p.id)}" onclick="mcpProductsEdit('${idAttr}')">✎</button>
|
|
4355
|
+
<button class="btn-icon" data-rbac="products:delete" title="Delete" aria-label="Delete ${escHtml(p.id)}" onclick="mcpProductsDelete('${idAttr}')">🗑</button>
|
|
4356
|
+
</div>
|
|
4357
|
+
</div>`;
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
function mcpProductTableHtml(products) {
|
|
4361
|
+
const rows = products.map((p) => {
|
|
4362
|
+
const status = p.status === 'staging'
|
|
4363
|
+
? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
|
|
4364
|
+
: '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
|
|
4365
|
+
const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
|
|
4366
|
+
const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
|
|
4367
|
+
const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
|
|
4368
|
+
return `<tr>
|
|
4369
|
+
<td><code>${escHtml(p.id)}</code></td>
|
|
4370
|
+
<td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
|
|
4371
|
+
<td>${tenantCell}</td>
|
|
4372
|
+
<td>${status}</td>
|
|
4373
|
+
<td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
|
|
4374
|
+
<td style="text-align:right">
|
|
4375
|
+
<button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">✎</button>
|
|
4376
|
+
<button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">🗑</button>
|
|
4377
|
+
</td>
|
|
4378
|
+
</tr>`;
|
|
4379
|
+
}).join('');
|
|
4380
|
+
return `<table class="data-table" style="width:100%">
|
|
4381
|
+
<thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
|
|
4382
|
+
<tbody>${rows}</tbody>
|
|
4383
|
+
</table>`;
|
|
4384
|
+
}
|
|
4385
|
+
|
|
4386
|
+
function mcpProductEmptyHtml(configured) {
|
|
4387
|
+
const tpls = ['ops', 'dev', 'compliance', 'blank']
|
|
4388
|
+
.map((k) => {
|
|
4389
|
+
const t = MCP_PRODUCT_TEMPLATES[k];
|
|
4390
|
+
return `<button class="pempty-tpl" data-rbac="products:write" onclick="mcpProductTemplate('${k}')">
|
|
4391
|
+
<div class="pempty-tpl-title">${escHtml(t.title)}</div>
|
|
4392
|
+
<div class="pempty-tpl-desc">${escHtml(t.desc)}</div>
|
|
4393
|
+
</button>`;
|
|
4394
|
+
}).join('');
|
|
4395
|
+
const persistedHint = configured
|
|
4396
|
+
? 'Changes here persist to <code>OMCP_PRODUCTS_FILE</code>.'
|
|
4397
|
+
: '<code>OMCP_PRODUCTS_FILE</code> is unset — changes live in memory only and won\'t survive a restart.';
|
|
4398
|
+
return `<div class="pempty">
|
|
4399
|
+
<h3>No products yet</h3>
|
|
4400
|
+
<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>
|
|
4401
|
+
<div class="pempty-templates" data-rbac="products:write">${tpls}</div>
|
|
4402
|
+
<p class="t-sm" style="margin-top:var(--sp-4);opacity:.7">${persistedHint}</p>
|
|
4403
|
+
</div>`;
|
|
4404
|
+
}
|
|
4405
|
+
|
|
3076
4406
|
async function loadMcpProducts() {
|
|
4407
|
+
mcpLeitfadenSync();
|
|
3077
4408
|
const box = document.getElementById('mcp-products-box');
|
|
3078
4409
|
const scope = document.getElementById('mcp-products-scope');
|
|
3079
4410
|
if (!box) return;
|
|
4411
|
+
// Sync view-toggle active state.
|
|
4412
|
+
const v = mcpProductsView();
|
|
4413
|
+
const btnC = document.getElementById('mcp-pv-cards');
|
|
4414
|
+
const btnT = document.getElementById('mcp-pv-table');
|
|
4415
|
+
if (btnC) btnC.classList.toggle('active', v === 'cards');
|
|
4416
|
+
if (btnT) btnT.classList.toggle('active', v === 'table');
|
|
3080
4417
|
try {
|
|
3081
4418
|
const r = await fetch('/api/products');
|
|
3082
4419
|
if (!r.ok) {
|
|
3083
|
-
// 403 = no products:read; render a friendly hint instead of an empty list
|
|
3084
4420
|
if (r.status === 403) {
|
|
3085
4421
|
box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
|
|
3086
4422
|
} else {
|
|
@@ -3094,59 +4430,312 @@ async function loadMcpProducts() {
|
|
|
3094
4430
|
scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
|
|
3095
4431
|
}
|
|
3096
4432
|
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>';
|
|
4433
|
+
box.innerHTML = mcpProductEmptyHtml(j.configured);
|
|
4434
|
+
omcpApplyRbacToDom();
|
|
3101
4435
|
return;
|
|
3102
4436
|
}
|
|
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>`;
|
|
4437
|
+
if (v === 'table') {
|
|
4438
|
+
box.innerHTML = mcpProductTableHtml(j.products);
|
|
4439
|
+
} else {
|
|
4440
|
+
box.innerHTML = `<div class="pcard-grid">${j.products.map(mcpProductCardHtml).join('')}</div>`;
|
|
4441
|
+
}
|
|
3126
4442
|
omcpApplyRbacToDom();
|
|
3127
4443
|
} catch (e) {
|
|
3128
4444
|
box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3129
4445
|
}
|
|
3130
4446
|
}
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
4447
|
+
// Form-driven Product modal — replaces the old chain of window.prompt
|
|
4448
|
+
// dialogs. Exposes id + name + description + status + tenant + tools
|
|
4449
|
+
// + version + branding in one place, validated client-side before
|
|
4450
|
+
// the server's strict parser sees it.
|
|
4451
|
+
// Wizard navigation state — current step (1..4) for the active
|
|
4452
|
+
// modal session. Reset on every mcpProductOpen call.
|
|
4453
|
+
let MCP_WIZ_STEP = 1;
|
|
4454
|
+
const MCP_WIZ_LAST = 4;
|
|
4455
|
+
|
|
4456
|
+
function mcpWizGoto(step) {
|
|
4457
|
+
if (step < 1 || step > MCP_WIZ_LAST) return;
|
|
4458
|
+
// Validate the current step before allowing forward navigation —
|
|
4459
|
+
// backwards / sidewards navigation always allowed (the operator
|
|
4460
|
+
// may want to revise).
|
|
4461
|
+
if (step > MCP_WIZ_STEP && !mcpWizValidateStep(MCP_WIZ_STEP)) return;
|
|
4462
|
+
MCP_WIZ_STEP = step;
|
|
4463
|
+
mcpWizRender();
|
|
4464
|
+
}
|
|
4465
|
+
function mcpWizNext() {
|
|
4466
|
+
if (MCP_WIZ_STEP < MCP_WIZ_LAST) mcpWizGoto(MCP_WIZ_STEP + 1);
|
|
4467
|
+
}
|
|
4468
|
+
function mcpWizBack() {
|
|
4469
|
+
if (MCP_WIZ_STEP > 1) mcpWizGoto(MCP_WIZ_STEP - 1);
|
|
4470
|
+
}
|
|
4471
|
+
function mcpWizValidateStep(step) {
|
|
4472
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
4473
|
+
errEl.style.display = 'none';
|
|
4474
|
+
errEl.textContent = '';
|
|
4475
|
+
if (step === 1) {
|
|
4476
|
+
const id = document.getElementById('mcp-product-id').value.trim();
|
|
4477
|
+
const name = document.getElementById('mcp-product-name').value.trim();
|
|
4478
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
|
|
4479
|
+
errEl.textContent = 'Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.';
|
|
4480
|
+
errEl.style.display = '';
|
|
4481
|
+
document.getElementById('mcp-product-id').focus();
|
|
4482
|
+
return false;
|
|
4483
|
+
}
|
|
4484
|
+
if (!name) {
|
|
4485
|
+
errEl.textContent = 'Display name is required.';
|
|
4486
|
+
errEl.style.display = '';
|
|
4487
|
+
document.getElementById('mcp-product-name').focus();
|
|
4488
|
+
return false;
|
|
4489
|
+
}
|
|
4490
|
+
}
|
|
4491
|
+
// Step 2 (tools) + step 3 (scope/branding) have no required fields.
|
|
4492
|
+
return true;
|
|
4493
|
+
}
|
|
4494
|
+
function mcpWizRender() {
|
|
4495
|
+
// Toggle pane visibility.
|
|
4496
|
+
for (let i = 1; i <= MCP_WIZ_LAST; i++) {
|
|
4497
|
+
const pane = document.getElementById('wiz-pane-' + i);
|
|
4498
|
+
if (pane) pane.hidden = i !== MCP_WIZ_STEP;
|
|
4499
|
+
}
|
|
4500
|
+
// Update stepper bullets — active = current, done = all earlier.
|
|
4501
|
+
const stepper = document.getElementById('mcp-wiz-stepper');
|
|
4502
|
+
if (stepper) {
|
|
4503
|
+
stepper.querySelectorAll('.wiz-step-btn').forEach((btn) => {
|
|
4504
|
+
const n = parseInt(btn.getAttribute('data-step') || '0', 10);
|
|
4505
|
+
btn.setAttribute('data-active', String(n === MCP_WIZ_STEP));
|
|
4506
|
+
btn.setAttribute('data-done', String(n < MCP_WIZ_STEP));
|
|
4507
|
+
btn.setAttribute('aria-selected', String(n === MCP_WIZ_STEP));
|
|
4508
|
+
});
|
|
4509
|
+
}
|
|
4510
|
+
// Footer button visibility.
|
|
4511
|
+
const back = document.getElementById('mcp-wiz-back');
|
|
4512
|
+
const next = document.getElementById('mcp-wiz-next');
|
|
4513
|
+
const save = document.getElementById('mcp-wiz-save');
|
|
4514
|
+
if (back) back.hidden = MCP_WIZ_STEP === 1;
|
|
4515
|
+
if (next) next.hidden = MCP_WIZ_STEP === MCP_WIZ_LAST;
|
|
4516
|
+
if (save) save.hidden = MCP_WIZ_STEP !== MCP_WIZ_LAST;
|
|
4517
|
+
// Render the review summary lazily when entering step 4.
|
|
4518
|
+
if (MCP_WIZ_STEP === MCP_WIZ_LAST) mcpWizRenderReview();
|
|
4519
|
+
}
|
|
4520
|
+
function mcpWizRenderReview() {
|
|
4521
|
+
const out = document.getElementById('mcp-wiz-review');
|
|
4522
|
+
if (!out) return;
|
|
4523
|
+
const v = (id) => (document.getElementById(id).value || '').trim();
|
|
4524
|
+
const id = v('mcp-product-id');
|
|
4525
|
+
const name = v('mcp-product-name');
|
|
4526
|
+
const desc = v('mcp-product-description');
|
|
4527
|
+
const status = v('mcp-product-status');
|
|
4528
|
+
const tenant = v('mcp-product-tenant');
|
|
4529
|
+
const version = v('mcp-product-version');
|
|
4530
|
+
const color = v('mcp-product-color');
|
|
4531
|
+
const icon = v('mcp-product-icon');
|
|
4532
|
+
const toolsRaw = (document.getElementById('mcp-product-tools').value || '');
|
|
4533
|
+
const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
4534
|
+
const empty = (s) => s ? escHtml(s) : '<span class="wiz-review-empty">—</span>';
|
|
4535
|
+
// Mirror mcpProductCardHtml's colour-validation regex so the
|
|
4536
|
+
// inline style attribute can't carry an arbitrary CSS value. Even
|
|
4537
|
+
// though escHtml prevents attribute breakout, restricting the
|
|
4538
|
+
// value to a hex literal makes a CSS-context injection
|
|
4539
|
+
// structurally impossible (defence-in-depth — reviewer-agent flag).
|
|
4540
|
+
const safeColor = /^#[0-9a-fA-F]{3,8}$/.test(color) ? color : null;
|
|
4541
|
+
const colorCell = !color
|
|
4542
|
+
? '<span class="wiz-review-empty">— (no brand colour)</span>'
|
|
4543
|
+
: safeColor
|
|
4544
|
+
? `<span class="wiz-review-swatch" style="background:${safeColor}"></span><code>${escHtml(color)}</code>`
|
|
4545
|
+
: `<code>${escHtml(color)}</code> <span class="t-sm" style="opacity:.6">(not a hex value)</span>`;
|
|
4546
|
+
const toolsCell = tools.length === 0
|
|
4547
|
+
? '<span class="wiz-review-empty">no filter — every registered tool</span>'
|
|
4548
|
+
: `<div class="wiz-review-tools">${tools.map((t) => `<span class="pcard-tool">${escHtml(t)}</span>`).join('')}</div>`;
|
|
4549
|
+
out.innerHTML = `<dl class="wiz-review-grid">
|
|
4550
|
+
<dt>Id</dt><dd><code>${empty(id)}</code></dd>
|
|
4551
|
+
<dt>Name</dt><dd>${empty(name)}</dd>
|
|
4552
|
+
<dt>Description</dt><dd>${empty(desc)}</dd>
|
|
4553
|
+
<dt>Status</dt><dd><span class="pill">${empty(status)}</span></dd>
|
|
4554
|
+
<dt>Tenant</dt><dd>${tenant ? escHtml(tenant) : '<span class="wiz-review-empty">(caller\'s tenant)</span>'}</dd>
|
|
4555
|
+
<dt>Tools</dt><dd>${toolsCell}</dd>
|
|
4556
|
+
<dt>Version</dt><dd>${empty(version)}</dd>
|
|
4557
|
+
<dt>Colour</dt><dd>${colorCell}</dd>
|
|
4558
|
+
<dt>Icon URL</dt><dd>${icon ? `<code>${escHtml(icon)}</code>` : '<span class="wiz-review-empty">—</span>'}</dd>
|
|
4559
|
+
</dl>
|
|
4560
|
+
<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>
|
|
4561
|
+
<div id="mcp-wiz-agent-preview" class="wiz-agent-preview"><div class="t-sm" style="opacity:.7">${tools.length === 0
|
|
4562
|
+
? 'No filter set. Agent would see every registered tool — load the live registry to confirm.'
|
|
4563
|
+
: `Filter active. Agent would see ${tools.length} tool${tools.length===1?'':'s'} from the allow-list.`}</div></div>`;
|
|
4564
|
+
// Resolve the preview from the live tool registry — same source the
|
|
4565
|
+
// server uses to filter /mcp tools/list. We don't hit
|
|
4566
|
+
// /api/products/:id/preview here because the wizard's form-state
|
|
4567
|
+
// hasn't been saved yet (the id may not exist on the server). The
|
|
4568
|
+
// resolution mirrors allowsTool's semantics exactly.
|
|
4569
|
+
mcpLoadToolsRegistry().then(() => {
|
|
4570
|
+
const previewEl = document.getElementById('mcp-wiz-agent-preview');
|
|
4571
|
+
if (!previewEl) return;
|
|
4572
|
+
const allow = new Set(tools);
|
|
4573
|
+
const filtered = (MCP_TOOLS_REGISTRY || []).filter((t) => tools.length === 0 || allow.has(t.name));
|
|
4574
|
+
previewEl.innerHTML = mcpAgentPreviewHtml(filtered, tools.length === 0);
|
|
4575
|
+
});
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
// Shared renderer for the agent-preview list — used by the wizard
|
|
4579
|
+
// Review pane and by the per-card "Preview as agent" action.
|
|
4580
|
+
function mcpAgentPreviewHtml(tools, unrestricted) {
|
|
4581
|
+
if (!tools || tools.length === 0) {
|
|
4582
|
+
return '<div class="t-sm" style="opacity:.7">No tools available.</div>';
|
|
4583
|
+
}
|
|
4584
|
+
const order = ['discovery', 'query', 'diagnose', 'topology'];
|
|
4585
|
+
const groups = {};
|
|
4586
|
+
for (const t of tools) (groups[t.category] = groups[t.category] || []).push(t);
|
|
4587
|
+
const labels = { discovery: 'Discovery', query: 'Query', diagnose: 'Diagnose', topology: 'Topology' };
|
|
4588
|
+
const banner = unrestricted
|
|
4589
|
+
? '<div class="wiz-agent-banner" data-unrestricted="true">Unrestricted — the bound agent sees every registered tool below.</div>'
|
|
4590
|
+
: '';
|
|
4591
|
+
const html = order.filter((c) => groups[c]).map((cat) => {
|
|
4592
|
+
const items = groups[cat].map((t) => `
|
|
4593
|
+
<div class="wiz-agent-tool">
|
|
4594
|
+
<div class="wiz-agent-tool-name">${escHtml(t.name)}</div>
|
|
4595
|
+
<div class="wiz-agent-tool-summary">${escHtml(t.summary)}</div>
|
|
4596
|
+
</div>`).join('');
|
|
4597
|
+
return `<div class="wiz-agent-group">
|
|
4598
|
+
<div class="tp-cat">${escHtml(labels[cat] || cat)}</div>
|
|
4599
|
+
${items}
|
|
4600
|
+
</div>`;
|
|
4601
|
+
}).join('');
|
|
4602
|
+
return banner + html;
|
|
3137
4603
|
}
|
|
4604
|
+
|
|
4605
|
+
// "Preview as agent" — called from the per-card button. Hits the
|
|
4606
|
+
// authoritative server endpoint so the operator sees what the bound
|
|
4607
|
+
// agent actually receives in production, not a client-side guess.
|
|
4608
|
+
async function mcpProductPreviewAgent(idEnc) {
|
|
4609
|
+
const id = decodeURIComponent(idEnc);
|
|
4610
|
+
let body;
|
|
4611
|
+
try {
|
|
4612
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id) + '/preview');
|
|
4613
|
+
if (!r.ok) { toast('Preview unavailable: HTTP ' + r.status); return; }
|
|
4614
|
+
body = await r.json();
|
|
4615
|
+
} catch (e) { toast('Preview failed: ' + (e && e.message || e)); return; }
|
|
4616
|
+
const accent = (body.product && body.product.branding && /^#[0-9a-fA-F]{3,8}$/.test(body.product.branding.color || ''))
|
|
4617
|
+
? body.product.branding.color : 'var(--accent)';
|
|
4618
|
+
const dlg = document.getElementById('mcp-agent-preview-modal');
|
|
4619
|
+
const title = document.getElementById('mcp-agent-preview-title');
|
|
4620
|
+
const meta = document.getElementById('mcp-agent-preview-meta');
|
|
4621
|
+
const list = document.getElementById('mcp-agent-preview-list');
|
|
4622
|
+
if (!dlg || !title || !meta || !list) return;
|
|
4623
|
+
title.textContent = body.product.name || body.product.id;
|
|
4624
|
+
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')}`;
|
|
4625
|
+
list.innerHTML = mcpAgentPreviewHtml(body.tools || [], !!body.unrestricted);
|
|
4626
|
+
// Apply branding to the modal's left rail for a quick at-a-glance
|
|
4627
|
+
// identity confirmation that the right product was probed.
|
|
4628
|
+
dlg.style.setProperty('--mcp-agent-accent', accent);
|
|
4629
|
+
dlg.classList.add('open');
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
function mcpProductOpen(mode, current) {
|
|
4633
|
+
const idEl = document.getElementById('mcp-product-id');
|
|
4634
|
+
const nameEl = document.getElementById('mcp-product-name');
|
|
4635
|
+
const descEl = document.getElementById('mcp-product-description');
|
|
4636
|
+
const statusEl = document.getElementById('mcp-product-status');
|
|
4637
|
+
const tenantEl = document.getElementById('mcp-product-tenant');
|
|
4638
|
+
const toolsEl = document.getElementById('mcp-product-tools');
|
|
4639
|
+
const verEl = document.getElementById('mcp-product-version');
|
|
4640
|
+
const colorEl = document.getElementById('mcp-product-color');
|
|
4641
|
+
const iconEl = document.getElementById('mcp-product-icon');
|
|
4642
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
4643
|
+
const titleEl = document.getElementById('mcp-product-modal-title');
|
|
4644
|
+
document.getElementById('mcp-product-mode').value = mode;
|
|
4645
|
+
document.getElementById('mcp-product-original-id').value = (current && current.id) || '';
|
|
4646
|
+
errEl.style.display = 'none';
|
|
4647
|
+
errEl.textContent = '';
|
|
4648
|
+
if (mode === 'edit' && current) {
|
|
4649
|
+
titleEl.textContent = 'Edit Product · ' + current.id;
|
|
4650
|
+
idEl.value = current.id;
|
|
4651
|
+
idEl.disabled = true;
|
|
4652
|
+
nameEl.value = current.name || '';
|
|
4653
|
+
descEl.value = current.description || '';
|
|
4654
|
+
statusEl.value = current.status || 'published';
|
|
4655
|
+
tenantEl.value = current.tenant || '';
|
|
4656
|
+
toolsEl.value = (current.tools || []).join('\n');
|
|
4657
|
+
verEl.value = current.version || '';
|
|
4658
|
+
colorEl.value = (current.branding && current.branding.color) || '';
|
|
4659
|
+
iconEl.value = (current.branding && current.branding.iconUrl) || '';
|
|
4660
|
+
} else {
|
|
4661
|
+
// 'new' mode. When the caller hands us a partial `current`
|
|
4662
|
+
// (the empty-state templates do this), prefill every field
|
|
4663
|
+
// they supplied — the operator only has to pick an id + tweak.
|
|
4664
|
+
// Falsy `current` zeroes everything (legacy "fresh modal" path).
|
|
4665
|
+
const c = current || {};
|
|
4666
|
+
titleEl.textContent = 'New Product';
|
|
4667
|
+
idEl.value = c.id || '';
|
|
4668
|
+
idEl.disabled = false;
|
|
4669
|
+
nameEl.value = c.name || '';
|
|
4670
|
+
descEl.value = c.description || '';
|
|
4671
|
+
statusEl.value = c.status || 'staging';
|
|
4672
|
+
tenantEl.value = c.tenant || '';
|
|
4673
|
+
toolsEl.value = (c.tools || []).join('\n');
|
|
4674
|
+
verEl.value = c.version || '';
|
|
4675
|
+
colorEl.value = (c.branding && c.branding.color) || '';
|
|
4676
|
+
iconEl.value = (c.branding && c.branding.iconUrl) || '';
|
|
4677
|
+
}
|
|
4678
|
+
document.getElementById('mcp-product-modal').classList.add('open');
|
|
4679
|
+
// Reset wizard state to step 1 on every open. Edit mode could
|
|
4680
|
+
// start on step 4 (Review), but starting at step 1 keeps the UX
|
|
4681
|
+
// uniform — an editor stepping through their own config catches
|
|
4682
|
+
// surprises.
|
|
4683
|
+
MCP_WIZ_STEP = 1;
|
|
4684
|
+
mcpWizRender();
|
|
4685
|
+
// Build the tools picker from the registry — fetched once on the
|
|
4686
|
+
// first modal open and cached client-side. Selected = whatever was
|
|
4687
|
+
// already in the (potentially template-prefilled) textarea.
|
|
4688
|
+
mcpLoadToolsRegistry().then(() => {
|
|
4689
|
+
const seeded = (toolsEl.value || '').split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
4690
|
+
mcpRenderToolsPicker(seeded);
|
|
4691
|
+
});
|
|
4692
|
+
// When opening from a template the id is the one thing the operator
|
|
4693
|
+
// hasn't picked yet; in every other case (edit, blank new) the name
|
|
4694
|
+
// is the most natural focus target.
|
|
4695
|
+
const focusEl = (mode === 'edit') ? nameEl
|
|
4696
|
+
: (current && (current.name || (current.tools && current.tools.length))) ? idEl
|
|
4697
|
+
: idEl;
|
|
4698
|
+
setTimeout(() => focusEl.focus(), 50);
|
|
4699
|
+
}
|
|
4700
|
+
async function mcpProductsNew() { mcpProductOpen('new'); }
|
|
3138
4701
|
async function mcpProductsEdit(idEnc) {
|
|
3139
4702
|
const id = decodeURIComponent(idEnc);
|
|
3140
4703
|
const r = await fetch('/api/products/' + encodeURIComponent(id));
|
|
3141
4704
|
if (!r.ok) { toast('Could not fetch ' + id); return; }
|
|
3142
4705
|
const current = await r.json();
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
4706
|
+
mcpProductOpen('edit', current);
|
|
4707
|
+
}
|
|
4708
|
+
async function mcpProductSave() {
|
|
4709
|
+
const mode = document.getElementById('mcp-product-mode').value;
|
|
4710
|
+
const id = document.getElementById('mcp-product-id').value.trim();
|
|
4711
|
+
const name = document.getElementById('mcp-product-name').value.trim();
|
|
4712
|
+
const errEl = document.getElementById('mcp-product-error');
|
|
4713
|
+
function fail(msg) { errEl.textContent = msg; errEl.style.display = ''; }
|
|
4714
|
+
// Mirror the server-side ID_RE so the user sees the rule before the
|
|
4715
|
+
// round-trip — server is still the source of truth for rejection.
|
|
4716
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id)) {
|
|
4717
|
+
fail('Id must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}.');
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
if (!name) { fail('Display name is required.'); return; }
|
|
4721
|
+
const body = { id, name, status: document.getElementById('mcp-product-status').value };
|
|
4722
|
+
const desc = document.getElementById('mcp-product-description').value.trim();
|
|
4723
|
+
if (desc) body.description = desc;
|
|
4724
|
+
const tenant = document.getElementById('mcp-product-tenant').value.trim();
|
|
4725
|
+
if (tenant) body.tenant = tenant;
|
|
4726
|
+
// Split on newlines or commas so paste-from-a-list works either way.
|
|
4727
|
+
const toolsRaw = document.getElementById('mcp-product-tools').value || '';
|
|
4728
|
+
const tools = toolsRaw.split(/[\n,]+/).map((s) => s.trim()).filter(Boolean);
|
|
4729
|
+
if (tools.length > 0) body.tools = tools;
|
|
4730
|
+
const version = document.getElementById('mcp-product-version').value.trim();
|
|
4731
|
+
if (version) body.version = version;
|
|
4732
|
+
const color = document.getElementById('mcp-product-color').value.trim();
|
|
4733
|
+
const icon = document.getElementById('mcp-product-icon').value.trim();
|
|
4734
|
+
if (color || icon) {
|
|
4735
|
+
body.branding = {};
|
|
4736
|
+
if (icon) body.branding.iconUrl = icon;
|
|
4737
|
+
if (color) body.branding.color = color;
|
|
4738
|
+
}
|
|
3150
4739
|
try {
|
|
3151
4740
|
const r = await fetch('/api/products/' + encodeURIComponent(id), {
|
|
3152
4741
|
method: 'PUT',
|
|
@@ -3155,12 +4744,13 @@ async function mcpProductsUpsert(id, body) {
|
|
|
3155
4744
|
});
|
|
3156
4745
|
if (!r.ok) {
|
|
3157
4746
|
const j = await r.json().catch(() => ({}));
|
|
3158
|
-
|
|
4747
|
+
fail(j.error || ('Save failed: HTTP ' + r.status));
|
|
3159
4748
|
return;
|
|
3160
4749
|
}
|
|
3161
|
-
|
|
4750
|
+
closeModal('mcp-product-modal');
|
|
4751
|
+
toast((mode === 'edit' ? 'Updated ' : 'Created ') + id);
|
|
3162
4752
|
await loadMcpProducts();
|
|
3163
|
-
} catch (e) {
|
|
4753
|
+
} catch (e) { fail('Save failed: ' + (e && e.message || e)); }
|
|
3164
4754
|
}
|
|
3165
4755
|
async function mcpProductsDelete(idEnc) {
|
|
3166
4756
|
const id = decodeURIComponent(idEnc);
|
|
@@ -3378,7 +4968,10 @@ function sortIndClass(key){
|
|
|
3378
4968
|
|
|
3379
4969
|
function srcRow(s){
|
|
3380
4970
|
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
3381
|
-
|
|
4971
|
+
// Show a tenant tag only when set — single-tenant deployments
|
|
4972
|
+
// stay visually identical to the pre-tenant rows.
|
|
4973
|
+
const tenantTag = s.tenant ? `<span class="tag" title="Tenant ${esc(s.tenant)}">${esc(s.tenant)}</span>` : '';
|
|
4974
|
+
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
4975
|
}
|
|
3383
4976
|
function renderSources() {
|
|
3384
4977
|
const emptyDash = richEmpty({
|
|
@@ -3407,13 +5000,22 @@ function renderSources() {
|
|
|
3407
5000
|
const filterBar=`<div class="list-filter">
|
|
3408
5001
|
<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
5002
|
<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>
|
|
5003
|
+
<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
5004
|
</div>`;
|
|
3412
5005
|
let body;
|
|
3413
5006
|
if(view==='table'){
|
|
3414
5007
|
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
|
-
|
|
5008
|
+
// Show the tenant column only when at least one source is
|
|
5009
|
+
// tagged — single-tenant deployments stay uncluttered.
|
|
5010
|
+
const anyTenant = visible.some(s => s.tenant);
|
|
5011
|
+
const tenantHead = anyTenant ? h('tenant','Tenant') : '';
|
|
5012
|
+
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>`+
|
|
5013
|
+
visible.map(s=>{
|
|
5014
|
+
const tenantCell = anyTenant
|
|
5015
|
+
? `<td>${s.tenant ? `<span class="badge">${esc(s.tenant)}</span>` : '<span class="t-muted">global</span>'}</td>`
|
|
5016
|
+
: '';
|
|
5017
|
+
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>`;
|
|
5018
|
+
}).join('')+
|
|
3417
5019
|
`</tbody></table>`;
|
|
3418
5020
|
} else {
|
|
3419
5021
|
body = visible.length === 0
|
|
@@ -3603,14 +5205,37 @@ function setAuthInForm(auth) {
|
|
|
3603
5205
|
if(auth.type==='bearer') { document.getElementById('src-auth-token').value=auth.token||''; }
|
|
3604
5206
|
toggleAuthFields();
|
|
3605
5207
|
}
|
|
5208
|
+
// Admins (users:delete) can pick any tenant or leave blank (global);
|
|
5209
|
+
// non-admins are silently scoped to their own tenant by the server.
|
|
5210
|
+
// Pre-fill + disable for non-admins so the UX matches the server
|
|
5211
|
+
// posture — they SEE the constraint instead of typing into a field
|
|
5212
|
+
// that the server will then quietly rewrite.
|
|
5213
|
+
function _omcpApplyTenantFieldMode() {
|
|
5214
|
+
const inp = document.getElementById('src-tenant');
|
|
5215
|
+
if (!inp) return;
|
|
5216
|
+
const isAdmin = (typeof omcpCan === 'function') && omcpCan('users', 'delete');
|
|
5217
|
+
const sess = window.__omcpMe;
|
|
5218
|
+
const isAnonymous = !sess || sess.mode === 'anonymous';
|
|
5219
|
+
if (isAdmin || isAnonymous) {
|
|
5220
|
+
inp.disabled = false;
|
|
5221
|
+
inp.placeholder = '(blank = global)';
|
|
5222
|
+
} else {
|
|
5223
|
+
const own = (sess && sess.user && sess.user.tenant) || 'default';
|
|
5224
|
+
inp.value = own;
|
|
5225
|
+
inp.disabled = true;
|
|
5226
|
+
inp.placeholder = own;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
3606
5229
|
function openAddModal() {
|
|
3607
5230
|
document.getElementById('modal-title').textContent='Add Source'; document.getElementById('modal-mode').value='add';
|
|
3608
5231
|
document.getElementById('modal-original-name').value=''; document.getElementById('src-name').value='';
|
|
3609
5232
|
document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
|
|
5233
|
+
document.getElementById('src-tenant').value='';
|
|
3610
5234
|
resetTlsFields();
|
|
3611
5235
|
document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
|
|
3612
5236
|
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
3613
5237
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
|
|
5238
|
+
_omcpApplyTenantFieldMode();
|
|
3614
5239
|
document.getElementById('source-modal').classList.add('open');
|
|
3615
5240
|
}
|
|
3616
5241
|
function openEditModal(name) {
|
|
@@ -3619,8 +5244,10 @@ function openEditModal(name) {
|
|
|
3619
5244
|
document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
|
|
3620
5245
|
document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
|
|
3621
5246
|
document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
|
|
5247
|
+
document.getElementById('src-tenant').value = s.tenant || '';
|
|
3622
5248
|
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
3623
5249
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
|
|
5250
|
+
_omcpApplyTenantFieldMode();
|
|
3624
5251
|
document.getElementById('source-modal').classList.add('open');
|
|
3625
5252
|
}
|
|
3626
5253
|
// --- Source modal form validation + banner ------------------------------
|
|
@@ -3680,6 +5307,8 @@ async function saveSource() {
|
|
|
3680
5307
|
if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
|
|
3681
5308
|
const mode=document.getElementById('modal-mode').value;
|
|
3682
5309
|
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()};
|
|
5310
|
+
const tenant = document.getElementById('src-tenant').value.trim();
|
|
5311
|
+
if (tenant) src.tenant = tenant;
|
|
3683
5312
|
const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
|
|
3684
5313
|
try {
|
|
3685
5314
|
let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
|