@thotischner/observability-mcp 1.7.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/config/products.yaml.example +48 -0
- 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 +108 -0
- package/dist/audit/log.js +200 -0
- package/dist/audit/log.test.d.ts +1 -0
- package/dist/audit/log.test.js +147 -0
- package/dist/audit/middleware.d.ts +20 -0
- package/dist/audit/middleware.js +50 -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 +29 -0
- package/dist/auth/credentials.js +53 -1
- package/dist/auth/credentials.test.js +46 -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 +68 -0
- package/dist/auth/local-users.js +154 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +121 -0
- package/dist/auth/middleware.d.ts +49 -0
- package/dist/auth/middleware.js +65 -0
- package/dist/auth/middleware.test.d.ts +1 -0
- package/dist/auth/middleware.test.js +90 -0
- package/dist/auth/oidc/client.d.ts +73 -0
- package/dist/auth/oidc/client.js +104 -0
- package/dist/auth/oidc/client.test.d.ts +1 -0
- package/dist/auth/oidc/client.test.js +121 -0
- 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/discovery.d.ts +38 -0
- package/dist/auth/oidc/discovery.js +48 -0
- package/dist/auth/oidc/discovery.test.d.ts +1 -0
- package/dist/auth/oidc/discovery.test.js +68 -0
- package/dist/auth/oidc/endpoints.d.ts +20 -0
- package/dist/auth/oidc/endpoints.js +168 -0
- package/dist/auth/oidc/endpoints.test.d.ts +7 -0
- package/dist/auth/oidc/endpoints.test.js +304 -0
- package/dist/auth/oidc/flow-cookie.d.ts +57 -0
- package/dist/auth/oidc/flow-cookie.js +142 -0
- package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
- package/dist/auth/oidc/flow-cookie.test.js +0 -0
- package/dist/auth/oidc/index.d.ts +7 -0
- package/dist/auth/oidc/index.js +6 -0
- package/dist/auth/oidc/jwks.d.ts +36 -0
- package/dist/auth/oidc/jwks.js +69 -0
- package/dist/auth/oidc/jwks.test.d.ts +1 -0
- package/dist/auth/oidc/jwks.test.js +65 -0
- package/dist/auth/oidc/jwt.d.ts +62 -0
- package/dist/auth/oidc/jwt.js +113 -0
- package/dist/auth/oidc/jwt.test.d.ts +1 -0
- package/dist/auth/oidc/jwt.test.js +141 -0
- package/dist/auth/oidc/pkce.d.ts +19 -0
- package/dist/auth/oidc/pkce.js +43 -0
- package/dist/auth/oidc/pkce.test.d.ts +1 -0
- package/dist/auth/oidc/pkce.test.js +55 -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 +66 -0
- package/dist/auth/oidc/runtime.js +142 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +181 -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 +64 -0
- package/dist/auth/policy/engine.js +87 -0
- package/dist/auth/policy/engine.test.d.ts +1 -0
- package/dist/auth/policy/engine.test.js +98 -0
- package/dist/auth/policy/loader.d.ts +45 -0
- package/dist/auth/policy/loader.js +137 -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 +69 -0
- package/dist/auth/policy/opa.js +173 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +206 -0
- package/dist/auth/rbac.d.ts +62 -0
- package/dist/auth/rbac.js +162 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +183 -0
- package/dist/auth/session.d.ts +66 -0
- package/dist/auth/session.js +146 -0
- package/dist/auth/session.test.d.ts +1 -0
- package/dist/auth/session.test.js +90 -0
- package/dist/catalog/loader.d.ts +67 -0
- package/dist/catalog/loader.js +122 -0
- package/dist/catalog/loader.test.d.ts +1 -0
- package/dist/catalog/loader.test.js +108 -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 +45 -1
- package/dist/context.js +40 -1
- 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 +2124 -73
- 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/net/egress-policy.js +2 -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 +654 -6
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +98 -0
- package/dist/policy/redact.d.ts +44 -0
- package/dist/policy/redact.js +144 -0
- package/dist/policy/redact.test.d.ts +1 -0
- package/dist/policy/redact.test.js +172 -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 +112 -0
- package/dist/products/loader.js +289 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +257 -0
- 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 +97 -0
- package/dist/quota/limiter.js +161 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +205 -0
- package/dist/quota/token-budget.d.ts +119 -0
- package/dist/quota/token-budget.js +297 -0
- package/dist/quota/token-budget.test.d.ts +1 -0
- package/dist/quota/token-budget.test.js +215 -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/tenancy/context.d.ts +45 -0
- package/dist/tenancy/context.js +97 -0
- package/dist/tenancy/context.test.d.ts +1 -0
- package/dist/tenancy/context.test.js +72 -0
- package/dist/tenancy/migration.test.d.ts +7 -0
- package/dist/tenancy/migration.test.js +75 -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 +3083 -88
- package/package.json +32 -5
package/dist/ui/index.html
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Observability MCP Gateway</title>
|
|
7
7
|
<script>
|
|
8
|
-
// Resolve the theme BEFORE first paint to avoid a flash.
|
|
9
|
-
// user choice (localStorage) wins; otherwise follow the OS
|
|
8
|
+
// Resolve the theme + density BEFORE first paint to avoid a flash.
|
|
9
|
+
// Explicit user choice (localStorage) wins; otherwise follow the OS
|
|
10
|
+
// setting for theme and default to comfortable density.
|
|
10
11
|
(function(){
|
|
11
12
|
try {
|
|
12
13
|
var t = localStorage.getItem('omcp-theme');
|
|
@@ -15,13 +16,32 @@
|
|
|
15
16
|
}
|
|
16
17
|
document.documentElement.setAttribute('data-theme', t);
|
|
17
18
|
} catch (e) { document.documentElement.setAttribute('data-theme','dark'); }
|
|
19
|
+
try {
|
|
20
|
+
var d = localStorage.getItem('omcp-density');
|
|
21
|
+
document.documentElement.setAttribute('data-density', d === 'compact' ? 'compact' : 'comfortable');
|
|
22
|
+
} catch (e) { document.documentElement.setAttribute('data-density','comfortable'); }
|
|
23
|
+
try {
|
|
24
|
+
var r = localStorage.getItem('omcp-rail');
|
|
25
|
+
if (r !== 'collapsed' && r !== 'expanded') {
|
|
26
|
+
// No explicit choice yet: collapse by default on narrow viewports.
|
|
27
|
+
r = (typeof window !== 'undefined' && window.innerWidth && window.innerWidth < 1100)
|
|
28
|
+
? 'collapsed' : 'expanded';
|
|
29
|
+
}
|
|
30
|
+
document.documentElement.setAttribute('data-rail', r);
|
|
31
|
+
} catch (e) { document.documentElement.setAttribute('data-rail','expanded'); }
|
|
18
32
|
})();
|
|
19
33
|
</script>
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
<!--
|
|
35
|
+
No external font CDN. The system-font fallback chain below renders
|
|
36
|
+
cleanly on every supported OS (macOS / iOS → -apple-system,
|
|
37
|
+
Windows → Segoe UI, Android / ChromeOS → Roboto, Linux → ui-sans-serif
|
|
38
|
+
/ DejaVu). Air-gapped deployments work out of the box.
|
|
39
|
+
See docs/airgapped-deployment.md.
|
|
40
|
+
-->
|
|
22
41
|
<style>
|
|
23
|
-
|
|
24
|
-
|
|
42
|
+
:root {
|
|
43
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
44
|
+
'Helvetica Neue', Arial, ui-sans-serif, sans-serif;
|
|
25
45
|
}
|
|
26
46
|
</style>
|
|
27
47
|
<style>
|
|
@@ -147,8 +167,8 @@
|
|
|
147
167
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
148
168
|
html { -webkit-text-size-adjust: 100%; }
|
|
149
169
|
body {
|
|
150
|
-
font-family:
|
|
151
|
-
|
|
170
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
|
171
|
+
'Helvetica Neue', Arial, ui-sans-serif, sans-serif;
|
|
152
172
|
font-size: var(--fs-md);
|
|
153
173
|
line-height: 1.5;
|
|
154
174
|
background: var(--bg);
|
|
@@ -447,7 +467,7 @@
|
|
|
447
467
|
margin-bottom: var(--sp-6);
|
|
448
468
|
display: flex; align-items: center; justify-content: space-between;
|
|
449
469
|
}
|
|
450
|
-
.endpoint-bar::before { content: 'POST'; display: inline-block; padding: 2px 6px; margin-right: 10px; background: var(--accent-soft); color: var(--accent); border-radius: 3px; font-size: var(--fs-xs); font-weight: 700; font-family:
|
|
470
|
+
.endpoint-bar::before { content: 'POST'; display: inline-block; padding: 2px 6px; margin-right: 10px; background: var(--accent-soft); color: var(--accent); border-radius: 3px; font-size: var(--fs-xs); font-weight: 700; font-family: inherit; }
|
|
451
471
|
.empty {
|
|
452
472
|
color: var(--text-dim); text-align: center;
|
|
453
473
|
padding: var(--sp-10) var(--sp-4); font-size: var(--fs-md);
|
|
@@ -480,6 +500,11 @@
|
|
|
480
500
|
.tab-btn:hover { color: var(--text); }
|
|
481
501
|
.tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
|
|
482
502
|
.tab-content { display: none; } .tab-content.active { display: block; }
|
|
503
|
+
/* `.tab-pad` opts a .tab-content panel into the standard 20px inset.
|
|
504
|
+
Settings, connectors-tab and similar use this; the topology subtab
|
|
505
|
+
panels deliberately render edge-to-edge so the inner cards control
|
|
506
|
+
their own padding. */
|
|
507
|
+
.tab-content.tab-pad { padding: var(--sp-5); }
|
|
483
508
|
/* Threshold cards */
|
|
484
509
|
.threshold-group { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-bottom: var(--sp-5); }
|
|
485
510
|
.threshold-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-4); }
|
|
@@ -590,8 +615,15 @@
|
|
|
590
615
|
|
|
591
616
|
@media (max-width: 768px) { .stats { grid-template-columns: repeat(2, 1fr); } .threshold-group { grid-template-columns: 1fr; } .form-row, .form-row-3 { grid-template-columns: 1fr; } }
|
|
592
617
|
|
|
593
|
-
/* =====
|
|
618
|
+
/* ===== Console shell: side rail + masthead =====
|
|
619
|
+
The rail collapses to an icon-only strip when
|
|
620
|
+
:root[data-rail="collapsed"] is set (via the brand-area toggle
|
|
621
|
+
or the auto-collapse threshold at <1100px). The narrower
|
|
622
|
+
--rail-w cascades through the fixed siderail + the body's
|
|
623
|
+
padding-left so the workspace reclaims the recovered pixels
|
|
624
|
+
without a layout jump. */
|
|
594
625
|
:root { --rail-w: 236px; }
|
|
626
|
+
:root[data-rail="collapsed"] { --rail-w: 60px; }
|
|
595
627
|
body { padding-left: var(--rail-w); }
|
|
596
628
|
.siderail {
|
|
597
629
|
position: fixed; left: 0; top: 0; bottom: 0; width: var(--rail-w);
|
|
@@ -671,6 +703,43 @@
|
|
|
671
703
|
border-left-color: var(--accent); background: var(--chrome-active);
|
|
672
704
|
}
|
|
673
705
|
.rail-foot { margin-top: auto; padding: var(--sp-4) var(--sp-5); border-top: 1px solid var(--chrome-border); font-size: var(--fs-xs); color: var(--chrome-text-muted); }
|
|
706
|
+
|
|
707
|
+
/* Rail-collapse affordance: a small toggle docked at the top of the
|
|
708
|
+
rail. When the rail is collapsed everything but the brand icon and
|
|
709
|
+
primary nav-icons is hidden; native browser tooltips on each
|
|
710
|
+
nav-btn keep the destinations discoverable. */
|
|
711
|
+
.rail-collapse-btn {
|
|
712
|
+
background: none; border: 1px solid transparent;
|
|
713
|
+
color: var(--chrome-text-muted); cursor: pointer;
|
|
714
|
+
margin-left: auto;
|
|
715
|
+
width: 26px; height: 26px;
|
|
716
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
717
|
+
border-radius: var(--radius-sm);
|
|
718
|
+
transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
719
|
+
}
|
|
720
|
+
.rail-collapse-btn:hover { color: var(--chrome-text); border-color: var(--chrome-border); }
|
|
721
|
+
:root[data-rail="collapsed"] .rail-collapse-btn .rc-ico { transform: scaleX(-1); }
|
|
722
|
+
|
|
723
|
+
:root[data-rail="collapsed"] .siderail .rail-title,
|
|
724
|
+
:root[data-rail="collapsed"] .siderail .rail-grp-hd,
|
|
725
|
+
:root[data-rail="collapsed"] .siderail .rail-item .sub-chev,
|
|
726
|
+
:root[data-rail="collapsed"] .siderail .rail-sub,
|
|
727
|
+
:root[data-rail="collapsed"] .siderail .rail-foot,
|
|
728
|
+
:root[data-rail="collapsed"] .siderail .nav-label,
|
|
729
|
+
:root[data-rail="collapsed"] .siderail .nav-btn > *:not(.nav-ico) { display: none; }
|
|
730
|
+
:root[data-rail="collapsed"] .rail-brand {
|
|
731
|
+
justify-content: center;
|
|
732
|
+
padding: var(--sp-4) 0 var(--sp-3);
|
|
733
|
+
}
|
|
734
|
+
:root[data-rail="collapsed"] .rail-collapse-btn { margin: 0; }
|
|
735
|
+
:root[data-rail="collapsed"] .rail-nav .nav-btn {
|
|
736
|
+
justify-content: center;
|
|
737
|
+
padding: 10px 0;
|
|
738
|
+
}
|
|
739
|
+
:root[data-rail="collapsed"] .rail-nav .nav-btn .nav-ico {
|
|
740
|
+
width: 100%;
|
|
741
|
+
font-size: 16px;
|
|
742
|
+
}
|
|
674
743
|
.masthead {
|
|
675
744
|
position: sticky; top: 0; z-index: 20;
|
|
676
745
|
display: flex; align-items: center; gap: var(--sp-3);
|
|
@@ -688,7 +757,7 @@
|
|
|
688
757
|
transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
689
758
|
}
|
|
690
759
|
.theme-toggle:hover { color: var(--chrome-text); border-color: var(--chrome-text-muted); }
|
|
691
|
-
/* Header notification feed (
|
|
760
|
+
/* Header notification feed (bell icon + dropdown panel) */
|
|
692
761
|
.notif-wrap { position: relative; }
|
|
693
762
|
.notif-btn {
|
|
694
763
|
position: relative; background: none; border: 1px solid var(--chrome-border);
|
|
@@ -831,7 +900,7 @@
|
|
|
831
900
|
.gate-failclosed { color: var(--danger); background: var(--danger-soft); border-color: rgba(239,91,110,0.35); }
|
|
832
901
|
.gate-unknown { color: var(--warning); background: var(--warning-soft); border-color: rgba(245,179,65,0.30); }
|
|
833
902
|
|
|
834
|
-
/*
|
|
903
|
+
/* Governance / catalog page primitives */
|
|
835
904
|
.ent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
|
|
836
905
|
/* Dashboard feed + ranked bars (ref: events / top-consumers) */
|
|
837
906
|
.feed-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-2); border-bottom: 1px solid var(--border); }
|
|
@@ -852,7 +921,7 @@
|
|
|
852
921
|
.rank-row .rk-fill.warn { background: var(--warning); }
|
|
853
922
|
.rank-row .rk-fill.bad { background: var(--danger); }
|
|
854
923
|
.rank-row .rk-val { text-align: right; font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
|
855
|
-
/* Data Products — toggleable cards / table
|
|
924
|
+
/* Data Products — toggleable cards / table for catalog views */
|
|
856
925
|
.dp-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
|
|
857
926
|
.dp-bar .dp-count { font-size: var(--fs-sm); color: var(--text-muted); }
|
|
858
927
|
.dp-bar .dp-search { flex: 1; max-width: 320px; }
|
|
@@ -867,6 +936,180 @@
|
|
|
867
936
|
.view-toggle button + button { border-left: 1px solid var(--border-strong); }
|
|
868
937
|
.view-toggle button.active { background: var(--accent-soft); color: var(--accent); }
|
|
869
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
|
+
|
|
870
1113
|
/* Form-first editor (Form / JSON / YAML — OpenShift-style) */
|
|
871
1114
|
.ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
|
|
872
1115
|
.ed-token { flex: 1; min-width: 200px; }
|
|
@@ -914,6 +1157,214 @@
|
|
|
914
1157
|
.pill-yes { color: var(--success); } .pill-no { color: var(--danger); }
|
|
915
1158
|
.chip { display: inline-block; padding: 1px 8px; border-radius: var(--radius-pill); font-size: var(--fs-xs); background: var(--surface-3); color: var(--text-muted); border: 1px solid var(--border); margin: 1px 3px 1px 0; }
|
|
916
1159
|
@media (max-width: 980px) { .ent-grid { grid-template-columns: 1fr; } }
|
|
1160
|
+
|
|
1161
|
+
/* ===== Utilities — composable classes used across the page to keep
|
|
1162
|
+
repeated layout/spacing/typography rules out of inline `style="…"`.
|
|
1163
|
+
Tokens stay sourced from the existing CSS variable system. ===== */
|
|
1164
|
+
.mt-1 { margin-top: var(--sp-1); }
|
|
1165
|
+
.mt-2 { margin-top: var(--sp-2); }
|
|
1166
|
+
.mt-3 { margin-top: var(--sp-3); }
|
|
1167
|
+
.mt-4 { margin-top: var(--sp-4); }
|
|
1168
|
+
.mt-5 { margin-top: var(--sp-5); }
|
|
1169
|
+
.mt-6 { margin-top: var(--sp-6); }
|
|
1170
|
+
.mb-1 { margin-bottom: var(--sp-1); }
|
|
1171
|
+
.mb-2 { margin-bottom: var(--sp-2); }
|
|
1172
|
+
.mb-3 { margin-bottom: var(--sp-3); }
|
|
1173
|
+
.mb-4 { margin-bottom: var(--sp-4); }
|
|
1174
|
+
.mb-5 { margin-bottom: var(--sp-5); }
|
|
1175
|
+
.mb-6 { margin-bottom: var(--sp-6); }
|
|
1176
|
+
.m-0 { margin: 0; }
|
|
1177
|
+
.p-0 { padding: 0; }
|
|
1178
|
+
.p-5 { padding: var(--sp-5); }
|
|
1179
|
+
.gap-2 { gap: var(--sp-2); }
|
|
1180
|
+
.gap-3 { gap: var(--sp-3); }
|
|
1181
|
+
|
|
1182
|
+
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
|
1183
|
+
.row-end { display: flex; justify-content: flex-end; gap: var(--sp-2); }
|
|
1184
|
+
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
1185
|
+
.row-inline { display: inline-flex; align-items: center; gap: var(--sp-1); }
|
|
1186
|
+
.col { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
1187
|
+
|
|
1188
|
+
.t-muted { color: var(--text-muted); }
|
|
1189
|
+
/* `.muted` is used in templates throughout the page but was previously
|
|
1190
|
+
not defined in CSS — it relied on inline font-size declarations and
|
|
1191
|
+
inherited color. Define it as muted text color so the markup renders
|
|
1192
|
+
with the styling the original author clearly intended. */
|
|
1193
|
+
.muted { color: var(--text-muted); }
|
|
1194
|
+
.t-dim { color: var(--text-dim); }
|
|
1195
|
+
.t-accent { color: var(--accent); }
|
|
1196
|
+
.t-xs { font-size: var(--fs-xs); }
|
|
1197
|
+
.t-sm { font-size: var(--fs-sm); }
|
|
1198
|
+
.t-md { font-size: var(--fs-md); }
|
|
1199
|
+
.t-lg { font-size: var(--fs-lg); }
|
|
1200
|
+
.t-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
1201
|
+
.t-break { word-break: break-all; }
|
|
1202
|
+
.t-nowrap { white-space: nowrap; }
|
|
1203
|
+
.t-label {
|
|
1204
|
+
text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em;
|
|
1205
|
+
color: var(--text-muted); margin-bottom: 6px; font-weight: 600;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
.is-hidden { display: none; }
|
|
1209
|
+
|
|
1210
|
+
/* Deployment-misconfig warning bar — sits between the masthead and
|
|
1211
|
+
the page body. Subtle danger-tint so it doesn't compete with the
|
|
1212
|
+
primary content but is impossible to miss while scrolling. */
|
|
1213
|
+
.omcp-warning-bar {
|
|
1214
|
+
padding: 8px 16px;
|
|
1215
|
+
background: var(--danger-soft);
|
|
1216
|
+
color: var(--danger);
|
|
1217
|
+
border-bottom: 1px solid rgba(239, 91, 110, 0.35);
|
|
1218
|
+
font-size: var(--fs-sm); line-height: 1.4;
|
|
1219
|
+
text-align: center;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/* ===== Density variants =====
|
|
1223
|
+
`data-density="compact"` on <html> shrinks row + cell padding
|
|
1224
|
+
across lists, tables and cards so dense operator workloads fit
|
|
1225
|
+
on smaller screens. Toggleable from the masthead. */
|
|
1226
|
+
:root[data-density="compact"] .source-row,
|
|
1227
|
+
:root[data-density="compact"] .service-row { padding-top: 4px; padding-bottom: 4px; }
|
|
1228
|
+
:root[data-density="compact"] .dtable th,
|
|
1229
|
+
:root[data-density="compact"] .dtable td { padding: 4px 8px; }
|
|
1230
|
+
:root[data-density="compact"] .metric-table th,
|
|
1231
|
+
:root[data-density="compact"] .metric-table td { padding: 4px 8px; }
|
|
1232
|
+
:root[data-density="compact"] .health-card { padding: var(--sp-3); }
|
|
1233
|
+
:root[data-density="compact"] .health-card .hc-header { margin-bottom: var(--sp-2); }
|
|
1234
|
+
:root[data-density="compact"] .hc-metric { padding: 4px 8px; }
|
|
1235
|
+
|
|
1236
|
+
/* ===== Sortable table column headers ===== */
|
|
1237
|
+
.dtable th.sortable, .metric-table th.sortable {
|
|
1238
|
+
cursor: pointer; user-select: none;
|
|
1239
|
+
transition: color var(--t-fast) var(--ease);
|
|
1240
|
+
}
|
|
1241
|
+
.dtable th.sortable:hover, .metric-table th.sortable:hover { color: var(--text); }
|
|
1242
|
+
.dtable th.sortable .sort-ind, .metric-table th.sortable .sort-ind {
|
|
1243
|
+
display: inline-block; width: 10px; opacity: 0.35;
|
|
1244
|
+
margin-left: 4px; font-size: 9px;
|
|
1245
|
+
}
|
|
1246
|
+
.dtable th.sortable.sort-asc .sort-ind::before,
|
|
1247
|
+
.metric-table th.sortable.sort-asc .sort-ind::before { content: '▲'; opacity: 1; }
|
|
1248
|
+
.dtable th.sortable.sort-desc .sort-ind::before,
|
|
1249
|
+
.metric-table th.sortable.sort-desc .sort-ind::before { content: '▼'; opacity: 1; }
|
|
1250
|
+
|
|
1251
|
+
/* ===== List filter input ===== */
|
|
1252
|
+
.list-filter {
|
|
1253
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
1254
|
+
margin-bottom: var(--sp-3);
|
|
1255
|
+
}
|
|
1256
|
+
.list-filter input[type="search"] {
|
|
1257
|
+
flex: 1; min-width: 0; max-width: 360px;
|
|
1258
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
1259
|
+
color: var(--text); padding: 6px 10px;
|
|
1260
|
+
border-radius: var(--radius-sm); font-size: var(--fs-sm);
|
|
1261
|
+
transition: border-color var(--t-fast) var(--ease);
|
|
1262
|
+
}
|
|
1263
|
+
.list-filter input[type="search"]:focus {
|
|
1264
|
+
outline: none; border-color: var(--accent);
|
|
1265
|
+
box-shadow: 0 0 0 3px var(--accent-ring);
|
|
1266
|
+
}
|
|
1267
|
+
.list-filter input[type="search"]::placeholder { color: var(--text-dim); }
|
|
1268
|
+
.list-filter .count { color: var(--text-muted); font-size: var(--fs-xs); white-space: nowrap; }
|
|
1269
|
+
|
|
1270
|
+
/* ===== Rich empty / loading states =====
|
|
1271
|
+
`.state` is the inviting empty-state block: icon + title + body
|
|
1272
|
+
+ CTA. Use richEmpty() / loadingBlock() helpers instead of the
|
|
1273
|
+
bare `.empty` for any container where the user has a meaningful
|
|
1274
|
+
next action — adding a source, navigating to settings, etc. */
|
|
1275
|
+
.state {
|
|
1276
|
+
display: flex; flex-direction: column; align-items: center;
|
|
1277
|
+
justify-content: center; text-align: center;
|
|
1278
|
+
padding: var(--sp-8) var(--sp-4);
|
|
1279
|
+
color: var(--text-dim); font-size: var(--fs-md);
|
|
1280
|
+
line-height: 1.6; gap: var(--sp-3);
|
|
1281
|
+
min-height: 180px;
|
|
1282
|
+
}
|
|
1283
|
+
.state-ico {
|
|
1284
|
+
width: 44px; height: 44px;
|
|
1285
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1286
|
+
color: var(--text-muted);
|
|
1287
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
1288
|
+
border-radius: var(--radius);
|
|
1289
|
+
}
|
|
1290
|
+
.state-ico svg { width: 22px; height: 22px; }
|
|
1291
|
+
.state-title { color: var(--text); font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; }
|
|
1292
|
+
.state-desc { color: var(--text-muted); font-size: var(--fs-sm); max-width: 420px; }
|
|
1293
|
+
.state-cta { margin-top: var(--sp-2); display: flex; gap: var(--sp-2); }
|
|
1294
|
+
.state.is-loading .state-ico { color: var(--accent); border-color: var(--accent-soft); }
|
|
1295
|
+
.state.is-loading .state-ico .spin {
|
|
1296
|
+
transform-origin: center;
|
|
1297
|
+
animation: spin 0.9s linear infinite;
|
|
1298
|
+
}
|
|
1299
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1300
|
+
.state.is-loading .state-ico .spin { animation: none; }
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/* ===== Topology graph affordances =====
|
|
1304
|
+
Zoom controls + keyboard-hint badge live inside #topology-graph-host
|
|
1305
|
+
(already position:relative). Both kept compact so they don't crowd
|
|
1306
|
+
the small graph viewport. Edge labels (rendered as SVG text) get
|
|
1307
|
+
a halo via paint-order so they're legible against bands / wires. */
|
|
1308
|
+
.topo-controls {
|
|
1309
|
+
position: absolute; top: 10px; right: 10px;
|
|
1310
|
+
display: flex; gap: 4px;
|
|
1311
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
1312
|
+
border-radius: var(--radius-sm); padding: 3px;
|
|
1313
|
+
z-index: 2;
|
|
1314
|
+
}
|
|
1315
|
+
.topo-controls .btn-icon {
|
|
1316
|
+
width: 26px; height: 26px;
|
|
1317
|
+
font-size: var(--fs-md); line-height: 1;
|
|
1318
|
+
border-radius: var(--radius-sm);
|
|
1319
|
+
}
|
|
1320
|
+
.topo-hint {
|
|
1321
|
+
position: absolute; top: 10px; left: 10px;
|
|
1322
|
+
font-size: var(--fs-xs); color: var(--text-muted);
|
|
1323
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
1324
|
+
border-radius: var(--radius-sm); padding: 4px 8px;
|
|
1325
|
+
pointer-events: none; opacity: 0.85;
|
|
1326
|
+
z-index: 2;
|
|
1327
|
+
}
|
|
1328
|
+
/* Form validation primitives — used by the Add Source modal and
|
|
1329
|
+
any future form. `.is-invalid` highlights the field, `.form-error`
|
|
1330
|
+
renders a small red explanation below it, `.form-banner` is a
|
|
1331
|
+
full-width error/success block at the top of a form body. */
|
|
1332
|
+
.form-group input.is-invalid,
|
|
1333
|
+
.form-group select.is-invalid,
|
|
1334
|
+
.form-group textarea.is-invalid {
|
|
1335
|
+
border-color: var(--danger);
|
|
1336
|
+
box-shadow: 0 0 0 3px rgba(239, 91, 110, 0.18);
|
|
1337
|
+
}
|
|
1338
|
+
.form-error {
|
|
1339
|
+
color: var(--danger); font-size: var(--fs-xs);
|
|
1340
|
+
margin-top: 4px; line-height: 1.4;
|
|
1341
|
+
}
|
|
1342
|
+
.form-banner {
|
|
1343
|
+
padding: 10px 12px; border-radius: var(--radius-sm);
|
|
1344
|
+
font-size: var(--fs-sm); line-height: 1.4;
|
|
1345
|
+
margin-bottom: var(--sp-3);
|
|
1346
|
+
border: 1px solid transparent;
|
|
1347
|
+
display: none;
|
|
1348
|
+
}
|
|
1349
|
+
.form-banner.show { display: block; }
|
|
1350
|
+
.form-banner.error {
|
|
1351
|
+
background: var(--danger-soft); color: var(--danger);
|
|
1352
|
+
border-color: rgba(239, 91, 110, 0.35);
|
|
1353
|
+
}
|
|
1354
|
+
.form-banner.success {
|
|
1355
|
+
background: var(--success-soft); color: var(--success);
|
|
1356
|
+
border-color: rgba(74, 222, 128, 0.25);
|
|
1357
|
+
}
|
|
1358
|
+
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
1359
|
+
|
|
1360
|
+
/* Graph node focus ring: the <g data-id> is focusable; the inner
|
|
1361
|
+
circle gets a wider stroke when its parent has :focus-visible. */
|
|
1362
|
+
#topology-graph-svg:focus { outline: none; }
|
|
1363
|
+
#topology-graph-svg g[data-id]:focus { outline: none; }
|
|
1364
|
+
#topology-graph-svg g[data-id]:focus-visible circle {
|
|
1365
|
+
stroke: var(--accent); stroke-width: 3;
|
|
1366
|
+
filter: drop-shadow(0 0 4px var(--accent-soft));
|
|
1367
|
+
}
|
|
917
1368
|
</style>
|
|
918
1369
|
</head>
|
|
919
1370
|
<body>
|
|
@@ -921,36 +1372,38 @@
|
|
|
921
1372
|
<div class="rail-brand">
|
|
922
1373
|
<span class="rail-mark"></span>
|
|
923
1374
|
<div class="rail-title">Observability<br><span>MCP Console</span></div>
|
|
1375
|
+
<button class="rail-collapse-btn" id="rail-collapse-btn" title="Collapse navigation" aria-label="Collapse navigation" onclick="toggleRail()"><span class="rc-ico">‹</span></button>
|
|
924
1376
|
</div>
|
|
925
1377
|
<nav class="rail-nav">
|
|
926
1378
|
<div class="rail-grp" data-grp="observability">
|
|
927
1379
|
<button class="rail-grp-hd" onclick="toggleNavGroup('observability')">Observability<span class="chev">▾</span></button>
|
|
928
1380
|
<div class="rail-grp-body">
|
|
929
|
-
<button class="nav-btn active" data-page="dashboard" onclick="showPage('dashboard')"><span class="nav-ico">▦</span>Dashboard</button>
|
|
930
|
-
<button class="nav-btn" data-page="sources" onclick="showPage('sources')"><span class="nav-ico">⊟</span>Sources</button>
|
|
931
|
-
<button class="nav-btn" data-page="services" onclick="showPage('services')"><span class="nav-ico">⊞</span>Services</button>
|
|
932
|
-
<button class="nav-btn" data-page="health" onclick="showPage('health')"><span class="nav-ico">✚</span>Health</button>
|
|
933
|
-
<button class="nav-btn" data-page="topology" onclick="showPage('topology')"><span class="nav-ico">◇</span>Topology</button>
|
|
1381
|
+
<button class="nav-btn active" data-page="dashboard" title="Dashboard" onclick="showPage('dashboard')"><span class="nav-ico">▦</span><span class="nav-label">Dashboard</span></button>
|
|
1382
|
+
<button class="nav-btn" data-page="sources" title="Sources" onclick="showPage('sources')"><span class="nav-ico">⊟</span><span class="nav-label">Sources</span></button>
|
|
1383
|
+
<button class="nav-btn" data-page="services" title="Services" onclick="showPage('services')"><span class="nav-ico">⊞</span><span class="nav-label">Services</span></button>
|
|
1384
|
+
<button class="nav-btn" data-page="health" title="Health" onclick="showPage('health')"><span class="nav-ico">✚</span><span class="nav-label">Health</span></button>
|
|
1385
|
+
<button class="nav-btn" data-page="topology" title="Topology" onclick="showPage('topology')"><span class="nav-ico">◇</span><span class="nav-label">Topology</span></button>
|
|
934
1386
|
</div>
|
|
935
1387
|
</div>
|
|
936
1388
|
<div class="rail-grp" data-grp="catalog">
|
|
937
1389
|
<button class="rail-grp-hd" onclick="toggleNavGroup('catalog')">Catalog<span class="chev">▾</span></button>
|
|
938
1390
|
<div class="rail-grp-body">
|
|
939
|
-
<button class="nav-btn" data-page="products" onclick="showPage('products')"><span class="nav-ico">◫</span>Products</button>
|
|
1391
|
+
<button class="nav-btn" data-page="products" title="Products" onclick="showPage('products')"><span class="nav-ico">◫</span><span class="nav-label">Products</span></button>
|
|
940
1392
|
</div>
|
|
941
1393
|
</div>
|
|
942
1394
|
<div class="rail-grp" data-grp="governance">
|
|
943
1395
|
<button class="rail-grp-hd" onclick="toggleNavGroup('governance')">Governance<span class="chev">▾</span></button>
|
|
944
1396
|
<div class="rail-grp-body">
|
|
945
|
-
<button class="nav-btn" data-page="access" onclick="showPage('access')"><span class="nav-ico">⛨</span>Access Control</button>
|
|
946
|
-
<button class="nav-btn" data-page="
|
|
1397
|
+
<button class="nav-btn" data-page="access" title="Access Control" onclick="showPage('access')"><span class="nav-ico">⛨</span><span class="nav-label">Access Control</span></button>
|
|
1398
|
+
<button class="nav-btn" data-page="policies" title="Policies" onclick="showPage('policies')" data-rbac="users:delete"><span class="nav-ico">≣</span><span class="nav-label">Policies</span></button>
|
|
1399
|
+
<button class="nav-btn" data-page="audit" title="Audit Log" onclick="showPage('audit')"><span class="nav-ico">❒</span><span class="nav-label">Audit Log</span></button>
|
|
947
1400
|
</div>
|
|
948
1401
|
</div>
|
|
949
1402
|
<div class="rail-grp" data-grp="system">
|
|
950
1403
|
<button class="rail-grp-hd" onclick="toggleNavGroup('system')">System<span class="chev">▾</span></button>
|
|
951
1404
|
<div class="rail-grp-body">
|
|
952
1405
|
<div class="rail-item" data-nav="connectors">
|
|
953
|
-
<button class="nav-btn nav-parent" onclick="navToggle('connectors')"><span class="nav-ico">⇄</span>Connectors
|
|
1406
|
+
<button class="nav-btn nav-parent" title="Connectors" onclick="navToggle('connectors')"><span class="nav-ico">⇄</span><span class="nav-label">Connectors</span><span class="sub-chev">▾</span></button>
|
|
954
1407
|
<div class="rail-sub">
|
|
955
1408
|
<button class="nav-sub" data-sub="installed" onclick="goTab('connectors','installed')">Installed</button>
|
|
956
1409
|
<button class="nav-sub" data-sub="hub" onclick="goTab('connectors','hub')">Connector Hub</button>
|
|
@@ -958,14 +1411,14 @@
|
|
|
958
1411
|
</div>
|
|
959
1412
|
</div>
|
|
960
1413
|
<div class="rail-item" data-nav="settings">
|
|
961
|
-
<button class="nav-btn nav-parent" onclick="navToggle('settings')"><span class="nav-ico">⚙</span>Settings
|
|
1414
|
+
<button class="nav-btn nav-parent" title="Settings" onclick="navToggle('settings')"><span class="nav-ico">⚙</span><span class="nav-label">Settings</span><span class="sub-chev">▾</span></button>
|
|
962
1415
|
<div class="rail-sub">
|
|
963
1416
|
<button class="nav-sub" data-sub="general" onclick="goTab('settings','general')">General</button>
|
|
964
1417
|
<button class="nav-sub" data-sub="health" onclick="goTab('settings','health')">Health Scoring</button>
|
|
965
1418
|
<button class="nav-sub" data-sub="metrics" onclick="goTab('settings','metrics')">Custom Metrics</button>
|
|
966
1419
|
</div>
|
|
967
1420
|
</div>
|
|
968
|
-
<button class="nav-btn" data-page="entitlement" onclick="showPage('entitlement')"><span class="nav-ico">⛁</span>Entitlement</button>
|
|
1421
|
+
<button class="nav-btn" data-page="entitlement" title="Entitlement" onclick="showPage('entitlement')"><span class="nav-ico">⛁</span><span class="nav-label">Entitlement</span></button>
|
|
969
1422
|
</div>
|
|
970
1423
|
</div>
|
|
971
1424
|
</nav>
|
|
@@ -982,10 +1435,54 @@
|
|
|
982
1435
|
<div id="notif-list"><div class="notif-empty">No notifications</div></div>
|
|
983
1436
|
</div>
|
|
984
1437
|
</div>
|
|
1438
|
+
<span id="user-badge" class="row-inline t-sm" style="display:none; margin-right: var(--sp-2);">
|
|
1439
|
+
<span class="t-muted">Signed in as</span>
|
|
1440
|
+
<span class="user-name" style="color: var(--chrome-text); font-weight: 600;"></span>
|
|
1441
|
+
<button class="btn btn-ghost btn-sm" onclick="omcpLogout()">Sign out</button>
|
|
1442
|
+
</span>
|
|
1443
|
+
<button class="theme-toggle" id="density-toggle" title="Toggle row density" aria-label="Toggle density" onclick="toggleDensity()">≡</button>
|
|
985
1444
|
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark" aria-label="Toggle theme" onclick="toggleTheme()">◐</button>
|
|
986
1445
|
<button class="btn btn-ghost btn-sm" onclick="refresh()">Refresh</button>
|
|
987
1446
|
</header>
|
|
988
1447
|
|
|
1448
|
+
<!-- Governance misconfig banner — populated by omcpCheckGovernance()
|
|
1449
|
+
from /api/info. Hidden by default; the JS makes it visible when
|
|
1450
|
+
there is something the operator needs to fix (currently only the
|
|
1451
|
+
ephemeral session secret case). -->
|
|
1452
|
+
<div id="omcp-warning-bar" class="omcp-warning-bar" style="display:none" role="alert"></div>
|
|
1453
|
+
|
|
1454
|
+
<!-- Login modal — shown when /api/* returns 401 OMCP_AUTH_REQUIRED -->
|
|
1455
|
+
<div class="modal-overlay" id="login-modal">
|
|
1456
|
+
<div class="modal" style="width:380px;">
|
|
1457
|
+
<div class="modal-header"><h3>Sign in</h3></div>
|
|
1458
|
+
<div class="modal-body">
|
|
1459
|
+
<div id="login-banner" class="form-banner"></div>
|
|
1460
|
+
<!-- OIDC: shown when authMode=oidc. Single primary button — the
|
|
1461
|
+
IdP collects credentials, not us. -->
|
|
1462
|
+
<div id="login-oidc" style="display:none;" class="form-group">
|
|
1463
|
+
<button class="btn btn-primary" id="login-sso" onclick="omcpStartSso()" style="width:100%;">
|
|
1464
|
+
Sign in with SSO
|
|
1465
|
+
</button>
|
|
1466
|
+
<div class="form-hint t-sm" id="login-sso-hint" style="margin-top: var(--sp-2);"></div>
|
|
1467
|
+
</div>
|
|
1468
|
+
<!-- Basic: shown when authMode=basic. Hidden in oidc mode. -->
|
|
1469
|
+
<div id="login-basic">
|
|
1470
|
+
<div class="form-group">
|
|
1471
|
+
<label>Username</label>
|
|
1472
|
+
<input id="login-username" type="text" autocomplete="username" onkeydown="omcpLoginKey(event)">
|
|
1473
|
+
</div>
|
|
1474
|
+
<div class="form-group">
|
|
1475
|
+
<label>Password</label>
|
|
1476
|
+
<input id="login-password" type="password" autocomplete="current-password" onkeydown="omcpLoginKey(event)">
|
|
1477
|
+
</div>
|
|
1478
|
+
</div>
|
|
1479
|
+
</div>
|
|
1480
|
+
<div class="modal-footer">
|
|
1481
|
+
<button class="btn btn-primary" id="login-submit" onclick="omcpSubmitLogin()">Sign in</button>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
</div>
|
|
1485
|
+
|
|
989
1486
|
<div class="container">
|
|
990
1487
|
<!-- ===== Dashboard ===== -->
|
|
991
1488
|
<div class="page active" id="page-dashboard">
|
|
@@ -1045,8 +1542,21 @@
|
|
|
1045
1542
|
<div class="flow-node"><span class="fn-ic">✦</span><span class="fn-t">Analysis</span><span class="fn-s">scored health</span></div>
|
|
1046
1543
|
</div>
|
|
1047
1544
|
</div>
|
|
1545
|
+
<!-- Today's usage strip (top-5 identities by activity in the trailing
|
|
1546
|
+
24h window). Reads /api/usage; hidden when no identity has any
|
|
1547
|
+
traffic yet OR the viewer lacks audit:read (silently — non-admin
|
|
1548
|
+
sessions just don't see it). -->
|
|
1549
|
+
<div class="card" id="dash-usage-card" style="display:none">
|
|
1550
|
+
<div class="card-header"><h2>Today's MCP usage
|
|
1551
|
+
<button class="info" aria-label="About the usage strip"
|
|
1552
|
+
data-title="Usage strip"
|
|
1553
|
+
data-info="Per-identity totals across the trailing 24h window: requests counted by the rate-limiter (1-minute resolution) and tokens estimated post-tool-execution. The token bucket is the same one OMCP_TOOL_DAILY_TOKENS gates against — when it fills, the corresponding identity gets OMCP_TOKEN_BUDGET_EXCEEDED on the next call. Anonymous /mcp traffic isn't shown (no per-identity bucket to charge)."
|
|
1554
|
+
onclick="infoPop(this)">?</button>
|
|
1555
|
+
</h2></div>
|
|
1556
|
+
<div id="dash-usage" class="content"><div class="empty">Loading…</div></div>
|
|
1557
|
+
</div>
|
|
1048
1558
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
1049
|
-
<div class="card"><div class="card-header"><h2>Sources</h2><button class="btn btn-primary btn-sm" onclick="showPage('sources');openAddModal()">+ Add Source</button></div><div id="dash-sources"><div class="empty">Loading...</div></div></div>
|
|
1559
|
+
<div class="card"><div class="card-header"><h2>Sources</h2><button class="btn btn-primary btn-sm" data-rbac="sources:write" onclick="showPage('sources');openAddModal()">+ Add Source</button></div><div id="dash-sources"><div class="empty">Loading...</div></div></div>
|
|
1050
1560
|
<div class="card"><div class="card-header"><h2>Services</h2></div><div id="dash-services"><div class="empty">Loading...</div></div></div>
|
|
1051
1561
|
</div>
|
|
1052
1562
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
|
|
@@ -1068,7 +1578,7 @@
|
|
|
1068
1578
|
<div class="breadcrumb">Console / Observability / <b>Sources</b></div>
|
|
1069
1579
|
<h1>Data Sources</h1>
|
|
1070
1580
|
</div>
|
|
1071
|
-
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Add Source</button></div>
|
|
1581
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" data-rbac="sources:write" onclick="openAddModal()">+ Add Source</button></div>
|
|
1072
1582
|
</div>
|
|
1073
1583
|
<div class="card">
|
|
1074
1584
|
<div class="card-header"><h2>All sources</h2></div>
|
|
@@ -1103,25 +1613,25 @@
|
|
|
1103
1613
|
</div>
|
|
1104
1614
|
|
|
1105
1615
|
<!-- Installed Tab -->
|
|
1106
|
-
<div class="tab-content active" id="tab-installed"
|
|
1616
|
+
<div class="tab-content tab-pad active" id="tab-installed">
|
|
1107
1617
|
<div class="card-header" style="margin-bottom:12px"><h2>Installed connectors</h2><button class="btn btn-ghost btn-sm" onclick="loadConnectors()">Refresh</button></div>
|
|
1108
1618
|
<div id="conn-installed"><div class="empty">Loading...</div></div>
|
|
1109
1619
|
</div>
|
|
1110
1620
|
|
|
1111
1621
|
<!-- Connector Hub Tab -->
|
|
1112
|
-
<div class="tab-content" id="tab-hub"
|
|
1622
|
+
<div class="tab-content tab-pad" id="tab-hub">
|
|
1113
1623
|
<div class="card-header" style="margin-bottom:12px"><h2>Available from the Connector Hub</h2>
|
|
1114
1624
|
<a class="btn btn-ghost btn-sm" href="https://thotischner.github.io/observability-mcp/hub/" target="_blank" rel="noopener">Open Hub ↗</a></div>
|
|
1115
1625
|
<div id="conn-hub"><div class="empty">Loading...</div></div>
|
|
1116
1626
|
</div>
|
|
1117
1627
|
|
|
1118
1628
|
<!-- Upload bundle Tab -->
|
|
1119
|
-
<div class="tab-content" id="tab-upload"
|
|
1629
|
+
<div class="tab-content tab-pad" id="tab-upload">
|
|
1120
1630
|
<div class="card-header" style="margin-bottom:12px"><h2>Upload a connector bundle</h2></div>
|
|
1121
1631
|
<p class="empty" style="margin:0 0 12px">Install a signed connector <code>.tgz</code> directly — handy for air-gapped environments. The bundle is always verified against the configured trust root before it is loaded.</p>
|
|
1122
1632
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
1123
1633
|
<input type="file" id="conn-upload-file" accept=".tgz,.tar.gz,application/octet-stream">
|
|
1124
|
-
<button class="btn btn-primary btn-sm" onclick="uploadConnector(this)">Upload & install</button>
|
|
1634
|
+
<button class="btn btn-primary btn-sm" data-rbac="connectors:write" onclick="uploadConnector(this)">Upload & install</button>
|
|
1125
1635
|
</div>
|
|
1126
1636
|
</div>
|
|
1127
1637
|
</div>
|
|
@@ -1174,7 +1684,7 @@
|
|
|
1174
1684
|
<option value="">All</option>
|
|
1175
1685
|
</select>
|
|
1176
1686
|
</label>
|
|
1177
|
-
<span class="muted
|
|
1687
|
+
<span class="muted t-xs">Scope = any resource pointed to by an <code>IN_NAMESPACE</code> edge (e.g. k8s namespaces, future: vCenter folders).</span>
|
|
1178
1688
|
</div>
|
|
1179
1689
|
<div id="topology-by-host" class="empty" style="padding: 0 16px 16px;">Loading...</div>
|
|
1180
1690
|
</div>
|
|
@@ -1183,11 +1693,17 @@
|
|
|
1183
1693
|
<div class="card">
|
|
1184
1694
|
<div class="card-header">
|
|
1185
1695
|
<h2>Layered graph</h2>
|
|
1186
|
-
<span class="muted
|
|
1696
|
+
<span class="muted t-xs">click a resource to inspect · drag to reposition · wheel to zoom · drag the background to pan</span>
|
|
1187
1697
|
</div>
|
|
1188
1698
|
<div style="display:grid; grid-template-columns: 1fr 340px; gap: 0; border-top: 1px solid var(--border); height: 660px;">
|
|
1189
1699
|
<div id="topology-graph-host" style="position:relative; overflow:hidden; background: var(--surface-2); border-right: 1px solid var(--border);">
|
|
1190
|
-
<svg id="topology-graph-svg" style="position:absolute; inset:0; width:100%; height:100%; cursor: grab;"></svg>
|
|
1700
|
+
<svg id="topology-graph-svg" style="position:absolute; inset:0; width:100%; height:100%; cursor: grab;" tabindex="0" role="application" aria-label="Topology graph — Tab through resources, Enter to inspect, Esc to clear"></svg>
|
|
1701
|
+
<div class="topo-controls" role="toolbar" aria-label="Graph zoom">
|
|
1702
|
+
<button class="btn-icon" title="Zoom in (or wheel up)" aria-label="Zoom in" onclick="topoZoom(1.2)">+</button>
|
|
1703
|
+
<button class="btn-icon" title="Zoom out (or wheel down)" aria-label="Zoom out" onclick="topoZoom(1/1.2)">−</button>
|
|
1704
|
+
<button class="btn-icon" title="Reset view" aria-label="Reset view" onclick="topoResetView()">⤾</button>
|
|
1705
|
+
</div>
|
|
1706
|
+
<div class="topo-hint" id="topo-hint">Tab to focus · Enter to inspect · arrows to move focus · Esc to clear</div>
|
|
1191
1707
|
<div id="topology-graph-legend" style="position:absolute; bottom:10px; left:10px; font-size: var(--fs-xs); padding:8px 10px; background: var(--surface); border:1px solid var(--border); border-radius: 6px; max-width: 75%;"></div>
|
|
1192
1708
|
</div>
|
|
1193
1709
|
<aside id="topology-inspector" style="background: var(--surface); padding: 16px; overflow-y: auto; font-size: var(--fs-sm);">
|
|
@@ -1215,7 +1731,7 @@
|
|
|
1215
1731
|
</div>
|
|
1216
1732
|
|
|
1217
1733
|
<!-- General Tab -->
|
|
1218
|
-
<div class="tab-content active" id="tab-general"
|
|
1734
|
+
<div class="tab-content tab-pad active" id="tab-general">
|
|
1219
1735
|
<div class="form-row">
|
|
1220
1736
|
<div class="form-group">
|
|
1221
1737
|
<label>Check Interval (ms)</label>
|
|
@@ -1232,13 +1748,13 @@
|
|
|
1232
1748
|
</div>
|
|
1233
1749
|
</div>
|
|
1234
1750
|
<div style="display:flex; gap:8px; justify-content:flex-end;">
|
|
1235
|
-
<button class="btn btn-ghost btn-sm" onclick="resetSettings()">Reset to Defaults</button>
|
|
1236
|
-
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Save Settings</button>
|
|
1751
|
+
<button class="btn btn-ghost btn-sm" data-rbac="settings:write" onclick="resetSettings()">Reset to Defaults</button>
|
|
1752
|
+
<button class="btn btn-primary btn-sm" id="btn-save-settings" data-rbac="settings:write" onclick="saveSettings()" disabled>Save Settings</button>
|
|
1237
1753
|
</div>
|
|
1238
1754
|
</div>
|
|
1239
1755
|
|
|
1240
1756
|
<!-- Health Scoring Tab -->
|
|
1241
|
-
<div class="tab-content" id="tab-health"
|
|
1757
|
+
<div class="tab-content tab-pad" id="tab-health">
|
|
1242
1758
|
<p style="font-size:13px; color:var(--text2); margin-bottom:16px;">Configure how service health scores are calculated. Weights must sum to 1.0.</p>
|
|
1243
1759
|
<h3 style="font-size:14px; margin-bottom:12px;">Weights</h3>
|
|
1244
1760
|
<div class="form-row" style="margin-bottom:20px;">
|
|
@@ -1288,13 +1804,13 @@
|
|
|
1288
1804
|
<div class="form-group"><label>Degraded above score</label><input type="number" id="ht-bound-degraded" min="0" max="100"></div>
|
|
1289
1805
|
</div>
|
|
1290
1806
|
<div style="display:flex; gap:8px; justify-content:flex-end;">
|
|
1291
|
-
<button class="btn btn-ghost btn-sm" onclick="resetHealth()">Reset to Defaults</button>
|
|
1292
|
-
<button class="btn btn-primary btn-sm" onclick="saveHealth()">Save Thresholds</button>
|
|
1807
|
+
<button class="btn btn-ghost btn-sm" data-rbac="health:write" onclick="resetHealth()">Reset to Defaults</button>
|
|
1808
|
+
<button class="btn btn-primary btn-sm" id="btn-save-health" data-rbac="health:write" onclick="saveHealth()" disabled>Save Thresholds</button>
|
|
1293
1809
|
</div>
|
|
1294
1810
|
</div>
|
|
1295
1811
|
|
|
1296
1812
|
<!-- Source Metrics Tab -->
|
|
1297
|
-
<div class="tab-content" id="tab-metrics"
|
|
1813
|
+
<div class="tab-content tab-pad" id="tab-metrics">
|
|
1298
1814
|
<p style="font-size:13px; color:var(--text2); margin-bottom:16px;">Each data source has its own metric definitions with backend-specific queries (PromQL, LogQL, etc.). Use <code style="background:var(--bg);padding:1px 5px;border-radius:3px;">{{service}}</code> as placeholder for the service/job name.</p>
|
|
1299
1815
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:16px;">
|
|
1300
1816
|
<label style="font-size:13px; color:var(--text2); white-space:nowrap;">Source:</label>
|
|
@@ -1389,8 +1905,66 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1389
1905
|
table; an admin can create or change products via the editor or the API example.
|
|
1390
1906
|
</div>
|
|
1391
1907
|
</div>
|
|
1908
|
+
<!-- MCP Products — governed via /api/products (the new, RBAC-gated
|
|
1909
|
+
surface from the MCP Products + RBAC phase). Replaces the
|
|
1910
|
+
legacy enterprise-catalog block below for new deployments;
|
|
1911
|
+
the legacy block stays so existing /api/enterprise/catalog
|
|
1912
|
+
operators see their data until they migrate. -->
|
|
1913
|
+
<!-- Inline leitfaden — collapsed by default once the operator
|
|
1914
|
+
dismisses it; re-opens by clicking the chevron. -->
|
|
1915
|
+
<div class="card" id="mcp-products-leitfaden">
|
|
1916
|
+
<div class="card-header" style="cursor:pointer" onclick="mcpLeitfadenToggle()">
|
|
1917
|
+
<h2 style="display:flex;align-items:center;gap:.5rem">
|
|
1918
|
+
<span class="leitfaden-chev" id="mcp-leitfaden-chev">▾</span>
|
|
1919
|
+
About Products
|
|
1920
|
+
</h2>
|
|
1921
|
+
<span class="t-sm" style="opacity:.7">click to collapse</span>
|
|
1922
|
+
</div>
|
|
1923
|
+
<div id="mcp-leitfaden-body">
|
|
1924
|
+
<div class="leitfaden-grid">
|
|
1925
|
+
<div>
|
|
1926
|
+
<h3>What is a product?</h3>
|
|
1927
|
+
<p class="t-sm">A curated, named bundle of MCP tools you expose to one agent. The bundle's <code>tools</code> allow-list filters <code>tools/list</code> at the <code>/mcp</code> transport, so an agent bound to <em>Ops Bundle</em> sees only the operations tools and not the developer tools.</p>
|
|
1928
|
+
</div>
|
|
1929
|
+
<div>
|
|
1930
|
+
<h3>When do I need one?</h3>
|
|
1931
|
+
<p class="t-sm">Whenever more than one agent connects. Each team / use-case gets its own product — SRE on-call vs coding assistant vs compliance auditor. Without a product the credential sees every registered tool, which is fine for a single-operator demo but not for a production multi-agent deployment.</p>
|
|
1932
|
+
</div>
|
|
1933
|
+
<div>
|
|
1934
|
+
<h3>How do agents pick one up?</h3>
|
|
1935
|
+
<p class="t-sm">Bind a credential to a product via <code>OMCP_KEY_PRODUCTS</code>:</p>
|
|
1936
|
+
<pre style="margin:.25rem 0"><code>OMCP_API_KEYS="agent:tok_ops,ci:tok_dev"
|
|
1937
|
+
OMCP_KEY_PRODUCTS="agent=ops-bundle;ci=dev-bundle"</code></pre>
|
|
1938
|
+
<p class="t-sm">The next <code>/mcp</code> session of <code>agent</code> sees only the tools in <code>ops-bundle</code>. <a href="https://github.com/ThoTischner/observability-mcp/blob/main/docs/products.md" target="_blank" rel="noreferrer">Full docs →</a></p>
|
|
1939
|
+
</div>
|
|
1940
|
+
</div>
|
|
1941
|
+
</div>
|
|
1942
|
+
</div>
|
|
1943
|
+
|
|
1944
|
+
<div class="card" id="mcp-products-card">
|
|
1945
|
+
<div class="card-header">
|
|
1946
|
+
<h2>MCP Products
|
|
1947
|
+
<button class="info" aria-label="About the MCP Products API"
|
|
1948
|
+
data-title="MCP Products"
|
|
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."
|
|
1950
|
+
onclick="infoPop(this)">?</button>
|
|
1951
|
+
</h2>
|
|
1952
|
+
<div class="row-inline">
|
|
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>
|
|
1958
|
+
<button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
|
|
1959
|
+
</div>
|
|
1960
|
+
</div>
|
|
1961
|
+
<div id="mcp-products-box"><div class="empty">Loading…</div></div>
|
|
1962
|
+
</div>
|
|
1963
|
+
|
|
1964
|
+
<!-- Legacy enterprise-products card (kept for operators that
|
|
1965
|
+
still wire OMCP_ENTERPRISE_CATALOG_FILE) -->
|
|
1392
1966
|
<div class="card">
|
|
1393
|
-
<div class="card-header"><h2>Products</h2></div>
|
|
1967
|
+
<div class="card-header"><h2>Products <span class="t-sm" style="opacity:.7">(legacy / enterprise catalog)</span></h2></div>
|
|
1394
1968
|
<div id="ent-catalog"><div class="empty">Loading…</div></div>
|
|
1395
1969
|
<div id="ent-cat-editor" class="hidden" style="margin-top:12px">
|
|
1396
1970
|
<div class="ed-bar">
|
|
@@ -1435,6 +2009,183 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1435
2009
|
</div>
|
|
1436
2010
|
</div>
|
|
1437
2011
|
|
|
2012
|
+
<!-- ===== Governance: Policies (PolicyEngine snapshot + dry-run) ===== -->
|
|
2013
|
+
<div class="page" id="page-policies">
|
|
2014
|
+
<div class="page-head">
|
|
2015
|
+
<div class="ph-left">
|
|
2016
|
+
<div class="breadcrumb">Console / Governance / <b>Policies</b></div>
|
|
2017
|
+
<h1>Policies</h1>
|
|
2018
|
+
</div>
|
|
2019
|
+
<div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
|
|
2020
|
+
</div>
|
|
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>
|
|
2034
|
+
</div>
|
|
2035
|
+
</div>
|
|
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">
|
|
2051
|
+
<label>Roles <span class="form-hint">comma-separated</span></label>
|
|
2052
|
+
<input id="pol-dry-roles" placeholder="admin, operator">
|
|
2053
|
+
</div>
|
|
2054
|
+
<div class="form-group">
|
|
2055
|
+
<label>Resource</label>
|
|
2056
|
+
<input id="pol-dry-resource" placeholder="sources">
|
|
2057
|
+
</div>
|
|
2058
|
+
<div class="form-group">
|
|
2059
|
+
<label>Action</label>
|
|
2060
|
+
<input id="pol-dry-action" placeholder="read">
|
|
2061
|
+
</div>
|
|
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>
|
|
2067
|
+
<button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
|
|
2068
|
+
</div>
|
|
2069
|
+
</div>
|
|
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>
|
|
2186
|
+
</div>
|
|
2187
|
+
</div>
|
|
2188
|
+
|
|
1438
2189
|
<!-- ===== Governance: Audit Log ===== -->
|
|
1439
2190
|
<div class="page" id="page-audit">
|
|
1440
2191
|
<div class="page-head">
|
|
@@ -1453,6 +2204,15 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1453
2204
|
</h2></div>
|
|
1454
2205
|
<div id="ent-audit"><div class="empty">Loading…</div></div>
|
|
1455
2206
|
</div>
|
|
2207
|
+
<div class="card mt-4">
|
|
2208
|
+
<div class="card-header"><h2>Management changes
|
|
2209
|
+
<button class="info" aria-label="About management audit"
|
|
2210
|
+
data-title="Management audit"
|
|
2211
|
+
data-info="Append-only hash-chained log of every mutating /api/* request (source / setting / health-threshold / connector changes). When OMCP_MGMT_AUDIT_FILE is set the log persists across restarts; otherwise it is an in-memory ring of the last 500 entries."
|
|
2212
|
+
onclick="infoPop(this)">?</button>
|
|
2213
|
+
</h2></div>
|
|
2214
|
+
<div id="mgmt-audit"><div class="empty">Loading…</div></div>
|
|
2215
|
+
</div>
|
|
1456
2216
|
</div>
|
|
1457
2217
|
|
|
1458
2218
|
<!-- ===== Governance: Entitlement ===== -->
|
|
@@ -1495,9 +2255,19 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1495
2255
|
<div class="modal-header"><h3 id="modal-title">Add Source</h3><button class="btn-icon" onclick="closeModal('source-modal')">×</button></div>
|
|
1496
2256
|
<div class="modal-body">
|
|
1497
2257
|
<input type="hidden" id="modal-mode" value="add"><input type="hidden" id="modal-original-name" value="">
|
|
1498
|
-
<div
|
|
2258
|
+
<div id="src-form-banner" class="form-banner"></div>
|
|
2259
|
+
<div class="form-group">
|
|
2260
|
+
<label>Name</label>
|
|
2261
|
+
<input type="text" id="src-name" placeholder="e.g. prometheus-prod" oninput="validateSrcName()" onblur="validateSrcName()">
|
|
2262
|
+
<div class="form-hint">Unique identifier</div>
|
|
2263
|
+
<div class="form-error" id="src-name-err" hidden></div>
|
|
2264
|
+
</div>
|
|
1499
2265
|
<div class="form-group"><label>Type</label><select id="src-type"></select></div>
|
|
1500
|
-
<div class="form-group"
|
|
2266
|
+
<div class="form-group">
|
|
2267
|
+
<label>URL</label>
|
|
2268
|
+
<input type="text" id="src-url" placeholder="e.g. http://prometheus:9090" oninput="validateSrcUrl()" onblur="validateSrcUrl()">
|
|
2269
|
+
<div class="form-error" id="src-url-err" hidden></div>
|
|
2270
|
+
</div>
|
|
1501
2271
|
<div class="form-group"><label>Authentication</label><select id="src-auth-type" onchange="toggleAuthFields()"><option value="none">None</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option></select></div>
|
|
1502
2272
|
<div id="auth-basic-fields" style="display:none">
|
|
1503
2273
|
<div class="form-group"><label>Username</label><input type="text" id="src-auth-username" placeholder="username"></div>
|
|
@@ -1510,6 +2280,11 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1510
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>
|
|
1511
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>
|
|
1512
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>
|
|
1513
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>
|
|
1514
2289
|
<div class="test-result" id="test-result"></div>
|
|
1515
2290
|
</div>
|
|
@@ -1550,6 +2325,143 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1550
2325
|
</div>
|
|
1551
2326
|
</div>
|
|
1552
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
|
+
|
|
1553
2465
|
<div class="toast" id="toast-el"></div>
|
|
1554
2466
|
|
|
1555
2467
|
<div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
|
|
@@ -1564,7 +2476,14 @@ let deleteTarget=null, deleteType=null;
|
|
|
1564
2476
|
// Per-source metrics state
|
|
1565
2477
|
let selectedMetricsSource='', sourceMetrics=[], sourceMetricDefaults=[];
|
|
1566
2478
|
|
|
1567
|
-
function toast(msg) {
|
|
2479
|
+
function toast(msg, kind) {
|
|
2480
|
+
const t=document.getElementById('toast-el');
|
|
2481
|
+
t.textContent=msg;
|
|
2482
|
+
t.classList.remove('toast-error');
|
|
2483
|
+
if (kind === 'error') t.classList.add('toast-error');
|
|
2484
|
+
t.classList.add('show');
|
|
2485
|
+
setTimeout(()=>t.classList.remove('show'),2000);
|
|
2486
|
+
}
|
|
1568
2487
|
|
|
1569
2488
|
// --- Nav ---
|
|
1570
2489
|
function showPage(name) {
|
|
@@ -1580,6 +2499,677 @@ function showPage(name) {
|
|
|
1580
2499
|
if(name==='topology') loadTopology();
|
|
1581
2500
|
if(name==='connectors') loadConnectors();
|
|
1582
2501
|
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
2502
|
+
if(name==='products') loadMcpProducts();
|
|
2503
|
+
if(name==='policies') loadPolicies();
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
// --- Policies tab (PolicyEngine snapshot + dry-run) ---
|
|
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.
|
|
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() {
|
|
3068
|
+
const rolesEl = document.getElementById('pol-roles');
|
|
3069
|
+
try {
|
|
3070
|
+
const r = await fetch('/api/policy');
|
|
3071
|
+
if (!r.ok) {
|
|
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');
|
|
3086
|
+
return;
|
|
3087
|
+
}
|
|
3088
|
+
const j = await r.json();
|
|
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>`);
|
|
3131
|
+
}
|
|
3132
|
+
rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
|
|
3133
|
+
}
|
|
3134
|
+
} catch (e) {
|
|
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 = '';
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
async function polDryRun() {
|
|
3144
|
+
const out = document.getElementById('pol-dry-out');
|
|
3145
|
+
const roles = document.getElementById('pol-dry-roles').value.trim();
|
|
3146
|
+
const resource = document.getElementById('pol-dry-resource').value.trim();
|
|
3147
|
+
const action = document.getElementById('pol-dry-action').value.trim();
|
|
3148
|
+
const tenant = document.getElementById('pol-dry-tenant').value.trim();
|
|
3149
|
+
if (!resource || !action) {
|
|
3150
|
+
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
const params = new URLSearchParams({ resource, action });
|
|
3154
|
+
if (roles) params.set('roles', roles);
|
|
3155
|
+
if (tenant) params.set('tenant', tenant);
|
|
3156
|
+
try {
|
|
3157
|
+
const r = await fetch('/api/policy?' + params.toString());
|
|
3158
|
+
const j = await r.json();
|
|
3159
|
+
const d = j.dryRun || {};
|
|
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>` : '';
|
|
3163
|
+
out.innerHTML = `
|
|
3164
|
+
<div class="pol-probe-result">
|
|
3165
|
+
<span class="pol-pv ${cls}" data-verdict="${verdict}">${verdict}</span>
|
|
3166
|
+
<code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
|
|
3167
|
+
${tenantTag}
|
|
3168
|
+
</div>
|
|
3169
|
+
<div class="form-hint" style="margin-top: var(--sp-1)">${escHtml(d.reason || '')}</div>`;
|
|
3170
|
+
} catch (e) {
|
|
3171
|
+
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3172
|
+
}
|
|
1583
3173
|
}
|
|
1584
3174
|
|
|
1585
3175
|
// --- Theme (light / dark) ---
|
|
@@ -1596,6 +3186,240 @@ function toggleTheme(){
|
|
|
1596
3186
|
syncThemeToggle();
|
|
1597
3187
|
}
|
|
1598
3188
|
|
|
3189
|
+
// --- Management-plane auth (basic mode) ----------------------------------
|
|
3190
|
+
// When OMCP_AUTH=basic on the server, /api/* returns 401 + body
|
|
3191
|
+
// { code: "OMCP_AUTH_REQUIRED" } for unauthenticated requests. This block
|
|
3192
|
+
// wraps window.fetch to catch that, show the login modal, and replay the
|
|
3193
|
+
// original request once the user signs in. In anonymous mode nothing here
|
|
3194
|
+
// fires — the wrapper is a passthrough.
|
|
3195
|
+
const _omcpRawFetch = window.fetch.bind(window);
|
|
3196
|
+
let _omcpLoginInflight = null;
|
|
3197
|
+
let _omcpLoginResolve = null;
|
|
3198
|
+
window.fetch = async function omcpAuthedFetch(input, init) {
|
|
3199
|
+
const res = await _omcpRawFetch(input, init);
|
|
3200
|
+
if (res.status !== 401) return res;
|
|
3201
|
+
let body = null;
|
|
3202
|
+
try { body = await res.clone().json(); } catch(e) {}
|
|
3203
|
+
if (!body || body.code !== 'OMCP_AUTH_REQUIRED') return res;
|
|
3204
|
+
await omcpEnsureLoggedIn();
|
|
3205
|
+
return _omcpRawFetch(input, init);
|
|
3206
|
+
};
|
|
3207
|
+
async function omcpEnsureLoggedIn() {
|
|
3208
|
+
if (_omcpLoginInflight) return _omcpLoginInflight;
|
|
3209
|
+
_omcpLoginInflight = new Promise((resolve) => { _omcpLoginResolve = resolve; });
|
|
3210
|
+
// Race fix (deferred from #290 reviewer): when a protected fetch
|
|
3211
|
+
// 401s before omcpSyncIdentity() has run, __omcpMe defaults to
|
|
3212
|
+
// anonymous and the basic-mode form would flash for one paint
|
|
3213
|
+
// even in OIDC mode. Force a sync before deciding which block to
|
|
3214
|
+
// show so the operator never sees the wrong form.
|
|
3215
|
+
if ((window.__omcpMe || {}).mode === 'anonymous') {
|
|
3216
|
+
try { await omcpSyncIdentity(); } catch (e) {}
|
|
3217
|
+
}
|
|
3218
|
+
const m = document.getElementById('login-modal');
|
|
3219
|
+
if (m) m.classList.add('open');
|
|
3220
|
+
const me = window.__omcpMe || {};
|
|
3221
|
+
const isOidc = me.mode === 'oidc';
|
|
3222
|
+
const ssoBlock = document.getElementById('login-oidc');
|
|
3223
|
+
const basicBlock = document.getElementById('login-basic');
|
|
3224
|
+
const ssoHint = document.getElementById('login-sso-hint');
|
|
3225
|
+
const submitBtn = document.getElementById('login-submit');
|
|
3226
|
+
if (ssoBlock) ssoBlock.style.display = isOidc ? '' : 'none';
|
|
3227
|
+
if (basicBlock) basicBlock.style.display = isOidc ? 'none' : '';
|
|
3228
|
+
if (submitBtn) submitBtn.style.display = isOidc ? 'none' : '';
|
|
3229
|
+
if (ssoHint && isOidc) {
|
|
3230
|
+
ssoHint.textContent = me.idpIssuer ? 'You will be redirected to ' + me.idpIssuer : '';
|
|
3231
|
+
}
|
|
3232
|
+
// Prefill the most recent username (basic mode only — OIDC needs no
|
|
3233
|
+
// local credential). Focus rules unchanged.
|
|
3234
|
+
setTimeout(() => {
|
|
3235
|
+
if (isOidc) {
|
|
3236
|
+
const sso = document.getElementById('login-sso');
|
|
3237
|
+
if (sso) sso.focus();
|
|
3238
|
+
return;
|
|
3239
|
+
}
|
|
3240
|
+
const u = document.getElementById('login-username');
|
|
3241
|
+
const p = document.getElementById('login-password');
|
|
3242
|
+
let lastUser = '';
|
|
3243
|
+
try { lastUser = localStorage.getItem('omcp-last-user') || ''; } catch (e) {}
|
|
3244
|
+
if (u && lastUser && !u.value) u.value = lastUser;
|
|
3245
|
+
if (u && p && u.value) p.focus();
|
|
3246
|
+
else if (u) u.focus();
|
|
3247
|
+
}, 50);
|
|
3248
|
+
return _omcpLoginInflight;
|
|
3249
|
+
}
|
|
3250
|
+
// Kick off the OIDC redirect. The return_to carries the current path
|
|
3251
|
+
// so the user lands back where they were after the IdP round-trip.
|
|
3252
|
+
function omcpStartSso() {
|
|
3253
|
+
const ret = location.pathname + location.search + location.hash;
|
|
3254
|
+
// Only same-origin paths are valid (isSafeReturnTo on the server
|
|
3255
|
+
// enforces this — but we sanitise client-side too so the URL stays
|
|
3256
|
+
// tidy in browser history).
|
|
3257
|
+
const safe = ret && ret.startsWith('/') && !ret.startsWith('//') ? ret : '/';
|
|
3258
|
+
location.href = '/api/auth/oidc/login?return_to=' + encodeURIComponent(safe);
|
|
3259
|
+
}
|
|
3260
|
+
function omcpLoginKey(e) { if (e.key === 'Enter') { e.preventDefault(); omcpSubmitLogin(); } }
|
|
3261
|
+
async function omcpSubmitLogin() {
|
|
3262
|
+
const u = document.getElementById('login-username').value.trim();
|
|
3263
|
+
const p = document.getElementById('login-password').value;
|
|
3264
|
+
const banner = document.getElementById('login-banner');
|
|
3265
|
+
if (!u || !p) {
|
|
3266
|
+
banner.className = 'form-banner show error';
|
|
3267
|
+
banner.textContent = 'Username and password are required';
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
const btn = document.getElementById('login-submit');
|
|
3271
|
+
btn.disabled = true;
|
|
3272
|
+
try {
|
|
3273
|
+
const res = await _omcpRawFetch('/api/auth/login', {
|
|
3274
|
+
method: 'POST',
|
|
3275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3276
|
+
body: JSON.stringify({ username: u, password: p }),
|
|
3277
|
+
});
|
|
3278
|
+
if (!res.ok) {
|
|
3279
|
+
const j = await res.json().catch(() => ({ error: 'sign-in failed' }));
|
|
3280
|
+
banner.className = 'form-banner show error';
|
|
3281
|
+
banner.textContent = j.error || 'sign-in failed';
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
document.getElementById('login-modal').classList.remove('open');
|
|
3285
|
+
document.getElementById('login-password').value = '';
|
|
3286
|
+
banner.className = 'form-banner';
|
|
3287
|
+
// Remember the username for next time so the operator doesn't
|
|
3288
|
+
// retype it on every cookie expiry. Password field never persists.
|
|
3289
|
+
try { localStorage.setItem('omcp-last-user', u); } catch (e) {}
|
|
3290
|
+
if (_omcpLoginResolve) { const r = _omcpLoginResolve; _omcpLoginResolve = null; _omcpLoginInflight = null; r(); }
|
|
3291
|
+
await omcpSyncIdentity();
|
|
3292
|
+
} finally { btn.disabled = false; }
|
|
3293
|
+
}
|
|
3294
|
+
async function omcpLogout() {
|
|
3295
|
+
try { await _omcpRawFetch('/api/auth/logout', { method: 'POST' }); } catch(e) {}
|
|
3296
|
+
await omcpSyncIdentity();
|
|
3297
|
+
// Reload the page so any cached in-memory state is dropped.
|
|
3298
|
+
location.reload();
|
|
3299
|
+
}
|
|
3300
|
+
// Identity + granted permissions snapshot, refreshed after sign-in /
|
|
3301
|
+
// out. Other code calls omcpCan('sources','write') to decide whether
|
|
3302
|
+
// to render write controls. Anonymous mode → window.__omcpMe.permissions
|
|
3303
|
+
// is undefined and omcpCan() returns true (no RBAC enforced).
|
|
3304
|
+
window.__omcpMe = { authenticated: false, mode: 'anonymous', permissions: null };
|
|
3305
|
+
function omcpCan(resource, action) {
|
|
3306
|
+
const me = window.__omcpMe;
|
|
3307
|
+
if (!me || me.mode === 'anonymous') return true;
|
|
3308
|
+
if (!Array.isArray(me.permissions)) return false;
|
|
3309
|
+
return me.permissions.some(p => p.resource === resource && p.action === action);
|
|
3310
|
+
}
|
|
3311
|
+
function omcpApplyRbacToDom() {
|
|
3312
|
+
// Hide elements marked with data-rbac="resource:action" when the
|
|
3313
|
+
// current user lacks that permission. Idempotent — safe to call on
|
|
3314
|
+
// every render.
|
|
3315
|
+
document.querySelectorAll('[data-rbac]').forEach(el => {
|
|
3316
|
+
const [r, a] = (el.getAttribute('data-rbac') || '').split(':');
|
|
3317
|
+
if (!r || !a) return;
|
|
3318
|
+
el.style.display = omcpCan(r, a) ? '' : 'none';
|
|
3319
|
+
});
|
|
3320
|
+
}
|
|
3321
|
+
async function omcpSyncIdentity() {
|
|
3322
|
+
const badge = document.getElementById('user-badge');
|
|
3323
|
+
try {
|
|
3324
|
+
const r = await _omcpRawFetch('/api/me');
|
|
3325
|
+
if (!r.ok) { if (badge) badge.style.display = 'none'; return; }
|
|
3326
|
+
const me = await r.json();
|
|
3327
|
+
window.__omcpMe = me;
|
|
3328
|
+
if (me.authenticated && badge) {
|
|
3329
|
+
// Show the email next to the name when the IdP gave us a
|
|
3330
|
+
// verified one. Also surface the IdP issuer host as a title
|
|
3331
|
+
// tooltip when this is an OIDC session — operators with
|
|
3332
|
+
// multiple IdPs can tell at a glance which one they're on.
|
|
3333
|
+
const nameEl = badge.querySelector('.user-name');
|
|
3334
|
+
const u = me.user || {};
|
|
3335
|
+
if (nameEl) {
|
|
3336
|
+
nameEl.textContent = u.email ? (u.name + ' · ' + u.email) : u.name;
|
|
3337
|
+
}
|
|
3338
|
+
// Surface the tenant when it's not the universal default. A
|
|
3339
|
+
// single-tenant deployment never sees the chip, so the demo
|
|
3340
|
+
// path stays uncluttered; multi-tenant deployments get a
|
|
3341
|
+
// permanent reminder of which tenant the session belongs to.
|
|
3342
|
+
let chip = badge.querySelector('.user-tenant');
|
|
3343
|
+
if (u.tenant && u.tenant !== 'default') {
|
|
3344
|
+
if (!chip) {
|
|
3345
|
+
chip = document.createElement('span');
|
|
3346
|
+
chip.className = 'user-tenant tag';
|
|
3347
|
+
chip.style.marginLeft = 'var(--sp-2)';
|
|
3348
|
+
badge.appendChild(chip);
|
|
3349
|
+
}
|
|
3350
|
+
chip.textContent = u.tenant;
|
|
3351
|
+
} else if (chip) {
|
|
3352
|
+
chip.remove();
|
|
3353
|
+
}
|
|
3354
|
+
// body[data-tenant=…] lets per-tenant CSS theming (e.g. a brand
|
|
3355
|
+
// colour bar) hook in without per-tenant builds.
|
|
3356
|
+
document.body.setAttribute('data-tenant', u.tenant || 'default');
|
|
3357
|
+
const tooltip = [];
|
|
3358
|
+
if (me.mode === 'oidc' && me.idpIssuer) {
|
|
3359
|
+
try { tooltip.push('signed in via ' + new URL(me.idpIssuer).host); } catch (e) { tooltip.push('signed in via ' + me.idpIssuer); }
|
|
3360
|
+
}
|
|
3361
|
+
if (u.tenant && u.tenant !== 'default') tooltip.push('tenant: ' + u.tenant);
|
|
3362
|
+
if (tooltip.length) badge.title = tooltip.join(' · ');
|
|
3363
|
+
else badge.removeAttribute('title');
|
|
3364
|
+
badge.style.display = '';
|
|
3365
|
+
} else if (badge) {
|
|
3366
|
+
badge.style.display = 'none';
|
|
3367
|
+
document.body.removeAttribute('data-tenant');
|
|
3368
|
+
}
|
|
3369
|
+
omcpApplyRbacToDom();
|
|
3370
|
+
} catch (e) { if (badge) badge.style.display = 'none'; }
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// Reads /api/info once at boot to surface deployment-misconfig warnings
|
|
3374
|
+
// the operator should fix. Currently only one trigger: the auth secret
|
|
3375
|
+
// is process-ephemeral (OMCP_SESSION_SECRET unset in basic mode), which
|
|
3376
|
+
// means every sign-in dies on restart. Pure additive — no banner shown
|
|
3377
|
+
// when the deployment is clean.
|
|
3378
|
+
async function omcpCheckGovernance() {
|
|
3379
|
+
try {
|
|
3380
|
+
const r = await _omcpRawFetch('/api/info');
|
|
3381
|
+
if (!r.ok) return;
|
|
3382
|
+
const info = await r.json();
|
|
3383
|
+
const g = info && info.governance;
|
|
3384
|
+
if (!g) return;
|
|
3385
|
+
if (g.authMode === 'basic' && g.authSecretEphemeral) {
|
|
3386
|
+
const bar = document.getElementById('omcp-warning-bar');
|
|
3387
|
+
if (bar) {
|
|
3388
|
+
bar.textContent = '⚠ OMCP_SESSION_SECRET is not set — every sign-in will be lost on server restart. Set a stable value in production.';
|
|
3389
|
+
bar.style.display = '';
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
} catch (e) { /* /api/info unreachable — no banner */ }
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
3396
|
+
omcpSyncIdentity();
|
|
3397
|
+
omcpCheckGovernance();
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3400
|
+
// --- Rail collapse toggle (icon-only mode) ---
|
|
3401
|
+
function toggleRail(){
|
|
3402
|
+
const cur = document.documentElement.getAttribute('data-rail') === 'collapsed' ? 'collapsed' : 'expanded';
|
|
3403
|
+
const next = cur === 'collapsed' ? 'expanded' : 'collapsed';
|
|
3404
|
+
document.documentElement.setAttribute('data-rail', next);
|
|
3405
|
+
try { localStorage.setItem('omcp-rail', next); } catch(e){}
|
|
3406
|
+
// Update the toggle button's tooltip so it reflects the action it will take.
|
|
3407
|
+
const btn = document.getElementById('rail-collapse-btn');
|
|
3408
|
+
if (btn) btn.title = next === 'collapsed' ? 'Expand navigation' : 'Collapse navigation';
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3411
|
+
// --- Topology graph viewport controls (driven by the +/-/reset toolbar) ---
|
|
3412
|
+
function topoZoom(k){ const v = window.__topoViewport; if (v) v.zoom(k); }
|
|
3413
|
+
function topoResetView(){ const v = window.__topoViewport; if (v) v.reset(); }
|
|
3414
|
+
|
|
3415
|
+
// --- Row density toggle (comfortable / compact), persisted ---
|
|
3416
|
+
function toggleDensity(){
|
|
3417
|
+
const cur=document.documentElement.getAttribute('data-density')==='compact'?'compact':'comfortable';
|
|
3418
|
+
const next=cur==='compact'?'comfortable':'compact';
|
|
3419
|
+
document.documentElement.setAttribute('data-density',next);
|
|
3420
|
+
try{ localStorage.setItem('omcp-density',next); }catch(e){}
|
|
3421
|
+
}
|
|
3422
|
+
|
|
1599
3423
|
// --- Navigation groups (collapsible, persisted) ---
|
|
1600
3424
|
function navState(){ try{ return JSON.parse(localStorage.getItem('omcp-nav')||'{}'); }catch(e){ return {}; } }
|
|
1601
3425
|
function toggleNavGroup(id){
|
|
@@ -1771,15 +3595,15 @@ function audDetail(seq){
|
|
|
1771
3595
|
const tone=ev.allow?'ok':'bad';
|
|
1772
3596
|
const reqRows=Object.keys(req).map(k=>`<tr><td>${escHtml(k)}</td><td class="mono">${escHtml(typeof req[k]==='object'?JSON.stringify(req[k]):String(req[k]))}</td></tr>`).join('')||'<tr><td colspan=2 style="color:var(--text-dim)">no request fields</td></tr>';
|
|
1773
3597
|
const html=
|
|
1774
|
-
dwSec('Decision', `<span class="stchip ${tone}">${ev.allow?'allow':'deny'}</span> <span
|
|
3598
|
+
dwSec('Decision', `<span class="stchip ${tone}">${ev.allow?'allow':'deny'}</span> <span class="muted t-sm">sequence #${escHtml(String(e.seq))}</span>`)+
|
|
1775
3599
|
dwSec('Principal & reason', `<table class="dtable"><tbody>
|
|
1776
3600
|
<tr><td>Principal</td><td class="mono">${escHtml(ev.principalId||'—')}</td></tr>
|
|
1777
3601
|
<tr><td>Timestamp</td><td class="mono">${escHtml(ev.ts||e.ts||'—')}</td></tr>
|
|
1778
3602
|
<tr><td>Reason</td><td>${escHtml(ev.reason||'—')}</td></tr></tbody></table>`)+
|
|
1779
3603
|
dwSec('Request', `<table class="dtable"><tbody>${reqRows}</tbody></table>`)+
|
|
1780
3604
|
dwSec('Chain integrity', `<table class="dtable"><tbody>
|
|
1781
|
-
<tr><td>Entry hash</td><td class="mono
|
|
1782
|
-
<tr><td>Prev hash</td><td class="mono
|
|
3605
|
+
<tr><td>Entry hash</td><td class="mono t-break">${escHtml(e.hash||'—')}</td></tr>
|
|
3606
|
+
<tr><td>Prev hash</td><td class="mono t-break">${escHtml(e.prevHash||'—')}</td></tr></tbody></table>`);
|
|
1783
3607
|
openDrawer('Decision #'+seq, html);
|
|
1784
3608
|
}
|
|
1785
3609
|
function dpView(){ try{ return localStorage.getItem('omcp-dp-view')==='table'?'table':'cards'; }catch(e){ return 'cards'; } }
|
|
@@ -1837,7 +3661,7 @@ function dpDetail(name){
|
|
|
1837
3661
|
dwSec('Sources', dwChips(p.sources))+
|
|
1838
3662
|
dwSec('Services', dwChips(p.services))+
|
|
1839
3663
|
dwSec('Tools', dwChips(p.tools))+
|
|
1840
|
-
dwSec('Granted to ('+grantedTo.length+')', grantedTo.length?grantedTo.map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''):'<span
|
|
3664
|
+
dwSec('Granted to ('+grantedTo.length+')', grantedTo.length?grantedTo.map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''):'<span class="t-dim t-sm">No principals granted this product.</span>')+
|
|
1841
3665
|
`<div class="codeblock collapsed" style="margin-top:14px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · grant this product</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>${ex}</pre></div>`;
|
|
1842
3666
|
openDrawer(name, html);
|
|
1843
3667
|
}
|
|
@@ -1854,7 +3678,34 @@ async function loadGatePill(){
|
|
|
1854
3678
|
}catch{ el.className='gate-pill gate-unknown'; el.textContent='Gate: n/a'; }
|
|
1855
3679
|
}
|
|
1856
3680
|
function entKV(o){return '<dl class="kv">'+Object.entries(o).map(([k,v])=>`<dt>${escHtml(k)}</dt><dd class="mono">${escHtml(v)}</dd>`).join('')+'</dl>';}
|
|
3681
|
+
// Operator-controlled page size for the management-plane audit feed.
|
|
3682
|
+
// Starts at 50 (the original default); the "Load more" button on the
|
|
3683
|
+
// table jumps to 500 (the in-memory ring cap) and the change persists
|
|
3684
|
+
// until the next reload. Doesn't read from localStorage on purpose —
|
|
3685
|
+
// inspecting more entries than usual is an investigative gesture, not
|
|
3686
|
+
// an everyday preference.
|
|
3687
|
+
let _omcpMgmtAuditLimit = 50;
|
|
3688
|
+
function omcpMgmtAuditMore(){ _omcpMgmtAuditLimit = 500; loadEnterprise(); }
|
|
3689
|
+
|
|
3690
|
+
// Self-refresh the audit + governance feeds while the user is on
|
|
3691
|
+
// page-audit, page-access, page-products or page-entitlement. Gated on
|
|
3692
|
+
// document.activeElement and the page's .active class so it doesn't
|
|
3693
|
+
// burn cycles when the tab is hidden or backgrounded. Reuses the same
|
|
3694
|
+
// loadEnterprise() the page switch fires.
|
|
3695
|
+
let _omcpGovInterval = null;
|
|
3696
|
+
function ensureGovAutoRefresh(){
|
|
3697
|
+
if (_omcpGovInterval) return;
|
|
3698
|
+
_omcpGovInterval = setInterval(() => {
|
|
3699
|
+
if (typeof document === 'undefined') return;
|
|
3700
|
+
if (document.hidden) return;
|
|
3701
|
+
const onGovPage = ['page-audit','page-access','page-products','page-entitlement']
|
|
3702
|
+
.some(id => { const el = document.getElementById(id); return el && el.classList.contains('active'); });
|
|
3703
|
+
if (onGovPage) loadEnterprise();
|
|
3704
|
+
}, 15000);
|
|
3705
|
+
}
|
|
3706
|
+
|
|
1857
3707
|
async function loadEnterprise(){
|
|
3708
|
+
ensureGovAutoRefresh();
|
|
1858
3709
|
// Status + entitlement claims
|
|
1859
3710
|
try{
|
|
1860
3711
|
const s=await(await fetch('/api/enterprise/status')).json();
|
|
@@ -1914,6 +3765,61 @@ async function loadEnterprise(){
|
|
|
1914
3765
|
box.querySelectorAll('[data-aud]').forEach(el=>el.addEventListener('click',()=>audDetail(el.getAttribute('data-aud'))));
|
|
1915
3766
|
}
|
|
1916
3767
|
}catch{ document.getElementById('ent-audit').innerHTML='<div class="empty">Audit unavailable.</div>'; }
|
|
3768
|
+
// Management-plane audit (separate feed from the enterprise gate).
|
|
3769
|
+
// Honours the per-page mgmt-audit-limit picker so operators can dial
|
|
3770
|
+
// up from 50 to 500 entries without leaving the page.
|
|
3771
|
+
try{
|
|
3772
|
+
const limit = (typeof _omcpMgmtAuditLimit === 'number') ? _omcpMgmtAuditLimit : 50;
|
|
3773
|
+
const r=await fetch('/api/audit?limit='+encodeURIComponent(limit));
|
|
3774
|
+
if(!r.ok) throw new Error('http '+r.status);
|
|
3775
|
+
const a=await r.json();
|
|
3776
|
+
const box=document.getElementById('mgmt-audit');
|
|
3777
|
+
const entries=a.entries||[];
|
|
3778
|
+
if(entries.length===0){
|
|
3779
|
+
box.innerHTML='<div class="empty">No management changes recorded yet.</div>';
|
|
3780
|
+
} else {
|
|
3781
|
+
const statusPill = (status) => {
|
|
3782
|
+
const cls = status >= 500 ? 'pill-no'
|
|
3783
|
+
: status >= 400 ? 'pill-no'
|
|
3784
|
+
: status >= 200 && status < 300 ? 'pill-yes'
|
|
3785
|
+
: '';
|
|
3786
|
+
return `<span class="mono ${cls}">${status}</span>`;
|
|
3787
|
+
};
|
|
3788
|
+
// Read = neutral, write = accent, delete = danger. Operators
|
|
3789
|
+
// scanning an outage's worth of audit entries can spot the
|
|
3790
|
+
// delete-shaped rows first — those are the ones most likely to
|
|
3791
|
+
// have caused the regression.
|
|
3792
|
+
const permPill = (resource, action) => {
|
|
3793
|
+
const cls = action === 'delete' ? 'pill-no'
|
|
3794
|
+
: action === 'write' ? 't-accent'
|
|
3795
|
+
: 't-muted';
|
|
3796
|
+
return `<span class="mono ${cls}">${escHtml(resource)}:${escHtml(action)}</span>`;
|
|
3797
|
+
};
|
|
3798
|
+
// Anonymous-mode entries render in muted italic so they read
|
|
3799
|
+
// distinctly from authenticated actors — useful for spotting
|
|
3800
|
+
// misconfigured deployments and pre-migration entries in the
|
|
3801
|
+
// same feed.
|
|
3802
|
+
const actorCell = (a) => {
|
|
3803
|
+
const label = a.name || a.sub;
|
|
3804
|
+
if (a.sub === 'anonymous') {
|
|
3805
|
+
return `<span class="t-muted" style="font-style:italic">${escHtml(label)}</span>`;
|
|
3806
|
+
}
|
|
3807
|
+
return escHtml(label);
|
|
3808
|
+
};
|
|
3809
|
+
const rows=entries.map(e=>
|
|
3810
|
+
`<tr><td class="mono">${e.seq}</td><td class="mono t-sm">${escHtml(e.ts)}</td><td>${actorCell(e.actor)}</td><td><span class="chip">${escHtml(e.method)}</span> <span class="mono t-sm">${escHtml(e.path)}</span></td><td>${permPill(e.resource, e.action)}</td><td>${statusPill(e.status)}</td></tr>`
|
|
3811
|
+
).join('');
|
|
3812
|
+
const note=a.persisted?'':' · in-memory only — set OMCP_MGMT_AUDIT_FILE for persistence';
|
|
3813
|
+
const showMore = entries.length === limit
|
|
3814
|
+
? `<button class="btn btn-ghost btn-sm" onclick="omcpMgmtAuditMore()">Load more (up to 500)</button>`
|
|
3815
|
+
: '';
|
|
3816
|
+
box.innerHTML=`<div class="row-between mb-2"><span class="t-muted t-sm">${entries.length} entries${escHtml(note)}</span>${showMore}</div>
|
|
3817
|
+
<table class="dtable"><thead><tr><th>#</th><th>When</th><th>Actor</th><th>Request</th><th>Permission</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
3818
|
+
}
|
|
3819
|
+
}catch(e){
|
|
3820
|
+
const box=document.getElementById('mgmt-audit');
|
|
3821
|
+
if(box) box.innerHTML='<div class="empty">Management audit unavailable.</div>';
|
|
3822
|
+
}
|
|
1917
3823
|
}
|
|
1918
3824
|
// ---- Minimal YAML for the flat policy/catalog schema (maps, string
|
|
1919
3825
|
// arrays, booleans, strings). Optional alternative view to the form. ----
|
|
@@ -2249,6 +4155,661 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
|
|
|
2249
4155
|
|
|
2250
4156
|
// --- Data Loading ---
|
|
2251
4157
|
async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
|
|
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
|
+
|
|
4406
|
+
async function loadMcpProducts() {
|
|
4407
|
+
mcpLeitfadenSync();
|
|
4408
|
+
const box = document.getElementById('mcp-products-box');
|
|
4409
|
+
const scope = document.getElementById('mcp-products-scope');
|
|
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');
|
|
4417
|
+
try {
|
|
4418
|
+
const r = await fetch('/api/products');
|
|
4419
|
+
if (!r.ok) {
|
|
4420
|
+
if (r.status === 403) {
|
|
4421
|
+
box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
|
|
4422
|
+
} else {
|
|
4423
|
+
box.innerHTML = '<div class="empty">/api/products unavailable.</div>';
|
|
4424
|
+
}
|
|
4425
|
+
return;
|
|
4426
|
+
}
|
|
4427
|
+
const j = await r.json();
|
|
4428
|
+
if (scope) {
|
|
4429
|
+
const s = j.scopedTo === null ? 'all tenants' : (j.scopedTo || 'default');
|
|
4430
|
+
scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
|
|
4431
|
+
}
|
|
4432
|
+
if (!j.products || j.products.length === 0) {
|
|
4433
|
+
box.innerHTML = mcpProductEmptyHtml(j.configured);
|
|
4434
|
+
omcpApplyRbacToDom();
|
|
4435
|
+
return;
|
|
4436
|
+
}
|
|
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
|
+
}
|
|
4442
|
+
omcpApplyRbacToDom();
|
|
4443
|
+
} catch (e) {
|
|
4444
|
+
box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
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;
|
|
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'); }
|
|
4701
|
+
async function mcpProductsEdit(idEnc) {
|
|
4702
|
+
const id = decodeURIComponent(idEnc);
|
|
4703
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id));
|
|
4704
|
+
if (!r.ok) { toast('Could not fetch ' + id); return; }
|
|
4705
|
+
const current = await r.json();
|
|
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
|
+
}
|
|
4739
|
+
try {
|
|
4740
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id), {
|
|
4741
|
+
method: 'PUT',
|
|
4742
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4743
|
+
body: JSON.stringify(body),
|
|
4744
|
+
});
|
|
4745
|
+
if (!r.ok) {
|
|
4746
|
+
const j = await r.json().catch(() => ({}));
|
|
4747
|
+
fail(j.error || ('Save failed: HTTP ' + r.status));
|
|
4748
|
+
return;
|
|
4749
|
+
}
|
|
4750
|
+
closeModal('mcp-product-modal');
|
|
4751
|
+
toast((mode === 'edit' ? 'Updated ' : 'Created ') + id);
|
|
4752
|
+
await loadMcpProducts();
|
|
4753
|
+
} catch (e) { fail('Save failed: ' + (e && e.message || e)); }
|
|
4754
|
+
}
|
|
4755
|
+
async function mcpProductsDelete(idEnc) {
|
|
4756
|
+
const id = decodeURIComponent(idEnc);
|
|
4757
|
+
if (!window.confirm('Delete product ' + id + '?')) return;
|
|
4758
|
+
try {
|
|
4759
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
4760
|
+
if (r.status === 204) {
|
|
4761
|
+
toast('Deleted ' + id);
|
|
4762
|
+
await loadMcpProducts();
|
|
4763
|
+
return;
|
|
4764
|
+
}
|
|
4765
|
+
const j = await r.json().catch(() => ({}));
|
|
4766
|
+
toast('Delete failed: ' + (j.error || r.status));
|
|
4767
|
+
} catch (e) { toast('Delete failed: ' + (e && e.message || e)); }
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
async function loadDashUsage() {
|
|
4771
|
+
const card = document.getElementById('dash-usage-card');
|
|
4772
|
+
const box = document.getElementById('dash-usage');
|
|
4773
|
+
if (!card || !box) return;
|
|
4774
|
+
try {
|
|
4775
|
+
const r = await fetch('/api/usage');
|
|
4776
|
+
if (!r.ok) { card.style.display = 'none'; return; }
|
|
4777
|
+
const j = await r.json();
|
|
4778
|
+
const ids = (j.identities || [])
|
|
4779
|
+
.map((i) => ({
|
|
4780
|
+
actor: i.actor,
|
|
4781
|
+
tenant: i.tenant || 'default',
|
|
4782
|
+
calls: i.count || 0,
|
|
4783
|
+
rateLimit: i.limit || 0,
|
|
4784
|
+
tokensUsed: (i.tokens && i.tokens.used) || 0,
|
|
4785
|
+
tokenLimit: (i.tokens && i.tokens.limit) || 0,
|
|
4786
|
+
}))
|
|
4787
|
+
.filter((i) => i.calls > 0 || i.tokensUsed > 0)
|
|
4788
|
+
.sort((a, b) => (b.tokensUsed - a.tokensUsed) || (b.calls - a.calls))
|
|
4789
|
+
.slice(0, 5);
|
|
4790
|
+
if (ids.length === 0) { card.style.display = 'none'; return; }
|
|
4791
|
+
card.style.display = '';
|
|
4792
|
+
// Header chip: which tenant the view is scoped to (admins see
|
|
4793
|
+
// either "all tenants" or a specific name; non-admins always
|
|
4794
|
+
// see their own).
|
|
4795
|
+
const scope = j.scopedTo === null ? 'all tenants' : (j.scopedTo || 'default');
|
|
4796
|
+
const showTenantCol = j.scopedTo === null; // only admins unscoped see multiple
|
|
4797
|
+
const rows = ids.map((i) => {
|
|
4798
|
+
const pct = i.tokenLimit > 0 ? Math.min(100, Math.round((i.tokensUsed / i.tokenLimit) * 100)) : null;
|
|
4799
|
+
const tokenCell = i.tokenLimit > 0
|
|
4800
|
+
? `${i.tokensUsed.toLocaleString()} / ${i.tokenLimit.toLocaleString()} <span class="t-sm" style="opacity:.7">(${pct}%)</span>`
|
|
4801
|
+
: `${i.tokensUsed.toLocaleString()} <span class="t-sm" style="opacity:.7">(uncapped)</span>`;
|
|
4802
|
+
const bar = pct !== null
|
|
4803
|
+
? `<div style="background: var(--surface-2); height: 6px; border-radius: 3px; overflow:hidden; margin-top: 2px;"><div style="width:${pct}%; height:100%; background: ${pct >= 90 ? 'var(--danger)' : pct >= 70 ? 'var(--warning)' : 'var(--success)'};"></div></div>`
|
|
4804
|
+
: '';
|
|
4805
|
+
const tenantCell = showTenantCol ? `<td><span class="tag">${escHtml(i.tenant)}</span></td>` : '';
|
|
4806
|
+
return `<tr><td><code>${escHtml(i.actor)}</code></td>${tenantCell}<td>${i.calls.toLocaleString()} / ${i.rateLimit.toLocaleString()}</td><td>${tokenCell}${bar}</td></tr>`;
|
|
4807
|
+
}).join('');
|
|
4808
|
+
const tenantHeader = showTenantCol ? '<th>Tenant</th>' : '';
|
|
4809
|
+
const scopeNote = `<div class="form-hint" style="padding: var(--sp-1) var(--sp-4) var(--sp-2); opacity:.7">scope: ${escHtml(scope)}</div>`;
|
|
4810
|
+
box.innerHTML = scopeNote + `<table class="data-table" style="width:100%"><thead><tr><th>Identity</th>${tenantHeader}<th>Calls (rate-limit/min)</th><th>Tokens (24h)</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
4811
|
+
} catch (e) { card.style.display = 'none'; }
|
|
4812
|
+
}
|
|
2252
4813
|
async function loadServices() { try { const d=await(await fetch('/api/services')).json(); servicesData=d.services||[]; renderServices(); updateStats(); } catch(e){} }
|
|
2253
4814
|
async function loadTypes() { try { supportedTypes=await(await fetch('/api/source-types')).json(); } catch(e){ supportedTypes=['prometheus','loki']; } }
|
|
2254
4815
|
async function loadSettingsData() {
|
|
@@ -2358,28 +4919,124 @@ function renderHealthPanels(svcs){
|
|
|
2358
4919
|
// --- Render Sources ---
|
|
2359
4920
|
function srcView(){ try{ return localStorage.getItem('omcp-src-view')==='table'?'table':'list'; }catch(e){ return 'list'; } }
|
|
2360
4921
|
function setSrcView(v){ try{ localStorage.setItem('omcp-src-view',v); }catch(e){} renderSources(); }
|
|
4922
|
+
|
|
4923
|
+
// --- Sources / Services filter + sort state (filter in-memory, sort persisted) ---
|
|
4924
|
+
let srcFilter = '';
|
|
4925
|
+
let svcFilter = '';
|
|
4926
|
+
function loadSrcSort(){
|
|
4927
|
+
try{ const s = JSON.parse(localStorage.getItem('omcp-src-sort')||''); if (s && s.key) return s; }catch(e){}
|
|
4928
|
+
return { key: 'name', dir: 'asc' };
|
|
4929
|
+
}
|
|
4930
|
+
let srcSort = loadSrcSort();
|
|
4931
|
+
function setSrcSort(key){
|
|
4932
|
+
if (srcSort.key === key) srcSort.dir = srcSort.dir === 'asc' ? 'desc' : 'asc';
|
|
4933
|
+
else { srcSort.key = key; srcSort.dir = 'asc'; }
|
|
4934
|
+
try{ localStorage.setItem('omcp-src-sort', JSON.stringify(srcSort)); }catch(e){}
|
|
4935
|
+
renderSources();
|
|
4936
|
+
}
|
|
4937
|
+
function setSrcFilter(v){ srcFilter = String(v||'').trim().toLowerCase(); renderSources(); }
|
|
4938
|
+
function setSvcFilter(v){ svcFilter = String(v||'').trim().toLowerCase(); renderServices(); }
|
|
4939
|
+
function compareSrc(a, b){
|
|
4940
|
+
const dir = srcSort.dir === 'desc' ? -1 : 1;
|
|
4941
|
+
let av, bv;
|
|
4942
|
+
switch (srcSort.key) {
|
|
4943
|
+
case 'type': av = a.type; bv = b.type; break;
|
|
4944
|
+
case 'signal': av = a.signalType||''; bv = b.signalType||''; break;
|
|
4945
|
+
case 'url': av = a.url; bv = b.url; break;
|
|
4946
|
+
case 'status': av = !a.enabled?'disabled':a.status; bv = !b.enabled?'disabled':b.status; break;
|
|
4947
|
+
case 'latency': return dir * ((a.latencyMs||0) - (b.latencyMs||0));
|
|
4948
|
+
case 'name':
|
|
4949
|
+
default: av = a.name; bv = b.name;
|
|
4950
|
+
}
|
|
4951
|
+
av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
|
|
4952
|
+
if (av < bv) return -1 * dir;
|
|
4953
|
+
if (av > bv) return 1 * dir;
|
|
4954
|
+
return 0;
|
|
4955
|
+
}
|
|
4956
|
+
function filterSrcRow(s){
|
|
4957
|
+
if (!srcFilter) return true;
|
|
4958
|
+
return [s.name, s.type, s.signalType||'', s.url].join(' ').toLowerCase().includes(srcFilter);
|
|
4959
|
+
}
|
|
4960
|
+
function filterSvcRow(s){
|
|
4961
|
+
if (!svcFilter) return true;
|
|
4962
|
+
return [s.name, (s.sources||[]).join(' '), (s.signalTypes||[]).join(' ')]
|
|
4963
|
+
.join(' ').toLowerCase().includes(svcFilter);
|
|
4964
|
+
}
|
|
4965
|
+
function sortIndClass(key){
|
|
4966
|
+
return key === srcSort.key ? (srcSort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
|
4967
|
+
}
|
|
4968
|
+
|
|
2361
4969
|
function srcRow(s){
|
|
2362
4970
|
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
2363
|
-
|
|
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>`;
|
|
2364
4975
|
}
|
|
2365
4976
|
function renderSources() {
|
|
2366
|
-
const
|
|
4977
|
+
const emptyDash = richEmpty({
|
|
4978
|
+
icon: 'plug',
|
|
4979
|
+
title: 'No sources yet',
|
|
4980
|
+
desc: 'Connect Prometheus, Loki or a Kubernetes cluster to start observing services.',
|
|
4981
|
+
ctaLabel: 'Add a source',
|
|
4982
|
+
ctaOnClick: "showPage('sources');openAddModal()",
|
|
4983
|
+
});
|
|
4984
|
+
const dashHtml = sourcesData.length===0 ? emptyDash : sourcesData.map(srcRow).join('');
|
|
2367
4985
|
document.getElementById('dash-sources').innerHTML=dashHtml;
|
|
2368
4986
|
const box=document.getElementById('sources-list'); if(!box) return;
|
|
2369
|
-
if(sourcesData.length===0){
|
|
4987
|
+
if(sourcesData.length===0){
|
|
4988
|
+
box.innerHTML = richEmpty({
|
|
4989
|
+
icon: 'plug',
|
|
4990
|
+
title: 'No sources configured',
|
|
4991
|
+
desc: 'Add a Prometheus, Loki or Kubernetes endpoint. Sources are auto-discovered for services and feed health, anomaly and topology tools.',
|
|
4992
|
+
ctaLabel: 'Add a source',
|
|
4993
|
+
ctaOnClick: 'openAddModal()',
|
|
4994
|
+
});
|
|
4995
|
+
return;
|
|
4996
|
+
}
|
|
2370
4997
|
const view=srcView();
|
|
2371
|
-
|
|
2372
|
-
|
|
4998
|
+
// Apply filter + sort. Keep `sourcesData` immutable; render from a copy.
|
|
4999
|
+
const visible = sourcesData.filter(filterSrcRow).slice().sort(compareSrc);
|
|
5000
|
+
const filterBar=`<div class="list-filter">
|
|
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">
|
|
5002
|
+
<span class="count">${visible.length} of ${sourcesData.length}</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>
|
|
5004
|
+
</div>`;
|
|
2373
5005
|
let body;
|
|
2374
5006
|
if(view==='table'){
|
|
2375
|
-
|
|
2376
|
-
|
|
5007
|
+
const h = (key, label) => `<th class="sortable ${sortIndClass(key)}" data-sort-key="${key}" onclick="setSrcSort('${key}')">${label}<span class="sort-ind"></span></th>`;
|
|
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('')+
|
|
2377
5019
|
`</tbody></table>`;
|
|
2378
5020
|
} else {
|
|
2379
|
-
body=
|
|
5021
|
+
body = visible.length === 0
|
|
5022
|
+
? `<div class="empty">No sources match "${esc(srcFilter)}".</div>`
|
|
5023
|
+
: visible.map(srcRow).join('');
|
|
2380
5024
|
}
|
|
2381
|
-
box.innerHTML=
|
|
2382
|
-
|
|
5025
|
+
box.innerHTML=filterBar+body;
|
|
5026
|
+
// Re-focus the filter input so typing doesn't lose focus across renders.
|
|
5027
|
+
if (srcFilter) {
|
|
5028
|
+
const inp = document.getElementById('src-filter');
|
|
5029
|
+
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
|
5030
|
+
}
|
|
5031
|
+
box.querySelectorAll('tr[data-src], .source-row[data-src]').forEach(el=>el.addEventListener('click',(ev)=>{
|
|
5032
|
+
// Don't trigger detail when clicking sortable column headers or actions.
|
|
5033
|
+
if (ev.target.closest('.source-actions, th.sortable')) return;
|
|
5034
|
+
srcDetail(el.getAttribute('data-src'));
|
|
5035
|
+
}));
|
|
5036
|
+
// Re-apply RBAC visibility — the new rows just got data-rbac="…" on
|
|
5037
|
+
// the per-row edit/delete/toggle controls and need to be hidden for
|
|
5038
|
+
// viewers who can't operate them. Idempotent.
|
|
5039
|
+
if (typeof omcpApplyRbacToDom === 'function') omcpApplyRbacToDom();
|
|
2383
5040
|
}
|
|
2384
5041
|
function srcDetail(name){
|
|
2385
5042
|
const s=sourcesData.find(x=>x.name===name);
|
|
@@ -2387,22 +5044,71 @@ function srcDetail(name){
|
|
|
2387
5044
|
const tone=!s.enabled?'warn':s.status==='up'?'ok':'bad';
|
|
2388
5045
|
const svc=(servicesData||[]).filter(x=>(x.sources||[]).includes(s.name)).map(x=>x.name);
|
|
2389
5046
|
const html=
|
|
2390
|
-
dwSec('Status', `<span class="stchip ${tone}">${!s.enabled?'disabled':s.status==='up'?'up':'down'}</span>${s.latencyMs?` <span
|
|
5047
|
+
dwSec('Status', `<span class="stchip ${tone}">${!s.enabled?'disabled':s.status==='up'?'up':'down'}</span>${s.latencyMs?` <span class="muted t-sm">${s.latencyMs}ms</span>`:''}`)+
|
|
2391
5048
|
dwSec('Configuration', `<table class="dtable"><tbody>
|
|
2392
5049
|
<tr><td>Type</td><td class="mono">${escHtml(s.type)}</td></tr>
|
|
2393
5050
|
<tr><td>Signal type</td><td class="mono">${escHtml(s.signalType||'—')}</td></tr>
|
|
2394
|
-
<tr><td>URL</td><td class="mono
|
|
5051
|
+
<tr><td>URL</td><td class="mono t-break">${escHtml(s.url)}</td></tr>
|
|
2395
5052
|
<tr><td>Enabled</td><td>${s.enabled?'yes':'no'}</td></tr></tbody></table>`)+
|
|
2396
|
-
dwSec('Services from this source', svc.length?svc.map(n=>`<span class="chip">${escHtml(n)}</span>`).join(' '):'<span
|
|
5053
|
+
dwSec('Services from this source', svc.length?svc.map(n=>`<span class="chip">${escHtml(n)}</span>`).join(' '):'<span class="t-dim t-sm">none discovered</span>')+
|
|
2397
5054
|
`<div style="display:flex;gap:8px;margin-top:14px"><button class="btn btn-sm" onclick="closeDrawer();openEditModal('${esc(s.name)}')">Edit source</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer();openDeleteConfirm('source','${esc(s.name)}')">Delete</button></div>`+
|
|
2398
5055
|
`<div class="codeblock collapsed" style="margin-top:12px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · all sources</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>curl -s http://localhost:3000/api/sources</pre></div>`;
|
|
2399
5056
|
openDrawer(name, html);
|
|
2400
5057
|
}
|
|
2401
5058
|
function renderServices() {
|
|
2402
|
-
const
|
|
2403
|
-
const
|
|
2404
|
-
|
|
2405
|
-
|
|
5059
|
+
const sl = document.getElementById('services-list');
|
|
5060
|
+
const dash = document.getElementById('dash-services');
|
|
5061
|
+
if(servicesData.length === 0){
|
|
5062
|
+
// Two flavors: if no sources are connected, point at the Sources tab;
|
|
5063
|
+
// otherwise it's a transient discovery gap, so just say "no services yet".
|
|
5064
|
+
const hasSources = (sourcesData||[]).some(s => s.enabled && s.status === 'up');
|
|
5065
|
+
const empty = hasSources
|
|
5066
|
+
? richEmpty({
|
|
5067
|
+
icon: 'service',
|
|
5068
|
+
title: 'No services discovered',
|
|
5069
|
+
desc: 'Sources are connected but no service-level signals are flowing yet. Health and anomaly tools will activate as soon as services start emitting metrics or logs.',
|
|
5070
|
+
})
|
|
5071
|
+
: richEmpty({
|
|
5072
|
+
icon: 'service',
|
|
5073
|
+
title: 'No services to show',
|
|
5074
|
+
desc: 'Connect at least one source — services are auto-discovered from the metrics or logs they emit.',
|
|
5075
|
+
ctaLabel: 'Go to Sources',
|
|
5076
|
+
ctaOnClick: "showPage('sources')",
|
|
5077
|
+
});
|
|
5078
|
+
if (sl) sl.innerHTML = empty;
|
|
5079
|
+
if (dash) dash.innerHTML = empty;
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
const visible = servicesData.filter(filterSvcRow);
|
|
5083
|
+
// Catalog enrichment is optional — when /api/services returned a
|
|
5084
|
+
// `.catalog` field (because OMCP_SERVICE_CATALOG_FILE is set and
|
|
5085
|
+
// the service name matched an entry), surface the most useful
|
|
5086
|
+
// fields inline: owner chip, criticality tier pill, on-call link.
|
|
5087
|
+
// Otherwise the row renders exactly as before.
|
|
5088
|
+
const catalogStrip = (c) => {
|
|
5089
|
+
if (!c) return '';
|
|
5090
|
+
const parts = [];
|
|
5091
|
+
if (c.owner) parts.push(`<span class="chip">owner: ${esc(c.owner)}</span>`);
|
|
5092
|
+
if (c.tier) parts.push(`<span class="chip">${esc(c.tier)}</span>`);
|
|
5093
|
+
if (c.onCall) parts.push(`<a class="chip" href="${esc(c.onCall)}" target="_blank" rel="noopener">on-call ↗</a>`);
|
|
5094
|
+
return parts.length ? ` <span class="row-inline gap-2">${parts.join('')}</span>` : '';
|
|
5095
|
+
};
|
|
5096
|
+
const rowsHtml = visible.length === 0
|
|
5097
|
+
? `<div class="empty">No services match "${esc(svcFilter)}".</div>`
|
|
5098
|
+
: visible.map(s=>`<div class="service-row" data-svc="${esc(s.name)}"><div><span class="name">${esc(s.name)}</span>${s.signalTypes.map(t=>`<span class="tag tag-${t}">${t}</span>`).join('')}${catalogStrip(s.catalog)}</div><span class="t-muted t-sm">${s.sources.join(', ')}</span></div>`).join('');
|
|
5099
|
+
if (sl) {
|
|
5100
|
+
const filterBar = `<div class="list-filter">
|
|
5101
|
+
<input id="svc-filter" type="search" placeholder="Filter services by name or source…" value="${esc(svcFilter)}" oninput="setSvcFilter(this.value)" aria-label="Filter services">
|
|
5102
|
+
<span class="count">${visible.length} of ${servicesData.length}</span>
|
|
5103
|
+
</div>`;
|
|
5104
|
+
sl.innerHTML = filterBar + rowsHtml;
|
|
5105
|
+
if (svcFilter) {
|
|
5106
|
+
const inp = document.getElementById('svc-filter');
|
|
5107
|
+
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
|
5108
|
+
}
|
|
5109
|
+
sl.querySelectorAll('[data-svc]').forEach(el=>el.addEventListener('click',()=>svcDetail(el.getAttribute('data-svc'))));
|
|
5110
|
+
}
|
|
5111
|
+
if (dash) dash.innerHTML = rowsHtml;
|
|
2406
5112
|
}
|
|
2407
5113
|
async function svcDetail(name){
|
|
2408
5114
|
let v=healthMap[name];
|
|
@@ -2411,16 +5117,35 @@ async function svcDetail(name){
|
|
|
2411
5117
|
const tone=v.status==='critical'?'bad':v.status!=='healthy'?'warn':'ok';
|
|
2412
5118
|
const m=(v.signals&&v.signals.metrics)||{};
|
|
2413
5119
|
const fmtN=x=>x==null?'—':(Math.round(Number(x)*100)/100);
|
|
5120
|
+
// Catalog enrichment may live on either the health payload or on the
|
|
5121
|
+
// service row in servicesData (whichever was fetched first).
|
|
5122
|
+
const cat = v.catalog || ((servicesData||[]).find(s => s.name === name) || {}).catalog;
|
|
5123
|
+
const catalogSection = cat ? dwSec('Catalog', `<table class="dtable"><tbody>${
|
|
5124
|
+
[
|
|
5125
|
+
['Owner', cat.owner],
|
|
5126
|
+
['Tier', cat.tier],
|
|
5127
|
+
['Data classification', cat.dataClassification],
|
|
5128
|
+
['SLO', cat.slo],
|
|
5129
|
+
['On-call', cat.onCall ? `<a href="${escHtml(cat.onCall)}" target="_blank" rel="noopener" class="mono">${escHtml(cat.onCall)} ↗</a>` : null],
|
|
5130
|
+
['Tags', (cat.tags && cat.tags.length) ? cat.tags.map(t => `<span class="chip">${escHtml(t)}</span>`).join(' ') : null],
|
|
5131
|
+
['Runbooks', (cat.runbooks && cat.runbooks.length) ? cat.runbooks.map(u => `<a href="${escHtml(u)}" target="_blank" rel="noopener" class="mono t-sm">${escHtml(u)} ↗</a>`).join('<br>') : null],
|
|
5132
|
+
['Description', cat.description],
|
|
5133
|
+
]
|
|
5134
|
+
.filter(([, val]) => val != null && val !== '')
|
|
5135
|
+
.map(([k, val]) => `<tr><td>${k}</td><td>${val}</td></tr>`)
|
|
5136
|
+
.join('')
|
|
5137
|
+
}</tbody></table>`) : '';
|
|
2414
5138
|
const html=
|
|
2415
|
-
dwSec('Status', `<span class="stchip ${tone}">${escHtml(v.status)}</span> <span
|
|
5139
|
+
dwSec('Status', `<span class="stchip ${tone}">${escHtml(v.status)}</span> <span class="muted t-sm">health score ${escHtml(v.score)}</span>`)+
|
|
5140
|
+
catalogSection+
|
|
2416
5141
|
dwSec('Signals', `<table class="dtable"><tbody>
|
|
2417
5142
|
<tr><td>CPU</td><td class="mono">${fmtN(m.cpu)}</td></tr>
|
|
2418
5143
|
<tr><td>Memory (MB)</td><td class="mono">${fmtN(m.memory)}</td></tr>
|
|
2419
5144
|
<tr><td>Error rate</td><td class="mono">${fmtN(m.errorRate)}</td></tr>
|
|
2420
5145
|
<tr><td>Latency p99 (s)</td><td class="mono">${fmtN(m.latencyP99)}</td></tr>
|
|
2421
5146
|
<tr><td>Log errors (5m)</td><td class="mono">${fmtN(v.signals&&v.signals.logs&&v.signals.logs.errorRate)}</td></tr></tbody></table>`)+
|
|
2422
|
-
dwSec('Anomalies', (v.anomalies&&v.anomalies.length)?v.anomalies.map(a=>`<div class="feed-row"><span class="stchip ${a.severity==='high'?'bad':'warn'}">${escHtml(a.metric)}</span><div class="fr-main"><div class="fr-sub">${escHtml(a.description||'')}</div></div></div>`).join(''):'<span
|
|
2423
|
-
dwSec('Correlations', (v.correlations&&v.correlations.length)?v.correlations.map(c=>`<div class="fr-sub" style="margin-bottom:4px">• ${escHtml(c)}</div>`).join(''):'<span
|
|
5147
|
+
dwSec('Anomalies', (v.anomalies&&v.anomalies.length)?v.anomalies.map(a=>`<div class="feed-row"><span class="stchip ${a.severity==='high'?'bad':'warn'}">${escHtml(a.metric)}</span><div class="fr-main"><div class="fr-sub">${escHtml(a.description||'')}</div></div></div>`).join(''):'<span class="t-dim t-sm">None detected.</span>')+
|
|
5148
|
+
dwSec('Correlations', (v.correlations&&v.correlations.length)?v.correlations.map(c=>`<div class="fr-sub" style="margin-bottom:4px">• ${escHtml(c)}</div>`).join(''):'<span class="t-dim t-sm">None.</span>')+
|
|
2424
5149
|
`<div class="codeblock collapsed" style="margin-top:6px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · this service's health</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>curl -s http://localhost:3000/api/health/${escHtml(name)}</pre></div>`;
|
|
2425
5150
|
openDrawer(name, html);
|
|
2426
5151
|
}
|
|
@@ -2480,13 +5205,37 @@ function setAuthInForm(auth) {
|
|
|
2480
5205
|
if(auth.type==='bearer') { document.getElementById('src-auth-token').value=auth.token||''; }
|
|
2481
5206
|
toggleAuthFields();
|
|
2482
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
|
+
}
|
|
2483
5229
|
function openAddModal() {
|
|
2484
5230
|
document.getElementById('modal-title').textContent='Add Source'; document.getElementById('modal-mode').value='add';
|
|
2485
5231
|
document.getElementById('modal-original-name').value=''; document.getElementById('src-name').value='';
|
|
2486
5232
|
document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
|
|
5233
|
+
document.getElementById('src-tenant').value='';
|
|
2487
5234
|
resetTlsFields();
|
|
2488
5235
|
document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
|
|
5236
|
+
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
2489
5237
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
|
|
5238
|
+
_omcpApplyTenantFieldMode();
|
|
2490
5239
|
document.getElementById('source-modal').classList.add('open');
|
|
2491
5240
|
}
|
|
2492
5241
|
function openEditModal(name) {
|
|
@@ -2495,19 +5244,80 @@ function openEditModal(name) {
|
|
|
2495
5244
|
document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
|
|
2496
5245
|
document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
|
|
2497
5246
|
document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
|
|
5247
|
+
document.getElementById('src-tenant').value = s.tenant || '';
|
|
5248
|
+
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
2498
5249
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
|
|
5250
|
+
_omcpApplyTenantFieldMode();
|
|
2499
5251
|
document.getElementById('source-modal').classList.add('open');
|
|
2500
5252
|
}
|
|
5253
|
+
// --- Source modal form validation + banner ------------------------------
|
|
5254
|
+
function setFieldError(inputId, errId, msg) {
|
|
5255
|
+
const inp = document.getElementById(inputId);
|
|
5256
|
+
const err = document.getElementById(errId);
|
|
5257
|
+
if (msg) {
|
|
5258
|
+
if (inp) inp.classList.add('is-invalid');
|
|
5259
|
+
if (err) { err.textContent = msg; err.hidden = false; }
|
|
5260
|
+
} else {
|
|
5261
|
+
if (inp) inp.classList.remove('is-invalid');
|
|
5262
|
+
if (err) { err.textContent = ''; err.hidden = true; }
|
|
5263
|
+
}
|
|
5264
|
+
}
|
|
5265
|
+
function showSrcBanner(kind, msg) {
|
|
5266
|
+
const b = document.getElementById('src-form-banner');
|
|
5267
|
+
if (!b) return;
|
|
5268
|
+
b.className = 'form-banner show ' + (kind || '');
|
|
5269
|
+
b.textContent = msg || '';
|
|
5270
|
+
}
|
|
5271
|
+
function clearSrcBanner() {
|
|
5272
|
+
const b = document.getElementById('src-form-banner');
|
|
5273
|
+
if (b) { b.className = 'form-banner'; b.textContent = ''; }
|
|
5274
|
+
}
|
|
5275
|
+
function validateSrcName() {
|
|
5276
|
+
const v = (document.getElementById('src-name').value || '').trim();
|
|
5277
|
+
if (!v) { setFieldError('src-name', 'src-name-err', 'Name is required'); return false; }
|
|
5278
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(v)) {
|
|
5279
|
+
setFieldError('src-name', 'src-name-err',
|
|
5280
|
+
'Use letters, digits and dashes (1–63 chars; must start with a letter or digit).');
|
|
5281
|
+
return false;
|
|
5282
|
+
}
|
|
5283
|
+
setFieldError('src-name', 'src-name-err', null);
|
|
5284
|
+
return true;
|
|
5285
|
+
}
|
|
5286
|
+
function validateSrcUrl() {
|
|
5287
|
+
const v = (document.getElementById('src-url').value || '').trim();
|
|
5288
|
+
if (!v) { setFieldError('src-url', 'src-url-err', 'URL is required'); return false; }
|
|
5289
|
+
try {
|
|
5290
|
+
const u = new URL(v);
|
|
5291
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
5292
|
+
setFieldError('src-url', 'src-url-err', 'URL must start with http:// or https://');
|
|
5293
|
+
return false;
|
|
5294
|
+
}
|
|
5295
|
+
} catch (e) {
|
|
5296
|
+
setFieldError('src-url', 'src-url-err', 'Not a valid URL');
|
|
5297
|
+
return false;
|
|
5298
|
+
}
|
|
5299
|
+
setFieldError('src-url', 'src-url-err', null);
|
|
5300
|
+
return true;
|
|
5301
|
+
}
|
|
5302
|
+
|
|
2501
5303
|
async function saveSource() {
|
|
5304
|
+
clearSrcBanner();
|
|
5305
|
+
const okName = validateSrcName();
|
|
5306
|
+
const okUrl = validateSrcUrl();
|
|
5307
|
+
if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
|
|
2502
5308
|
const mode=document.getElementById('modal-mode').value;
|
|
2503
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()};
|
|
2504
|
-
|
|
5310
|
+
const tenant = document.getElementById('src-tenant').value.trim();
|
|
5311
|
+
if (tenant) src.tenant = tenant;
|
|
2505
5312
|
const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
|
|
2506
5313
|
try {
|
|
2507
5314
|
let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
|
|
2508
5315
|
else res=await fetch('/api/sources/'+encodeURIComponent(document.getElementById('modal-original-name').value),{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
|
|
2509
|
-
const d=await res.json();
|
|
2510
|
-
|
|
5316
|
+
const d=await res.json();
|
|
5317
|
+
if(!res.ok){ showSrcBanner('error', d.error || ('HTTP '+res.status)); return; }
|
|
5318
|
+
closeModal('source-modal'); toast('Source saved'); await refresh();
|
|
5319
|
+
} catch(e){ showSrcBanner('error', String(e && e.message || e)); }
|
|
5320
|
+
finally{btn.disabled=false;btn.textContent='Save';}
|
|
2511
5321
|
}
|
|
2512
5322
|
async function testConnection() {
|
|
2513
5323
|
const btn=document.getElementById('test-btn'),r=document.getElementById('test-result');
|
|
@@ -2531,15 +5341,55 @@ async function confirmDelete(){
|
|
|
2531
5341
|
deleteTarget=null;deleteType=null;
|
|
2532
5342
|
}
|
|
2533
5343
|
|
|
5344
|
+
// --- Dirty-state tracking for forms with a Save button -----------------
|
|
5345
|
+
// Snapshots input values inside a root element on mount/populate; toggles
|
|
5346
|
+
// the save button between disabled (clean) and enabled (any input changed
|
|
5347
|
+
// from baseline). Reset on successful save resets the baseline.
|
|
5348
|
+
function bindDirtyState(rootId, btnId) {
|
|
5349
|
+
const root = document.getElementById(rootId);
|
|
5350
|
+
const btn = document.getElementById(btnId);
|
|
5351
|
+
if (!root || !btn) return;
|
|
5352
|
+
function snapshot() {
|
|
5353
|
+
const out = {};
|
|
5354
|
+
root.querySelectorAll('input,select,textarea').forEach(el => {
|
|
5355
|
+
if (!el.id) return;
|
|
5356
|
+
out[el.id] = el.type === 'checkbox' ? !!el.checked : (el.value ?? '');
|
|
5357
|
+
});
|
|
5358
|
+
return out;
|
|
5359
|
+
}
|
|
5360
|
+
const baseline = snapshot();
|
|
5361
|
+
function isDirty() {
|
|
5362
|
+
const cur = snapshot();
|
|
5363
|
+
for (const k of Object.keys(baseline)) {
|
|
5364
|
+
if (cur[k] !== baseline[k]) return true;
|
|
5365
|
+
}
|
|
5366
|
+
return false;
|
|
5367
|
+
}
|
|
5368
|
+
function update() { btn.disabled = !isDirty(); }
|
|
5369
|
+
root._omcpDirty = { baseline, update, reset(){ Object.assign(baseline, snapshot()); update(); } };
|
|
5370
|
+
root.removeEventListener('input', update);
|
|
5371
|
+
root.removeEventListener('change', update);
|
|
5372
|
+
root.addEventListener('input', update);
|
|
5373
|
+
root.addEventListener('change', update);
|
|
5374
|
+
update();
|
|
5375
|
+
}
|
|
5376
|
+
function resetDirtyState(rootId) {
|
|
5377
|
+
const root = document.getElementById(rootId);
|
|
5378
|
+
if (root && root._omcpDirty) root._omcpDirty.reset();
|
|
5379
|
+
}
|
|
5380
|
+
|
|
2534
5381
|
// --- Settings: General ---
|
|
2535
5382
|
function populateSettingsForm() {
|
|
2536
5383
|
document.getElementById('set-interval').value=settings.checkIntervalMs||30000;
|
|
2537
5384
|
document.getElementById('set-sensitivity').value=settings.defaultSensitivity||'medium';
|
|
5385
|
+
bindDirtyState('tab-general', 'btn-save-settings');
|
|
2538
5386
|
}
|
|
2539
5387
|
async function saveSettings() {
|
|
2540
5388
|
const body={checkIntervalMs:parseInt(document.getElementById('set-interval').value),defaultSensitivity:document.getElementById('set-sensitivity').value};
|
|
2541
|
-
await fetch('/api/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
5389
|
+
const res = await fetch('/api/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
5390
|
+
if (!res.ok) { toast('Failed to save settings'); return; }
|
|
2542
5391
|
toast('Settings saved');
|
|
5392
|
+
resetDirtyState('tab-general');
|
|
2543
5393
|
}
|
|
2544
5394
|
async function resetSettings() { settings=defaults.settings; populateSettingsForm(); toast('Reset to defaults (save to persist)'); }
|
|
2545
5395
|
|
|
@@ -2555,6 +5405,7 @@ function populateHealthForm() {
|
|
|
2555
5405
|
document.getElementById('ht-lat-good').value=h.latencyP99.good; document.getElementById('ht-lat-warn').value=h.latencyP99.warn; document.getElementById('ht-lat-crit').value=h.latencyP99.crit;
|
|
2556
5406
|
document.getElementById('ht-log-good').value=h.logErrors.good; document.getElementById('ht-log-warn').value=h.logErrors.warn; document.getElementById('ht-log-crit').value=h.logErrors.crit;
|
|
2557
5407
|
document.getElementById('ht-bound-healthy').value=h.statusBoundaries.healthy; document.getElementById('ht-bound-degraded').value=h.statusBoundaries.degraded;
|
|
5408
|
+
bindDirtyState('tab-health', 'btn-save-health');
|
|
2558
5409
|
}
|
|
2559
5410
|
async function saveHealth() {
|
|
2560
5411
|
const body={
|
|
@@ -2566,9 +5417,11 @@ async function saveHealth() {
|
|
|
2566
5417
|
statusBoundaries:{healthy:parseFloat(document.getElementById('ht-bound-healthy').value),degraded:parseFloat(document.getElementById('ht-bound-degraded').value)}
|
|
2567
5418
|
};
|
|
2568
5419
|
const ws=body.weights; const sum=ws.errorRate+ws.latency+ws.cpu+ws.logErrors;
|
|
2569
|
-
if(Math.abs(sum-1)>0.01){
|
|
2570
|
-
await fetch('/api/health-thresholds',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
5420
|
+
if(Math.abs(sum-1)>0.01){toast(`Weights must sum to 1.0 (currently ${sum.toFixed(2)})`, 'error');return;}
|
|
5421
|
+
const res = await fetch('/api/health-thresholds',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
5422
|
+
if (!res.ok) { toast('Failed to save thresholds'); return; }
|
|
2571
5423
|
healthThresholds=body; toast('Health thresholds saved');
|
|
5424
|
+
resetDirtyState('tab-health');
|
|
2572
5425
|
}
|
|
2573
5426
|
async function resetHealth() { healthThresholds=defaults.healthThresholds; populateHealthForm(); toast('Reset to defaults (save to persist)'); }
|
|
2574
5427
|
|
|
@@ -2781,8 +5634,13 @@ async function loadTopology(){
|
|
|
2781
5634
|
} catch(e) {
|
|
2782
5635
|
topologyData = null;
|
|
2783
5636
|
topologyLastRevHash = '';
|
|
2784
|
-
document.getElementById('topology-summary').innerHTML =
|
|
2785
|
-
|
|
5637
|
+
document.getElementById('topology-summary').innerHTML = richEmpty({
|
|
5638
|
+
icon: 'graph',
|
|
5639
|
+
title: 'No topology data',
|
|
5640
|
+
desc: 'Add a topology-capable source (currently: kubernetes) under Sources to populate the resource graph used by get_blast_radius and get_topology.',
|
|
5641
|
+
ctaLabel: 'Go to Sources',
|
|
5642
|
+
ctaOnClick: "showPage('sources')",
|
|
5643
|
+
});
|
|
2786
5644
|
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2787
5645
|
}
|
|
2788
5646
|
if(!topologyInterval) topologyInterval = setInterval(()=>{
|
|
@@ -2821,7 +5679,13 @@ function renderTopology(){
|
|
|
2821
5679
|
// --- Summary tab ---
|
|
2822
5680
|
const summary = document.getElementById('topology-summary');
|
|
2823
5681
|
if (d.sources.length === 0) {
|
|
2824
|
-
summary.innerHTML =
|
|
5682
|
+
summary.innerHTML = richEmpty({
|
|
5683
|
+
icon: 'graph',
|
|
5684
|
+
title: 'No topology-capable sources',
|
|
5685
|
+
desc: 'Add a topology source (currently: kubernetes) under Sources. The graph powers the blast-radius and dependency tools used by the agent.',
|
|
5686
|
+
ctaLabel: 'Add a source',
|
|
5687
|
+
ctaOnClick: "showPage('sources');openAddModal()",
|
|
5688
|
+
});
|
|
2825
5689
|
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2826
5690
|
renderTopologyGraph(); // shows empty-state legend too
|
|
2827
5691
|
return;
|
|
@@ -3144,6 +6008,18 @@ function renderTopologyGraph(){
|
|
|
3144
6008
|
function applyView(){ g.setAttribute('transform', `translate(${view.tx} ${view.ty}) scale(${view.scale})`); }
|
|
3145
6009
|
applyView();
|
|
3146
6010
|
svg.appendChild(g);
|
|
6011
|
+
// Expose view + setters so the zoom toolbar buttons and the keyboard
|
|
6012
|
+
// handler can drive the same `view` object that wheel-zoom mutates.
|
|
6013
|
+
window.__topoViewport = {
|
|
6014
|
+
zoom(k){
|
|
6015
|
+
const cx = W/2, cy = H/2;
|
|
6016
|
+
view.tx = cx - (cx - view.tx) * k;
|
|
6017
|
+
view.ty = cy - (cy - view.ty) * k;
|
|
6018
|
+
view.scale *= k;
|
|
6019
|
+
applyView();
|
|
6020
|
+
},
|
|
6021
|
+
reset(){ view.tx = 0; view.ty = 0; view.scale = 1; applyView(); },
|
|
6022
|
+
};
|
|
3147
6023
|
|
|
3148
6024
|
// Alternating tier background bands + tier labels in the left gutter.
|
|
3149
6025
|
for (let i = 0; i < tierIndices.length; i++){
|
|
@@ -3208,6 +6084,7 @@ function renderTopologyGraph(){
|
|
|
3208
6084
|
const cy = (a.y + b.y) / 2;
|
|
3209
6085
|
return `M ${a.x} ${a.y} C ${a.x} ${cy}, ${b.x} ${cy}, ${b.x} ${b.y}`;
|
|
3210
6086
|
}
|
|
6087
|
+
const edgeLabelEls = new Map(); // "<from> <to>" → text element, for repaint on drag
|
|
3211
6088
|
for (const e of drawEdges){
|
|
3212
6089
|
const a = positions.get(e.from), b = positions.get(e.to);
|
|
3213
6090
|
const s = topoRelStyle(e.relation);
|
|
@@ -3220,6 +6097,22 @@ function renderTopologyGraph(){
|
|
|
3220
6097
|
path.setAttribute('opacity', '0.85');
|
|
3221
6098
|
path.dataset.from = e.from; path.dataset.to = e.to;
|
|
3222
6099
|
g.appendChild(path);
|
|
6100
|
+
// Edge label at midpoint, with a halo so it remains readable
|
|
6101
|
+
// against tier bands and crossing wires in both themes.
|
|
6102
|
+
const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
6103
|
+
lbl.setAttribute('x', String((a.x + b.x) / 2));
|
|
6104
|
+
lbl.setAttribute('y', String((a.y + b.y) / 2));
|
|
6105
|
+
lbl.setAttribute('font-size', '9');
|
|
6106
|
+
lbl.setAttribute('text-anchor', 'middle');
|
|
6107
|
+
lbl.setAttribute('dominant-baseline', 'middle');
|
|
6108
|
+
lbl.setAttribute('fill', 'var(--text-muted)');
|
|
6109
|
+
lbl.setAttribute('stroke', 'var(--surface-2)');
|
|
6110
|
+
lbl.setAttribute('stroke-width', '3');
|
|
6111
|
+
lbl.setAttribute('paint-order', 'stroke');
|
|
6112
|
+
lbl.setAttribute('style', 'pointer-events: none; letter-spacing: 0.04em;');
|
|
6113
|
+
lbl.textContent = e.relation;
|
|
6114
|
+
g.appendChild(lbl);
|
|
6115
|
+
edgeLabelEls.set(e.from + '' + e.to, lbl);
|
|
3223
6116
|
}
|
|
3224
6117
|
|
|
3225
6118
|
// Nodes
|
|
@@ -3243,12 +6136,18 @@ function renderTopologyGraph(){
|
|
|
3243
6136
|
}
|
|
3244
6137
|
|
|
3245
6138
|
const nodeEls = new Map();
|
|
6139
|
+
const focusOrder = []; // resource ids in deterministic visual order for arrow nav
|
|
3246
6140
|
for (const r of drawables){
|
|
3247
6141
|
const p = positions.get(r.id); if (!p) continue;
|
|
3248
6142
|
const grp = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
3249
6143
|
grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
|
|
3250
6144
|
grp.style.cursor = 'grab';
|
|
3251
6145
|
grp.dataset.id = r.id;
|
|
6146
|
+
// a11y: keyboard-focusable, screen-reader announces "<name>, <kind>".
|
|
6147
|
+
grp.setAttribute('tabindex', '0');
|
|
6148
|
+
grp.setAttribute('role', 'button');
|
|
6149
|
+
grp.setAttribute('aria-label', `${r.name}, ${r.kind}`);
|
|
6150
|
+
focusOrder.push(r.id);
|
|
3252
6151
|
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
3253
6152
|
// Hosts a touch bigger to read as anchors of the layout.
|
|
3254
6153
|
const radius = incomingRunsOn.has(r.id) ? 9 : 6.5;
|
|
@@ -3293,7 +6192,14 @@ function renderTopologyGraph(){
|
|
|
3293
6192
|
if (pth.dataset.from === id || pth.dataset.to === id){
|
|
3294
6193
|
const a = positions.get(pth.dataset.from);
|
|
3295
6194
|
const b = positions.get(pth.dataset.to);
|
|
3296
|
-
if (a && b)
|
|
6195
|
+
if (a && b){
|
|
6196
|
+
pth.setAttribute('d', bezierPath(a, b));
|
|
6197
|
+
const lbl = edgeLabelEls.get(pth.dataset.from + ' ' + pth.dataset.to);
|
|
6198
|
+
if (lbl){
|
|
6199
|
+
lbl.setAttribute('x', String((a.x + b.x) / 2));
|
|
6200
|
+
lbl.setAttribute('y', String((a.y + b.y) / 2));
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
3297
6203
|
}
|
|
3298
6204
|
});
|
|
3299
6205
|
}
|
|
@@ -3385,6 +6291,65 @@ function renderTopologyGraph(){
|
|
|
3385
6291
|
selectResource(el.dataset.id);
|
|
3386
6292
|
};
|
|
3387
6293
|
|
|
6294
|
+
// Keyboard navigation: Tab cycles natively (every <g data-id> is
|
|
6295
|
+
// tabindex=0); Enter / Space inspects the focused node; Esc clears
|
|
6296
|
+
// selection; arrows move focus to the nearest neighbour by 2-D distance.
|
|
6297
|
+
function focusedId(){
|
|
6298
|
+
const el = document.activeElement;
|
|
6299
|
+
if (el && el.closest && el.closest('#topology-graph-svg')){
|
|
6300
|
+
const g2 = el.closest('g[data-id]');
|
|
6301
|
+
if (g2) return g2.dataset.id;
|
|
6302
|
+
}
|
|
6303
|
+
return null;
|
|
6304
|
+
}
|
|
6305
|
+
function moveFocusDir(dx, dy){
|
|
6306
|
+
const cur = focusedId() || topologySelectedId || focusOrder[0];
|
|
6307
|
+
if (!cur) return;
|
|
6308
|
+
const p = positions.get(cur); if (!p) return;
|
|
6309
|
+
let best = null, bestScore = Infinity;
|
|
6310
|
+
for (const id of focusOrder){
|
|
6311
|
+
if (id === cur) continue;
|
|
6312
|
+
const q = positions.get(id); if (!q) continue;
|
|
6313
|
+
const ex = q.x - p.x, ey = q.y - p.y;
|
|
6314
|
+
const align = ex*dx + ey*dy; // positive = same direction
|
|
6315
|
+
if (align <= 0) continue;
|
|
6316
|
+
const perp = Math.abs(ex*dy - ey*dx); // distance off-axis
|
|
6317
|
+
const score = perp - align * 0.25;
|
|
6318
|
+
if (score < bestScore){ bestScore = score; best = id; }
|
|
6319
|
+
}
|
|
6320
|
+
if (best){
|
|
6321
|
+
const el = nodeEls.get(best);
|
|
6322
|
+
if (el && el.grp.focus) el.grp.focus();
|
|
6323
|
+
}
|
|
6324
|
+
}
|
|
6325
|
+
svg.addEventListener('focus', ()=>{
|
|
6326
|
+
// First focus on the svg shell: forward to the previously-selected node
|
|
6327
|
+
// or the first node so the user gets immediate context.
|
|
6328
|
+
if (document.activeElement === svg){
|
|
6329
|
+
const target = topologySelectedId || focusOrder[0];
|
|
6330
|
+
const el = target ? nodeEls.get(target) : null;
|
|
6331
|
+
if (el && el.grp.focus) el.grp.focus();
|
|
6332
|
+
}
|
|
6333
|
+
});
|
|
6334
|
+
svg.addEventListener('keydown', (ev)=>{
|
|
6335
|
+
if (ev.key === 'Escape'){
|
|
6336
|
+
topologySelectedId = null;
|
|
6337
|
+
clearHighlight();
|
|
6338
|
+
showInspectorEmpty();
|
|
6339
|
+
ev.preventDefault();
|
|
6340
|
+
return;
|
|
6341
|
+
}
|
|
6342
|
+
if (ev.key === 'Enter' || ev.key === ' '){
|
|
6343
|
+
const id = focusedId();
|
|
6344
|
+
if (id){ selectResource(id); ev.preventDefault(); }
|
|
6345
|
+
return;
|
|
6346
|
+
}
|
|
6347
|
+
if (ev.key === 'ArrowLeft') { moveFocusDir(-1, 0); ev.preventDefault(); return; }
|
|
6348
|
+
if (ev.key === 'ArrowRight') { moveFocusDir( 1, 0); ev.preventDefault(); return; }
|
|
6349
|
+
if (ev.key === 'ArrowUp') { moveFocusDir( 0,-1); ev.preventDefault(); return; }
|
|
6350
|
+
if (ev.key === 'ArrowDown') { moveFocusDir( 0, 1); ev.preventDefault(); return; }
|
|
6351
|
+
});
|
|
6352
|
+
|
|
3388
6353
|
function showInspectorEmpty(){
|
|
3389
6354
|
document.getElementById('topology-inspector-empty').style.display = '';
|
|
3390
6355
|
document.getElementById('topology-inspector-body').style.display = 'none';
|
|
@@ -3396,13 +6361,13 @@ function renderTopologyGraph(){
|
|
|
3396
6361
|
const labelEntries = Object.entries(ref.labels || {});
|
|
3397
6362
|
const attrEntries = Object.entries(ref.attributes || {});
|
|
3398
6363
|
function kvList(entries){
|
|
3399
|
-
if (entries.length === 0) return '<div class="muted
|
|
6364
|
+
if (entries.length === 0) return '<div class="muted t-xs">(none)</div>';
|
|
3400
6365
|
return '<div style="display:grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-size: var(--fs-xs);">' +
|
|
3401
6366
|
entries.map(([k,v])=>`<div class="muted" style="font-weight:600;">${esc(k)}</div><div style="font-family: 'JetBrains Mono', ui-monospace, monospace; word-break: break-all;">${esc(String(v))}</div>`).join('') +
|
|
3402
6367
|
'</div>';
|
|
3403
6368
|
}
|
|
3404
6369
|
function neighbourList(edges, dir){
|
|
3405
|
-
if (edges.length === 0) return '<div class="muted
|
|
6370
|
+
if (edges.length === 0) return '<div class="muted t-xs">(none)</div>';
|
|
3406
6371
|
return '<ul style="margin:0; padding-left: 0; list-style: none; display:flex; flex-direction:column; gap:4px;">' +
|
|
3407
6372
|
edges.map(e=>{
|
|
3408
6373
|
const otherId = dir === 'in' ? e.from : e.to;
|
|
@@ -3425,35 +6390,35 @@ function renderTopologyGraph(){
|
|
|
3425
6390
|
<div style="font-weight:600; font-size: var(--fs-md); color: var(--text); word-break: break-all;">${esc(ref.name)}</div>
|
|
3426
6391
|
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top: 4px;">
|
|
3427
6392
|
<span class="badge">${esc(ref.kind)}</span>
|
|
3428
|
-
<span class="muted
|
|
6393
|
+
<span class="muted t-xs">source: ${esc(ref.source)}</span>
|
|
3429
6394
|
</div>
|
|
3430
6395
|
</div>
|
|
3431
6396
|
</div>
|
|
3432
6397
|
<div style="margin-top: 10px; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10px; color: var(--text-muted); word-break: break-all;">${esc(ref.id)}</div>
|
|
3433
6398
|
|
|
3434
6399
|
<div style="margin-top: 16px;">
|
|
3435
|
-
<div
|
|
6400
|
+
<div class="t-label">Labels</div>
|
|
3436
6401
|
${kvList(labelEntries)}
|
|
3437
6402
|
</div>
|
|
3438
6403
|
|
|
3439
6404
|
<div style="margin-top: 16px;">
|
|
3440
|
-
<div
|
|
6405
|
+
<div class="t-label">Attributes</div>
|
|
3441
6406
|
${kvList(attrEntries.filter(([k])=>k!=='uid'))}
|
|
3442
6407
|
</div>
|
|
3443
6408
|
|
|
3444
6409
|
<div style="margin-top: 16px;">
|
|
3445
|
-
<div
|
|
6410
|
+
<div class="t-label">Outgoing (${outgoing.length})</div>
|
|
3446
6411
|
${neighbourList(outgoing, 'out')}
|
|
3447
6412
|
</div>
|
|
3448
6413
|
|
|
3449
6414
|
<div style="margin-top: 12px;">
|
|
3450
|
-
<div
|
|
6415
|
+
<div class="t-label">Incoming (${incoming.length})</div>
|
|
3451
6416
|
${neighbourList(incoming, 'in')}
|
|
3452
6417
|
</div>
|
|
3453
6418
|
|
|
3454
6419
|
<div style="margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
3455
|
-
<div
|
|
3456
|
-
<div class="muted
|
|
6420
|
+
<div class="t-label">Linked telemetry</div>
|
|
6421
|
+
<div class="muted t-xs">No metrics or logs linked to this resource. When demo workloads run inside the cluster and emit Prometheus/Loki signals under matching service labels, charts will appear here.</div>
|
|
3457
6422
|
</div>
|
|
3458
6423
|
`;
|
|
3459
6424
|
// Wire neighbour links to navigate inside the graph.
|
|
@@ -3480,7 +6445,37 @@ function renderTopologyGraph(){
|
|
|
3480
6445
|
|
|
3481
6446
|
// --- Utils ---
|
|
3482
6447
|
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
|
3483
|
-
|
|
6448
|
+
|
|
6449
|
+
// --- Empty / loading state helpers ----------------------------------------
|
|
6450
|
+
// Inline SVGs so the markup is self-contained and styleable via currentColor.
|
|
6451
|
+
const STATE_ICONS = {
|
|
6452
|
+
spinner: '<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 3a9 9 0 1 1-6.36 2.64" /></svg>',
|
|
6453
|
+
inbox: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l3-8h12l3 8"/><path d="M3 13h6l1 3h4l1-3h6"/><path d="M3 13v6h18v-6"/></svg>',
|
|
6454
|
+
plug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3v6"/><path d="M15 3v6"/><path d="M6 9h12v4a6 6 0 0 1-12 0z"/><path d="M12 19v3"/></svg>',
|
|
6455
|
+
graph: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.5 7.6l3 7.4"/><path d="M16.5 7.6l-3 7.4"/></svg>',
|
|
6456
|
+
service: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="6" rx="1.5"/><rect x="3" y="14" width="18" height="6" rx="1.5"/><circle cx="7" cy="7" r="1"/><circle cx="7" cy="17" r="1"/></svg>',
|
|
6457
|
+
};
|
|
6458
|
+
// richEmpty({icon, title, desc, ctaLabel, ctaOnClick, secondaryLabel, secondaryOnClick})
|
|
6459
|
+
// SECURITY: title / desc / ctaLabel / secondaryLabel are HTML-escaped before
|
|
6460
|
+
// insertion. ctaOnClick / secondaryOnClick are inlined verbatim into the
|
|
6461
|
+
// onclick attribute — only ever pass trusted static literals here, never
|
|
6462
|
+
// strings containing user input or backend data.
|
|
6463
|
+
function richEmpty(opts) {
|
|
6464
|
+
const o = opts || {};
|
|
6465
|
+
const icon = STATE_ICONS[o.icon || 'inbox'] || STATE_ICONS.inbox;
|
|
6466
|
+
const cta = o.ctaLabel
|
|
6467
|
+
? `<div class="state-cta"><button class="btn btn-primary btn-sm" onclick="${o.ctaOnClick||''}">${esc(o.ctaLabel)}</button>${
|
|
6468
|
+
o.secondaryLabel ? `<button class="btn btn-ghost btn-sm" onclick="${o.secondaryOnClick||''}">${esc(o.secondaryLabel)}</button>` : ''
|
|
6469
|
+
}</div>`
|
|
6470
|
+
: '';
|
|
6471
|
+
return `<div class="state"><div class="state-ico">${icon}</div><div class="state-title">${esc(o.title||'Nothing here yet')}</div>${
|
|
6472
|
+
o.desc ? `<div class="state-desc">${esc(o.desc)}</div>` : ''
|
|
6473
|
+
}${cta}</div>`;
|
|
6474
|
+
}
|
|
6475
|
+
function loadingBlock(label) {
|
|
6476
|
+
return `<div class="state is-loading" role="status" aria-live="polite"><div class="state-ico">${STATE_ICONS.spinner}</div><div class="state-title">${esc(label||'Loading…')}</div></div>`;
|
|
6477
|
+
}
|
|
6478
|
+
async function refresh(){await Promise.all([loadSources(),loadServices(),loadDashUsage()]);}
|
|
3484
6479
|
|
|
3485
6480
|
async function loadInfo(){
|
|
3486
6481
|
try{
|