@thotischner/observability-mcp 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/products.yaml.example +48 -0
- package/dist/audit/log.d.ts +99 -0
- package/dist/audit/log.js +180 -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/auth/credentials.d.ts +18 -0
- package/dist/auth/credentials.js +26 -1
- package/dist/auth/credentials.test.js +26 -1
- package/dist/auth/local-users.d.ts +62 -0
- package/dist/auth/local-users.js +143 -0
- package/dist/auth/local-users.test.d.ts +1 -0
- package/dist/auth/local-users.test.js +80 -0
- package/dist/auth/middleware.d.ts +48 -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/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 +124 -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/runtime.d.ts +63 -0
- package/dist/auth/oidc/runtime.js +129 -0
- package/dist/auth/oidc/runtime.test.d.ts +1 -0
- package/dist/auth/oidc/runtime.test.js +180 -0
- package/dist/auth/policy/engine.d.ts +48 -0
- package/dist/auth/policy/engine.js +73 -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 +35 -0
- package/dist/auth/policy/loader.js +100 -0
- package/dist/auth/policy/opa.d.ts +69 -0
- package/dist/auth/policy/opa.js +162 -0
- package/dist/auth/policy/opa.test.d.ts +1 -0
- package/dist/auth/policy/opa.test.js +158 -0
- package/dist/auth/rbac.d.ts +40 -0
- package/dist/auth/rbac.js +120 -0
- package/dist/auth/rbac.test.d.ts +1 -0
- package/dist/auth/rbac.test.js +121 -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/context.d.ts +13 -1
- package/dist/context.js +5 -1
- package/dist/index.js +1012 -29
- package/dist/net/egress-policy.js +2 -0
- package/dist/openapi.js +440 -0
- package/dist/openapi.test.d.ts +1 -0
- package/dist/openapi.test.js +64 -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/products/loader.d.ts +84 -0
- package/dist/products/loader.js +216 -0
- package/dist/products/loader.test.d.ts +1 -0
- package/dist/products/loader.test.js +168 -0
- package/dist/quota/limiter.d.ts +72 -0
- package/dist/quota/limiter.js +105 -0
- package/dist/quota/limiter.test.d.ts +1 -0
- package/dist/quota/limiter.test.js +119 -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/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/ui/index.html +1454 -88
- package/package.json +20 -3
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; }
|
|
@@ -914,6 +983,214 @@
|
|
|
914
983
|
.pill-yes { color: var(--success); } .pill-no { color: var(--danger); }
|
|
915
984
|
.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
985
|
@media (max-width: 980px) { .ent-grid { grid-template-columns: 1fr; } }
|
|
986
|
+
|
|
987
|
+
/* ===== Utilities — composable classes used across the page to keep
|
|
988
|
+
repeated layout/spacing/typography rules out of inline `style="…"`.
|
|
989
|
+
Tokens stay sourced from the existing CSS variable system. ===== */
|
|
990
|
+
.mt-1 { margin-top: var(--sp-1); }
|
|
991
|
+
.mt-2 { margin-top: var(--sp-2); }
|
|
992
|
+
.mt-3 { margin-top: var(--sp-3); }
|
|
993
|
+
.mt-4 { margin-top: var(--sp-4); }
|
|
994
|
+
.mt-5 { margin-top: var(--sp-5); }
|
|
995
|
+
.mt-6 { margin-top: var(--sp-6); }
|
|
996
|
+
.mb-1 { margin-bottom: var(--sp-1); }
|
|
997
|
+
.mb-2 { margin-bottom: var(--sp-2); }
|
|
998
|
+
.mb-3 { margin-bottom: var(--sp-3); }
|
|
999
|
+
.mb-4 { margin-bottom: var(--sp-4); }
|
|
1000
|
+
.mb-5 { margin-bottom: var(--sp-5); }
|
|
1001
|
+
.mb-6 { margin-bottom: var(--sp-6); }
|
|
1002
|
+
.m-0 { margin: 0; }
|
|
1003
|
+
.p-0 { padding: 0; }
|
|
1004
|
+
.p-5 { padding: var(--sp-5); }
|
|
1005
|
+
.gap-2 { gap: var(--sp-2); }
|
|
1006
|
+
.gap-3 { gap: var(--sp-3); }
|
|
1007
|
+
|
|
1008
|
+
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
|
1009
|
+
.row-end { display: flex; justify-content: flex-end; gap: var(--sp-2); }
|
|
1010
|
+
.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
|
1011
|
+
.row-inline { display: inline-flex; align-items: center; gap: var(--sp-1); }
|
|
1012
|
+
.col { display: flex; flex-direction: column; gap: var(--sp-2); }
|
|
1013
|
+
|
|
1014
|
+
.t-muted { color: var(--text-muted); }
|
|
1015
|
+
/* `.muted` is used in templates throughout the page but was previously
|
|
1016
|
+
not defined in CSS — it relied on inline font-size declarations and
|
|
1017
|
+
inherited color. Define it as muted text color so the markup renders
|
|
1018
|
+
with the styling the original author clearly intended. */
|
|
1019
|
+
.muted { color: var(--text-muted); }
|
|
1020
|
+
.t-dim { color: var(--text-dim); }
|
|
1021
|
+
.t-accent { color: var(--accent); }
|
|
1022
|
+
.t-xs { font-size: var(--fs-xs); }
|
|
1023
|
+
.t-sm { font-size: var(--fs-sm); }
|
|
1024
|
+
.t-md { font-size: var(--fs-md); }
|
|
1025
|
+
.t-lg { font-size: var(--fs-lg); }
|
|
1026
|
+
.t-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
1027
|
+
.t-break { word-break: break-all; }
|
|
1028
|
+
.t-nowrap { white-space: nowrap; }
|
|
1029
|
+
.t-label {
|
|
1030
|
+
text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em;
|
|
1031
|
+
color: var(--text-muted); margin-bottom: 6px; font-weight: 600;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
.is-hidden { display: none; }
|
|
1035
|
+
|
|
1036
|
+
/* Deployment-misconfig warning bar — sits between the masthead and
|
|
1037
|
+
the page body. Subtle danger-tint so it doesn't compete with the
|
|
1038
|
+
primary content but is impossible to miss while scrolling. */
|
|
1039
|
+
.omcp-warning-bar {
|
|
1040
|
+
padding: 8px 16px;
|
|
1041
|
+
background: var(--danger-soft);
|
|
1042
|
+
color: var(--danger);
|
|
1043
|
+
border-bottom: 1px solid rgba(239, 91, 110, 0.35);
|
|
1044
|
+
font-size: var(--fs-sm); line-height: 1.4;
|
|
1045
|
+
text-align: center;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/* ===== Density variants =====
|
|
1049
|
+
`data-density="compact"` on <html> shrinks row + cell padding
|
|
1050
|
+
across lists, tables and cards so dense operator workloads fit
|
|
1051
|
+
on smaller screens. Toggleable from the masthead. */
|
|
1052
|
+
:root[data-density="compact"] .source-row,
|
|
1053
|
+
:root[data-density="compact"] .service-row { padding-top: 4px; padding-bottom: 4px; }
|
|
1054
|
+
:root[data-density="compact"] .dtable th,
|
|
1055
|
+
:root[data-density="compact"] .dtable td { padding: 4px 8px; }
|
|
1056
|
+
:root[data-density="compact"] .metric-table th,
|
|
1057
|
+
:root[data-density="compact"] .metric-table td { padding: 4px 8px; }
|
|
1058
|
+
:root[data-density="compact"] .health-card { padding: var(--sp-3); }
|
|
1059
|
+
:root[data-density="compact"] .health-card .hc-header { margin-bottom: var(--sp-2); }
|
|
1060
|
+
:root[data-density="compact"] .hc-metric { padding: 4px 8px; }
|
|
1061
|
+
|
|
1062
|
+
/* ===== Sortable table column headers ===== */
|
|
1063
|
+
.dtable th.sortable, .metric-table th.sortable {
|
|
1064
|
+
cursor: pointer; user-select: none;
|
|
1065
|
+
transition: color var(--t-fast) var(--ease);
|
|
1066
|
+
}
|
|
1067
|
+
.dtable th.sortable:hover, .metric-table th.sortable:hover { color: var(--text); }
|
|
1068
|
+
.dtable th.sortable .sort-ind, .metric-table th.sortable .sort-ind {
|
|
1069
|
+
display: inline-block; width: 10px; opacity: 0.35;
|
|
1070
|
+
margin-left: 4px; font-size: 9px;
|
|
1071
|
+
}
|
|
1072
|
+
.dtable th.sortable.sort-asc .sort-ind::before,
|
|
1073
|
+
.metric-table th.sortable.sort-asc .sort-ind::before { content: '▲'; opacity: 1; }
|
|
1074
|
+
.dtable th.sortable.sort-desc .sort-ind::before,
|
|
1075
|
+
.metric-table th.sortable.sort-desc .sort-ind::before { content: '▼'; opacity: 1; }
|
|
1076
|
+
|
|
1077
|
+
/* ===== List filter input ===== */
|
|
1078
|
+
.list-filter {
|
|
1079
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
1080
|
+
margin-bottom: var(--sp-3);
|
|
1081
|
+
}
|
|
1082
|
+
.list-filter input[type="search"] {
|
|
1083
|
+
flex: 1; min-width: 0; max-width: 360px;
|
|
1084
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
1085
|
+
color: var(--text); padding: 6px 10px;
|
|
1086
|
+
border-radius: var(--radius-sm); font-size: var(--fs-sm);
|
|
1087
|
+
transition: border-color var(--t-fast) var(--ease);
|
|
1088
|
+
}
|
|
1089
|
+
.list-filter input[type="search"]:focus {
|
|
1090
|
+
outline: none; border-color: var(--accent);
|
|
1091
|
+
box-shadow: 0 0 0 3px var(--accent-ring);
|
|
1092
|
+
}
|
|
1093
|
+
.list-filter input[type="search"]::placeholder { color: var(--text-dim); }
|
|
1094
|
+
.list-filter .count { color: var(--text-muted); font-size: var(--fs-xs); white-space: nowrap; }
|
|
1095
|
+
|
|
1096
|
+
/* ===== Rich empty / loading states =====
|
|
1097
|
+
`.state` is the inviting empty-state block: icon + title + body
|
|
1098
|
+
+ CTA. Use richEmpty() / loadingBlock() helpers instead of the
|
|
1099
|
+
bare `.empty` for any container where the user has a meaningful
|
|
1100
|
+
next action — adding a source, navigating to settings, etc. */
|
|
1101
|
+
.state {
|
|
1102
|
+
display: flex; flex-direction: column; align-items: center;
|
|
1103
|
+
justify-content: center; text-align: center;
|
|
1104
|
+
padding: var(--sp-8) var(--sp-4);
|
|
1105
|
+
color: var(--text-dim); font-size: var(--fs-md);
|
|
1106
|
+
line-height: 1.6; gap: var(--sp-3);
|
|
1107
|
+
min-height: 180px;
|
|
1108
|
+
}
|
|
1109
|
+
.state-ico {
|
|
1110
|
+
width: 44px; height: 44px;
|
|
1111
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
1112
|
+
color: var(--text-muted);
|
|
1113
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
1114
|
+
border-radius: var(--radius);
|
|
1115
|
+
}
|
|
1116
|
+
.state-ico svg { width: 22px; height: 22px; }
|
|
1117
|
+
.state-title { color: var(--text); font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; }
|
|
1118
|
+
.state-desc { color: var(--text-muted); font-size: var(--fs-sm); max-width: 420px; }
|
|
1119
|
+
.state-cta { margin-top: var(--sp-2); display: flex; gap: var(--sp-2); }
|
|
1120
|
+
.state.is-loading .state-ico { color: var(--accent); border-color: var(--accent-soft); }
|
|
1121
|
+
.state.is-loading .state-ico .spin {
|
|
1122
|
+
transform-origin: center;
|
|
1123
|
+
animation: spin 0.9s linear infinite;
|
|
1124
|
+
}
|
|
1125
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1126
|
+
.state.is-loading .state-ico .spin { animation: none; }
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/* ===== Topology graph affordances =====
|
|
1130
|
+
Zoom controls + keyboard-hint badge live inside #topology-graph-host
|
|
1131
|
+
(already position:relative). Both kept compact so they don't crowd
|
|
1132
|
+
the small graph viewport. Edge labels (rendered as SVG text) get
|
|
1133
|
+
a halo via paint-order so they're legible against bands / wires. */
|
|
1134
|
+
.topo-controls {
|
|
1135
|
+
position: absolute; top: 10px; right: 10px;
|
|
1136
|
+
display: flex; gap: 4px;
|
|
1137
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
1138
|
+
border-radius: var(--radius-sm); padding: 3px;
|
|
1139
|
+
z-index: 2;
|
|
1140
|
+
}
|
|
1141
|
+
.topo-controls .btn-icon {
|
|
1142
|
+
width: 26px; height: 26px;
|
|
1143
|
+
font-size: var(--fs-md); line-height: 1;
|
|
1144
|
+
border-radius: var(--radius-sm);
|
|
1145
|
+
}
|
|
1146
|
+
.topo-hint {
|
|
1147
|
+
position: absolute; top: 10px; left: 10px;
|
|
1148
|
+
font-size: var(--fs-xs); color: var(--text-muted);
|
|
1149
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
1150
|
+
border-radius: var(--radius-sm); padding: 4px 8px;
|
|
1151
|
+
pointer-events: none; opacity: 0.85;
|
|
1152
|
+
z-index: 2;
|
|
1153
|
+
}
|
|
1154
|
+
/* Form validation primitives — used by the Add Source modal and
|
|
1155
|
+
any future form. `.is-invalid` highlights the field, `.form-error`
|
|
1156
|
+
renders a small red explanation below it, `.form-banner` is a
|
|
1157
|
+
full-width error/success block at the top of a form body. */
|
|
1158
|
+
.form-group input.is-invalid,
|
|
1159
|
+
.form-group select.is-invalid,
|
|
1160
|
+
.form-group textarea.is-invalid {
|
|
1161
|
+
border-color: var(--danger);
|
|
1162
|
+
box-shadow: 0 0 0 3px rgba(239, 91, 110, 0.18);
|
|
1163
|
+
}
|
|
1164
|
+
.form-error {
|
|
1165
|
+
color: var(--danger); font-size: var(--fs-xs);
|
|
1166
|
+
margin-top: 4px; line-height: 1.4;
|
|
1167
|
+
}
|
|
1168
|
+
.form-banner {
|
|
1169
|
+
padding: 10px 12px; border-radius: var(--radius-sm);
|
|
1170
|
+
font-size: var(--fs-sm); line-height: 1.4;
|
|
1171
|
+
margin-bottom: var(--sp-3);
|
|
1172
|
+
border: 1px solid transparent;
|
|
1173
|
+
display: none;
|
|
1174
|
+
}
|
|
1175
|
+
.form-banner.show { display: block; }
|
|
1176
|
+
.form-banner.error {
|
|
1177
|
+
background: var(--danger-soft); color: var(--danger);
|
|
1178
|
+
border-color: rgba(239, 91, 110, 0.35);
|
|
1179
|
+
}
|
|
1180
|
+
.form-banner.success {
|
|
1181
|
+
background: var(--success-soft); color: var(--success);
|
|
1182
|
+
border-color: rgba(74, 222, 128, 0.25);
|
|
1183
|
+
}
|
|
1184
|
+
.btn[disabled] { opacity: 0.5; cursor: not-allowed; }
|
|
1185
|
+
|
|
1186
|
+
/* Graph node focus ring: the <g data-id> is focusable; the inner
|
|
1187
|
+
circle gets a wider stroke when its parent has :focus-visible. */
|
|
1188
|
+
#topology-graph-svg:focus { outline: none; }
|
|
1189
|
+
#topology-graph-svg g[data-id]:focus { outline: none; }
|
|
1190
|
+
#topology-graph-svg g[data-id]:focus-visible circle {
|
|
1191
|
+
stroke: var(--accent); stroke-width: 3;
|
|
1192
|
+
filter: drop-shadow(0 0 4px var(--accent-soft));
|
|
1193
|
+
}
|
|
917
1194
|
</style>
|
|
918
1195
|
</head>
|
|
919
1196
|
<body>
|
|
@@ -921,36 +1198,38 @@
|
|
|
921
1198
|
<div class="rail-brand">
|
|
922
1199
|
<span class="rail-mark"></span>
|
|
923
1200
|
<div class="rail-title">Observability<br><span>MCP Console</span></div>
|
|
1201
|
+
<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
1202
|
</div>
|
|
925
1203
|
<nav class="rail-nav">
|
|
926
1204
|
<div class="rail-grp" data-grp="observability">
|
|
927
1205
|
<button class="rail-grp-hd" onclick="toggleNavGroup('observability')">Observability<span class="chev">▾</span></button>
|
|
928
1206
|
<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>
|
|
1207
|
+
<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>
|
|
1208
|
+
<button class="nav-btn" data-page="sources" title="Sources" onclick="showPage('sources')"><span class="nav-ico">⊟</span><span class="nav-label">Sources</span></button>
|
|
1209
|
+
<button class="nav-btn" data-page="services" title="Services" onclick="showPage('services')"><span class="nav-ico">⊞</span><span class="nav-label">Services</span></button>
|
|
1210
|
+
<button class="nav-btn" data-page="health" title="Health" onclick="showPage('health')"><span class="nav-ico">✚</span><span class="nav-label">Health</span></button>
|
|
1211
|
+
<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
1212
|
</div>
|
|
935
1213
|
</div>
|
|
936
1214
|
<div class="rail-grp" data-grp="catalog">
|
|
937
1215
|
<button class="rail-grp-hd" onclick="toggleNavGroup('catalog')">Catalog<span class="chev">▾</span></button>
|
|
938
1216
|
<div class="rail-grp-body">
|
|
939
|
-
<button class="nav-btn" data-page="products" onclick="showPage('products')"><span class="nav-ico">◫</span>Products</button>
|
|
1217
|
+
<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
1218
|
</div>
|
|
941
1219
|
</div>
|
|
942
1220
|
<div class="rail-grp" data-grp="governance">
|
|
943
1221
|
<button class="rail-grp-hd" onclick="toggleNavGroup('governance')">Governance<span class="chev">▾</span></button>
|
|
944
1222
|
<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="
|
|
1223
|
+
<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>
|
|
1224
|
+
<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>
|
|
1225
|
+
<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
1226
|
</div>
|
|
948
1227
|
</div>
|
|
949
1228
|
<div class="rail-grp" data-grp="system">
|
|
950
1229
|
<button class="rail-grp-hd" onclick="toggleNavGroup('system')">System<span class="chev">▾</span></button>
|
|
951
1230
|
<div class="rail-grp-body">
|
|
952
1231
|
<div class="rail-item" data-nav="connectors">
|
|
953
|
-
<button class="nav-btn nav-parent" onclick="navToggle('connectors')"><span class="nav-ico">⇄</span>Connectors
|
|
1232
|
+
<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
1233
|
<div class="rail-sub">
|
|
955
1234
|
<button class="nav-sub" data-sub="installed" onclick="goTab('connectors','installed')">Installed</button>
|
|
956
1235
|
<button class="nav-sub" data-sub="hub" onclick="goTab('connectors','hub')">Connector Hub</button>
|
|
@@ -958,14 +1237,14 @@
|
|
|
958
1237
|
</div>
|
|
959
1238
|
</div>
|
|
960
1239
|
<div class="rail-item" data-nav="settings">
|
|
961
|
-
<button class="nav-btn nav-parent" onclick="navToggle('settings')"><span class="nav-ico">⚙</span>Settings
|
|
1240
|
+
<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
1241
|
<div class="rail-sub">
|
|
963
1242
|
<button class="nav-sub" data-sub="general" onclick="goTab('settings','general')">General</button>
|
|
964
1243
|
<button class="nav-sub" data-sub="health" onclick="goTab('settings','health')">Health Scoring</button>
|
|
965
1244
|
<button class="nav-sub" data-sub="metrics" onclick="goTab('settings','metrics')">Custom Metrics</button>
|
|
966
1245
|
</div>
|
|
967
1246
|
</div>
|
|
968
|
-
<button class="nav-btn" data-page="entitlement" onclick="showPage('entitlement')"><span class="nav-ico">⛁</span>Entitlement</button>
|
|
1247
|
+
<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
1248
|
</div>
|
|
970
1249
|
</div>
|
|
971
1250
|
</nav>
|
|
@@ -982,10 +1261,54 @@
|
|
|
982
1261
|
<div id="notif-list"><div class="notif-empty">No notifications</div></div>
|
|
983
1262
|
</div>
|
|
984
1263
|
</div>
|
|
1264
|
+
<span id="user-badge" class="row-inline t-sm" style="display:none; margin-right: var(--sp-2);">
|
|
1265
|
+
<span class="t-muted">Signed in as</span>
|
|
1266
|
+
<span class="user-name" style="color: var(--chrome-text); font-weight: 600;"></span>
|
|
1267
|
+
<button class="btn btn-ghost btn-sm" onclick="omcpLogout()">Sign out</button>
|
|
1268
|
+
</span>
|
|
1269
|
+
<button class="theme-toggle" id="density-toggle" title="Toggle row density" aria-label="Toggle density" onclick="toggleDensity()">≡</button>
|
|
985
1270
|
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark" aria-label="Toggle theme" onclick="toggleTheme()">◐</button>
|
|
986
1271
|
<button class="btn btn-ghost btn-sm" onclick="refresh()">Refresh</button>
|
|
987
1272
|
</header>
|
|
988
1273
|
|
|
1274
|
+
<!-- Governance misconfig banner — populated by omcpCheckGovernance()
|
|
1275
|
+
from /api/info. Hidden by default; the JS makes it visible when
|
|
1276
|
+
there is something the operator needs to fix (currently only the
|
|
1277
|
+
ephemeral session secret case). -->
|
|
1278
|
+
<div id="omcp-warning-bar" class="omcp-warning-bar" style="display:none" role="alert"></div>
|
|
1279
|
+
|
|
1280
|
+
<!-- Login modal — shown when /api/* returns 401 OMCP_AUTH_REQUIRED -->
|
|
1281
|
+
<div class="modal-overlay" id="login-modal">
|
|
1282
|
+
<div class="modal" style="width:380px;">
|
|
1283
|
+
<div class="modal-header"><h3>Sign in</h3></div>
|
|
1284
|
+
<div class="modal-body">
|
|
1285
|
+
<div id="login-banner" class="form-banner"></div>
|
|
1286
|
+
<!-- OIDC: shown when authMode=oidc. Single primary button — the
|
|
1287
|
+
IdP collects credentials, not us. -->
|
|
1288
|
+
<div id="login-oidc" style="display:none;" class="form-group">
|
|
1289
|
+
<button class="btn btn-primary" id="login-sso" onclick="omcpStartSso()" style="width:100%;">
|
|
1290
|
+
Sign in with SSO
|
|
1291
|
+
</button>
|
|
1292
|
+
<div class="form-hint t-sm" id="login-sso-hint" style="margin-top: var(--sp-2);"></div>
|
|
1293
|
+
</div>
|
|
1294
|
+
<!-- Basic: shown when authMode=basic. Hidden in oidc mode. -->
|
|
1295
|
+
<div id="login-basic">
|
|
1296
|
+
<div class="form-group">
|
|
1297
|
+
<label>Username</label>
|
|
1298
|
+
<input id="login-username" type="text" autocomplete="username" onkeydown="omcpLoginKey(event)">
|
|
1299
|
+
</div>
|
|
1300
|
+
<div class="form-group">
|
|
1301
|
+
<label>Password</label>
|
|
1302
|
+
<input id="login-password" type="password" autocomplete="current-password" onkeydown="omcpLoginKey(event)">
|
|
1303
|
+
</div>
|
|
1304
|
+
</div>
|
|
1305
|
+
</div>
|
|
1306
|
+
<div class="modal-footer">
|
|
1307
|
+
<button class="btn btn-primary" id="login-submit" onclick="omcpSubmitLogin()">Sign in</button>
|
|
1308
|
+
</div>
|
|
1309
|
+
</div>
|
|
1310
|
+
</div>
|
|
1311
|
+
|
|
989
1312
|
<div class="container">
|
|
990
1313
|
<!-- ===== Dashboard ===== -->
|
|
991
1314
|
<div class="page active" id="page-dashboard">
|
|
@@ -1045,8 +1368,21 @@
|
|
|
1045
1368
|
<div class="flow-node"><span class="fn-ic">✦</span><span class="fn-t">Analysis</span><span class="fn-s">scored health</span></div>
|
|
1046
1369
|
</div>
|
|
1047
1370
|
</div>
|
|
1371
|
+
<!-- Today's usage strip (top-5 identities by activity in the trailing
|
|
1372
|
+
24h window). Reads /api/usage; hidden when no identity has any
|
|
1373
|
+
traffic yet OR the viewer lacks audit:read (silently — non-admin
|
|
1374
|
+
sessions just don't see it). -->
|
|
1375
|
+
<div class="card" id="dash-usage-card" style="display:none">
|
|
1376
|
+
<div class="card-header"><h2>Today's MCP usage
|
|
1377
|
+
<button class="info" aria-label="About the usage strip"
|
|
1378
|
+
data-title="Usage strip"
|
|
1379
|
+
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)."
|
|
1380
|
+
onclick="infoPop(this)">?</button>
|
|
1381
|
+
</h2></div>
|
|
1382
|
+
<div id="dash-usage" class="content"><div class="empty">Loading…</div></div>
|
|
1383
|
+
</div>
|
|
1048
1384
|
<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>
|
|
1385
|
+
<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
1386
|
<div class="card"><div class="card-header"><h2>Services</h2></div><div id="dash-services"><div class="empty">Loading...</div></div></div>
|
|
1051
1387
|
</div>
|
|
1052
1388
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
|
|
@@ -1068,7 +1404,7 @@
|
|
|
1068
1404
|
<div class="breadcrumb">Console / Observability / <b>Sources</b></div>
|
|
1069
1405
|
<h1>Data Sources</h1>
|
|
1070
1406
|
</div>
|
|
1071
|
-
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Add Source</button></div>
|
|
1407
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" data-rbac="sources:write" onclick="openAddModal()">+ Add Source</button></div>
|
|
1072
1408
|
</div>
|
|
1073
1409
|
<div class="card">
|
|
1074
1410
|
<div class="card-header"><h2>All sources</h2></div>
|
|
@@ -1103,25 +1439,25 @@
|
|
|
1103
1439
|
</div>
|
|
1104
1440
|
|
|
1105
1441
|
<!-- Installed Tab -->
|
|
1106
|
-
<div class="tab-content active" id="tab-installed"
|
|
1442
|
+
<div class="tab-content tab-pad active" id="tab-installed">
|
|
1107
1443
|
<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
1444
|
<div id="conn-installed"><div class="empty">Loading...</div></div>
|
|
1109
1445
|
</div>
|
|
1110
1446
|
|
|
1111
1447
|
<!-- Connector Hub Tab -->
|
|
1112
|
-
<div class="tab-content" id="tab-hub"
|
|
1448
|
+
<div class="tab-content tab-pad" id="tab-hub">
|
|
1113
1449
|
<div class="card-header" style="margin-bottom:12px"><h2>Available from the Connector Hub</h2>
|
|
1114
1450
|
<a class="btn btn-ghost btn-sm" href="https://thotischner.github.io/observability-mcp/hub/" target="_blank" rel="noopener">Open Hub ↗</a></div>
|
|
1115
1451
|
<div id="conn-hub"><div class="empty">Loading...</div></div>
|
|
1116
1452
|
</div>
|
|
1117
1453
|
|
|
1118
1454
|
<!-- Upload bundle Tab -->
|
|
1119
|
-
<div class="tab-content" id="tab-upload"
|
|
1455
|
+
<div class="tab-content tab-pad" id="tab-upload">
|
|
1120
1456
|
<div class="card-header" style="margin-bottom:12px"><h2>Upload a connector bundle</h2></div>
|
|
1121
1457
|
<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
1458
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
1123
1459
|
<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>
|
|
1460
|
+
<button class="btn btn-primary btn-sm" data-rbac="connectors:write" onclick="uploadConnector(this)">Upload & install</button>
|
|
1125
1461
|
</div>
|
|
1126
1462
|
</div>
|
|
1127
1463
|
</div>
|
|
@@ -1174,7 +1510,7 @@
|
|
|
1174
1510
|
<option value="">All</option>
|
|
1175
1511
|
</select>
|
|
1176
1512
|
</label>
|
|
1177
|
-
<span class="muted
|
|
1513
|
+
<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
1514
|
</div>
|
|
1179
1515
|
<div id="topology-by-host" class="empty" style="padding: 0 16px 16px;">Loading...</div>
|
|
1180
1516
|
</div>
|
|
@@ -1183,11 +1519,17 @@
|
|
|
1183
1519
|
<div class="card">
|
|
1184
1520
|
<div class="card-header">
|
|
1185
1521
|
<h2>Layered graph</h2>
|
|
1186
|
-
<span class="muted
|
|
1522
|
+
<span class="muted t-xs">click a resource to inspect · drag to reposition · wheel to zoom · drag the background to pan</span>
|
|
1187
1523
|
</div>
|
|
1188
1524
|
<div style="display:grid; grid-template-columns: 1fr 340px; gap: 0; border-top: 1px solid var(--border); height: 660px;">
|
|
1189
1525
|
<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>
|
|
1526
|
+
<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>
|
|
1527
|
+
<div class="topo-controls" role="toolbar" aria-label="Graph zoom">
|
|
1528
|
+
<button class="btn-icon" title="Zoom in (or wheel up)" aria-label="Zoom in" onclick="topoZoom(1.2)">+</button>
|
|
1529
|
+
<button class="btn-icon" title="Zoom out (or wheel down)" aria-label="Zoom out" onclick="topoZoom(1/1.2)">−</button>
|
|
1530
|
+
<button class="btn-icon" title="Reset view" aria-label="Reset view" onclick="topoResetView()">⤾</button>
|
|
1531
|
+
</div>
|
|
1532
|
+
<div class="topo-hint" id="topo-hint">Tab to focus · Enter to inspect · arrows to move focus · Esc to clear</div>
|
|
1191
1533
|
<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
1534
|
</div>
|
|
1193
1535
|
<aside id="topology-inspector" style="background: var(--surface); padding: 16px; overflow-y: auto; font-size: var(--fs-sm);">
|
|
@@ -1215,7 +1557,7 @@
|
|
|
1215
1557
|
</div>
|
|
1216
1558
|
|
|
1217
1559
|
<!-- General Tab -->
|
|
1218
|
-
<div class="tab-content active" id="tab-general"
|
|
1560
|
+
<div class="tab-content tab-pad active" id="tab-general">
|
|
1219
1561
|
<div class="form-row">
|
|
1220
1562
|
<div class="form-group">
|
|
1221
1563
|
<label>Check Interval (ms)</label>
|
|
@@ -1232,13 +1574,13 @@
|
|
|
1232
1574
|
</div>
|
|
1233
1575
|
</div>
|
|
1234
1576
|
<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>
|
|
1577
|
+
<button class="btn btn-ghost btn-sm" data-rbac="settings:write" onclick="resetSettings()">Reset to Defaults</button>
|
|
1578
|
+
<button class="btn btn-primary btn-sm" id="btn-save-settings" data-rbac="settings:write" onclick="saveSettings()" disabled>Save Settings</button>
|
|
1237
1579
|
</div>
|
|
1238
1580
|
</div>
|
|
1239
1581
|
|
|
1240
1582
|
<!-- Health Scoring Tab -->
|
|
1241
|
-
<div class="tab-content" id="tab-health"
|
|
1583
|
+
<div class="tab-content tab-pad" id="tab-health">
|
|
1242
1584
|
<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
1585
|
<h3 style="font-size:14px; margin-bottom:12px;">Weights</h3>
|
|
1244
1586
|
<div class="form-row" style="margin-bottom:20px;">
|
|
@@ -1288,13 +1630,13 @@
|
|
|
1288
1630
|
<div class="form-group"><label>Degraded above score</label><input type="number" id="ht-bound-degraded" min="0" max="100"></div>
|
|
1289
1631
|
</div>
|
|
1290
1632
|
<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>
|
|
1633
|
+
<button class="btn btn-ghost btn-sm" data-rbac="health:write" onclick="resetHealth()">Reset to Defaults</button>
|
|
1634
|
+
<button class="btn btn-primary btn-sm" id="btn-save-health" data-rbac="health:write" onclick="saveHealth()" disabled>Save Thresholds</button>
|
|
1293
1635
|
</div>
|
|
1294
1636
|
</div>
|
|
1295
1637
|
|
|
1296
1638
|
<!-- Source Metrics Tab -->
|
|
1297
|
-
<div class="tab-content" id="tab-metrics"
|
|
1639
|
+
<div class="tab-content tab-pad" id="tab-metrics">
|
|
1298
1640
|
<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
1641
|
<div style="display:flex; align-items:center; gap:12px; margin-bottom:16px;">
|
|
1300
1642
|
<label style="font-size:13px; color:var(--text2); white-space:nowrap;">Source:</label>
|
|
@@ -1389,8 +1731,31 @@ curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
|
1389
1731
|
table; an admin can create or change products via the editor or the API example.
|
|
1390
1732
|
</div>
|
|
1391
1733
|
</div>
|
|
1734
|
+
<!-- MCP Products — governed via /api/products (the new, RBAC-gated
|
|
1735
|
+
surface from the MCP Products + RBAC phase). Replaces the
|
|
1736
|
+
legacy enterprise-catalog block below for new deployments;
|
|
1737
|
+
the legacy block stays so existing /api/enterprise/catalog
|
|
1738
|
+
operators see their data until they migrate. -->
|
|
1739
|
+
<div class="card" id="mcp-products-card">
|
|
1740
|
+
<div class="card-header">
|
|
1741
|
+
<h2>MCP Products
|
|
1742
|
+
<button class="info" aria-label="About the MCP Products API"
|
|
1743
|
+
data-title="MCP Products"
|
|
1744
|
+
data-info="Curated tool bundles loaded from OMCP_PRODUCTS_FILE. Each product names an id, display name, optional tool allowlist (filters tools/list at the /mcp transport in a future slice), branding metadata, and a status (published | staging). Non-admins see only their own tenant's published entries; admins see everything plus staging. Edits go through PUT /api/products/{id} with strict body validation."
|
|
1745
|
+
onclick="infoPop(this)">?</button>
|
|
1746
|
+
</h2>
|
|
1747
|
+
<div class="row-inline">
|
|
1748
|
+
<span id="mcp-products-scope" class="badge"></span>
|
|
1749
|
+
<button class="btn btn-primary btn-sm" data-rbac="products:write" onclick="mcpProductsNew()">+ New product</button>
|
|
1750
|
+
</div>
|
|
1751
|
+
</div>
|
|
1752
|
+
<div id="mcp-products-box"><div class="empty">Loading…</div></div>
|
|
1753
|
+
</div>
|
|
1754
|
+
|
|
1755
|
+
<!-- Legacy enterprise-products card (kept for operators that
|
|
1756
|
+
still wire OMCP_ENTERPRISE_CATALOG_FILE) -->
|
|
1392
1757
|
<div class="card">
|
|
1393
|
-
<div class="card-header"><h2>Products</h2></div>
|
|
1758
|
+
<div class="card-header"><h2>Products <span class="t-sm" style="opacity:.7">(legacy / enterprise catalog)</span></h2></div>
|
|
1394
1759
|
<div id="ent-catalog"><div class="empty">Loading…</div></div>
|
|
1395
1760
|
<div id="ent-cat-editor" class="hidden" style="margin-top:12px">
|
|
1396
1761
|
<div class="ed-bar">
|
|
@@ -1435,6 +1800,50 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1435
1800
|
</div>
|
|
1436
1801
|
</div>
|
|
1437
1802
|
|
|
1803
|
+
<!-- ===== Governance: Policies (PolicyEngine snapshot + dry-run) ===== -->
|
|
1804
|
+
<div class="page" id="page-policies">
|
|
1805
|
+
<div class="page-head">
|
|
1806
|
+
<div class="ph-left">
|
|
1807
|
+
<div class="breadcrumb">Console / Governance / <b>Policies</b></div>
|
|
1808
|
+
<h1>Policies</h1>
|
|
1809
|
+
</div>
|
|
1810
|
+
<div class="ph-actions"><span id="pol-engine" class="badge"></span></div>
|
|
1811
|
+
</div>
|
|
1812
|
+
<div class="card">
|
|
1813
|
+
<div class="card-header"><h2>Active policy
|
|
1814
|
+
<button class="info" aria-label="About the active policy"
|
|
1815
|
+
data-title="Active policy"
|
|
1816
|
+
data-info="Read-only view of the RBAC policy this build is running. When OMCP_RBAC_POLICY_FILE is set the file replaces the built-in defaults; the badge above identifies which is active. Use the dry-run panel below to probe specific role/resource/action combinations without writing a test."
|
|
1817
|
+
onclick="infoPop(this)">?</button>
|
|
1818
|
+
</h2></div>
|
|
1819
|
+
<div id="pol-roles" class="content"><div class="empty">Loading…</div></div>
|
|
1820
|
+
<div class="form-hint" style="padding: var(--sp-3) var(--sp-4)">
|
|
1821
|
+
File source: read-only here. To change, edit <code>OMCP_RBAC_POLICY_FILE</code> on the server and restart.
|
|
1822
|
+
</div>
|
|
1823
|
+
</div>
|
|
1824
|
+
<div class="card">
|
|
1825
|
+
<div class="card-header"><h2>Dry-run probe</h2></div>
|
|
1826
|
+
<div class="content" style="display:grid; gap: var(--sp-3); grid-template-columns: 1fr 1fr 1fr auto;">
|
|
1827
|
+
<div class="form-group" style="margin:0">
|
|
1828
|
+
<label>Roles <span class="form-hint">comma-separated</span></label>
|
|
1829
|
+
<input id="pol-dry-roles" placeholder="admin, operator">
|
|
1830
|
+
</div>
|
|
1831
|
+
<div class="form-group" style="margin:0">
|
|
1832
|
+
<label>Resource</label>
|
|
1833
|
+
<input id="pol-dry-resource" placeholder="sources">
|
|
1834
|
+
</div>
|
|
1835
|
+
<div class="form-group" style="margin:0">
|
|
1836
|
+
<label>Action</label>
|
|
1837
|
+
<input id="pol-dry-action" placeholder="write">
|
|
1838
|
+
</div>
|
|
1839
|
+
<div class="form-group" style="margin:0; align-self:end">
|
|
1840
|
+
<button class="btn btn-primary" onclick="polDryRun()">Evaluate</button>
|
|
1841
|
+
</div>
|
|
1842
|
+
</div>
|
|
1843
|
+
<div id="pol-dry-out" style="padding: 0 var(--sp-4) var(--sp-4)"></div>
|
|
1844
|
+
</div>
|
|
1845
|
+
</div>
|
|
1846
|
+
|
|
1438
1847
|
<!-- ===== Governance: Audit Log ===== -->
|
|
1439
1848
|
<div class="page" id="page-audit">
|
|
1440
1849
|
<div class="page-head">
|
|
@@ -1453,6 +1862,15 @@ curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
|
1453
1862
|
</h2></div>
|
|
1454
1863
|
<div id="ent-audit"><div class="empty">Loading…</div></div>
|
|
1455
1864
|
</div>
|
|
1865
|
+
<div class="card mt-4">
|
|
1866
|
+
<div class="card-header"><h2>Management changes
|
|
1867
|
+
<button class="info" aria-label="About management audit"
|
|
1868
|
+
data-title="Management audit"
|
|
1869
|
+
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."
|
|
1870
|
+
onclick="infoPop(this)">?</button>
|
|
1871
|
+
</h2></div>
|
|
1872
|
+
<div id="mgmt-audit"><div class="empty">Loading…</div></div>
|
|
1873
|
+
</div>
|
|
1456
1874
|
</div>
|
|
1457
1875
|
|
|
1458
1876
|
<!-- ===== Governance: Entitlement ===== -->
|
|
@@ -1495,9 +1913,19 @@ curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
|
1495
1913
|
<div class="modal-header"><h3 id="modal-title">Add Source</h3><button class="btn-icon" onclick="closeModal('source-modal')">×</button></div>
|
|
1496
1914
|
<div class="modal-body">
|
|
1497
1915
|
<input type="hidden" id="modal-mode" value="add"><input type="hidden" id="modal-original-name" value="">
|
|
1498
|
-
<div
|
|
1916
|
+
<div id="src-form-banner" class="form-banner"></div>
|
|
1917
|
+
<div class="form-group">
|
|
1918
|
+
<label>Name</label>
|
|
1919
|
+
<input type="text" id="src-name" placeholder="e.g. prometheus-prod" oninput="validateSrcName()" onblur="validateSrcName()">
|
|
1920
|
+
<div class="form-hint">Unique identifier</div>
|
|
1921
|
+
<div class="form-error" id="src-name-err" hidden></div>
|
|
1922
|
+
</div>
|
|
1499
1923
|
<div class="form-group"><label>Type</label><select id="src-type"></select></div>
|
|
1500
|
-
<div class="form-group"
|
|
1924
|
+
<div class="form-group">
|
|
1925
|
+
<label>URL</label>
|
|
1926
|
+
<input type="text" id="src-url" placeholder="e.g. http://prometheus:9090" oninput="validateSrcUrl()" onblur="validateSrcUrl()">
|
|
1927
|
+
<div class="form-error" id="src-url-err" hidden></div>
|
|
1928
|
+
</div>
|
|
1501
1929
|
<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
1930
|
<div id="auth-basic-fields" style="display:none">
|
|
1503
1931
|
<div class="form-group"><label>Username</label><input type="text" id="src-auth-username" placeholder="username"></div>
|
|
@@ -1564,7 +1992,14 @@ let deleteTarget=null, deleteType=null;
|
|
|
1564
1992
|
// Per-source metrics state
|
|
1565
1993
|
let selectedMetricsSource='', sourceMetrics=[], sourceMetricDefaults=[];
|
|
1566
1994
|
|
|
1567
|
-
function toast(msg) {
|
|
1995
|
+
function toast(msg, kind) {
|
|
1996
|
+
const t=document.getElementById('toast-el');
|
|
1997
|
+
t.textContent=msg;
|
|
1998
|
+
t.classList.remove('toast-error');
|
|
1999
|
+
if (kind === 'error') t.classList.add('toast-error');
|
|
2000
|
+
t.classList.add('show');
|
|
2001
|
+
setTimeout(()=>t.classList.remove('show'),2000);
|
|
2002
|
+
}
|
|
1568
2003
|
|
|
1569
2004
|
// --- Nav ---
|
|
1570
2005
|
function showPage(name) {
|
|
@@ -1580,6 +2015,78 @@ function showPage(name) {
|
|
|
1580
2015
|
if(name==='topology') loadTopology();
|
|
1581
2016
|
if(name==='connectors') loadConnectors();
|
|
1582
2017
|
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
2018
|
+
if(name==='products') loadMcpProducts();
|
|
2019
|
+
if(name==='policies') loadPolicies();
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
// --- Policies tab (PolicyEngine snapshot + dry-run) ---
|
|
2023
|
+
async function loadPolicies() {
|
|
2024
|
+
const engineEl = document.getElementById('pol-engine');
|
|
2025
|
+
const rolesEl = document.getElementById('pol-roles');
|
|
2026
|
+
try {
|
|
2027
|
+
const r = await fetch('/api/policy');
|
|
2028
|
+
if (!r.ok) {
|
|
2029
|
+
rolesEl.innerHTML = '<div class="empty">Policy view requires the <code>users:delete</code> permission (admin role).</div>';
|
|
2030
|
+
engineEl.textContent = '';
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
const j = await r.json();
|
|
2034
|
+
engineEl.textContent = 'engine: ' + (j.engine || 'unknown');
|
|
2035
|
+
engineEl.className = 'badge ' + (j.engine === 'builtin' ? 'badge-ok' : '');
|
|
2036
|
+
const blocks = [];
|
|
2037
|
+
for (const role of (j.roles || [])) {
|
|
2038
|
+
const grants = (j.policy && j.policy[role]) || [];
|
|
2039
|
+
// Group grants by resource for compact rendering.
|
|
2040
|
+
const byRes = {};
|
|
2041
|
+
for (const g of grants) {
|
|
2042
|
+
(byRes[g.resource] = byRes[g.resource] || []).push(g.action);
|
|
2043
|
+
}
|
|
2044
|
+
const rows = Object.entries(byRes).map(([res, actions]) =>
|
|
2045
|
+
`<tr><td><code>${escHtml(res)}</code></td><td>${actions.map(a => `<span class="pill">${escHtml(a)}</span>`).join(' ')}</td></tr>`
|
|
2046
|
+
).join('');
|
|
2047
|
+
blocks.push(`
|
|
2048
|
+
<div style="padding: var(--sp-3) var(--sp-4)">
|
|
2049
|
+
<h3 style="margin: 0 0 var(--sp-2); display:flex; gap: var(--sp-2); align-items:baseline;">
|
|
2050
|
+
<span>${escHtml(role)}</span>
|
|
2051
|
+
<span class="t-sm" style="opacity:.7">${grants.length} permission${grants.length===1?'':'s'}</span>
|
|
2052
|
+
</h3>
|
|
2053
|
+
<table class="data-table" style="width:100%"><thead><tr><th>Resource</th><th>Actions</th></tr></thead><tbody>${rows || '<tr><td colspan="2" class="empty">no grants</td></tr>'}</tbody></table>
|
|
2054
|
+
</div>`);
|
|
2055
|
+
}
|
|
2056
|
+
rolesEl.innerHTML = blocks.join('') || '<div class="empty">No roles defined.</div>';
|
|
2057
|
+
if (j.note) {
|
|
2058
|
+
rolesEl.insertAdjacentHTML('beforeend', `<div class="form-hint" style="padding: var(--sp-2) var(--sp-4) var(--sp-3)">${escHtml(j.note)}</div>`);
|
|
2059
|
+
}
|
|
2060
|
+
} catch (e) {
|
|
2061
|
+
rolesEl.innerHTML = '<div class="empty">Policy unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
async function polDryRun() {
|
|
2065
|
+
const out = document.getElementById('pol-dry-out');
|
|
2066
|
+
const roles = document.getElementById('pol-dry-roles').value.trim();
|
|
2067
|
+
const resource = document.getElementById('pol-dry-resource').value.trim();
|
|
2068
|
+
const action = document.getElementById('pol-dry-action').value.trim();
|
|
2069
|
+
if (!resource || !action) {
|
|
2070
|
+
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Resource and action are required.</div>';
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
const params = new URLSearchParams({ resource, action });
|
|
2074
|
+
if (roles) params.set('roles', roles);
|
|
2075
|
+
try {
|
|
2076
|
+
const r = await fetch('/api/policy?' + params.toString());
|
|
2077
|
+
const j = await r.json();
|
|
2078
|
+
const d = j.dryRun || {};
|
|
2079
|
+
const cls = d.allowed ? 'badge-ok' : 'badge-err';
|
|
2080
|
+
const verdict = d.allowed ? 'allowed' : 'denied';
|
|
2081
|
+
out.innerHTML = `
|
|
2082
|
+
<div style="margin-top: var(--sp-3); display:flex; gap: var(--sp-2); align-items:center;">
|
|
2083
|
+
<span class="badge ${cls}">${verdict}</span>
|
|
2084
|
+
<code>${escHtml(Array.isArray(d.roles) ? d.roles.join(',') : '')} → ${escHtml(resource)}:${escHtml(action)}</code>
|
|
2085
|
+
</div>
|
|
2086
|
+
<div class="form-hint" style="margin-top: var(--sp-2)">${escHtml(d.reason || '')}</div>`;
|
|
2087
|
+
} catch (e) {
|
|
2088
|
+
out.innerHTML = '<div class="form-banner show error" style="margin-top: var(--sp-3)">Probe failed: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
2089
|
+
}
|
|
1583
2090
|
}
|
|
1584
2091
|
|
|
1585
2092
|
// --- Theme (light / dark) ---
|
|
@@ -1596,6 +2103,240 @@ function toggleTheme(){
|
|
|
1596
2103
|
syncThemeToggle();
|
|
1597
2104
|
}
|
|
1598
2105
|
|
|
2106
|
+
// --- Management-plane auth (basic mode) ----------------------------------
|
|
2107
|
+
// When OMCP_AUTH=basic on the server, /api/* returns 401 + body
|
|
2108
|
+
// { code: "OMCP_AUTH_REQUIRED" } for unauthenticated requests. This block
|
|
2109
|
+
// wraps window.fetch to catch that, show the login modal, and replay the
|
|
2110
|
+
// original request once the user signs in. In anonymous mode nothing here
|
|
2111
|
+
// fires — the wrapper is a passthrough.
|
|
2112
|
+
const _omcpRawFetch = window.fetch.bind(window);
|
|
2113
|
+
let _omcpLoginInflight = null;
|
|
2114
|
+
let _omcpLoginResolve = null;
|
|
2115
|
+
window.fetch = async function omcpAuthedFetch(input, init) {
|
|
2116
|
+
const res = await _omcpRawFetch(input, init);
|
|
2117
|
+
if (res.status !== 401) return res;
|
|
2118
|
+
let body = null;
|
|
2119
|
+
try { body = await res.clone().json(); } catch(e) {}
|
|
2120
|
+
if (!body || body.code !== 'OMCP_AUTH_REQUIRED') return res;
|
|
2121
|
+
await omcpEnsureLoggedIn();
|
|
2122
|
+
return _omcpRawFetch(input, init);
|
|
2123
|
+
};
|
|
2124
|
+
async function omcpEnsureLoggedIn() {
|
|
2125
|
+
if (_omcpLoginInflight) return _omcpLoginInflight;
|
|
2126
|
+
_omcpLoginInflight = new Promise((resolve) => { _omcpLoginResolve = resolve; });
|
|
2127
|
+
// Race fix (deferred from #290 reviewer): when a protected fetch
|
|
2128
|
+
// 401s before omcpSyncIdentity() has run, __omcpMe defaults to
|
|
2129
|
+
// anonymous and the basic-mode form would flash for one paint
|
|
2130
|
+
// even in OIDC mode. Force a sync before deciding which block to
|
|
2131
|
+
// show so the operator never sees the wrong form.
|
|
2132
|
+
if ((window.__omcpMe || {}).mode === 'anonymous') {
|
|
2133
|
+
try { await omcpSyncIdentity(); } catch (e) {}
|
|
2134
|
+
}
|
|
2135
|
+
const m = document.getElementById('login-modal');
|
|
2136
|
+
if (m) m.classList.add('open');
|
|
2137
|
+
const me = window.__omcpMe || {};
|
|
2138
|
+
const isOidc = me.mode === 'oidc';
|
|
2139
|
+
const ssoBlock = document.getElementById('login-oidc');
|
|
2140
|
+
const basicBlock = document.getElementById('login-basic');
|
|
2141
|
+
const ssoHint = document.getElementById('login-sso-hint');
|
|
2142
|
+
const submitBtn = document.getElementById('login-submit');
|
|
2143
|
+
if (ssoBlock) ssoBlock.style.display = isOidc ? '' : 'none';
|
|
2144
|
+
if (basicBlock) basicBlock.style.display = isOidc ? 'none' : '';
|
|
2145
|
+
if (submitBtn) submitBtn.style.display = isOidc ? 'none' : '';
|
|
2146
|
+
if (ssoHint && isOidc) {
|
|
2147
|
+
ssoHint.textContent = me.idpIssuer ? 'You will be redirected to ' + me.idpIssuer : '';
|
|
2148
|
+
}
|
|
2149
|
+
// Prefill the most recent username (basic mode only — OIDC needs no
|
|
2150
|
+
// local credential). Focus rules unchanged.
|
|
2151
|
+
setTimeout(() => {
|
|
2152
|
+
if (isOidc) {
|
|
2153
|
+
const sso = document.getElementById('login-sso');
|
|
2154
|
+
if (sso) sso.focus();
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
const u = document.getElementById('login-username');
|
|
2158
|
+
const p = document.getElementById('login-password');
|
|
2159
|
+
let lastUser = '';
|
|
2160
|
+
try { lastUser = localStorage.getItem('omcp-last-user') || ''; } catch (e) {}
|
|
2161
|
+
if (u && lastUser && !u.value) u.value = lastUser;
|
|
2162
|
+
if (u && p && u.value) p.focus();
|
|
2163
|
+
else if (u) u.focus();
|
|
2164
|
+
}, 50);
|
|
2165
|
+
return _omcpLoginInflight;
|
|
2166
|
+
}
|
|
2167
|
+
// Kick off the OIDC redirect. The return_to carries the current path
|
|
2168
|
+
// so the user lands back where they were after the IdP round-trip.
|
|
2169
|
+
function omcpStartSso() {
|
|
2170
|
+
const ret = location.pathname + location.search + location.hash;
|
|
2171
|
+
// Only same-origin paths are valid (isSafeReturnTo on the server
|
|
2172
|
+
// enforces this — but we sanitise client-side too so the URL stays
|
|
2173
|
+
// tidy in browser history).
|
|
2174
|
+
const safe = ret && ret.startsWith('/') && !ret.startsWith('//') ? ret : '/';
|
|
2175
|
+
location.href = '/api/auth/oidc/login?return_to=' + encodeURIComponent(safe);
|
|
2176
|
+
}
|
|
2177
|
+
function omcpLoginKey(e) { if (e.key === 'Enter') { e.preventDefault(); omcpSubmitLogin(); } }
|
|
2178
|
+
async function omcpSubmitLogin() {
|
|
2179
|
+
const u = document.getElementById('login-username').value.trim();
|
|
2180
|
+
const p = document.getElementById('login-password').value;
|
|
2181
|
+
const banner = document.getElementById('login-banner');
|
|
2182
|
+
if (!u || !p) {
|
|
2183
|
+
banner.className = 'form-banner show error';
|
|
2184
|
+
banner.textContent = 'Username and password are required';
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
const btn = document.getElementById('login-submit');
|
|
2188
|
+
btn.disabled = true;
|
|
2189
|
+
try {
|
|
2190
|
+
const res = await _omcpRawFetch('/api/auth/login', {
|
|
2191
|
+
method: 'POST',
|
|
2192
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2193
|
+
body: JSON.stringify({ username: u, password: p }),
|
|
2194
|
+
});
|
|
2195
|
+
if (!res.ok) {
|
|
2196
|
+
const j = await res.json().catch(() => ({ error: 'sign-in failed' }));
|
|
2197
|
+
banner.className = 'form-banner show error';
|
|
2198
|
+
banner.textContent = j.error || 'sign-in failed';
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
document.getElementById('login-modal').classList.remove('open');
|
|
2202
|
+
document.getElementById('login-password').value = '';
|
|
2203
|
+
banner.className = 'form-banner';
|
|
2204
|
+
// Remember the username for next time so the operator doesn't
|
|
2205
|
+
// retype it on every cookie expiry. Password field never persists.
|
|
2206
|
+
try { localStorage.setItem('omcp-last-user', u); } catch (e) {}
|
|
2207
|
+
if (_omcpLoginResolve) { const r = _omcpLoginResolve; _omcpLoginResolve = null; _omcpLoginInflight = null; r(); }
|
|
2208
|
+
await omcpSyncIdentity();
|
|
2209
|
+
} finally { btn.disabled = false; }
|
|
2210
|
+
}
|
|
2211
|
+
async function omcpLogout() {
|
|
2212
|
+
try { await _omcpRawFetch('/api/auth/logout', { method: 'POST' }); } catch(e) {}
|
|
2213
|
+
await omcpSyncIdentity();
|
|
2214
|
+
// Reload the page so any cached in-memory state is dropped.
|
|
2215
|
+
location.reload();
|
|
2216
|
+
}
|
|
2217
|
+
// Identity + granted permissions snapshot, refreshed after sign-in /
|
|
2218
|
+
// out. Other code calls omcpCan('sources','write') to decide whether
|
|
2219
|
+
// to render write controls. Anonymous mode → window.__omcpMe.permissions
|
|
2220
|
+
// is undefined and omcpCan() returns true (no RBAC enforced).
|
|
2221
|
+
window.__omcpMe = { authenticated: false, mode: 'anonymous', permissions: null };
|
|
2222
|
+
function omcpCan(resource, action) {
|
|
2223
|
+
const me = window.__omcpMe;
|
|
2224
|
+
if (!me || me.mode === 'anonymous') return true;
|
|
2225
|
+
if (!Array.isArray(me.permissions)) return false;
|
|
2226
|
+
return me.permissions.some(p => p.resource === resource && p.action === action);
|
|
2227
|
+
}
|
|
2228
|
+
function omcpApplyRbacToDom() {
|
|
2229
|
+
// Hide elements marked with data-rbac="resource:action" when the
|
|
2230
|
+
// current user lacks that permission. Idempotent — safe to call on
|
|
2231
|
+
// every render.
|
|
2232
|
+
document.querySelectorAll('[data-rbac]').forEach(el => {
|
|
2233
|
+
const [r, a] = (el.getAttribute('data-rbac') || '').split(':');
|
|
2234
|
+
if (!r || !a) return;
|
|
2235
|
+
el.style.display = omcpCan(r, a) ? '' : 'none';
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
async function omcpSyncIdentity() {
|
|
2239
|
+
const badge = document.getElementById('user-badge');
|
|
2240
|
+
try {
|
|
2241
|
+
const r = await _omcpRawFetch('/api/me');
|
|
2242
|
+
if (!r.ok) { if (badge) badge.style.display = 'none'; return; }
|
|
2243
|
+
const me = await r.json();
|
|
2244
|
+
window.__omcpMe = me;
|
|
2245
|
+
if (me.authenticated && badge) {
|
|
2246
|
+
// Show the email next to the name when the IdP gave us a
|
|
2247
|
+
// verified one. Also surface the IdP issuer host as a title
|
|
2248
|
+
// tooltip when this is an OIDC session — operators with
|
|
2249
|
+
// multiple IdPs can tell at a glance which one they're on.
|
|
2250
|
+
const nameEl = badge.querySelector('.user-name');
|
|
2251
|
+
const u = me.user || {};
|
|
2252
|
+
if (nameEl) {
|
|
2253
|
+
nameEl.textContent = u.email ? (u.name + ' · ' + u.email) : u.name;
|
|
2254
|
+
}
|
|
2255
|
+
// Surface the tenant when it's not the universal default. A
|
|
2256
|
+
// single-tenant deployment never sees the chip, so the demo
|
|
2257
|
+
// path stays uncluttered; multi-tenant deployments get a
|
|
2258
|
+
// permanent reminder of which tenant the session belongs to.
|
|
2259
|
+
let chip = badge.querySelector('.user-tenant');
|
|
2260
|
+
if (u.tenant && u.tenant !== 'default') {
|
|
2261
|
+
if (!chip) {
|
|
2262
|
+
chip = document.createElement('span');
|
|
2263
|
+
chip.className = 'user-tenant tag';
|
|
2264
|
+
chip.style.marginLeft = 'var(--sp-2)';
|
|
2265
|
+
badge.appendChild(chip);
|
|
2266
|
+
}
|
|
2267
|
+
chip.textContent = u.tenant;
|
|
2268
|
+
} else if (chip) {
|
|
2269
|
+
chip.remove();
|
|
2270
|
+
}
|
|
2271
|
+
// body[data-tenant=…] lets per-tenant CSS theming (e.g. a brand
|
|
2272
|
+
// colour bar) hook in without per-tenant builds.
|
|
2273
|
+
document.body.setAttribute('data-tenant', u.tenant || 'default');
|
|
2274
|
+
const tooltip = [];
|
|
2275
|
+
if (me.mode === 'oidc' && me.idpIssuer) {
|
|
2276
|
+
try { tooltip.push('signed in via ' + new URL(me.idpIssuer).host); } catch (e) { tooltip.push('signed in via ' + me.idpIssuer); }
|
|
2277
|
+
}
|
|
2278
|
+
if (u.tenant && u.tenant !== 'default') tooltip.push('tenant: ' + u.tenant);
|
|
2279
|
+
if (tooltip.length) badge.title = tooltip.join(' · ');
|
|
2280
|
+
else badge.removeAttribute('title');
|
|
2281
|
+
badge.style.display = '';
|
|
2282
|
+
} else if (badge) {
|
|
2283
|
+
badge.style.display = 'none';
|
|
2284
|
+
document.body.removeAttribute('data-tenant');
|
|
2285
|
+
}
|
|
2286
|
+
omcpApplyRbacToDom();
|
|
2287
|
+
} catch (e) { if (badge) badge.style.display = 'none'; }
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// Reads /api/info once at boot to surface deployment-misconfig warnings
|
|
2291
|
+
// the operator should fix. Currently only one trigger: the auth secret
|
|
2292
|
+
// is process-ephemeral (OMCP_SESSION_SECRET unset in basic mode), which
|
|
2293
|
+
// means every sign-in dies on restart. Pure additive — no banner shown
|
|
2294
|
+
// when the deployment is clean.
|
|
2295
|
+
async function omcpCheckGovernance() {
|
|
2296
|
+
try {
|
|
2297
|
+
const r = await _omcpRawFetch('/api/info');
|
|
2298
|
+
if (!r.ok) return;
|
|
2299
|
+
const info = await r.json();
|
|
2300
|
+
const g = info && info.governance;
|
|
2301
|
+
if (!g) return;
|
|
2302
|
+
if (g.authMode === 'basic' && g.authSecretEphemeral) {
|
|
2303
|
+
const bar = document.getElementById('omcp-warning-bar');
|
|
2304
|
+
if (bar) {
|
|
2305
|
+
bar.textContent = '⚠ OMCP_SESSION_SECRET is not set — every sign-in will be lost on server restart. Set a stable value in production.';
|
|
2306
|
+
bar.style.display = '';
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
} catch (e) { /* /api/info unreachable — no banner */ }
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
2313
|
+
omcpSyncIdentity();
|
|
2314
|
+
omcpCheckGovernance();
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
// --- Rail collapse toggle (icon-only mode) ---
|
|
2318
|
+
function toggleRail(){
|
|
2319
|
+
const cur = document.documentElement.getAttribute('data-rail') === 'collapsed' ? 'collapsed' : 'expanded';
|
|
2320
|
+
const next = cur === 'collapsed' ? 'expanded' : 'collapsed';
|
|
2321
|
+
document.documentElement.setAttribute('data-rail', next);
|
|
2322
|
+
try { localStorage.setItem('omcp-rail', next); } catch(e){}
|
|
2323
|
+
// Update the toggle button's tooltip so it reflects the action it will take.
|
|
2324
|
+
const btn = document.getElementById('rail-collapse-btn');
|
|
2325
|
+
if (btn) btn.title = next === 'collapsed' ? 'Expand navigation' : 'Collapse navigation';
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// --- Topology graph viewport controls (driven by the +/-/reset toolbar) ---
|
|
2329
|
+
function topoZoom(k){ const v = window.__topoViewport; if (v) v.zoom(k); }
|
|
2330
|
+
function topoResetView(){ const v = window.__topoViewport; if (v) v.reset(); }
|
|
2331
|
+
|
|
2332
|
+
// --- Row density toggle (comfortable / compact), persisted ---
|
|
2333
|
+
function toggleDensity(){
|
|
2334
|
+
const cur=document.documentElement.getAttribute('data-density')==='compact'?'compact':'comfortable';
|
|
2335
|
+
const next=cur==='compact'?'comfortable':'compact';
|
|
2336
|
+
document.documentElement.setAttribute('data-density',next);
|
|
2337
|
+
try{ localStorage.setItem('omcp-density',next); }catch(e){}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1599
2340
|
// --- Navigation groups (collapsible, persisted) ---
|
|
1600
2341
|
function navState(){ try{ return JSON.parse(localStorage.getItem('omcp-nav')||'{}'); }catch(e){ return {}; } }
|
|
1601
2342
|
function toggleNavGroup(id){
|
|
@@ -1771,15 +2512,15 @@ function audDetail(seq){
|
|
|
1771
2512
|
const tone=ev.allow?'ok':'bad';
|
|
1772
2513
|
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
2514
|
const html=
|
|
1774
|
-
dwSec('Decision', `<span class="stchip ${tone}">${ev.allow?'allow':'deny'}</span> <span
|
|
2515
|
+
dwSec('Decision', `<span class="stchip ${tone}">${ev.allow?'allow':'deny'}</span> <span class="muted t-sm">sequence #${escHtml(String(e.seq))}</span>`)+
|
|
1775
2516
|
dwSec('Principal & reason', `<table class="dtable"><tbody>
|
|
1776
2517
|
<tr><td>Principal</td><td class="mono">${escHtml(ev.principalId||'—')}</td></tr>
|
|
1777
2518
|
<tr><td>Timestamp</td><td class="mono">${escHtml(ev.ts||e.ts||'—')}</td></tr>
|
|
1778
2519
|
<tr><td>Reason</td><td>${escHtml(ev.reason||'—')}</td></tr></tbody></table>`)+
|
|
1779
2520
|
dwSec('Request', `<table class="dtable"><tbody>${reqRows}</tbody></table>`)+
|
|
1780
2521
|
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
|
|
2522
|
+
<tr><td>Entry hash</td><td class="mono t-break">${escHtml(e.hash||'—')}</td></tr>
|
|
2523
|
+
<tr><td>Prev hash</td><td class="mono t-break">${escHtml(e.prevHash||'—')}</td></tr></tbody></table>`);
|
|
1783
2524
|
openDrawer('Decision #'+seq, html);
|
|
1784
2525
|
}
|
|
1785
2526
|
function dpView(){ try{ return localStorage.getItem('omcp-dp-view')==='table'?'table':'cards'; }catch(e){ return 'cards'; } }
|
|
@@ -1837,7 +2578,7 @@ function dpDetail(name){
|
|
|
1837
2578
|
dwSec('Sources', dwChips(p.sources))+
|
|
1838
2579
|
dwSec('Services', dwChips(p.services))+
|
|
1839
2580
|
dwSec('Tools', dwChips(p.tools))+
|
|
1840
|
-
dwSec('Granted to ('+grantedTo.length+')', grantedTo.length?grantedTo.map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''):'<span
|
|
2581
|
+
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
2582
|
`<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
2583
|
openDrawer(name, html);
|
|
1843
2584
|
}
|
|
@@ -1854,7 +2595,34 @@ async function loadGatePill(){
|
|
|
1854
2595
|
}catch{ el.className='gate-pill gate-unknown'; el.textContent='Gate: n/a'; }
|
|
1855
2596
|
}
|
|
1856
2597
|
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>';}
|
|
2598
|
+
// Operator-controlled page size for the management-plane audit feed.
|
|
2599
|
+
// Starts at 50 (the original default); the "Load more" button on the
|
|
2600
|
+
// table jumps to 500 (the in-memory ring cap) and the change persists
|
|
2601
|
+
// until the next reload. Doesn't read from localStorage on purpose —
|
|
2602
|
+
// inspecting more entries than usual is an investigative gesture, not
|
|
2603
|
+
// an everyday preference.
|
|
2604
|
+
let _omcpMgmtAuditLimit = 50;
|
|
2605
|
+
function omcpMgmtAuditMore(){ _omcpMgmtAuditLimit = 500; loadEnterprise(); }
|
|
2606
|
+
|
|
2607
|
+
// Self-refresh the audit + governance feeds while the user is on
|
|
2608
|
+
// page-audit, page-access, page-products or page-entitlement. Gated on
|
|
2609
|
+
// document.activeElement and the page's .active class so it doesn't
|
|
2610
|
+
// burn cycles when the tab is hidden or backgrounded. Reuses the same
|
|
2611
|
+
// loadEnterprise() the page switch fires.
|
|
2612
|
+
let _omcpGovInterval = null;
|
|
2613
|
+
function ensureGovAutoRefresh(){
|
|
2614
|
+
if (_omcpGovInterval) return;
|
|
2615
|
+
_omcpGovInterval = setInterval(() => {
|
|
2616
|
+
if (typeof document === 'undefined') return;
|
|
2617
|
+
if (document.hidden) return;
|
|
2618
|
+
const onGovPage = ['page-audit','page-access','page-products','page-entitlement']
|
|
2619
|
+
.some(id => { const el = document.getElementById(id); return el && el.classList.contains('active'); });
|
|
2620
|
+
if (onGovPage) loadEnterprise();
|
|
2621
|
+
}, 15000);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
1857
2624
|
async function loadEnterprise(){
|
|
2625
|
+
ensureGovAutoRefresh();
|
|
1858
2626
|
// Status + entitlement claims
|
|
1859
2627
|
try{
|
|
1860
2628
|
const s=await(await fetch('/api/enterprise/status')).json();
|
|
@@ -1914,6 +2682,61 @@ async function loadEnterprise(){
|
|
|
1914
2682
|
box.querySelectorAll('[data-aud]').forEach(el=>el.addEventListener('click',()=>audDetail(el.getAttribute('data-aud'))));
|
|
1915
2683
|
}
|
|
1916
2684
|
}catch{ document.getElementById('ent-audit').innerHTML='<div class="empty">Audit unavailable.</div>'; }
|
|
2685
|
+
// Management-plane audit (separate feed from the enterprise gate).
|
|
2686
|
+
// Honours the per-page mgmt-audit-limit picker so operators can dial
|
|
2687
|
+
// up from 50 to 500 entries without leaving the page.
|
|
2688
|
+
try{
|
|
2689
|
+
const limit = (typeof _omcpMgmtAuditLimit === 'number') ? _omcpMgmtAuditLimit : 50;
|
|
2690
|
+
const r=await fetch('/api/audit?limit='+encodeURIComponent(limit));
|
|
2691
|
+
if(!r.ok) throw new Error('http '+r.status);
|
|
2692
|
+
const a=await r.json();
|
|
2693
|
+
const box=document.getElementById('mgmt-audit');
|
|
2694
|
+
const entries=a.entries||[];
|
|
2695
|
+
if(entries.length===0){
|
|
2696
|
+
box.innerHTML='<div class="empty">No management changes recorded yet.</div>';
|
|
2697
|
+
} else {
|
|
2698
|
+
const statusPill = (status) => {
|
|
2699
|
+
const cls = status >= 500 ? 'pill-no'
|
|
2700
|
+
: status >= 400 ? 'pill-no'
|
|
2701
|
+
: status >= 200 && status < 300 ? 'pill-yes'
|
|
2702
|
+
: '';
|
|
2703
|
+
return `<span class="mono ${cls}">${status}</span>`;
|
|
2704
|
+
};
|
|
2705
|
+
// Read = neutral, write = accent, delete = danger. Operators
|
|
2706
|
+
// scanning an outage's worth of audit entries can spot the
|
|
2707
|
+
// delete-shaped rows first — those are the ones most likely to
|
|
2708
|
+
// have caused the regression.
|
|
2709
|
+
const permPill = (resource, action) => {
|
|
2710
|
+
const cls = action === 'delete' ? 'pill-no'
|
|
2711
|
+
: action === 'write' ? 't-accent'
|
|
2712
|
+
: 't-muted';
|
|
2713
|
+
return `<span class="mono ${cls}">${escHtml(resource)}:${escHtml(action)}</span>`;
|
|
2714
|
+
};
|
|
2715
|
+
// Anonymous-mode entries render in muted italic so they read
|
|
2716
|
+
// distinctly from authenticated actors — useful for spotting
|
|
2717
|
+
// misconfigured deployments and pre-migration entries in the
|
|
2718
|
+
// same feed.
|
|
2719
|
+
const actorCell = (a) => {
|
|
2720
|
+
const label = a.name || a.sub;
|
|
2721
|
+
if (a.sub === 'anonymous') {
|
|
2722
|
+
return `<span class="t-muted" style="font-style:italic">${escHtml(label)}</span>`;
|
|
2723
|
+
}
|
|
2724
|
+
return escHtml(label);
|
|
2725
|
+
};
|
|
2726
|
+
const rows=entries.map(e=>
|
|
2727
|
+
`<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>`
|
|
2728
|
+
).join('');
|
|
2729
|
+
const note=a.persisted?'':' · in-memory only — set OMCP_MGMT_AUDIT_FILE for persistence';
|
|
2730
|
+
const showMore = entries.length === limit
|
|
2731
|
+
? `<button class="btn btn-ghost btn-sm" onclick="omcpMgmtAuditMore()">Load more (up to 500)</button>`
|
|
2732
|
+
: '';
|
|
2733
|
+
box.innerHTML=`<div class="row-between mb-2"><span class="t-muted t-sm">${entries.length} entries${escHtml(note)}</span>${showMore}</div>
|
|
2734
|
+
<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>`;
|
|
2735
|
+
}
|
|
2736
|
+
}catch(e){
|
|
2737
|
+
const box=document.getElementById('mgmt-audit');
|
|
2738
|
+
if(box) box.innerHTML='<div class="empty">Management audit unavailable.</div>';
|
|
2739
|
+
}
|
|
1917
2740
|
}
|
|
1918
2741
|
// ---- Minimal YAML for the flat policy/catalog schema (maps, string
|
|
1919
2742
|
// arrays, booleans, strings). Optional alternative view to the form. ----
|
|
@@ -2249,6 +3072,154 @@ function closeModal(id) { document.getElementById(id).classList.remove('open');
|
|
|
2249
3072
|
|
|
2250
3073
|
// --- Data Loading ---
|
|
2251
3074
|
async function loadSources() { try { sourcesData=await(await fetch('/api/sources')).json(); renderSources(); updateStats(); } catch(e){} }
|
|
3075
|
+
// --- MCP Products (new /api/products surface) ---
|
|
3076
|
+
async function loadMcpProducts() {
|
|
3077
|
+
const box = document.getElementById('mcp-products-box');
|
|
3078
|
+
const scope = document.getElementById('mcp-products-scope');
|
|
3079
|
+
if (!box) return;
|
|
3080
|
+
try {
|
|
3081
|
+
const r = await fetch('/api/products');
|
|
3082
|
+
if (!r.ok) {
|
|
3083
|
+
// 403 = no products:read; render a friendly hint instead of an empty list
|
|
3084
|
+
if (r.status === 403) {
|
|
3085
|
+
box.innerHTML = '<div class="empty">Requires <code>products:read</code> permission (granted to viewer / operator / admin by default).</div>';
|
|
3086
|
+
} else {
|
|
3087
|
+
box.innerHTML = '<div class="empty">/api/products unavailable.</div>';
|
|
3088
|
+
}
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
const j = await r.json();
|
|
3092
|
+
if (scope) {
|
|
3093
|
+
const s = j.scopedTo === null ? 'all tenants' : (j.scopedTo || 'default');
|
|
3094
|
+
scope.textContent = 'scope: ' + s + (j.includesStaging ? ' · staging visible' : '');
|
|
3095
|
+
}
|
|
3096
|
+
if (!j.products || j.products.length === 0) {
|
|
3097
|
+
const hint = j.configured
|
|
3098
|
+
? 'No products yet. Click <b>+ New product</b> or edit <code>OMCP_PRODUCTS_FILE</code> directly.'
|
|
3099
|
+
: 'Set <code>OMCP_PRODUCTS_FILE=<path></code> on the server (or use <b>+ New product</b> to start an in-memory catalog).';
|
|
3100
|
+
box.innerHTML = '<div class="empty">' + hint + '</div>';
|
|
3101
|
+
return;
|
|
3102
|
+
}
|
|
3103
|
+
const rows = j.products.map((p) => {
|
|
3104
|
+
const status = p.status === 'staging'
|
|
3105
|
+
? '<span class="pill" style="background:var(--warning-soft);color:var(--warning)">staging</span>'
|
|
3106
|
+
: '<span class="pill" style="background:var(--success-soft);color:var(--success)">published</span>';
|
|
3107
|
+
const tools = (p.tools || []).slice(0, 5).map((t) => `<code class="t-sm">${escHtml(t)}</code>`).join(' ');
|
|
3108
|
+
const moreTools = (p.tools || []).length > 5 ? ` <span class="t-sm" style="opacity:.7">+${(p.tools.length - 5)} more</span>` : '';
|
|
3109
|
+
const tenantCell = `<span class="tag">${escHtml(p.tenant || 'default')}</span>`;
|
|
3110
|
+
return `<tr>
|
|
3111
|
+
<td><code>${escHtml(p.id)}</code></td>
|
|
3112
|
+
<td>${escHtml(p.name)}${p.description ? `<div class="t-sm" style="opacity:.7">${escHtml(p.description)}</div>` : ''}</td>
|
|
3113
|
+
<td>${tenantCell}</td>
|
|
3114
|
+
<td>${status}</td>
|
|
3115
|
+
<td>${tools || '<span class="t-sm" style="opacity:.5">all tools</span>'}${moreTools}</td>
|
|
3116
|
+
<td style="text-align:right">
|
|
3117
|
+
<button class="btn-icon" data-rbac="products:write" title="Edit" onclick="mcpProductsEdit('${encodeURIComponent(p.id)}')">✎</button>
|
|
3118
|
+
<button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">🗑</button>
|
|
3119
|
+
</td>
|
|
3120
|
+
</tr>`;
|
|
3121
|
+
}).join('');
|
|
3122
|
+
box.innerHTML = `<table class="data-table" style="width:100%">
|
|
3123
|
+
<thead><tr><th>id</th><th>Name</th><th>Tenant</th><th>Status</th><th>Tools</th><th></th></tr></thead>
|
|
3124
|
+
<tbody>${rows}</tbody>
|
|
3125
|
+
</table>`;
|
|
3126
|
+
omcpApplyRbacToDom();
|
|
3127
|
+
} catch (e) {
|
|
3128
|
+
box.innerHTML = '<div class="empty">/api/products unavailable: ' + escHtml(String(e && e.message || e)) + '</div>';
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
async function mcpProductsNew() {
|
|
3132
|
+
const id = (window.prompt('New product id (lowercase, [a-z0-9._-]):') || '').trim();
|
|
3133
|
+
if (!id) return;
|
|
3134
|
+
const name = (window.prompt('Display name:') || '').trim();
|
|
3135
|
+
if (!name) return;
|
|
3136
|
+
await mcpProductsUpsert(id, { id, name, status: 'staging' });
|
|
3137
|
+
}
|
|
3138
|
+
async function mcpProductsEdit(idEnc) {
|
|
3139
|
+
const id = decodeURIComponent(idEnc);
|
|
3140
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id));
|
|
3141
|
+
if (!r.ok) { toast('Could not fetch ' + id); return; }
|
|
3142
|
+
const current = await r.json();
|
|
3143
|
+
const newName = window.prompt('Display name', current.name);
|
|
3144
|
+
if (newName === null) return;
|
|
3145
|
+
const status = window.prompt('Status (published | staging)', current.status || 'published');
|
|
3146
|
+
if (status === null) return;
|
|
3147
|
+
await mcpProductsUpsert(id, { ...current, name: newName, status });
|
|
3148
|
+
}
|
|
3149
|
+
async function mcpProductsUpsert(id, body) {
|
|
3150
|
+
try {
|
|
3151
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id), {
|
|
3152
|
+
method: 'PUT',
|
|
3153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3154
|
+
body: JSON.stringify(body),
|
|
3155
|
+
});
|
|
3156
|
+
if (!r.ok) {
|
|
3157
|
+
const j = await r.json().catch(() => ({}));
|
|
3158
|
+
toast('Save failed: ' + (j.error || r.status));
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
toast('Saved ' + id);
|
|
3162
|
+
await loadMcpProducts();
|
|
3163
|
+
} catch (e) { toast('Save failed: ' + (e && e.message || e)); }
|
|
3164
|
+
}
|
|
3165
|
+
async function mcpProductsDelete(idEnc) {
|
|
3166
|
+
const id = decodeURIComponent(idEnc);
|
|
3167
|
+
if (!window.confirm('Delete product ' + id + '?')) return;
|
|
3168
|
+
try {
|
|
3169
|
+
const r = await fetch('/api/products/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
3170
|
+
if (r.status === 204) {
|
|
3171
|
+
toast('Deleted ' + id);
|
|
3172
|
+
await loadMcpProducts();
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
const j = await r.json().catch(() => ({}));
|
|
3176
|
+
toast('Delete failed: ' + (j.error || r.status));
|
|
3177
|
+
} catch (e) { toast('Delete failed: ' + (e && e.message || e)); }
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
async function loadDashUsage() {
|
|
3181
|
+
const card = document.getElementById('dash-usage-card');
|
|
3182
|
+
const box = document.getElementById('dash-usage');
|
|
3183
|
+
if (!card || !box) return;
|
|
3184
|
+
try {
|
|
3185
|
+
const r = await fetch('/api/usage');
|
|
3186
|
+
if (!r.ok) { card.style.display = 'none'; return; }
|
|
3187
|
+
const j = await r.json();
|
|
3188
|
+
const ids = (j.identities || [])
|
|
3189
|
+
.map((i) => ({
|
|
3190
|
+
actor: i.actor,
|
|
3191
|
+
tenant: i.tenant || 'default',
|
|
3192
|
+
calls: i.count || 0,
|
|
3193
|
+
rateLimit: i.limit || 0,
|
|
3194
|
+
tokensUsed: (i.tokens && i.tokens.used) || 0,
|
|
3195
|
+
tokenLimit: (i.tokens && i.tokens.limit) || 0,
|
|
3196
|
+
}))
|
|
3197
|
+
.filter((i) => i.calls > 0 || i.tokensUsed > 0)
|
|
3198
|
+
.sort((a, b) => (b.tokensUsed - a.tokensUsed) || (b.calls - a.calls))
|
|
3199
|
+
.slice(0, 5);
|
|
3200
|
+
if (ids.length === 0) { card.style.display = 'none'; return; }
|
|
3201
|
+
card.style.display = '';
|
|
3202
|
+
// Header chip: which tenant the view is scoped to (admins see
|
|
3203
|
+
// either "all tenants" or a specific name; non-admins always
|
|
3204
|
+
// see their own).
|
|
3205
|
+
const scope = j.scopedTo === null ? 'all tenants' : (j.scopedTo || 'default');
|
|
3206
|
+
const showTenantCol = j.scopedTo === null; // only admins unscoped see multiple
|
|
3207
|
+
const rows = ids.map((i) => {
|
|
3208
|
+
const pct = i.tokenLimit > 0 ? Math.min(100, Math.round((i.tokensUsed / i.tokenLimit) * 100)) : null;
|
|
3209
|
+
const tokenCell = i.tokenLimit > 0
|
|
3210
|
+
? `${i.tokensUsed.toLocaleString()} / ${i.tokenLimit.toLocaleString()} <span class="t-sm" style="opacity:.7">(${pct}%)</span>`
|
|
3211
|
+
: `${i.tokensUsed.toLocaleString()} <span class="t-sm" style="opacity:.7">(uncapped)</span>`;
|
|
3212
|
+
const bar = pct !== null
|
|
3213
|
+
? `<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>`
|
|
3214
|
+
: '';
|
|
3215
|
+
const tenantCell = showTenantCol ? `<td><span class="tag">${escHtml(i.tenant)}</span></td>` : '';
|
|
3216
|
+
return `<tr><td><code>${escHtml(i.actor)}</code></td>${tenantCell}<td>${i.calls.toLocaleString()} / ${i.rateLimit.toLocaleString()}</td><td>${tokenCell}${bar}</td></tr>`;
|
|
3217
|
+
}).join('');
|
|
3218
|
+
const tenantHeader = showTenantCol ? '<th>Tenant</th>' : '';
|
|
3219
|
+
const scopeNote = `<div class="form-hint" style="padding: var(--sp-1) var(--sp-4) var(--sp-2); opacity:.7">scope: ${escHtml(scope)}</div>`;
|
|
3220
|
+
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>`;
|
|
3221
|
+
} catch (e) { card.style.display = 'none'; }
|
|
3222
|
+
}
|
|
2252
3223
|
async function loadServices() { try { const d=await(await fetch('/api/services')).json(); servicesData=d.services||[]; renderServices(); updateStats(); } catch(e){} }
|
|
2253
3224
|
async function loadTypes() { try { supportedTypes=await(await fetch('/api/source-types')).json(); } catch(e){ supportedTypes=['prometheus','loki']; } }
|
|
2254
3225
|
async function loadSettingsData() {
|
|
@@ -2358,28 +3329,112 @@ function renderHealthPanels(svcs){
|
|
|
2358
3329
|
// --- Render Sources ---
|
|
2359
3330
|
function srcView(){ try{ return localStorage.getItem('omcp-src-view')==='table'?'table':'list'; }catch(e){ return 'list'; } }
|
|
2360
3331
|
function setSrcView(v){ try{ localStorage.setItem('omcp-src-view',v); }catch(e){} renderSources(); }
|
|
3332
|
+
|
|
3333
|
+
// --- Sources / Services filter + sort state (filter in-memory, sort persisted) ---
|
|
3334
|
+
let srcFilter = '';
|
|
3335
|
+
let svcFilter = '';
|
|
3336
|
+
function loadSrcSort(){
|
|
3337
|
+
try{ const s = JSON.parse(localStorage.getItem('omcp-src-sort')||''); if (s && s.key) return s; }catch(e){}
|
|
3338
|
+
return { key: 'name', dir: 'asc' };
|
|
3339
|
+
}
|
|
3340
|
+
let srcSort = loadSrcSort();
|
|
3341
|
+
function setSrcSort(key){
|
|
3342
|
+
if (srcSort.key === key) srcSort.dir = srcSort.dir === 'asc' ? 'desc' : 'asc';
|
|
3343
|
+
else { srcSort.key = key; srcSort.dir = 'asc'; }
|
|
3344
|
+
try{ localStorage.setItem('omcp-src-sort', JSON.stringify(srcSort)); }catch(e){}
|
|
3345
|
+
renderSources();
|
|
3346
|
+
}
|
|
3347
|
+
function setSrcFilter(v){ srcFilter = String(v||'').trim().toLowerCase(); renderSources(); }
|
|
3348
|
+
function setSvcFilter(v){ svcFilter = String(v||'').trim().toLowerCase(); renderServices(); }
|
|
3349
|
+
function compareSrc(a, b){
|
|
3350
|
+
const dir = srcSort.dir === 'desc' ? -1 : 1;
|
|
3351
|
+
let av, bv;
|
|
3352
|
+
switch (srcSort.key) {
|
|
3353
|
+
case 'type': av = a.type; bv = b.type; break;
|
|
3354
|
+
case 'signal': av = a.signalType||''; bv = b.signalType||''; break;
|
|
3355
|
+
case 'url': av = a.url; bv = b.url; break;
|
|
3356
|
+
case 'status': av = !a.enabled?'disabled':a.status; bv = !b.enabled?'disabled':b.status; break;
|
|
3357
|
+
case 'latency': return dir * ((a.latencyMs||0) - (b.latencyMs||0));
|
|
3358
|
+
case 'name':
|
|
3359
|
+
default: av = a.name; bv = b.name;
|
|
3360
|
+
}
|
|
3361
|
+
av = String(av).toLowerCase(); bv = String(bv).toLowerCase();
|
|
3362
|
+
if (av < bv) return -1 * dir;
|
|
3363
|
+
if (av > bv) return 1 * dir;
|
|
3364
|
+
return 0;
|
|
3365
|
+
}
|
|
3366
|
+
function filterSrcRow(s){
|
|
3367
|
+
if (!srcFilter) return true;
|
|
3368
|
+
return [s.name, s.type, s.signalType||'', s.url].join(' ').toLowerCase().includes(srcFilter);
|
|
3369
|
+
}
|
|
3370
|
+
function filterSvcRow(s){
|
|
3371
|
+
if (!svcFilter) return true;
|
|
3372
|
+
return [s.name, (s.sources||[]).join(' '), (s.signalTypes||[]).join(' ')]
|
|
3373
|
+
.join(' ').toLowerCase().includes(svcFilter);
|
|
3374
|
+
}
|
|
3375
|
+
function sortIndClass(key){
|
|
3376
|
+
return key === srcSort.key ? (srcSort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
|
|
3377
|
+
}
|
|
3378
|
+
|
|
2361
3379
|
function srcRow(s){
|
|
2362
3380
|
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
2363
|
-
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>`:''}</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"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" onclick="openEditModal('${esc(s.name)}')">✎</button><button class="btn-icon" onclick="openDeleteConfirm('source','${esc(s.name)}')">🗑</button></div></div>`;
|
|
3381
|
+
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>`:''}</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
3382
|
}
|
|
2365
3383
|
function renderSources() {
|
|
2366
|
-
const
|
|
3384
|
+
const emptyDash = richEmpty({
|
|
3385
|
+
icon: 'plug',
|
|
3386
|
+
title: 'No sources yet',
|
|
3387
|
+
desc: 'Connect Prometheus, Loki or a Kubernetes cluster to start observing services.',
|
|
3388
|
+
ctaLabel: 'Add a source',
|
|
3389
|
+
ctaOnClick: "showPage('sources');openAddModal()",
|
|
3390
|
+
});
|
|
3391
|
+
const dashHtml = sourcesData.length===0 ? emptyDash : sourcesData.map(srcRow).join('');
|
|
2367
3392
|
document.getElementById('dash-sources').innerHTML=dashHtml;
|
|
2368
3393
|
const box=document.getElementById('sources-list'); if(!box) return;
|
|
2369
|
-
if(sourcesData.length===0){
|
|
3394
|
+
if(sourcesData.length===0){
|
|
3395
|
+
box.innerHTML = richEmpty({
|
|
3396
|
+
icon: 'plug',
|
|
3397
|
+
title: 'No sources configured',
|
|
3398
|
+
desc: 'Add a Prometheus, Loki or Kubernetes endpoint. Sources are auto-discovered for services and feed health, anomaly and topology tools.',
|
|
3399
|
+
ctaLabel: 'Add a source',
|
|
3400
|
+
ctaOnClick: 'openAddModal()',
|
|
3401
|
+
});
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
2370
3404
|
const view=srcView();
|
|
2371
|
-
|
|
2372
|
-
|
|
3405
|
+
// Apply filter + sort. Keep `sourcesData` immutable; render from a copy.
|
|
3406
|
+
const visible = sourcesData.filter(filterSrcRow).slice().sort(compareSrc);
|
|
3407
|
+
const filterBar=`<div class="list-filter">
|
|
3408
|
+
<input id="src-filter" type="search" placeholder="Filter sources by name, type or URL…" value="${esc(srcFilter)}" oninput="setSrcFilter(this.value)" aria-label="Filter sources">
|
|
3409
|
+
<span class="count">${visible.length} of ${sourcesData.length}</span>
|
|
3410
|
+
<span class="view-toggle" style="margin-left:auto"><button class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span>
|
|
3411
|
+
</div>`;
|
|
2373
3412
|
let body;
|
|
2374
3413
|
if(view==='table'){
|
|
2375
|
-
|
|
2376
|
-
|
|
3414
|
+
const h = (key, label) => `<th class="sortable ${sortIndClass(key)}" data-sort-key="${key}" onclick="setSrcSort('${key}')">${label}<span class="sort-ind"></span></th>`;
|
|
3415
|
+
body=`<table class="dtable"><thead><tr>${h('name','Name')}${h('type','Type')}${h('signal','Signal')}${h('url','URL')}${h('status','Status')}${h('latency','Latency')}</tr></thead><tbody>`+
|
|
3416
|
+
visible.map(s=>`<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><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>`).join('')+
|
|
2377
3417
|
`</tbody></table>`;
|
|
2378
3418
|
} else {
|
|
2379
|
-
body=
|
|
3419
|
+
body = visible.length === 0
|
|
3420
|
+
? `<div class="empty">No sources match "${esc(srcFilter)}".</div>`
|
|
3421
|
+
: visible.map(srcRow).join('');
|
|
2380
3422
|
}
|
|
2381
|
-
box.innerHTML=
|
|
2382
|
-
|
|
3423
|
+
box.innerHTML=filterBar+body;
|
|
3424
|
+
// Re-focus the filter input so typing doesn't lose focus across renders.
|
|
3425
|
+
if (srcFilter) {
|
|
3426
|
+
const inp = document.getElementById('src-filter');
|
|
3427
|
+
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
|
3428
|
+
}
|
|
3429
|
+
box.querySelectorAll('tr[data-src], .source-row[data-src]').forEach(el=>el.addEventListener('click',(ev)=>{
|
|
3430
|
+
// Don't trigger detail when clicking sortable column headers or actions.
|
|
3431
|
+
if (ev.target.closest('.source-actions, th.sortable')) return;
|
|
3432
|
+
srcDetail(el.getAttribute('data-src'));
|
|
3433
|
+
}));
|
|
3434
|
+
// Re-apply RBAC visibility — the new rows just got data-rbac="…" on
|
|
3435
|
+
// the per-row edit/delete/toggle controls and need to be hidden for
|
|
3436
|
+
// viewers who can't operate them. Idempotent.
|
|
3437
|
+
if (typeof omcpApplyRbacToDom === 'function') omcpApplyRbacToDom();
|
|
2383
3438
|
}
|
|
2384
3439
|
function srcDetail(name){
|
|
2385
3440
|
const s=sourcesData.find(x=>x.name===name);
|
|
@@ -2387,22 +3442,71 @@ function srcDetail(name){
|
|
|
2387
3442
|
const tone=!s.enabled?'warn':s.status==='up'?'ok':'bad';
|
|
2388
3443
|
const svc=(servicesData||[]).filter(x=>(x.sources||[]).includes(s.name)).map(x=>x.name);
|
|
2389
3444
|
const html=
|
|
2390
|
-
dwSec('Status', `<span class="stchip ${tone}">${!s.enabled?'disabled':s.status==='up'?'up':'down'}</span>${s.latencyMs?` <span
|
|
3445
|
+
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
3446
|
dwSec('Configuration', `<table class="dtable"><tbody>
|
|
2392
3447
|
<tr><td>Type</td><td class="mono">${escHtml(s.type)}</td></tr>
|
|
2393
3448
|
<tr><td>Signal type</td><td class="mono">${escHtml(s.signalType||'—')}</td></tr>
|
|
2394
|
-
<tr><td>URL</td><td class="mono
|
|
3449
|
+
<tr><td>URL</td><td class="mono t-break">${escHtml(s.url)}</td></tr>
|
|
2395
3450
|
<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
|
|
3451
|
+
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
3452
|
`<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
3453
|
`<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
3454
|
openDrawer(name, html);
|
|
2400
3455
|
}
|
|
2401
3456
|
function renderServices() {
|
|
2402
|
-
const
|
|
2403
|
-
const
|
|
2404
|
-
|
|
2405
|
-
|
|
3457
|
+
const sl = document.getElementById('services-list');
|
|
3458
|
+
const dash = document.getElementById('dash-services');
|
|
3459
|
+
if(servicesData.length === 0){
|
|
3460
|
+
// Two flavors: if no sources are connected, point at the Sources tab;
|
|
3461
|
+
// otherwise it's a transient discovery gap, so just say "no services yet".
|
|
3462
|
+
const hasSources = (sourcesData||[]).some(s => s.enabled && s.status === 'up');
|
|
3463
|
+
const empty = hasSources
|
|
3464
|
+
? richEmpty({
|
|
3465
|
+
icon: 'service',
|
|
3466
|
+
title: 'No services discovered',
|
|
3467
|
+
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.',
|
|
3468
|
+
})
|
|
3469
|
+
: richEmpty({
|
|
3470
|
+
icon: 'service',
|
|
3471
|
+
title: 'No services to show',
|
|
3472
|
+
desc: 'Connect at least one source — services are auto-discovered from the metrics or logs they emit.',
|
|
3473
|
+
ctaLabel: 'Go to Sources',
|
|
3474
|
+
ctaOnClick: "showPage('sources')",
|
|
3475
|
+
});
|
|
3476
|
+
if (sl) sl.innerHTML = empty;
|
|
3477
|
+
if (dash) dash.innerHTML = empty;
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
const visible = servicesData.filter(filterSvcRow);
|
|
3481
|
+
// Catalog enrichment is optional — when /api/services returned a
|
|
3482
|
+
// `.catalog` field (because OMCP_SERVICE_CATALOG_FILE is set and
|
|
3483
|
+
// the service name matched an entry), surface the most useful
|
|
3484
|
+
// fields inline: owner chip, criticality tier pill, on-call link.
|
|
3485
|
+
// Otherwise the row renders exactly as before.
|
|
3486
|
+
const catalogStrip = (c) => {
|
|
3487
|
+
if (!c) return '';
|
|
3488
|
+
const parts = [];
|
|
3489
|
+
if (c.owner) parts.push(`<span class="chip">owner: ${esc(c.owner)}</span>`);
|
|
3490
|
+
if (c.tier) parts.push(`<span class="chip">${esc(c.tier)}</span>`);
|
|
3491
|
+
if (c.onCall) parts.push(`<a class="chip" href="${esc(c.onCall)}" target="_blank" rel="noopener">on-call ↗</a>`);
|
|
3492
|
+
return parts.length ? ` <span class="row-inline gap-2">${parts.join('')}</span>` : '';
|
|
3493
|
+
};
|
|
3494
|
+
const rowsHtml = visible.length === 0
|
|
3495
|
+
? `<div class="empty">No services match "${esc(svcFilter)}".</div>`
|
|
3496
|
+
: 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('');
|
|
3497
|
+
if (sl) {
|
|
3498
|
+
const filterBar = `<div class="list-filter">
|
|
3499
|
+
<input id="svc-filter" type="search" placeholder="Filter services by name or source…" value="${esc(svcFilter)}" oninput="setSvcFilter(this.value)" aria-label="Filter services">
|
|
3500
|
+
<span class="count">${visible.length} of ${servicesData.length}</span>
|
|
3501
|
+
</div>`;
|
|
3502
|
+
sl.innerHTML = filterBar + rowsHtml;
|
|
3503
|
+
if (svcFilter) {
|
|
3504
|
+
const inp = document.getElementById('svc-filter');
|
|
3505
|
+
if (inp) { inp.focus(); inp.setSelectionRange(inp.value.length, inp.value.length); }
|
|
3506
|
+
}
|
|
3507
|
+
sl.querySelectorAll('[data-svc]').forEach(el=>el.addEventListener('click',()=>svcDetail(el.getAttribute('data-svc'))));
|
|
3508
|
+
}
|
|
3509
|
+
if (dash) dash.innerHTML = rowsHtml;
|
|
2406
3510
|
}
|
|
2407
3511
|
async function svcDetail(name){
|
|
2408
3512
|
let v=healthMap[name];
|
|
@@ -2411,16 +3515,35 @@ async function svcDetail(name){
|
|
|
2411
3515
|
const tone=v.status==='critical'?'bad':v.status!=='healthy'?'warn':'ok';
|
|
2412
3516
|
const m=(v.signals&&v.signals.metrics)||{};
|
|
2413
3517
|
const fmtN=x=>x==null?'—':(Math.round(Number(x)*100)/100);
|
|
3518
|
+
// Catalog enrichment may live on either the health payload or on the
|
|
3519
|
+
// service row in servicesData (whichever was fetched first).
|
|
3520
|
+
const cat = v.catalog || ((servicesData||[]).find(s => s.name === name) || {}).catalog;
|
|
3521
|
+
const catalogSection = cat ? dwSec('Catalog', `<table class="dtable"><tbody>${
|
|
3522
|
+
[
|
|
3523
|
+
['Owner', cat.owner],
|
|
3524
|
+
['Tier', cat.tier],
|
|
3525
|
+
['Data classification', cat.dataClassification],
|
|
3526
|
+
['SLO', cat.slo],
|
|
3527
|
+
['On-call', cat.onCall ? `<a href="${escHtml(cat.onCall)}" target="_blank" rel="noopener" class="mono">${escHtml(cat.onCall)} ↗</a>` : null],
|
|
3528
|
+
['Tags', (cat.tags && cat.tags.length) ? cat.tags.map(t => `<span class="chip">${escHtml(t)}</span>`).join(' ') : null],
|
|
3529
|
+
['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],
|
|
3530
|
+
['Description', cat.description],
|
|
3531
|
+
]
|
|
3532
|
+
.filter(([, val]) => val != null && val !== '')
|
|
3533
|
+
.map(([k, val]) => `<tr><td>${k}</td><td>${val}</td></tr>`)
|
|
3534
|
+
.join('')
|
|
3535
|
+
}</tbody></table>`) : '';
|
|
2414
3536
|
const html=
|
|
2415
|
-
dwSec('Status', `<span class="stchip ${tone}">${escHtml(v.status)}</span> <span
|
|
3537
|
+
dwSec('Status', `<span class="stchip ${tone}">${escHtml(v.status)}</span> <span class="muted t-sm">health score ${escHtml(v.score)}</span>`)+
|
|
3538
|
+
catalogSection+
|
|
2416
3539
|
dwSec('Signals', `<table class="dtable"><tbody>
|
|
2417
3540
|
<tr><td>CPU</td><td class="mono">${fmtN(m.cpu)}</td></tr>
|
|
2418
3541
|
<tr><td>Memory (MB)</td><td class="mono">${fmtN(m.memory)}</td></tr>
|
|
2419
3542
|
<tr><td>Error rate</td><td class="mono">${fmtN(m.errorRate)}</td></tr>
|
|
2420
3543
|
<tr><td>Latency p99 (s)</td><td class="mono">${fmtN(m.latencyP99)}</td></tr>
|
|
2421
3544
|
<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
|
|
3545
|
+
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>')+
|
|
3546
|
+
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
3547
|
`<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
3548
|
openDrawer(name, html);
|
|
2426
3549
|
}
|
|
@@ -2486,6 +3609,7 @@ function openAddModal() {
|
|
|
2486
3609
|
document.getElementById('src-url').value=''; document.getElementById('src-enabled').checked=true;
|
|
2487
3610
|
resetTlsFields();
|
|
2488
3611
|
document.getElementById('src-name').disabled=false; resetAuthFields(); hideTestResult();
|
|
3612
|
+
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
2489
3613
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}">${t}</option>`).join('');
|
|
2490
3614
|
document.getElementById('source-modal').classList.add('open');
|
|
2491
3615
|
}
|
|
@@ -2495,19 +3619,76 @@ function openEditModal(name) {
|
|
|
2495
3619
|
document.getElementById('modal-original-name').value=name; document.getElementById('src-name').value=s.name;
|
|
2496
3620
|
document.getElementById('src-name').disabled=true; document.getElementById('src-url').value=s.url;
|
|
2497
3621
|
document.getElementById('src-enabled').checked=s.enabled; setTlsInForm(s.tls); setAuthInForm(s.auth); hideTestResult();
|
|
3622
|
+
setFieldError('src-name','src-name-err',null); setFieldError('src-url','src-url-err',null); clearSrcBanner();
|
|
2498
3623
|
const sel=document.getElementById('src-type'); sel.innerHTML=supportedTypes.map(t=>`<option value="${t}" ${t===s.type?'selected':''}>${t}</option>`).join('');
|
|
2499
3624
|
document.getElementById('source-modal').classList.add('open');
|
|
2500
3625
|
}
|
|
3626
|
+
// --- Source modal form validation + banner ------------------------------
|
|
3627
|
+
function setFieldError(inputId, errId, msg) {
|
|
3628
|
+
const inp = document.getElementById(inputId);
|
|
3629
|
+
const err = document.getElementById(errId);
|
|
3630
|
+
if (msg) {
|
|
3631
|
+
if (inp) inp.classList.add('is-invalid');
|
|
3632
|
+
if (err) { err.textContent = msg; err.hidden = false; }
|
|
3633
|
+
} else {
|
|
3634
|
+
if (inp) inp.classList.remove('is-invalid');
|
|
3635
|
+
if (err) { err.textContent = ''; err.hidden = true; }
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
function showSrcBanner(kind, msg) {
|
|
3639
|
+
const b = document.getElementById('src-form-banner');
|
|
3640
|
+
if (!b) return;
|
|
3641
|
+
b.className = 'form-banner show ' + (kind || '');
|
|
3642
|
+
b.textContent = msg || '';
|
|
3643
|
+
}
|
|
3644
|
+
function clearSrcBanner() {
|
|
3645
|
+
const b = document.getElementById('src-form-banner');
|
|
3646
|
+
if (b) { b.className = 'form-banner'; b.textContent = ''; }
|
|
3647
|
+
}
|
|
3648
|
+
function validateSrcName() {
|
|
3649
|
+
const v = (document.getElementById('src-name').value || '').trim();
|
|
3650
|
+
if (!v) { setFieldError('src-name', 'src-name-err', 'Name is required'); return false; }
|
|
3651
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/i.test(v)) {
|
|
3652
|
+
setFieldError('src-name', 'src-name-err',
|
|
3653
|
+
'Use letters, digits and dashes (1–63 chars; must start with a letter or digit).');
|
|
3654
|
+
return false;
|
|
3655
|
+
}
|
|
3656
|
+
setFieldError('src-name', 'src-name-err', null);
|
|
3657
|
+
return true;
|
|
3658
|
+
}
|
|
3659
|
+
function validateSrcUrl() {
|
|
3660
|
+
const v = (document.getElementById('src-url').value || '').trim();
|
|
3661
|
+
if (!v) { setFieldError('src-url', 'src-url-err', 'URL is required'); return false; }
|
|
3662
|
+
try {
|
|
3663
|
+
const u = new URL(v);
|
|
3664
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
3665
|
+
setFieldError('src-url', 'src-url-err', 'URL must start with http:// or https://');
|
|
3666
|
+
return false;
|
|
3667
|
+
}
|
|
3668
|
+
} catch (e) {
|
|
3669
|
+
setFieldError('src-url', 'src-url-err', 'Not a valid URL');
|
|
3670
|
+
return false;
|
|
3671
|
+
}
|
|
3672
|
+
setFieldError('src-url', 'src-url-err', null);
|
|
3673
|
+
return true;
|
|
3674
|
+
}
|
|
3675
|
+
|
|
2501
3676
|
async function saveSource() {
|
|
3677
|
+
clearSrcBanner();
|
|
3678
|
+
const okName = validateSrcName();
|
|
3679
|
+
const okUrl = validateSrcUrl();
|
|
3680
|
+
if (!okName || !okUrl) { showSrcBanner('error', 'Fix the highlighted fields and try again.'); return; }
|
|
2502
3681
|
const mode=document.getElementById('modal-mode').value;
|
|
2503
3682
|
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
|
-
if(!src.name||!src.url){alert('Name and URL are required');return;}
|
|
2505
3683
|
const btn=document.getElementById('save-btn'); btn.disabled=true; btn.textContent='Saving...';
|
|
2506
3684
|
try {
|
|
2507
3685
|
let res; if(mode==='add') res=await fetch('/api/sources',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(src)});
|
|
2508
3686
|
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
|
-
|
|
3687
|
+
const d=await res.json();
|
|
3688
|
+
if(!res.ok){ showSrcBanner('error', d.error || ('HTTP '+res.status)); return; }
|
|
3689
|
+
closeModal('source-modal'); toast('Source saved'); await refresh();
|
|
3690
|
+
} catch(e){ showSrcBanner('error', String(e && e.message || e)); }
|
|
3691
|
+
finally{btn.disabled=false;btn.textContent='Save';}
|
|
2511
3692
|
}
|
|
2512
3693
|
async function testConnection() {
|
|
2513
3694
|
const btn=document.getElementById('test-btn'),r=document.getElementById('test-result');
|
|
@@ -2531,15 +3712,55 @@ async function confirmDelete(){
|
|
|
2531
3712
|
deleteTarget=null;deleteType=null;
|
|
2532
3713
|
}
|
|
2533
3714
|
|
|
3715
|
+
// --- Dirty-state tracking for forms with a Save button -----------------
|
|
3716
|
+
// Snapshots input values inside a root element on mount/populate; toggles
|
|
3717
|
+
// the save button between disabled (clean) and enabled (any input changed
|
|
3718
|
+
// from baseline). Reset on successful save resets the baseline.
|
|
3719
|
+
function bindDirtyState(rootId, btnId) {
|
|
3720
|
+
const root = document.getElementById(rootId);
|
|
3721
|
+
const btn = document.getElementById(btnId);
|
|
3722
|
+
if (!root || !btn) return;
|
|
3723
|
+
function snapshot() {
|
|
3724
|
+
const out = {};
|
|
3725
|
+
root.querySelectorAll('input,select,textarea').forEach(el => {
|
|
3726
|
+
if (!el.id) return;
|
|
3727
|
+
out[el.id] = el.type === 'checkbox' ? !!el.checked : (el.value ?? '');
|
|
3728
|
+
});
|
|
3729
|
+
return out;
|
|
3730
|
+
}
|
|
3731
|
+
const baseline = snapshot();
|
|
3732
|
+
function isDirty() {
|
|
3733
|
+
const cur = snapshot();
|
|
3734
|
+
for (const k of Object.keys(baseline)) {
|
|
3735
|
+
if (cur[k] !== baseline[k]) return true;
|
|
3736
|
+
}
|
|
3737
|
+
return false;
|
|
3738
|
+
}
|
|
3739
|
+
function update() { btn.disabled = !isDirty(); }
|
|
3740
|
+
root._omcpDirty = { baseline, update, reset(){ Object.assign(baseline, snapshot()); update(); } };
|
|
3741
|
+
root.removeEventListener('input', update);
|
|
3742
|
+
root.removeEventListener('change', update);
|
|
3743
|
+
root.addEventListener('input', update);
|
|
3744
|
+
root.addEventListener('change', update);
|
|
3745
|
+
update();
|
|
3746
|
+
}
|
|
3747
|
+
function resetDirtyState(rootId) {
|
|
3748
|
+
const root = document.getElementById(rootId);
|
|
3749
|
+
if (root && root._omcpDirty) root._omcpDirty.reset();
|
|
3750
|
+
}
|
|
3751
|
+
|
|
2534
3752
|
// --- Settings: General ---
|
|
2535
3753
|
function populateSettingsForm() {
|
|
2536
3754
|
document.getElementById('set-interval').value=settings.checkIntervalMs||30000;
|
|
2537
3755
|
document.getElementById('set-sensitivity').value=settings.defaultSensitivity||'medium';
|
|
3756
|
+
bindDirtyState('tab-general', 'btn-save-settings');
|
|
2538
3757
|
}
|
|
2539
3758
|
async function saveSettings() {
|
|
2540
3759
|
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)});
|
|
3760
|
+
const res = await fetch('/api/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
3761
|
+
if (!res.ok) { toast('Failed to save settings'); return; }
|
|
2542
3762
|
toast('Settings saved');
|
|
3763
|
+
resetDirtyState('tab-general');
|
|
2543
3764
|
}
|
|
2544
3765
|
async function resetSettings() { settings=defaults.settings; populateSettingsForm(); toast('Reset to defaults (save to persist)'); }
|
|
2545
3766
|
|
|
@@ -2555,6 +3776,7 @@ function populateHealthForm() {
|
|
|
2555
3776
|
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
3777
|
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
3778
|
document.getElementById('ht-bound-healthy').value=h.statusBoundaries.healthy; document.getElementById('ht-bound-degraded').value=h.statusBoundaries.degraded;
|
|
3779
|
+
bindDirtyState('tab-health', 'btn-save-health');
|
|
2558
3780
|
}
|
|
2559
3781
|
async function saveHealth() {
|
|
2560
3782
|
const body={
|
|
@@ -2566,9 +3788,11 @@ async function saveHealth() {
|
|
|
2566
3788
|
statusBoundaries:{healthy:parseFloat(document.getElementById('ht-bound-healthy').value),degraded:parseFloat(document.getElementById('ht-bound-degraded').value)}
|
|
2567
3789
|
};
|
|
2568
3790
|
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)});
|
|
3791
|
+
if(Math.abs(sum-1)>0.01){toast(`Weights must sum to 1.0 (currently ${sum.toFixed(2)})`, 'error');return;}
|
|
3792
|
+
const res = await fetch('/api/health-thresholds',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
3793
|
+
if (!res.ok) { toast('Failed to save thresholds'); return; }
|
|
2571
3794
|
healthThresholds=body; toast('Health thresholds saved');
|
|
3795
|
+
resetDirtyState('tab-health');
|
|
2572
3796
|
}
|
|
2573
3797
|
async function resetHealth() { healthThresholds=defaults.healthThresholds; populateHealthForm(); toast('Reset to defaults (save to persist)'); }
|
|
2574
3798
|
|
|
@@ -2781,8 +4005,13 @@ async function loadTopology(){
|
|
|
2781
4005
|
} catch(e) {
|
|
2782
4006
|
topologyData = null;
|
|
2783
4007
|
topologyLastRevHash = '';
|
|
2784
|
-
document.getElementById('topology-summary').innerHTML =
|
|
2785
|
-
|
|
4008
|
+
document.getElementById('topology-summary').innerHTML = richEmpty({
|
|
4009
|
+
icon: 'graph',
|
|
4010
|
+
title: 'No topology data',
|
|
4011
|
+
desc: 'Add a topology-capable source (currently: kubernetes) under Sources to populate the resource graph used by get_blast_radius and get_topology.',
|
|
4012
|
+
ctaLabel: 'Go to Sources',
|
|
4013
|
+
ctaOnClick: "showPage('sources')",
|
|
4014
|
+
});
|
|
2786
4015
|
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2787
4016
|
}
|
|
2788
4017
|
if(!topologyInterval) topologyInterval = setInterval(()=>{
|
|
@@ -2821,7 +4050,13 @@ function renderTopology(){
|
|
|
2821
4050
|
// --- Summary tab ---
|
|
2822
4051
|
const summary = document.getElementById('topology-summary');
|
|
2823
4052
|
if (d.sources.length === 0) {
|
|
2824
|
-
summary.innerHTML =
|
|
4053
|
+
summary.innerHTML = richEmpty({
|
|
4054
|
+
icon: 'graph',
|
|
4055
|
+
title: 'No topology-capable sources',
|
|
4056
|
+
desc: 'Add a topology source (currently: kubernetes) under Sources. The graph powers the blast-radius and dependency tools used by the agent.',
|
|
4057
|
+
ctaLabel: 'Add a source',
|
|
4058
|
+
ctaOnClick: "showPage('sources');openAddModal()",
|
|
4059
|
+
});
|
|
2825
4060
|
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2826
4061
|
renderTopologyGraph(); // shows empty-state legend too
|
|
2827
4062
|
return;
|
|
@@ -3144,6 +4379,18 @@ function renderTopologyGraph(){
|
|
|
3144
4379
|
function applyView(){ g.setAttribute('transform', `translate(${view.tx} ${view.ty}) scale(${view.scale})`); }
|
|
3145
4380
|
applyView();
|
|
3146
4381
|
svg.appendChild(g);
|
|
4382
|
+
// Expose view + setters so the zoom toolbar buttons and the keyboard
|
|
4383
|
+
// handler can drive the same `view` object that wheel-zoom mutates.
|
|
4384
|
+
window.__topoViewport = {
|
|
4385
|
+
zoom(k){
|
|
4386
|
+
const cx = W/2, cy = H/2;
|
|
4387
|
+
view.tx = cx - (cx - view.tx) * k;
|
|
4388
|
+
view.ty = cy - (cy - view.ty) * k;
|
|
4389
|
+
view.scale *= k;
|
|
4390
|
+
applyView();
|
|
4391
|
+
},
|
|
4392
|
+
reset(){ view.tx = 0; view.ty = 0; view.scale = 1; applyView(); },
|
|
4393
|
+
};
|
|
3147
4394
|
|
|
3148
4395
|
// Alternating tier background bands + tier labels in the left gutter.
|
|
3149
4396
|
for (let i = 0; i < tierIndices.length; i++){
|
|
@@ -3208,6 +4455,7 @@ function renderTopologyGraph(){
|
|
|
3208
4455
|
const cy = (a.y + b.y) / 2;
|
|
3209
4456
|
return `M ${a.x} ${a.y} C ${a.x} ${cy}, ${b.x} ${cy}, ${b.x} ${b.y}`;
|
|
3210
4457
|
}
|
|
4458
|
+
const edgeLabelEls = new Map(); // "<from> <to>" → text element, for repaint on drag
|
|
3211
4459
|
for (const e of drawEdges){
|
|
3212
4460
|
const a = positions.get(e.from), b = positions.get(e.to);
|
|
3213
4461
|
const s = topoRelStyle(e.relation);
|
|
@@ -3220,6 +4468,22 @@ function renderTopologyGraph(){
|
|
|
3220
4468
|
path.setAttribute('opacity', '0.85');
|
|
3221
4469
|
path.dataset.from = e.from; path.dataset.to = e.to;
|
|
3222
4470
|
g.appendChild(path);
|
|
4471
|
+
// Edge label at midpoint, with a halo so it remains readable
|
|
4472
|
+
// against tier bands and crossing wires in both themes.
|
|
4473
|
+
const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
4474
|
+
lbl.setAttribute('x', String((a.x + b.x) / 2));
|
|
4475
|
+
lbl.setAttribute('y', String((a.y + b.y) / 2));
|
|
4476
|
+
lbl.setAttribute('font-size', '9');
|
|
4477
|
+
lbl.setAttribute('text-anchor', 'middle');
|
|
4478
|
+
lbl.setAttribute('dominant-baseline', 'middle');
|
|
4479
|
+
lbl.setAttribute('fill', 'var(--text-muted)');
|
|
4480
|
+
lbl.setAttribute('stroke', 'var(--surface-2)');
|
|
4481
|
+
lbl.setAttribute('stroke-width', '3');
|
|
4482
|
+
lbl.setAttribute('paint-order', 'stroke');
|
|
4483
|
+
lbl.setAttribute('style', 'pointer-events: none; letter-spacing: 0.04em;');
|
|
4484
|
+
lbl.textContent = e.relation;
|
|
4485
|
+
g.appendChild(lbl);
|
|
4486
|
+
edgeLabelEls.set(e.from + '' + e.to, lbl);
|
|
3223
4487
|
}
|
|
3224
4488
|
|
|
3225
4489
|
// Nodes
|
|
@@ -3243,12 +4507,18 @@ function renderTopologyGraph(){
|
|
|
3243
4507
|
}
|
|
3244
4508
|
|
|
3245
4509
|
const nodeEls = new Map();
|
|
4510
|
+
const focusOrder = []; // resource ids in deterministic visual order for arrow nav
|
|
3246
4511
|
for (const r of drawables){
|
|
3247
4512
|
const p = positions.get(r.id); if (!p) continue;
|
|
3248
4513
|
const grp = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
3249
4514
|
grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
|
|
3250
4515
|
grp.style.cursor = 'grab';
|
|
3251
4516
|
grp.dataset.id = r.id;
|
|
4517
|
+
// a11y: keyboard-focusable, screen-reader announces "<name>, <kind>".
|
|
4518
|
+
grp.setAttribute('tabindex', '0');
|
|
4519
|
+
grp.setAttribute('role', 'button');
|
|
4520
|
+
grp.setAttribute('aria-label', `${r.name}, ${r.kind}`);
|
|
4521
|
+
focusOrder.push(r.id);
|
|
3252
4522
|
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
3253
4523
|
// Hosts a touch bigger to read as anchors of the layout.
|
|
3254
4524
|
const radius = incomingRunsOn.has(r.id) ? 9 : 6.5;
|
|
@@ -3293,7 +4563,14 @@ function renderTopologyGraph(){
|
|
|
3293
4563
|
if (pth.dataset.from === id || pth.dataset.to === id){
|
|
3294
4564
|
const a = positions.get(pth.dataset.from);
|
|
3295
4565
|
const b = positions.get(pth.dataset.to);
|
|
3296
|
-
if (a && b)
|
|
4566
|
+
if (a && b){
|
|
4567
|
+
pth.setAttribute('d', bezierPath(a, b));
|
|
4568
|
+
const lbl = edgeLabelEls.get(pth.dataset.from + ' ' + pth.dataset.to);
|
|
4569
|
+
if (lbl){
|
|
4570
|
+
lbl.setAttribute('x', String((a.x + b.x) / 2));
|
|
4571
|
+
lbl.setAttribute('y', String((a.y + b.y) / 2));
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
3297
4574
|
}
|
|
3298
4575
|
});
|
|
3299
4576
|
}
|
|
@@ -3385,6 +4662,65 @@ function renderTopologyGraph(){
|
|
|
3385
4662
|
selectResource(el.dataset.id);
|
|
3386
4663
|
};
|
|
3387
4664
|
|
|
4665
|
+
// Keyboard navigation: Tab cycles natively (every <g data-id> is
|
|
4666
|
+
// tabindex=0); Enter / Space inspects the focused node; Esc clears
|
|
4667
|
+
// selection; arrows move focus to the nearest neighbour by 2-D distance.
|
|
4668
|
+
function focusedId(){
|
|
4669
|
+
const el = document.activeElement;
|
|
4670
|
+
if (el && el.closest && el.closest('#topology-graph-svg')){
|
|
4671
|
+
const g2 = el.closest('g[data-id]');
|
|
4672
|
+
if (g2) return g2.dataset.id;
|
|
4673
|
+
}
|
|
4674
|
+
return null;
|
|
4675
|
+
}
|
|
4676
|
+
function moveFocusDir(dx, dy){
|
|
4677
|
+
const cur = focusedId() || topologySelectedId || focusOrder[0];
|
|
4678
|
+
if (!cur) return;
|
|
4679
|
+
const p = positions.get(cur); if (!p) return;
|
|
4680
|
+
let best = null, bestScore = Infinity;
|
|
4681
|
+
for (const id of focusOrder){
|
|
4682
|
+
if (id === cur) continue;
|
|
4683
|
+
const q = positions.get(id); if (!q) continue;
|
|
4684
|
+
const ex = q.x - p.x, ey = q.y - p.y;
|
|
4685
|
+
const align = ex*dx + ey*dy; // positive = same direction
|
|
4686
|
+
if (align <= 0) continue;
|
|
4687
|
+
const perp = Math.abs(ex*dy - ey*dx); // distance off-axis
|
|
4688
|
+
const score = perp - align * 0.25;
|
|
4689
|
+
if (score < bestScore){ bestScore = score; best = id; }
|
|
4690
|
+
}
|
|
4691
|
+
if (best){
|
|
4692
|
+
const el = nodeEls.get(best);
|
|
4693
|
+
if (el && el.grp.focus) el.grp.focus();
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
svg.addEventListener('focus', ()=>{
|
|
4697
|
+
// First focus on the svg shell: forward to the previously-selected node
|
|
4698
|
+
// or the first node so the user gets immediate context.
|
|
4699
|
+
if (document.activeElement === svg){
|
|
4700
|
+
const target = topologySelectedId || focusOrder[0];
|
|
4701
|
+
const el = target ? nodeEls.get(target) : null;
|
|
4702
|
+
if (el && el.grp.focus) el.grp.focus();
|
|
4703
|
+
}
|
|
4704
|
+
});
|
|
4705
|
+
svg.addEventListener('keydown', (ev)=>{
|
|
4706
|
+
if (ev.key === 'Escape'){
|
|
4707
|
+
topologySelectedId = null;
|
|
4708
|
+
clearHighlight();
|
|
4709
|
+
showInspectorEmpty();
|
|
4710
|
+
ev.preventDefault();
|
|
4711
|
+
return;
|
|
4712
|
+
}
|
|
4713
|
+
if (ev.key === 'Enter' || ev.key === ' '){
|
|
4714
|
+
const id = focusedId();
|
|
4715
|
+
if (id){ selectResource(id); ev.preventDefault(); }
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
if (ev.key === 'ArrowLeft') { moveFocusDir(-1, 0); ev.preventDefault(); return; }
|
|
4719
|
+
if (ev.key === 'ArrowRight') { moveFocusDir( 1, 0); ev.preventDefault(); return; }
|
|
4720
|
+
if (ev.key === 'ArrowUp') { moveFocusDir( 0,-1); ev.preventDefault(); return; }
|
|
4721
|
+
if (ev.key === 'ArrowDown') { moveFocusDir( 0, 1); ev.preventDefault(); return; }
|
|
4722
|
+
});
|
|
4723
|
+
|
|
3388
4724
|
function showInspectorEmpty(){
|
|
3389
4725
|
document.getElementById('topology-inspector-empty').style.display = '';
|
|
3390
4726
|
document.getElementById('topology-inspector-body').style.display = 'none';
|
|
@@ -3396,13 +4732,13 @@ function renderTopologyGraph(){
|
|
|
3396
4732
|
const labelEntries = Object.entries(ref.labels || {});
|
|
3397
4733
|
const attrEntries = Object.entries(ref.attributes || {});
|
|
3398
4734
|
function kvList(entries){
|
|
3399
|
-
if (entries.length === 0) return '<div class="muted
|
|
4735
|
+
if (entries.length === 0) return '<div class="muted t-xs">(none)</div>';
|
|
3400
4736
|
return '<div style="display:grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-size: var(--fs-xs);">' +
|
|
3401
4737
|
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
4738
|
'</div>';
|
|
3403
4739
|
}
|
|
3404
4740
|
function neighbourList(edges, dir){
|
|
3405
|
-
if (edges.length === 0) return '<div class="muted
|
|
4741
|
+
if (edges.length === 0) return '<div class="muted t-xs">(none)</div>';
|
|
3406
4742
|
return '<ul style="margin:0; padding-left: 0; list-style: none; display:flex; flex-direction:column; gap:4px;">' +
|
|
3407
4743
|
edges.map(e=>{
|
|
3408
4744
|
const otherId = dir === 'in' ? e.from : e.to;
|
|
@@ -3425,35 +4761,35 @@ function renderTopologyGraph(){
|
|
|
3425
4761
|
<div style="font-weight:600; font-size: var(--fs-md); color: var(--text); word-break: break-all;">${esc(ref.name)}</div>
|
|
3426
4762
|
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top: 4px;">
|
|
3427
4763
|
<span class="badge">${esc(ref.kind)}</span>
|
|
3428
|
-
<span class="muted
|
|
4764
|
+
<span class="muted t-xs">source: ${esc(ref.source)}</span>
|
|
3429
4765
|
</div>
|
|
3430
4766
|
</div>
|
|
3431
4767
|
</div>
|
|
3432
4768
|
<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
4769
|
|
|
3434
4770
|
<div style="margin-top: 16px;">
|
|
3435
|
-
<div
|
|
4771
|
+
<div class="t-label">Labels</div>
|
|
3436
4772
|
${kvList(labelEntries)}
|
|
3437
4773
|
</div>
|
|
3438
4774
|
|
|
3439
4775
|
<div style="margin-top: 16px;">
|
|
3440
|
-
<div
|
|
4776
|
+
<div class="t-label">Attributes</div>
|
|
3441
4777
|
${kvList(attrEntries.filter(([k])=>k!=='uid'))}
|
|
3442
4778
|
</div>
|
|
3443
4779
|
|
|
3444
4780
|
<div style="margin-top: 16px;">
|
|
3445
|
-
<div
|
|
4781
|
+
<div class="t-label">Outgoing (${outgoing.length})</div>
|
|
3446
4782
|
${neighbourList(outgoing, 'out')}
|
|
3447
4783
|
</div>
|
|
3448
4784
|
|
|
3449
4785
|
<div style="margin-top: 12px;">
|
|
3450
|
-
<div
|
|
4786
|
+
<div class="t-label">Incoming (${incoming.length})</div>
|
|
3451
4787
|
${neighbourList(incoming, 'in')}
|
|
3452
4788
|
</div>
|
|
3453
4789
|
|
|
3454
4790
|
<div style="margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
3455
|
-
<div
|
|
3456
|
-
<div class="muted
|
|
4791
|
+
<div class="t-label">Linked telemetry</div>
|
|
4792
|
+
<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
4793
|
</div>
|
|
3458
4794
|
`;
|
|
3459
4795
|
// Wire neighbour links to navigate inside the graph.
|
|
@@ -3480,7 +4816,37 @@ function renderTopologyGraph(){
|
|
|
3480
4816
|
|
|
3481
4817
|
// --- Utils ---
|
|
3482
4818
|
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
|
3483
|
-
|
|
4819
|
+
|
|
4820
|
+
// --- Empty / loading state helpers ----------------------------------------
|
|
4821
|
+
// Inline SVGs so the markup is self-contained and styleable via currentColor.
|
|
4822
|
+
const STATE_ICONS = {
|
|
4823
|
+
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>',
|
|
4824
|
+
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>',
|
|
4825
|
+
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>',
|
|
4826
|
+
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>',
|
|
4827
|
+
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>',
|
|
4828
|
+
};
|
|
4829
|
+
// richEmpty({icon, title, desc, ctaLabel, ctaOnClick, secondaryLabel, secondaryOnClick})
|
|
4830
|
+
// SECURITY: title / desc / ctaLabel / secondaryLabel are HTML-escaped before
|
|
4831
|
+
// insertion. ctaOnClick / secondaryOnClick are inlined verbatim into the
|
|
4832
|
+
// onclick attribute — only ever pass trusted static literals here, never
|
|
4833
|
+
// strings containing user input or backend data.
|
|
4834
|
+
function richEmpty(opts) {
|
|
4835
|
+
const o = opts || {};
|
|
4836
|
+
const icon = STATE_ICONS[o.icon || 'inbox'] || STATE_ICONS.inbox;
|
|
4837
|
+
const cta = o.ctaLabel
|
|
4838
|
+
? `<div class="state-cta"><button class="btn btn-primary btn-sm" onclick="${o.ctaOnClick||''}">${esc(o.ctaLabel)}</button>${
|
|
4839
|
+
o.secondaryLabel ? `<button class="btn btn-ghost btn-sm" onclick="${o.secondaryOnClick||''}">${esc(o.secondaryLabel)}</button>` : ''
|
|
4840
|
+
}</div>`
|
|
4841
|
+
: '';
|
|
4842
|
+
return `<div class="state"><div class="state-ico">${icon}</div><div class="state-title">${esc(o.title||'Nothing here yet')}</div>${
|
|
4843
|
+
o.desc ? `<div class="state-desc">${esc(o.desc)}</div>` : ''
|
|
4844
|
+
}${cta}</div>`;
|
|
4845
|
+
}
|
|
4846
|
+
function loadingBlock(label) {
|
|
4847
|
+
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>`;
|
|
4848
|
+
}
|
|
4849
|
+
async function refresh(){await Promise.all([loadSources(),loadServices(),loadDashUsage()]);}
|
|
3484
4850
|
|
|
3485
4851
|
async function loadInfo(){
|
|
3486
4852
|
try{
|