@thotischner/observability-mcp 1.7.0 → 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.
Files changed (111) hide show
  1. package/config/products.yaml.example +48 -0
  2. package/dist/audit/log.d.ts +99 -0
  3. package/dist/audit/log.js +180 -0
  4. package/dist/audit/log.test.d.ts +1 -0
  5. package/dist/audit/log.test.js +147 -0
  6. package/dist/audit/middleware.d.ts +20 -0
  7. package/dist/audit/middleware.js +50 -0
  8. package/dist/auth/credentials.d.ts +18 -0
  9. package/dist/auth/credentials.js +26 -1
  10. package/dist/auth/credentials.test.js +26 -1
  11. package/dist/auth/local-users.d.ts +62 -0
  12. package/dist/auth/local-users.js +143 -0
  13. package/dist/auth/local-users.test.d.ts +1 -0
  14. package/dist/auth/local-users.test.js +80 -0
  15. package/dist/auth/middleware.d.ts +48 -0
  16. package/dist/auth/middleware.js +65 -0
  17. package/dist/auth/middleware.test.d.ts +1 -0
  18. package/dist/auth/middleware.test.js +90 -0
  19. package/dist/auth/oidc/client.d.ts +73 -0
  20. package/dist/auth/oidc/client.js +104 -0
  21. package/dist/auth/oidc/client.test.d.ts +1 -0
  22. package/dist/auth/oidc/client.test.js +121 -0
  23. package/dist/auth/oidc/discovery.d.ts +38 -0
  24. package/dist/auth/oidc/discovery.js +48 -0
  25. package/dist/auth/oidc/discovery.test.d.ts +1 -0
  26. package/dist/auth/oidc/discovery.test.js +68 -0
  27. package/dist/auth/oidc/endpoints.d.ts +20 -0
  28. package/dist/auth/oidc/endpoints.js +124 -0
  29. package/dist/auth/oidc/endpoints.test.d.ts +7 -0
  30. package/dist/auth/oidc/endpoints.test.js +304 -0
  31. package/dist/auth/oidc/flow-cookie.d.ts +57 -0
  32. package/dist/auth/oidc/flow-cookie.js +142 -0
  33. package/dist/auth/oidc/flow-cookie.test.d.ts +1 -0
  34. package/dist/auth/oidc/flow-cookie.test.js +0 -0
  35. package/dist/auth/oidc/index.d.ts +7 -0
  36. package/dist/auth/oidc/index.js +6 -0
  37. package/dist/auth/oidc/jwks.d.ts +36 -0
  38. package/dist/auth/oidc/jwks.js +69 -0
  39. package/dist/auth/oidc/jwks.test.d.ts +1 -0
  40. package/dist/auth/oidc/jwks.test.js +65 -0
  41. package/dist/auth/oidc/jwt.d.ts +62 -0
  42. package/dist/auth/oidc/jwt.js +113 -0
  43. package/dist/auth/oidc/jwt.test.d.ts +1 -0
  44. package/dist/auth/oidc/jwt.test.js +141 -0
  45. package/dist/auth/oidc/pkce.d.ts +19 -0
  46. package/dist/auth/oidc/pkce.js +43 -0
  47. package/dist/auth/oidc/pkce.test.d.ts +1 -0
  48. package/dist/auth/oidc/pkce.test.js +55 -0
  49. package/dist/auth/oidc/runtime.d.ts +63 -0
  50. package/dist/auth/oidc/runtime.js +129 -0
  51. package/dist/auth/oidc/runtime.test.d.ts +1 -0
  52. package/dist/auth/oidc/runtime.test.js +180 -0
  53. package/dist/auth/policy/engine.d.ts +48 -0
  54. package/dist/auth/policy/engine.js +73 -0
  55. package/dist/auth/policy/engine.test.d.ts +1 -0
  56. package/dist/auth/policy/engine.test.js +98 -0
  57. package/dist/auth/policy/loader.d.ts +35 -0
  58. package/dist/auth/policy/loader.js +100 -0
  59. package/dist/auth/policy/opa.d.ts +69 -0
  60. package/dist/auth/policy/opa.js +162 -0
  61. package/dist/auth/policy/opa.test.d.ts +1 -0
  62. package/dist/auth/policy/opa.test.js +158 -0
  63. package/dist/auth/rbac.d.ts +40 -0
  64. package/dist/auth/rbac.js +120 -0
  65. package/dist/auth/rbac.test.d.ts +1 -0
  66. package/dist/auth/rbac.test.js +121 -0
  67. package/dist/auth/session.d.ts +66 -0
  68. package/dist/auth/session.js +146 -0
  69. package/dist/auth/session.test.d.ts +1 -0
  70. package/dist/auth/session.test.js +90 -0
  71. package/dist/catalog/loader.d.ts +67 -0
  72. package/dist/catalog/loader.js +122 -0
  73. package/dist/catalog/loader.test.d.ts +1 -0
  74. package/dist/catalog/loader.test.js +108 -0
  75. package/dist/connectors/kubernetes.d.ts +1 -0
  76. package/dist/connectors/kubernetes.js +12 -2
  77. package/dist/connectors/topology-vocabulary.d.ts +41 -0
  78. package/dist/connectors/topology-vocabulary.js +120 -0
  79. package/dist/connectors/topology-vocabulary.test.d.ts +1 -0
  80. package/dist/connectors/topology-vocabulary.test.js +63 -0
  81. package/dist/context.d.ts +13 -1
  82. package/dist/context.js +5 -1
  83. package/dist/index.js +1012 -29
  84. package/dist/net/egress-policy.js +2 -0
  85. package/dist/openapi.js +440 -0
  86. package/dist/openapi.test.d.ts +1 -0
  87. package/dist/openapi.test.js +64 -0
  88. package/dist/policy/redact.d.ts +44 -0
  89. package/dist/policy/redact.js +144 -0
  90. package/dist/policy/redact.test.d.ts +1 -0
  91. package/dist/policy/redact.test.js +172 -0
  92. package/dist/products/loader.d.ts +84 -0
  93. package/dist/products/loader.js +216 -0
  94. package/dist/products/loader.test.d.ts +1 -0
  95. package/dist/products/loader.test.js +168 -0
  96. package/dist/quota/limiter.d.ts +72 -0
  97. package/dist/quota/limiter.js +105 -0
  98. package/dist/quota/limiter.test.d.ts +1 -0
  99. package/dist/quota/limiter.test.js +119 -0
  100. package/dist/quota/token-budget.d.ts +119 -0
  101. package/dist/quota/token-budget.js +297 -0
  102. package/dist/quota/token-budget.test.d.ts +1 -0
  103. package/dist/quota/token-budget.test.js +215 -0
  104. package/dist/tenancy/context.d.ts +45 -0
  105. package/dist/tenancy/context.js +97 -0
  106. package/dist/tenancy/context.test.d.ts +1 -0
  107. package/dist/tenancy/context.test.js +72 -0
  108. package/dist/tenancy/migration.test.d.ts +7 -0
  109. package/dist/tenancy/migration.test.js +75 -0
  110. package/dist/ui/index.html +1454 -88
  111. package/package.json +20 -3
@@ -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. Explicit
9
- // user choice (localStorage) wins; otherwise follow the OS setting.
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
- <link rel="preconnect" href="https://rsms.me/">
21
- <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
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
- @supports (font-variation-settings: normal) {
24
- :root { font-family: 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
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: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
151
- font-feature-settings: 'cv11', 'ss01', 'ss03';
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: 'Inter var', sans-serif; }
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
- /* ===== Enterprise console shell: side rail + masthead ===== */
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 (ref: enterprise events / bell) */
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
- /* Enterprise page primitives */
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 (ref: enterprise catalogs) */
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="audit" onclick="showPage('audit')"><span class="nav-ico">❒</span>Audit Log</button>
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<span class="sub-chev">▾</span></button>
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<span class="sub-chev">▾</span></button>
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" style="padding:20px;">
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" style="padding:20px;">
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" style="padding:20px;">
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 &amp; install</button>
1460
+ <button class="btn btn-primary btn-sm" data-rbac="connectors:write" onclick="uploadConnector(this)">Upload &amp; 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" style="font-size: var(--fs-xs);">Scope = any resource pointed to by an <code>IN_NAMESPACE</code> edge (e.g. k8s namespaces, future: vCenter folders).</span>
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" style="font-size: var(--fs-xs);">click a resource to inspect · drag to reposition · wheel to zoom · drag the background to pan</span>
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" style="padding:20px;">
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" style="padding:20px;">
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" style="padding:20px;">
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')">&times;</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 class="form-group"><label>Name</label><input type="text" id="src-name" placeholder="e.g. prometheus-prod"><div class="form-hint">Unique identifier</div></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"><label>URL</label><input type="text" id="src-url" placeholder="e.g. http://prometheus:9090"></div>
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) { const t=document.getElementById('toast-el'); t.textContent=msg; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2000); }
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 style="color:var(--text-muted);font-size:var(--fs-sm)">sequence #${escHtml(String(e.seq))}</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" style="word-break:break-all">${escHtml(e.hash||'—')}</td></tr>
1782
- <tr><td>Prev hash</td><td class="mono" style="word-break:break-all">${escHtml(e.prevHash||'—')}</td></tr></tbody></table>`);
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 style="color:var(--text-dim);font-size:var(--fs-sm)">No principals granted this product.</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=&lt;path&gt;</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)}')">&#9998;</button>
3118
+ <button class="btn-icon" data-rbac="products:delete" title="Delete" onclick="mcpProductsDelete('${encodeURIComponent(p.id)}')">&#128465;</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)}')">&#9998;</button><button class="btn-icon" onclick="openDeleteConfirm('source','${esc(s.name)}')">&#128465;</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)}')">&#9998;</button><button class="btn-icon" data-rbac="sources:delete" title="Delete source" aria-label="Delete source" onclick="openDeleteConfirm('source','${esc(s.name)}')">&#128465;</button></div></div>`;
2364
3382
  }
2365
3383
  function renderSources() {
2366
- const dashHtml = sourcesData.length===0 ? '<div class="empty">No sources configured.</div>' : sourcesData.map(srcRow).join('');
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){ box.innerHTML='<div class="empty">No sources configured.</div>'; return; }
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
- const bar=`<div class="dp-bar"><span style="color:var(--text-muted);font-size:12px">${sourcesData.length} source${sourcesData.length===1?'':'s'} · click for detail</span>
2372
- <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></div>`;
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
- body=`<table class="dtable"><thead><tr><th>Name</th><th>Type</th><th>Signal</th><th>URL</th><th>Status</th><th>Latency</th></tr></thead><tbody>`+
2376
- sourcesData.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" style="color:var(--text-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('')+
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=sourcesData.map(srcRow).join('');
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=bar+body;
2382
- box.querySelectorAll('[data-src]').forEach(el=>el.addEventListener('click',()=>srcDetail(el.getAttribute('data-src'))));
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 style="color:var(--text-muted);font-size:var(--fs-sm)">${s.latencyMs}ms</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" style="word-break:break-all">${escHtml(s.url)}</td></tr>
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 style="color:var(--text-dim);font-size:var(--fs-sm)">none discovered</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 html = servicesData.length===0 ? '<div class="empty">No services discovered.</div>' : servicesData.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('')}</div><span style="color:var(--text2);font-size:12px">${s.sources.join(', ')}</span></div>`).join('');
2403
- const sl=document.getElementById('services-list'); sl.innerHTML=html;
2404
- sl.querySelectorAll('[data-svc]').forEach(el=>el.addEventListener('click',()=>svcDetail(el.getAttribute('data-svc'))));
2405
- document.getElementById('dash-services').innerHTML=html;
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 style="color:var(--text-muted);font-size:var(--fs-sm)">health score ${escHtml(v.score)}</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 style="color:var(--text-dim);font-size:var(--fs-sm)">None detected.</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 style="color:var(--text-dim);font-size:var(--fs-sm)">None.</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(); if(!res.ok){alert(d.error);return;} closeModal('source-modal'); toast('Source saved'); await refresh();
2510
- } catch(e){alert(e);} finally{btn.disabled=false;btn.textContent='Save';}
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){alert(`Weights must sum to 1.0 (currently ${sum.toFixed(2)})`);return;}
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
- '<div class="empty">No topology data available. Add a topology-capable source (e.g. <code>kubernetes</code>) under Sources.</div>';
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 = '<div class="empty">No topology-capable sources connected. Add a topology source (e.g. <b>kubernetes</b>) under Sources.</div>';
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) pth.setAttribute('d', bezierPath(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" style="font-size: var(--fs-xs);">(none)</div>';
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" style="font-size: var(--fs-xs);">(none)</div>';
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" style="font-size: var(--fs-xs);">source: ${esc(ref.source)}</span>
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 style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Labels</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 style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Attributes</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 style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Outgoing (${outgoing.length})</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 style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Incoming (${incoming.length})</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 style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Linked telemetry</div>
3456
- <div class="muted" style="font-size: var(--fs-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>
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
- async function refresh(){await Promise.all([loadSources(),loadServices()]);}
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{