@thotischner/observability-mcp 1.5.1 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/enterprise-gate.d.ts +132 -0
- package/dist/enterprise-gate.js +510 -0
- package/dist/enterprise-gate.test.d.ts +1 -0
- package/dist/enterprise-gate.test.js +178 -0
- package/dist/index.js +78 -6
- package/dist/tools/get-service-health.js +11 -8
- package/dist/tools/handlers.test.js +31 -0
- package/dist/ui/index.html +1510 -67
- package/package.json +2 -2
package/dist/ui/index.html
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Observability MCP Gateway</title>
|
|
7
|
+
<script>
|
|
8
|
+
// Resolve the theme BEFORE first paint to avoid a flash. Explicit
|
|
9
|
+
// user choice (localStorage) wins; otherwise follow the OS setting.
|
|
10
|
+
(function(){
|
|
11
|
+
try {
|
|
12
|
+
var t = localStorage.getItem('omcp-theme');
|
|
13
|
+
if (t !== 'light' && t !== 'dark') {
|
|
14
|
+
t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
15
|
+
}
|
|
16
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
17
|
+
} catch (e) { document.documentElement.setAttribute('data-theme','dark'); }
|
|
18
|
+
})();
|
|
19
|
+
</script>
|
|
7
20
|
<link rel="preconnect" href="https://rsms.me/">
|
|
8
21
|
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
|
|
9
22
|
<style>
|
|
@@ -79,7 +92,58 @@
|
|
|
79
92
|
/* Motion */
|
|
80
93
|
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
81
94
|
--t-fast: 120ms;
|
|
95
|
+
|
|
96
|
+
/* Chrome — the masthead + nav rail stay dark in BOTH themes
|
|
97
|
+
(enterprise consoles keep dark chrome over a light workspace).
|
|
98
|
+
These tokens never flip. */
|
|
99
|
+
--chrome-bg: #12151c;
|
|
100
|
+
--chrome-bg-2: #181c25;
|
|
101
|
+
--chrome-border: rgba(255,255,255,0.10);
|
|
102
|
+
--chrome-text: #e8ecf2;
|
|
103
|
+
--chrome-text-muted: #9aa3b2;
|
|
104
|
+
--chrome-active: rgba(79,140,255,0.16);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ===== Light theme — only the workspace tokens flip ===== */
|
|
108
|
+
:root[data-theme="light"] {
|
|
109
|
+
--bg: #f3f4f6;
|
|
110
|
+
--surface: #ffffff;
|
|
111
|
+
--surface-2: #f5f6f8;
|
|
112
|
+
--surface-3: #e9ebef;
|
|
113
|
+
--border: rgba(17,24,39,0.10);
|
|
114
|
+
--border-strong: rgba(17,24,39,0.16);
|
|
115
|
+
|
|
116
|
+
--text: #161a22;
|
|
117
|
+
--text-muted: #565d6b;
|
|
118
|
+
--text-dim: #7b8290;
|
|
119
|
+
--text-inv: #ffffff;
|
|
120
|
+
|
|
121
|
+
--accent-soft: rgba(79,140,255,0.12);
|
|
122
|
+
--accent-ring: rgba(79,140,255,0.40);
|
|
123
|
+
--success-soft: rgba(22,163,74,0.12);
|
|
124
|
+
--warning-soft: rgba(202,138,4,0.14);
|
|
125
|
+
--danger-soft: rgba(220,38,38,0.12);
|
|
126
|
+
--info-soft: rgba(124,58,237,0.12);
|
|
127
|
+
/* Slightly deepen semantic hues so they read on white */
|
|
128
|
+
--success: #16a34a;
|
|
129
|
+
--warning: #b45309;
|
|
130
|
+
--danger: #dc2626;
|
|
131
|
+
--info: #7c3aed;
|
|
132
|
+
|
|
133
|
+
--shadow-sm: 0 1px 2px rgba(17,24,39,0.06);
|
|
134
|
+
--shadow: 0 4px 12px rgba(17,24,39,0.08), 0 0 0 1px var(--border);
|
|
135
|
+
--shadow-lg: 0 12px 32px rgba(17,24,39,0.14), 0 0 0 1px var(--border-strong);
|
|
136
|
+
|
|
137
|
+
/* Chrome follows the theme too — the WHOLE UI switches, not just
|
|
138
|
+
the workspace. Light chrome = near-white, dark text. */
|
|
139
|
+
--chrome-bg: #ffffff;
|
|
140
|
+
--chrome-bg-2: #f5f6f8;
|
|
141
|
+
--chrome-border: rgba(17,24,39,0.12);
|
|
142
|
+
--chrome-text: #161a22;
|
|
143
|
+
--chrome-text-muted: #565d6b;
|
|
144
|
+
--chrome-active: rgba(79,140,255,0.14);
|
|
82
145
|
}
|
|
146
|
+
|
|
83
147
|
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
|
84
148
|
html { -webkit-text-size-adjust: 100%; }
|
|
85
149
|
body {
|
|
@@ -172,6 +236,16 @@
|
|
|
172
236
|
}
|
|
173
237
|
.stat-card .context.good { color: var(--success, #3fb950); }
|
|
174
238
|
.stat-card .context.warn { color: var(--warn, #d29922); }
|
|
239
|
+
/* Stat card with a ring gauge (capacity-style, ref: cluster dashboards) */
|
|
240
|
+
.stat-card.has-gauge { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); }
|
|
241
|
+
.stat-card .stat-body { min-width: 0; }
|
|
242
|
+
.gauge { width: 56px; height: 56px; flex-shrink: 0; }
|
|
243
|
+
.gauge .g-track { stroke: var(--surface-3); }
|
|
244
|
+
.gauge .g-fill { stroke: var(--accent); transition: stroke-dashoffset 600ms var(--ease), stroke 200ms var(--ease); }
|
|
245
|
+
.gauge.tone-ok .g-fill { stroke: var(--success); }
|
|
246
|
+
.gauge.tone-warn .g-fill { stroke: var(--warning); }
|
|
247
|
+
.gauge.tone-bad .g-fill { stroke: var(--danger); }
|
|
248
|
+
.gauge text { fill: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
175
249
|
|
|
176
250
|
.live-indicator {
|
|
177
251
|
display: inline-flex; align-items: center; gap: var(--sp-2);
|
|
@@ -197,6 +271,16 @@
|
|
|
197
271
|
font-size: var(--fs-lg); font-weight: 600; color: var(--text);
|
|
198
272
|
letter-spacing: -0.01em; margin: 0;
|
|
199
273
|
}
|
|
274
|
+
.page-head {
|
|
275
|
+
display: flex; align-items: flex-end; justify-content: space-between;
|
|
276
|
+
gap: var(--sp-4); margin-bottom: var(--sp-5);
|
|
277
|
+
padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border);
|
|
278
|
+
}
|
|
279
|
+
.page-head .ph-left { min-width: 0; }
|
|
280
|
+
.page-head .breadcrumb { font-size: var(--fs-xs); color: var(--text-dim); letter-spacing: 0.02em; margin-bottom: 6px; }
|
|
281
|
+
.page-head .breadcrumb b { color: var(--text-muted); font-weight: 600; }
|
|
282
|
+
.page-head h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--text); margin: 0; line-height: 1.2; }
|
|
283
|
+
.page-head .ph-actions { display: flex; gap: var(--sp-2); align-items: center; flex-shrink: 0; }
|
|
200
284
|
.card {
|
|
201
285
|
background: var(--surface);
|
|
202
286
|
border: 1px solid var(--border);
|
|
@@ -206,53 +290,54 @@
|
|
|
206
290
|
}
|
|
207
291
|
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); }
|
|
208
292
|
.card-header h2 { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; color: var(--text); }
|
|
293
|
+
/* Dense, quiet list rows — hairline-separated, table-like (ref:
|
|
294
|
+
enterprise resource/list views). No per-row card chrome. */
|
|
209
295
|
.source-row {
|
|
210
296
|
display: flex; align-items: center; gap: var(--sp-3);
|
|
211
|
-
padding: var(--sp-3)
|
|
212
|
-
border: 1px solid var(--border);
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
background: var(--surface);
|
|
216
|
-
transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
297
|
+
padding: 10px var(--sp-3);
|
|
298
|
+
border-bottom: 1px solid var(--border);
|
|
299
|
+
background: transparent;
|
|
300
|
+
transition: background var(--t-fast) var(--ease);
|
|
217
301
|
}
|
|
218
|
-
.source-row:
|
|
219
|
-
.
|
|
220
|
-
.dot
|
|
221
|
-
.dot-up
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
.dot-
|
|
302
|
+
.source-row:last-child { border-bottom: none; }
|
|
303
|
+
.source-row:hover { background: var(--surface-2); }
|
|
304
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; position: relative; }
|
|
305
|
+
.dot-up { background: var(--success); box-shadow: 0 0 0 2px var(--success-soft); }
|
|
306
|
+
.dot-up::after { content: ''; position: absolute; inset: -3px; border-radius: 50%; background: var(--success); opacity: 0.35; animation: pulse 2.4s var(--ease) infinite; }
|
|
307
|
+
@keyframes pulse { 0%, 100% { opacity: 0.35; transform: scale(1); } 50% { opacity: 0; transform: scale(1.7); } }
|
|
308
|
+
.dot-down { background: var(--danger); box-shadow: 0 0 0 2px var(--danger-soft); }
|
|
309
|
+
.dot-disabled { background: var(--text-dim); box-shadow: 0 0 0 2px rgba(107,114,128,0.15); }
|
|
225
310
|
.source-info { flex: 1; min-width: 0; }
|
|
226
|
-
.source-info .name { font-weight: 600; font-size: var(--fs-
|
|
311
|
+
.source-info .name { font-weight: 600; font-size: var(--fs-md); letter-spacing: -0.005em; }
|
|
227
312
|
.source-info .url {
|
|
228
|
-
color: var(--text-
|
|
313
|
+
color: var(--text-dim); font-size: var(--fs-xs);
|
|
229
314
|
font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-ligatures: none;
|
|
230
315
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
231
316
|
}
|
|
232
317
|
.source-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
|
318
|
+
/* Quiet, square enterprise chips (was pill) */
|
|
233
319
|
.tag {
|
|
234
320
|
display: inline-flex; align-items: center; gap: 4px;
|
|
235
|
-
padding:
|
|
236
|
-
border-radius: var(--radius-
|
|
237
|
-
font-size:
|
|
238
|
-
letter-spacing: 0.
|
|
321
|
+
padding: 1px 7px;
|
|
322
|
+
border-radius: var(--radius-sm);
|
|
323
|
+
font-size: 10px; font-weight: 600;
|
|
324
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
239
325
|
margin-left: 6px;
|
|
240
326
|
}
|
|
241
|
-
.tag-metrics { color: var(--accent); background: var(--accent-soft); border: 1px solid
|
|
242
|
-
.tag-logs { color: var(--info); background: var(--info-soft); border: 1px solid
|
|
243
|
-
.tag-type { color: var(--text-muted); background: var(--surface-
|
|
244
|
-
.tag-latency { color: var(--text-
|
|
327
|
+
.tag-metrics { color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; }
|
|
328
|
+
.tag-logs { color: var(--info); background: var(--info-soft); border: 1px solid transparent; }
|
|
329
|
+
.tag-type { color: var(--text-muted); background: var(--surface-3); border: 1px solid var(--border); text-transform: uppercase; }
|
|
330
|
+
.tag-latency { color: var(--text-dim); font-size: 10px; font-variant-numeric: tabular-nums; text-transform: none; letter-spacing: 0; }
|
|
245
331
|
.service-row {
|
|
246
332
|
display: flex; align-items: center; justify-content: space-between;
|
|
247
|
-
padding: var(--sp-3)
|
|
248
|
-
border: 1px solid var(--border);
|
|
249
|
-
|
|
250
|
-
margin-bottom: var(--sp-2);
|
|
251
|
-
background: var(--surface);
|
|
333
|
+
padding: 10px var(--sp-3);
|
|
334
|
+
border-bottom: 1px solid var(--border);
|
|
335
|
+
background: transparent;
|
|
252
336
|
transition: background var(--t-fast) var(--ease);
|
|
253
337
|
}
|
|
338
|
+
.service-row:last-child { border-bottom: none; }
|
|
254
339
|
.service-row:hover { background: var(--surface-2); }
|
|
255
|
-
.service-row .name { font-weight: 600; font-size: var(--fs-
|
|
340
|
+
.service-row .name { font-weight: 600; font-size: var(--fs-md); }
|
|
256
341
|
.btn {
|
|
257
342
|
padding: 7px 14px;
|
|
258
343
|
border-radius: var(--radius-sm);
|
|
@@ -363,9 +448,32 @@
|
|
|
363
448
|
display: flex; align-items: center; justify-content: space-between;
|
|
364
449
|
}
|
|
365
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; }
|
|
366
|
-
.empty {
|
|
451
|
+
.empty {
|
|
452
|
+
color: var(--text-dim); text-align: center;
|
|
453
|
+
padding: var(--sp-10) var(--sp-4); font-size: var(--fs-md);
|
|
454
|
+
line-height: 1.6;
|
|
455
|
+
}
|
|
456
|
+
.empty::before {
|
|
457
|
+
content: '⌀'; display: block;
|
|
458
|
+
font-size: 26px; line-height: 1; margin-bottom: var(--sp-3);
|
|
459
|
+
color: var(--text-dim); opacity: 0.45;
|
|
460
|
+
}
|
|
367
461
|
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
|
|
368
462
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
463
|
+
/* Accessibility: clear keyboard focus on chrome + interactive rows;
|
|
464
|
+
honour reduced-motion (no decorative pulses/spins/slide-ins). */
|
|
465
|
+
.rail-nav .nav-btn:focus-visible, .rail-grp-hd:focus-visible,
|
|
466
|
+
.theme-toggle:focus-visible {
|
|
467
|
+
outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 2px;
|
|
468
|
+
}
|
|
469
|
+
.tab-btn:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: -2px; }
|
|
470
|
+
@media (prefers-reduced-motion: reduce) {
|
|
471
|
+
*, *::before, *::after {
|
|
472
|
+
animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
|
|
473
|
+
transition-duration: 0.001ms !important; scroll-behavior: auto !important;
|
|
474
|
+
}
|
|
475
|
+
.dot-up::after, .live-indicator::before { animation: none !important; }
|
|
476
|
+
}
|
|
369
477
|
/* Tabs inside settings */
|
|
370
478
|
.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-5); }
|
|
371
479
|
.tab-btn { background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted); padding: 10px 18px; cursor: pointer; font-size: var(--fs-md); font-weight: 500; transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); }
|
|
@@ -481,30 +589,411 @@
|
|
|
481
589
|
.footer-pill-meta { color: var(--text-muted); font-weight: 400; }
|
|
482
590
|
|
|
483
591
|
@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
|
+
|
|
593
|
+
/* ===== Enterprise console shell: side rail + masthead ===== */
|
|
594
|
+
:root { --rail-w: 236px; }
|
|
595
|
+
body { padding-left: var(--rail-w); }
|
|
596
|
+
.siderail {
|
|
597
|
+
position: fixed; left: 0; top: 0; bottom: 0; width: var(--rail-w);
|
|
598
|
+
background: linear-gradient(180deg, var(--chrome-bg-2), var(--chrome-bg));
|
|
599
|
+
border-right: 1px solid var(--chrome-border);
|
|
600
|
+
color: var(--chrome-text);
|
|
601
|
+
display: flex; flex-direction: column; z-index: 30;
|
|
602
|
+
}
|
|
603
|
+
.rail-brand {
|
|
604
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
605
|
+
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
|
606
|
+
border-bottom: 1px solid var(--chrome-border);
|
|
607
|
+
}
|
|
608
|
+
.rail-mark {
|
|
609
|
+
width: 26px; height: 26px; border-radius: var(--radius-sm); flex-shrink: 0;
|
|
610
|
+
background: linear-gradient(135deg, var(--accent), var(--info));
|
|
611
|
+
box-shadow: 0 0 0 1px var(--chrome-border) inset;
|
|
612
|
+
}
|
|
613
|
+
.rail-title { font-size: var(--fs-md); font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; color: var(--chrome-text); }
|
|
614
|
+
.rail-title span { font-weight: 500; color: var(--chrome-text-muted); font-size: var(--fs-xs); letter-spacing: 0.06em; text-transform: uppercase; }
|
|
615
|
+
.rail-nav { display: flex; flex-direction: column; padding: var(--sp-2) 0; overflow-y: auto; }
|
|
616
|
+
.rail-grp { display: flex; flex-direction: column; }
|
|
617
|
+
.rail-grp-hd {
|
|
618
|
+
display: flex; align-items: center; gap: 6px; width: 100%;
|
|
619
|
+
background: none; border: none; cursor: pointer;
|
|
620
|
+
font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase;
|
|
621
|
+
color: var(--chrome-text-muted); opacity: 0.65;
|
|
622
|
+
padding: var(--sp-4) var(--sp-5) var(--sp-2);
|
|
623
|
+
transition: opacity var(--t-fast) var(--ease);
|
|
624
|
+
}
|
|
625
|
+
.rail-grp-hd:hover { opacity: 1; }
|
|
626
|
+
.rail-grp-hd .chev {
|
|
627
|
+
margin-left: auto; font-size: 9px; transition: transform var(--t-fast) var(--ease);
|
|
628
|
+
opacity: 0.8;
|
|
629
|
+
}
|
|
630
|
+
.rail-grp.collapsed .rail-grp-hd .chev { transform: rotate(-90deg); }
|
|
631
|
+
.rail-grp-body { display: flex; flex-direction: column; overflow: hidden; }
|
|
632
|
+
.rail-grp.collapsed .rail-grp-body { display: none; }
|
|
633
|
+
.rail-nav .nav-btn {
|
|
634
|
+
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
|
635
|
+
border-radius: 0; padding: 8px var(--sp-5);
|
|
636
|
+
border-left: 2px solid transparent;
|
|
637
|
+
color: var(--chrome-text-muted);
|
|
638
|
+
}
|
|
639
|
+
.rail-nav .nav-btn .nav-ico {
|
|
640
|
+
width: 16px; text-align: center; font-size: 14px; opacity: 0.75; flex-shrink: 0;
|
|
641
|
+
}
|
|
642
|
+
.rail-nav .nav-btn:hover { background: var(--chrome-bg-2); color: var(--chrome-text); }
|
|
643
|
+
.rail-nav .nav-btn:hover .nav-ico { opacity: 1; }
|
|
644
|
+
.rail-nav .nav-btn.active {
|
|
645
|
+
background: var(--chrome-active); color: var(--chrome-text);
|
|
646
|
+
box-shadow: none; border-left-color: var(--accent);
|
|
647
|
+
}
|
|
648
|
+
.rail-nav .nav-btn.active .nav-ico { opacity: 1; color: var(--accent); }
|
|
649
|
+
/* Sub-points in the nav replace the in-page tab bar for these pages */
|
|
650
|
+
#page-connectors .tabs, #page-settings .tabs { display: none; }
|
|
651
|
+
.rail-nav .nav-parent { cursor: pointer; }
|
|
652
|
+
.rail-nav .nav-parent.active { background: none; border-left-color: transparent; }
|
|
653
|
+
.rail-item { display: flex; flex-direction: column; }
|
|
654
|
+
.rail-item .sub-chev {
|
|
655
|
+
margin-left: auto; font-size: 9px; opacity: 0.7;
|
|
656
|
+
transition: transform var(--t-fast) var(--ease);
|
|
657
|
+
}
|
|
658
|
+
.rail-item:not(.open) .nav-btn .sub-chev { transform: rotate(-90deg); }
|
|
659
|
+
.rail-sub { display: none; flex-direction: column; }
|
|
660
|
+
.rail-item.open .rail-sub { display: flex; }
|
|
661
|
+
.rail-nav .nav-sub {
|
|
662
|
+
display: block; width: 100%; text-align: left; cursor: pointer;
|
|
663
|
+
background: none; border: none; border-left: 2px solid transparent;
|
|
664
|
+
padding: 6px var(--sp-5) 6px 42px; font-size: var(--fs-sm);
|
|
665
|
+
color: var(--chrome-text-muted); opacity: 0.85;
|
|
666
|
+
transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
|
|
667
|
+
}
|
|
668
|
+
.rail-nav .nav-sub:hover { background: var(--chrome-bg-2); color: var(--chrome-text); opacity: 1; }
|
|
669
|
+
.rail-nav .nav-sub.active {
|
|
670
|
+
color: var(--chrome-text); opacity: 1;
|
|
671
|
+
border-left-color: var(--accent); background: var(--chrome-active);
|
|
672
|
+
}
|
|
673
|
+
.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); }
|
|
674
|
+
.masthead {
|
|
675
|
+
position: sticky; top: 0; z-index: 20;
|
|
676
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
677
|
+
padding: var(--sp-3) var(--sp-6);
|
|
678
|
+
background: var(--chrome-bg);
|
|
679
|
+
border-bottom: 1px solid var(--chrome-border);
|
|
680
|
+
color: var(--chrome-text);
|
|
681
|
+
}
|
|
682
|
+
.masthead .btn-ghost { color: var(--chrome-text-muted); }
|
|
683
|
+
.masthead .btn-ghost:hover { color: var(--chrome-text); background: var(--chrome-bg-2); }
|
|
684
|
+
.theme-toggle {
|
|
685
|
+
background: none; border: 1px solid var(--chrome-border); color: var(--chrome-text-muted);
|
|
686
|
+
width: 30px; height: 30px; border-radius: var(--radius-sm); cursor: pointer;
|
|
687
|
+
display: inline-flex; align-items: center; justify-content: center; font-size: 15px;
|
|
688
|
+
transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
689
|
+
}
|
|
690
|
+
.theme-toggle:hover { color: var(--chrome-text); border-color: var(--chrome-text-muted); }
|
|
691
|
+
/* Header notification feed (ref: enterprise events / bell) */
|
|
692
|
+
.notif-wrap { position: relative; }
|
|
693
|
+
.notif-btn {
|
|
694
|
+
position: relative; background: none; border: 1px solid var(--chrome-border);
|
|
695
|
+
color: var(--chrome-text-muted); width: 30px; height: 30px; border-radius: var(--radius-sm);
|
|
696
|
+
cursor: pointer; font-size: 15px; display: inline-flex; align-items: center; justify-content: center;
|
|
697
|
+
transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
698
|
+
}
|
|
699
|
+
.notif-btn:hover { color: var(--chrome-text); border-color: var(--chrome-text-muted); }
|
|
700
|
+
.notif-count {
|
|
701
|
+
position: absolute; top: -6px; right: -6px; min-width: 16px; height: 16px;
|
|
702
|
+
padding: 0 4px; border-radius: var(--radius-pill); background: var(--danger);
|
|
703
|
+
color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center;
|
|
704
|
+
}
|
|
705
|
+
.notif-panel {
|
|
706
|
+
position: absolute; right: 0; top: calc(100% + 8px); width: 360px; max-height: 60vh;
|
|
707
|
+
overflow-y: auto; background: var(--surface); color: var(--text);
|
|
708
|
+
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
|
709
|
+
box-shadow: var(--shadow-lg); z-index: 50;
|
|
710
|
+
}
|
|
711
|
+
.notif-head {
|
|
712
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
713
|
+
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
|
|
714
|
+
font-weight: 600; font-size: var(--fs-md); position: sticky; top: 0; background: var(--surface);
|
|
715
|
+
}
|
|
716
|
+
.notif-clear { background: none; border: none; color: var(--accent); font-size: var(--fs-xs); cursor: pointer; }
|
|
717
|
+
.notif-clear:hover { text-decoration: underline; }
|
|
718
|
+
.notif-item { display: flex; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border); }
|
|
719
|
+
.notif-item:last-child { border-bottom: none; }
|
|
720
|
+
.notif-sev { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
|
|
721
|
+
.notif-sev.bad { background: var(--danger); } .notif-sev.warn { background: var(--warning); } .notif-sev.info { background: var(--accent); }
|
|
722
|
+
.notif-body { flex: 1; min-width: 0; }
|
|
723
|
+
.notif-text { font-size: var(--fs-sm); line-height: 1.45; }
|
|
724
|
+
.notif-time { font-size: var(--fs-xs); color: var(--text-dim); margin-top: 2px; }
|
|
725
|
+
.notif-ack {
|
|
726
|
+
background: none; border: 1px solid var(--border-strong); color: var(--text-muted);
|
|
727
|
+
width: 22px; height: 22px; border-radius: var(--radius-sm); cursor: pointer; flex-shrink: 0;
|
|
728
|
+
font-size: 12px; line-height: 1;
|
|
729
|
+
}
|
|
730
|
+
.notif-ack:hover { color: var(--success); border-color: var(--success); }
|
|
731
|
+
.notif-empty { padding: var(--sp-8) var(--sp-4); text-align: center; color: var(--text-dim); font-size: var(--fs-sm); }
|
|
732
|
+
/* Inline "?" info popover */
|
|
733
|
+
.info {
|
|
734
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
735
|
+
width: 16px; height: 16px; border-radius: 50%; margin-left: 7px;
|
|
736
|
+
border: 1px solid var(--border-strong); background: none; color: var(--text-dim);
|
|
737
|
+
font-size: 10px; font-weight: 700; cursor: pointer; vertical-align: middle;
|
|
738
|
+
transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
|
|
739
|
+
}
|
|
740
|
+
.info:hover { color: var(--accent); border-color: var(--accent); }
|
|
741
|
+
.popover {
|
|
742
|
+
position: fixed; z-index: 60; width: 300px; max-width: 86vw;
|
|
743
|
+
background: var(--surface); color: var(--text);
|
|
744
|
+
border: 1px solid var(--border-strong); border-radius: var(--radius);
|
|
745
|
+
box-shadow: var(--shadow-lg); padding: var(--sp-4); font-size: var(--fs-sm); line-height: 1.55;
|
|
746
|
+
}
|
|
747
|
+
.popover[hidden] { display: none; }
|
|
748
|
+
.popover h4 { font-size: var(--fs-md); margin-bottom: var(--sp-2); }
|
|
749
|
+
/* Clickable list/card affordance + detail slide-over (ref: 3-pane inspector) */
|
|
750
|
+
.dp-card, .dtable tr[data-dp], .dtable tr[data-aud], #services-list .service-row[data-svc], .src-card[data-src] { cursor: pointer; }
|
|
751
|
+
#services-list .service-row[data-svc]:hover { background: var(--surface-2); }
|
|
752
|
+
.dtable tr[data-dp]:hover, .dtable tr[data-aud]:hover { background: var(--surface-2); }
|
|
753
|
+
.src-card[data-src]:hover { border-color: var(--accent); }
|
|
754
|
+
.dp-card[data-dp]:hover { border-color: var(--accent); }
|
|
755
|
+
.drawer-ov {
|
|
756
|
+
position: fixed; inset: 0; background: rgba(8,10,15,0.45);
|
|
757
|
+
opacity: 0; pointer-events: none; transition: opacity var(--t-fast) var(--ease); z-index: 70;
|
|
758
|
+
}
|
|
759
|
+
.drawer-ov.open { opacity: 1; pointer-events: auto; }
|
|
760
|
+
.drawer {
|
|
761
|
+
position: fixed; top: 0; right: 0; bottom: 0; width: 440px; max-width: 92vw;
|
|
762
|
+
background: var(--surface); color: var(--text); border-left: 1px solid var(--border-strong);
|
|
763
|
+
box-shadow: var(--shadow-lg); transform: translateX(100%);
|
|
764
|
+
transition: transform 220ms var(--ease); z-index: 71;
|
|
765
|
+
display: flex; flex-direction: column;
|
|
766
|
+
}
|
|
767
|
+
.drawer.drawer-wide { width: 780px; max-width: 96vw; }
|
|
768
|
+
.drawer.open { transform: none; }
|
|
769
|
+
.drawer-hd {
|
|
770
|
+
display: flex; align-items: center; gap: var(--sp-3);
|
|
771
|
+
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
|
|
772
|
+
}
|
|
773
|
+
.drawer-hd h3 { font-size: var(--fs-xl); font-weight: 600; flex: 1; min-width: 0; }
|
|
774
|
+
.drawer-hd .dw-x {
|
|
775
|
+
background: none; border: none; color: var(--text-muted); font-size: 20px;
|
|
776
|
+
cursor: pointer; line-height: 1; padding: 2px 6px; border-radius: var(--radius-sm);
|
|
777
|
+
}
|
|
778
|
+
.drawer-hd .dw-x:hover { color: var(--text); background: var(--surface-2); }
|
|
779
|
+
.drawer-bd { padding: var(--sp-5); overflow-y: auto; }
|
|
780
|
+
.dw-sec { margin-bottom: var(--sp-5); }
|
|
781
|
+
.dw-sec .dw-lbl { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); margin-bottom: var(--sp-2); }
|
|
782
|
+
/* Copyable CLI / API example */
|
|
783
|
+
.codeblock { margin-top: var(--sp-3); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; }
|
|
784
|
+
.codeblock-hd {
|
|
785
|
+
display: flex; align-items: center; gap: var(--sp-2);
|
|
786
|
+
padding: 7px var(--sp-3); background: var(--surface-2);
|
|
787
|
+
font-size: var(--fs-xs); color: var(--text-muted); cursor: pointer;
|
|
788
|
+
border-bottom: 1px solid var(--border);
|
|
789
|
+
}
|
|
790
|
+
.codeblock.collapsed .codeblock-hd { border-bottom: none; }
|
|
791
|
+
.codeblock-hd .cb-chev { transition: transform var(--t-fast) var(--ease); font-size: 9px; }
|
|
792
|
+
.codeblock.collapsed .codeblock-hd .cb-chev { transform: rotate(-90deg); }
|
|
793
|
+
.codeblock-hd .cb-title { flex: 1; font-weight: 600; }
|
|
794
|
+
.codeblock.collapsed pre { display: none; }
|
|
795
|
+
.codeblock-cp { background: none; border: none; color: var(--accent); cursor: pointer; font-size: var(--fs-xs); }
|
|
796
|
+
.codeblock-cp:hover { text-decoration: underline; }
|
|
797
|
+
.codeblock pre {
|
|
798
|
+
margin: 0; padding: var(--sp-3); overflow-x: auto;
|
|
799
|
+
font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; line-height: 1.6;
|
|
800
|
+
color: var(--text); background: var(--surface);
|
|
801
|
+
}
|
|
802
|
+
.codeblock pre .tok-cmd { color: var(--accent); }
|
|
803
|
+
.codeblock pre .tok-cmt { color: var(--text-dim); }
|
|
804
|
+
/* Animated "how it works" flow diagram */
|
|
805
|
+
.flow { display: flex; align-items: center; gap: 0; flex-wrap: wrap; padding: var(--sp-4) 0; }
|
|
806
|
+
.flow-node {
|
|
807
|
+
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
|
808
|
+
padding: var(--sp-3) var(--sp-4); border: 1px solid var(--border-strong);
|
|
809
|
+
border-radius: var(--radius); background: var(--surface-2); min-width: 96px; text-align: center;
|
|
810
|
+
}
|
|
811
|
+
.flow-node .fn-ic { font-size: 20px; color: var(--accent); }
|
|
812
|
+
.flow-node .fn-t { font-size: var(--fs-xs); font-weight: 600; }
|
|
813
|
+
.flow-node .fn-s { font-size: 10px; color: var(--text-dim); }
|
|
814
|
+
.flow-arrow { flex: 1; min-width: 28px; height: 2px; background: var(--border-strong); position: relative; }
|
|
815
|
+
.flow-arrow::after {
|
|
816
|
+
content: ''; position: absolute; top: -3px; width: 8px; height: 8px; border-radius: 50%;
|
|
817
|
+
background: var(--accent); left: 0; animation: flowdot 2.4s var(--ease) infinite;
|
|
818
|
+
}
|
|
819
|
+
@keyframes flowdot { 0% { left: 0; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 100%; opacity: 0; } }
|
|
820
|
+
.flow-arrow:nth-of-type(4)::after { animation-delay: 0.6s; }
|
|
821
|
+
.flow-arrow:nth-of-type(6)::after { animation-delay: 1.2s; }
|
|
822
|
+
.gate-pill {
|
|
823
|
+
display: inline-flex; align-items: center; gap: 7px;
|
|
824
|
+
padding: 4px 12px; border-radius: var(--radius-pill);
|
|
825
|
+
font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.02em;
|
|
826
|
+
border: 1px solid var(--border-strong); cursor: default;
|
|
827
|
+
}
|
|
828
|
+
.gate-pill::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 6px currentColor; }
|
|
829
|
+
.gate-off { color: var(--text-muted); background: var(--surface-2); }
|
|
830
|
+
.gate-active { color: var(--success); background: var(--success-soft); border-color: rgba(74,222,128,0.30); }
|
|
831
|
+
.gate-failclosed { color: var(--danger); background: var(--danger-soft); border-color: rgba(239,91,110,0.35); }
|
|
832
|
+
.gate-unknown { color: var(--warning); background: var(--warning-soft); border-color: rgba(245,179,65,0.30); }
|
|
833
|
+
|
|
834
|
+
/* Enterprise page primitives */
|
|
835
|
+
.ent-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
|
|
836
|
+
/* Dashboard feed + ranked bars (ref: events / top-consumers) */
|
|
837
|
+
.feed-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-2); border-bottom: 1px solid var(--border); }
|
|
838
|
+
.feed-row:last-child { border-bottom: none; }
|
|
839
|
+
.feed-row .fr-main { flex: 1; min-width: 0; }
|
|
840
|
+
.feed-row .fr-title { font-size: var(--fs-md); font-weight: 600; }
|
|
841
|
+
.feed-row .fr-sub { font-size: var(--fs-xs); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
842
|
+
.stchip { display: inline-flex; align-items: center; padding: 1px 7px; border-radius: var(--radius-sm); font-size: 10px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
|
|
843
|
+
.stchip.ok { color: var(--success); background: var(--success-soft); }
|
|
844
|
+
.stchip.warn { color: var(--warning); background: var(--warning-soft); }
|
|
845
|
+
.stchip.bad { color: var(--danger); background: var(--danger-soft); }
|
|
846
|
+
.rank { display: flex; flex-direction: column; gap: 9px; padding: var(--sp-1) 0; }
|
|
847
|
+
.rank-row { display: grid; grid-template-columns: 120px 1fr 42px; align-items: center; gap: var(--sp-3); font-size: var(--fs-sm); }
|
|
848
|
+
.rank-row .rk-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
|
849
|
+
.rank-row .rk-track { height: 6px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
|
850
|
+
.rank-row .rk-fill { height: 100%; border-radius: 3px; transition: width 600ms var(--ease); }
|
|
851
|
+
.rank-row .rk-fill.ok { background: var(--success); }
|
|
852
|
+
.rank-row .rk-fill.warn { background: var(--warning); }
|
|
853
|
+
.rank-row .rk-fill.bad { background: var(--danger); }
|
|
854
|
+
.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) */
|
|
856
|
+
.dp-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
|
|
857
|
+
.dp-bar .dp-count { font-size: var(--fs-sm); color: var(--text-muted); }
|
|
858
|
+
.dp-bar .dp-search { flex: 1; max-width: 320px; }
|
|
859
|
+
.dp-bar .dp-search input { width: 100%; }
|
|
860
|
+
.dp-spacer { flex: 1; }
|
|
861
|
+
.view-toggle { display: inline-flex; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; }
|
|
862
|
+
.view-toggle button {
|
|
863
|
+
background: var(--surface); border: none; color: var(--text-muted);
|
|
864
|
+
padding: 6px 11px; cursor: pointer; font-size: 13px; line-height: 1;
|
|
865
|
+
transition: background var(--t-fast) var(--ease), color var(--t-fast) var(--ease);
|
|
866
|
+
}
|
|
867
|
+
.view-toggle button + button { border-left: 1px solid var(--border-strong); }
|
|
868
|
+
.view-toggle button.active { background: var(--accent-soft); color: var(--accent); }
|
|
869
|
+
.view-toggle button:hover { color: var(--text); }
|
|
870
|
+
/* Form-first editor (Form / JSON / YAML — OpenShift-style) */
|
|
871
|
+
.ed-bar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); flex-wrap: wrap; }
|
|
872
|
+
.ed-token { flex: 1; min-width: 200px; }
|
|
873
|
+
.ed-code { width: 100%; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 12px; }
|
|
874
|
+
.fe-block { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); margin-bottom: var(--sp-3); }
|
|
875
|
+
.fe-block > .fe-hd { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
|
|
876
|
+
.fe-block > .fe-hd .fe-t { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim); }
|
|
877
|
+
.fe-row { display: flex; gap: var(--sp-2); align-items: flex-start; margin-bottom: var(--sp-2); flex-wrap: wrap; }
|
|
878
|
+
.fe-row .fe-grow { flex: 1; min-width: 140px; }
|
|
879
|
+
.fe-row label.fe-il { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--text-muted); white-space: nowrap; }
|
|
880
|
+
.fe-card { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-3); margin-bottom: var(--sp-2); background: var(--surface-2); }
|
|
881
|
+
.fe-card .fe-cardhd { display: flex; gap: var(--sp-2); align-items: center; margin-bottom: var(--sp-2); }
|
|
882
|
+
.fe-fieldlbl { font-size: 11px; color: var(--text-dim); margin: 6px 0 2px; }
|
|
883
|
+
.fe-x { background: none; border: 1px solid var(--border-strong); color: var(--text-muted); width: 24px; height: 26px; border-radius: var(--radius-sm); cursor: pointer; flex-shrink: 0; }
|
|
884
|
+
.fe-x:hover { color: var(--danger); border-color: var(--danger); }
|
|
885
|
+
.fe-add { background: none; border: 1px dashed var(--border-strong); color: var(--accent); padding: 6px 12px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-sm); }
|
|
886
|
+
.fe-add:hover { border-color: var(--accent); }
|
|
887
|
+
#ent-rbac-editor input[type=text], #ent-rbac-editor input[type=password], #ent-rbac-editor textarea,
|
|
888
|
+
#ent-cat-editor input[type=text], #ent-cat-editor input[type=password], #ent-cat-editor textarea {
|
|
889
|
+
background: var(--surface); border: 1px solid var(--border-strong); color: var(--text);
|
|
890
|
+
border-radius: var(--radius-sm); padding: 7px 10px; font-size: var(--fs-sm); width: 100%;
|
|
891
|
+
}
|
|
892
|
+
.dp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: var(--sp-3); }
|
|
893
|
+
.dp-card {
|
|
894
|
+
border: 1px solid var(--border); border-radius: var(--radius);
|
|
895
|
+
background: var(--surface); padding: var(--sp-4);
|
|
896
|
+
display: flex; flex-direction: column; gap: var(--sp-2);
|
|
897
|
+
transition: border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
|
|
898
|
+
}
|
|
899
|
+
.dp-card:hover { border-color: var(--border-strong); box-shadow: var(--shadow-sm); }
|
|
900
|
+
.dp-card h3 { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; }
|
|
901
|
+
.dp-card .dp-desc { font-size: var(--fs-sm); color: var(--text-muted); min-height: 32px; }
|
|
902
|
+
.dp-attr { font-size: var(--fs-xs); color: var(--text-dim); margin-top: var(--sp-1); }
|
|
903
|
+
.dp-attr .lbl { text-transform: uppercase; letter-spacing: 0.06em; font-weight: 700; margin-right: 6px; }
|
|
904
|
+
.dp-card .dp-foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: var(--sp-3); border-top: 1px solid var(--border); }
|
|
905
|
+
.dp-grants { font-size: var(--fs-xs); color: var(--text-muted); }
|
|
906
|
+
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px var(--sp-5); font-size: var(--fs-md); }
|
|
907
|
+
.kv dt { color: var(--text-muted); }
|
|
908
|
+
.kv dd { font-variant-numeric: tabular-nums; word-break: break-word; }
|
|
909
|
+
.dtable { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
|
|
910
|
+
.dtable th, .dtable td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
|
911
|
+
.dtable th { color: var(--text-dim); text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; font-weight: 700; }
|
|
912
|
+
.dtable tr:last-child td { border-bottom: none; }
|
|
913
|
+
.dtable td.mono, .kv dd.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: var(--fs-sm); }
|
|
914
|
+
.pill-yes { color: var(--success); } .pill-no { color: var(--danger); }
|
|
915
|
+
.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
|
+
@media (max-width: 980px) { .ent-grid { grid-template-columns: 1fr; } }
|
|
484
917
|
</style>
|
|
485
918
|
</head>
|
|
486
919
|
<body>
|
|
487
|
-
<
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
<button class="nav-btn active" data-page="dashboard" onclick="showPage('dashboard')">Dashboard</button>
|
|
492
|
-
<button class="nav-btn" data-page="sources" onclick="showPage('sources')">Sources</button>
|
|
493
|
-
<button class="nav-btn" data-page="services" onclick="showPage('services')">Services</button>
|
|
494
|
-
<button class="nav-btn" data-page="connectors" onclick="showPage('connectors')">Connectors</button>
|
|
495
|
-
<button class="nav-btn" data-page="health" onclick="showPage('health')">Health</button>
|
|
496
|
-
<button class="nav-btn" data-page="settings" onclick="showPage('settings')">Settings</button>
|
|
920
|
+
<aside class="siderail">
|
|
921
|
+
<div class="rail-brand">
|
|
922
|
+
<span class="rail-mark"></span>
|
|
923
|
+
<div class="rail-title">Observability<br><span>MCP Console</span></div>
|
|
497
924
|
</div>
|
|
925
|
+
<nav class="rail-nav">
|
|
926
|
+
<div class="rail-grp" data-grp="observability">
|
|
927
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('observability')">Observability<span class="chev">▾</span></button>
|
|
928
|
+
<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
|
+
</div>
|
|
934
|
+
</div>
|
|
935
|
+
<div class="rail-grp" data-grp="catalog">
|
|
936
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('catalog')">Catalog<span class="chev">▾</span></button>
|
|
937
|
+
<div class="rail-grp-body">
|
|
938
|
+
<button class="nav-btn" data-page="products" onclick="showPage('products')"><span class="nav-ico">◫</span>Products</button>
|
|
939
|
+
</div>
|
|
940
|
+
</div>
|
|
941
|
+
<div class="rail-grp" data-grp="governance">
|
|
942
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('governance')">Governance<span class="chev">▾</span></button>
|
|
943
|
+
<div class="rail-grp-body">
|
|
944
|
+
<button class="nav-btn" data-page="access" onclick="showPage('access')"><span class="nav-ico">⛨</span>Access Control</button>
|
|
945
|
+
<button class="nav-btn" data-page="audit" onclick="showPage('audit')"><span class="nav-ico">❒</span>Audit Log</button>
|
|
946
|
+
</div>
|
|
947
|
+
</div>
|
|
948
|
+
<div class="rail-grp" data-grp="system">
|
|
949
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('system')">System<span class="chev">▾</span></button>
|
|
950
|
+
<div class="rail-grp-body">
|
|
951
|
+
<div class="rail-item" data-nav="connectors">
|
|
952
|
+
<button class="nav-btn nav-parent" onclick="navToggle('connectors')"><span class="nav-ico">⇄</span>Connectors<span class="sub-chev">▾</span></button>
|
|
953
|
+
<div class="rail-sub">
|
|
954
|
+
<button class="nav-sub" data-sub="installed" onclick="goTab('connectors','installed')">Installed</button>
|
|
955
|
+
<button class="nav-sub" data-sub="hub" onclick="goTab('connectors','hub')">Connector Hub</button>
|
|
956
|
+
<button class="nav-sub" data-sub="upload" onclick="goTab('connectors','upload')">Upload Bundle</button>
|
|
957
|
+
</div>
|
|
958
|
+
</div>
|
|
959
|
+
<div class="rail-item" data-nav="settings">
|
|
960
|
+
<button class="nav-btn nav-parent" onclick="navToggle('settings')"><span class="nav-ico">⚙</span>Settings<span class="sub-chev">▾</span></button>
|
|
961
|
+
<div class="rail-sub">
|
|
962
|
+
<button class="nav-sub" data-sub="general" onclick="goTab('settings','general')">General</button>
|
|
963
|
+
<button class="nav-sub" data-sub="health" onclick="goTab('settings','health')">Health Scoring</button>
|
|
964
|
+
<button class="nav-sub" data-sub="metrics" onclick="goTab('settings','metrics')">Custom Metrics</button>
|
|
965
|
+
</div>
|
|
966
|
+
</div>
|
|
967
|
+
<button class="nav-btn" data-page="entitlement" onclick="showPage('entitlement')"><span class="nav-ico">⛁</span>Entitlement</button>
|
|
968
|
+
</div>
|
|
969
|
+
</div>
|
|
970
|
+
</nav>
|
|
971
|
+
<div class="rail-foot" id="rail-foot">loading…</div>
|
|
972
|
+
</aside>
|
|
973
|
+
<header class="masthead">
|
|
498
974
|
<div style="flex:1"></div>
|
|
975
|
+
<div class="notif-wrap">
|
|
976
|
+
<button class="notif-btn" id="notif-btn" aria-label="Notifications" title="Notifications" onclick="toggleNotif()">
|
|
977
|
+
<span class="notif-bell">◔</span><span class="notif-count" id="notif-count" hidden>0</span>
|
|
978
|
+
</button>
|
|
979
|
+
<div class="notif-panel" id="notif-panel" hidden>
|
|
980
|
+
<div class="notif-head"><span>Notifications</span><button class="notif-clear" id="notif-clear" onclick="notifAckAll()">Mark all read</button></div>
|
|
981
|
+
<div id="notif-list"><div class="notif-empty">No notifications</div></div>
|
|
982
|
+
</div>
|
|
983
|
+
</div>
|
|
984
|
+
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark" aria-label="Toggle theme" onclick="toggleTheme()">◐</button>
|
|
499
985
|
<button class="btn btn-ghost btn-sm" onclick="refresh()">Refresh</button>
|
|
500
|
-
</
|
|
986
|
+
</header>
|
|
501
987
|
|
|
502
988
|
<div class="container">
|
|
503
989
|
<!-- ===== Dashboard ===== -->
|
|
504
990
|
<div class="page active" id="page-dashboard">
|
|
505
|
-
<div class="
|
|
506
|
-
<
|
|
507
|
-
|
|
991
|
+
<div class="page-head">
|
|
992
|
+
<div class="ph-left">
|
|
993
|
+
<div class="breadcrumb">Console / <b>Overview</b></div>
|
|
994
|
+
<h1>Overview</h1>
|
|
995
|
+
</div>
|
|
996
|
+
<div class="ph-actions"><span class="live-indicator" id="dash-live">Live</span></div>
|
|
508
997
|
</div>
|
|
509
998
|
<div class="stats">
|
|
510
999
|
<div class="stat-card">
|
|
@@ -512,47 +1001,99 @@
|
|
|
512
1001
|
<div class="value" id="stat-sources">-</div>
|
|
513
1002
|
<div class="context" id="stat-sources-ctx">configured</div>
|
|
514
1003
|
</div>
|
|
515
|
-
<div class="stat-card">
|
|
516
|
-
<div class="
|
|
517
|
-
|
|
518
|
-
|
|
1004
|
+
<div class="stat-card has-gauge">
|
|
1005
|
+
<div class="stat-body">
|
|
1006
|
+
<div class="label">Sources Up</div>
|
|
1007
|
+
<div class="value" id="stat-sources-up">-</div>
|
|
1008
|
+
<div class="context" id="stat-sources-up-ctx">connected</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
<span id="gauge-sources" class="gauge"></span>
|
|
519
1011
|
</div>
|
|
520
1012
|
<div class="stat-card">
|
|
521
1013
|
<div class="label">Services</div>
|
|
522
1014
|
<div class="value" id="stat-services">-</div>
|
|
523
1015
|
<div class="context" id="stat-services-ctx">discovered</div>
|
|
524
1016
|
</div>
|
|
525
|
-
<div class="stat-card">
|
|
526
|
-
<div class="
|
|
527
|
-
|
|
528
|
-
|
|
1017
|
+
<div class="stat-card has-gauge">
|
|
1018
|
+
<div class="stat-body">
|
|
1019
|
+
<div class="label">Services Healthy</div>
|
|
1020
|
+
<div class="value" id="stat-health">-</div>
|
|
1021
|
+
<div class="context" id="stat-health-ctx">live</div>
|
|
1022
|
+
</div>
|
|
1023
|
+
<span id="gauge-health" class="gauge"></span>
|
|
529
1024
|
</div>
|
|
530
1025
|
</div>
|
|
531
1026
|
<div class="endpoint-bar">
|
|
532
1027
|
<span>MCP Endpoint: <strong id="mcp-url">http://localhost:3000/mcp</strong></span>
|
|
533
1028
|
<button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('mcp-url').textContent);toast('Copied!')">Copy</button>
|
|
534
1029
|
</div>
|
|
1030
|
+
<div class="card">
|
|
1031
|
+
<div class="card-header"><h2>How it works
|
|
1032
|
+
<button class="info" aria-label="Explain the request path"
|
|
1033
|
+
data-title="Request path"
|
|
1034
|
+
data-info="An agent calls an MCP tool over the endpoint above. The optional access-control gate evaluates the request (entitlement → RBAC → catalog) and records the decision to the audit log. Allowed calls hit the connectors (Prometheus, Loki, …); the analysis engine turns the raw signals into a scored health verdict."
|
|
1035
|
+
onclick="infoPop(this)">?</button>
|
|
1036
|
+
</h2></div>
|
|
1037
|
+
<div class="flow">
|
|
1038
|
+
<div class="flow-node"><span class="fn-ic">◇</span><span class="fn-t">Agent</span><span class="fn-s">MCP call</span></div>
|
|
1039
|
+
<span class="flow-arrow"></span>
|
|
1040
|
+
<div class="flow-node"><span class="fn-ic">⛨</span><span class="fn-t">Gate</span><span class="fn-s">entitlement · RBAC · catalog</span></div>
|
|
1041
|
+
<span class="flow-arrow"></span>
|
|
1042
|
+
<div class="flow-node"><span class="fn-ic">⇄</span><span class="fn-t">Connectors</span><span class="fn-s">Prometheus · Loki</span></div>
|
|
1043
|
+
<span class="flow-arrow"></span>
|
|
1044
|
+
<div class="flow-node"><span class="fn-ic">✦</span><span class="fn-t">Analysis</span><span class="fn-s">scored health</span></div>
|
|
1045
|
+
</div>
|
|
1046
|
+
</div>
|
|
535
1047
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
536
1048
|
<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>
|
|
537
1049
|
<div class="card"><div class="card-header"><h2>Services</h2></div><div id="dash-services"><div class="empty">Loading...</div></div></div>
|
|
538
1050
|
</div>
|
|
1051
|
+
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
|
|
1052
|
+
<div class="card">
|
|
1053
|
+
<div class="card-header"><h2>Needs attention</h2><span id="dash-attn-count" class="tag tag-type">—</span></div>
|
|
1054
|
+
<div id="dash-attention"><div class="empty">Loading…</div></div>
|
|
1055
|
+
</div>
|
|
1056
|
+
<div class="card">
|
|
1057
|
+
<div class="card-header"><h2>Services by health score</h2></div>
|
|
1058
|
+
<div id="dash-top"><div class="empty">Loading…</div></div>
|
|
1059
|
+
</div>
|
|
1060
|
+
</div>
|
|
539
1061
|
</div>
|
|
540
1062
|
|
|
541
1063
|
<!-- ===== Sources ===== -->
|
|
542
1064
|
<div class="page" id="page-sources">
|
|
1065
|
+
<div class="page-head">
|
|
1066
|
+
<div class="ph-left">
|
|
1067
|
+
<div class="breadcrumb">Console / Observability / <b>Sources</b></div>
|
|
1068
|
+
<h1>Data Sources</h1>
|
|
1069
|
+
</div>
|
|
1070
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Add Source</button></div>
|
|
1071
|
+
</div>
|
|
543
1072
|
<div class="card">
|
|
544
|
-
<div class="card-header"><h2>
|
|
1073
|
+
<div class="card-header"><h2>All sources</h2></div>
|
|
545
1074
|
<div id="sources-list"><div class="empty">Loading...</div></div>
|
|
546
1075
|
</div>
|
|
547
1076
|
</div>
|
|
548
1077
|
|
|
549
1078
|
<!-- ===== Services ===== -->
|
|
550
1079
|
<div class="page" id="page-services">
|
|
551
|
-
<div class="
|
|
1080
|
+
<div class="page-head">
|
|
1081
|
+
<div class="ph-left">
|
|
1082
|
+
<div class="breadcrumb">Console / Observability / <b>Services</b></div>
|
|
1083
|
+
<h1>Services</h1>
|
|
1084
|
+
</div>
|
|
1085
|
+
</div>
|
|
1086
|
+
<div class="card"><div class="card-header"><h2>Discovered services</h2></div><div id="services-list"><div class="empty">Loading...</div></div></div>
|
|
552
1087
|
</div>
|
|
553
1088
|
|
|
554
1089
|
<!-- ===== Connectors ===== -->
|
|
555
1090
|
<div class="page" id="page-connectors">
|
|
1091
|
+
<div class="page-head">
|
|
1092
|
+
<div class="ph-left">
|
|
1093
|
+
<div class="breadcrumb">Console / System / <b>Connectors</b></div>
|
|
1094
|
+
<h1>Connectors</h1>
|
|
1095
|
+
</div>
|
|
1096
|
+
</div>
|
|
556
1097
|
<div class="card" style="padding:0;">
|
|
557
1098
|
<div class="tabs">
|
|
558
1099
|
<button class="tab-btn active" onclick="showTab('installed')">Installed</button>
|
|
@@ -587,6 +1128,12 @@
|
|
|
587
1128
|
|
|
588
1129
|
<!-- ===== Health ===== -->
|
|
589
1130
|
<div class="page" id="page-health">
|
|
1131
|
+
<div class="page-head">
|
|
1132
|
+
<div class="ph-left">
|
|
1133
|
+
<div class="breadcrumb">Console / Observability / <b>Health</b></div>
|
|
1134
|
+
<h1>Service Health</h1>
|
|
1135
|
+
</div>
|
|
1136
|
+
</div>
|
|
590
1137
|
<div id="health-cards" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;">
|
|
591
1138
|
<div class="empty">Loading health data...</div>
|
|
592
1139
|
</div>
|
|
@@ -594,6 +1141,12 @@
|
|
|
594
1141
|
|
|
595
1142
|
<!-- ===== Settings ===== -->
|
|
596
1143
|
<div class="page" id="page-settings">
|
|
1144
|
+
<div class="page-head">
|
|
1145
|
+
<div class="ph-left">
|
|
1146
|
+
<div class="breadcrumb">Console / System / <b>Settings</b></div>
|
|
1147
|
+
<h1>Settings</h1>
|
|
1148
|
+
</div>
|
|
1149
|
+
</div>
|
|
597
1150
|
<div class="card" style="padding:0;">
|
|
598
1151
|
<div class="tabs">
|
|
599
1152
|
<button class="tab-btn active" onclick="showTab('general')">General</button>
|
|
@@ -699,6 +1252,181 @@
|
|
|
699
1252
|
</div>
|
|
700
1253
|
</div>
|
|
701
1254
|
</div>
|
|
1255
|
+
<!-- ===== Governance: Access Control ===== -->
|
|
1256
|
+
<div class="page" id="page-access">
|
|
1257
|
+
<div class="page-head">
|
|
1258
|
+
<div class="ph-left">
|
|
1259
|
+
<div class="breadcrumb">Console / Governance / <b>Access Control</b></div>
|
|
1260
|
+
<h1>Access Control</h1>
|
|
1261
|
+
</div>
|
|
1262
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="entEditNew('rbac')">+ New role</button><button class="btn btn-sm" id="ent-rbac-editbtn" onclick="entEditOpen('rbac')">Edit policy</button></div>
|
|
1263
|
+
</div>
|
|
1264
|
+
<div class="card">
|
|
1265
|
+
<div class="card-header"><h2>Roles & bindings
|
|
1266
|
+
<button class="info" aria-label="About roles and bindings"
|
|
1267
|
+
data-title="Roles & bindings"
|
|
1268
|
+
data-info="A role grants tools/sources/services (use * for all; readOnly blocks mutating tools). A binding maps an API-key principal to one or more roles. Default-deny: a principal with no binding is denied."
|
|
1269
|
+
onclick="infoPop(this)">?</button>
|
|
1270
|
+
</h2></div>
|
|
1271
|
+
<div id="ent-rbac"><div class="empty">Loading…</div></div>
|
|
1272
|
+
<div class="form-hint" style="margin-top:14px">Use <b>Edit policy</b> above — no command line needed. The block below is the optional API equivalent for automation/CI.</div>
|
|
1273
|
+
<div class="codeblock collapsed">
|
|
1274
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1275
|
+
<span class="cb-chev">▾</span><span class="cb-title">API · add a role & binding via curl</span>
|
|
1276
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1277
|
+
</div>
|
|
1278
|
+
<pre><span class="tok-cmt"># grant the "viewer" role read-only metrics, bind a principal to it.</span>
|
|
1279
|
+
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1280
|
+
curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
1281
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
1282
|
+
-H "Content-Type: application/json" \
|
|
1283
|
+
-d '{
|
|
1284
|
+
"roles": {
|
|
1285
|
+
"viewer": { "tools": ["query_metrics","get_service_health"], "readOnly": true }
|
|
1286
|
+
},
|
|
1287
|
+
"bindings": { "alice": ["viewer"] }
|
|
1288
|
+
}'</pre>
|
|
1289
|
+
</div>
|
|
1290
|
+
<div id="ent-rbac-editor" class="hidden" style="margin-top:12px">
|
|
1291
|
+
<div class="ed-bar">
|
|
1292
|
+
<span class="view-toggle" id="rbac-views">
|
|
1293
|
+
<button data-v="form" class="active" onclick="rbacView('form')">Form</button>
|
|
1294
|
+
<button data-v="json" onclick="rbacView('json')">JSON</button>
|
|
1295
|
+
<button data-v="yaml" onclick="rbacView('yaml')">YAML</button>
|
|
1296
|
+
</span>
|
|
1297
|
+
<input type="password" id="ent-admin-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1298
|
+
</div>
|
|
1299
|
+
<div class="form-hint" style="margin-bottom:8px">Edit roles & bindings below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
|
|
1300
|
+
<div id="rbac-form"></div>
|
|
1301
|
+
<textarea id="ent-rbac-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1302
|
+
<div id="ent-rbac-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1303
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1304
|
+
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1305
|
+
<button class="btn btn-primary btn-sm" onclick="entSaveRbac()">Save policy</button>
|
|
1306
|
+
</div>
|
|
1307
|
+
</div>
|
|
1308
|
+
</div>
|
|
1309
|
+
</div>
|
|
1310
|
+
|
|
1311
|
+
<!-- ===== Catalog: Context Products ===== -->
|
|
1312
|
+
<div class="page" id="page-products">
|
|
1313
|
+
<div class="page-head">
|
|
1314
|
+
<div class="ph-left">
|
|
1315
|
+
<div class="breadcrumb">Console / Catalog / <b>Products</b></div>
|
|
1316
|
+
<h1>Context Products
|
|
1317
|
+
<button class="info" aria-label="What is a context product"
|
|
1318
|
+
data-title="Context products"
|
|
1319
|
+
data-info="A context product is a named, governed bundle of observability context an agent may consume — a curated set of sources, services and tools. It is the unit you grant to a principal. The catalog is the collection of all products plus the grants that map principals to them; it composes with access control (a request must satisfy both RBAC and the catalog)."
|
|
1320
|
+
onclick="infoPop(this)">?</button>
|
|
1321
|
+
</h1>
|
|
1322
|
+
</div>
|
|
1323
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="entEditNew('cat')">+ New product</button><button class="btn btn-sm" id="ent-cat-editbtn" onclick="entEditOpen('cat')">Edit catalog</button></div>
|
|
1324
|
+
</div>
|
|
1325
|
+
<div class="card" style="background:var(--accent-soft);border-color:transparent">
|
|
1326
|
+
<div style="font-size:var(--fs-sm);color:var(--text);line-height:1.6">
|
|
1327
|
+
<b>The catalog</b> publishes <b>context products</b> — reusable bundles of sources, services and tools —
|
|
1328
|
+
and the <b>grants</b> that decide which principals may consume each product. Browse below as cards or a
|
|
1329
|
+
table; an admin can create or change products via the editor or the API example.
|
|
1330
|
+
</div>
|
|
1331
|
+
</div>
|
|
1332
|
+
<div class="card">
|
|
1333
|
+
<div class="card-header"><h2>Products</h2></div>
|
|
1334
|
+
<div id="ent-catalog"><div class="empty">Loading…</div></div>
|
|
1335
|
+
<div id="ent-cat-editor" class="hidden" style="margin-top:12px">
|
|
1336
|
+
<div class="ed-bar">
|
|
1337
|
+
<span class="view-toggle" id="cat-views">
|
|
1338
|
+
<button data-v="form" class="active" onclick="catView('form')">Form</button>
|
|
1339
|
+
<button data-v="json" onclick="catView('json')">JSON</button>
|
|
1340
|
+
<button data-v="yaml" onclick="catView('yaml')">YAML</button>
|
|
1341
|
+
</span>
|
|
1342
|
+
<input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1343
|
+
</div>
|
|
1344
|
+
<div class="form-hint" style="margin-bottom:8px">Define products & grants below — no JSON required. JSON / YAML are optional alternative views. Saving needs an admin API key.</div>
|
|
1345
|
+
<div id="cat-form"></div>
|
|
1346
|
+
<textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1347
|
+
<div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1348
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1349
|
+
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1350
|
+
<button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
|
|
1351
|
+
</div>
|
|
1352
|
+
</div>
|
|
1353
|
+
<div class="form-hint" style="margin-top:14px">Use <b>Edit catalog</b> above — no command line needed. The block below is the optional API equivalent for automation/CI.</div>
|
|
1354
|
+
<div class="codeblock collapsed">
|
|
1355
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1356
|
+
<span class="cb-chev">▾</span><span class="cb-title">API · create a context product via curl</span>
|
|
1357
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1358
|
+
</div>
|
|
1359
|
+
<pre><span class="tok-cmt"># publish a "payments-eu" product and grant it to a principal.</span>
|
|
1360
|
+
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1361
|
+
curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
1362
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
1363
|
+
-H "Content-Type: application/json" \
|
|
1364
|
+
-d '{
|
|
1365
|
+
"products": {
|
|
1366
|
+
"payments-eu": {
|
|
1367
|
+
"description": "EU payment-service metrics + logs",
|
|
1368
|
+
"sources": ["prometheus","loki"],
|
|
1369
|
+
"services": ["payment-service"]
|
|
1370
|
+
}
|
|
1371
|
+
},
|
|
1372
|
+
"grants": { "alice": ["payments-eu"] }
|
|
1373
|
+
}'</pre>
|
|
1374
|
+
</div>
|
|
1375
|
+
</div>
|
|
1376
|
+
</div>
|
|
1377
|
+
|
|
1378
|
+
<!-- ===== Governance: Audit Log ===== -->
|
|
1379
|
+
<div class="page" id="page-audit">
|
|
1380
|
+
<div class="page-head">
|
|
1381
|
+
<div class="ph-left">
|
|
1382
|
+
<div class="breadcrumb">Console / Governance / <b>Audit Log</b></div>
|
|
1383
|
+
<h1>Audit Log</h1>
|
|
1384
|
+
</div>
|
|
1385
|
+
<div class="ph-actions"><span id="ent-chain"></span></div>
|
|
1386
|
+
</div>
|
|
1387
|
+
<div class="card">
|
|
1388
|
+
<div class="card-header"><h2>Recent decisions
|
|
1389
|
+
<button class="info" aria-label="About the audit log"
|
|
1390
|
+
data-title="Audit log"
|
|
1391
|
+
data-info="Every access decision is appended to a hash-chained, tamper-evident log. The chain badge shows whether re-verification of the whole log passes — a break means an entry was modified, reordered or removed."
|
|
1392
|
+
onclick="infoPop(this)">?</button>
|
|
1393
|
+
</h2></div>
|
|
1394
|
+
<div id="ent-audit"><div class="empty">Loading…</div></div>
|
|
1395
|
+
</div>
|
|
1396
|
+
</div>
|
|
1397
|
+
|
|
1398
|
+
<!-- ===== Governance: Entitlement ===== -->
|
|
1399
|
+
<div class="page" id="page-entitlement">
|
|
1400
|
+
<div class="page-head">
|
|
1401
|
+
<div class="ph-left">
|
|
1402
|
+
<div class="breadcrumb">Console / Governance / <b>Entitlement</b></div>
|
|
1403
|
+
<h1>Entitlement</h1>
|
|
1404
|
+
</div>
|
|
1405
|
+
<div class="ph-actions"><span class="live-indicator" id="ent-live">Read-only</span></div>
|
|
1406
|
+
</div>
|
|
1407
|
+
<div class="card">
|
|
1408
|
+
<div class="card-header"><h2>Access-control gate
|
|
1409
|
+
<button class="info" aria-label="About the gate"
|
|
1410
|
+
data-title="Entitlement gate"
|
|
1411
|
+
data-info="The gate has three modes — off (no controls), active (valid signed entitlement → enforcing), fail-closed (a control is configured but the entitlement is missing/invalid → everything denied). Verification is offline (Ed25519)."
|
|
1412
|
+
onclick="infoPop(this)">?</button>
|
|
1413
|
+
</h2><span id="ent-mode"></span></div>
|
|
1414
|
+
<div id="ent-status"><div class="empty">Loading…</div></div>
|
|
1415
|
+
<div class="codeblock collapsed">
|
|
1416
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1417
|
+
<span class="cb-chev">▾</span><span class="cb-title">CLI · mint a token · API · check the gate</span>
|
|
1418
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1419
|
+
</div>
|
|
1420
|
+
<pre><span class="tok-cmt"># issuer side — sign an entitlement token</span>
|
|
1421
|
+
node enterprise/entitlement/mint.mjs \
|
|
1422
|
+
--key issuer-ed25519.pem \
|
|
1423
|
+
--sub org-acme --features access-control,audit --ttl 365d
|
|
1424
|
+
|
|
1425
|
+
<span class="tok-cmt"># deployment — current gate mode</span>
|
|
1426
|
+
curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
1427
|
+
</div>
|
|
1428
|
+
</div>
|
|
1429
|
+
</div>
|
|
702
1430
|
</div>
|
|
703
1431
|
|
|
704
1432
|
<!-- Source Modal -->
|
|
@@ -764,6 +1492,12 @@
|
|
|
764
1492
|
|
|
765
1493
|
<div class="toast" id="toast-el"></div>
|
|
766
1494
|
|
|
1495
|
+
<div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
|
|
1496
|
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
|
1497
|
+
<div class="drawer-hd"><h3 id="drawer-title">Detail</h3><button class="dw-x" aria-label="Close" onclick="closeDrawer()">×</button></div>
|
|
1498
|
+
<div class="drawer-bd" id="drawer-body"></div>
|
|
1499
|
+
</aside>
|
|
1500
|
+
|
|
767
1501
|
<script>
|
|
768
1502
|
let sourcesData=[], servicesData=[], supportedTypes=[], settings={}, healthThresholds={}, defaults={};
|
|
769
1503
|
let deleteTarget=null, deleteType=null;
|
|
@@ -777,10 +1511,594 @@ function showPage(name) {
|
|
|
777
1511
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
|
778
1512
|
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
|
|
779
1513
|
document.getElementById('page-'+name).classList.add('active');
|
|
780
|
-
document.querySelector(`.nav-btn[data-page="${name}"]`).classList.add('active');
|
|
1514
|
+
const nb=document.querySelector(`.nav-btn[data-page="${name}"]`); if(nb) nb.classList.add('active');
|
|
1515
|
+
const it=document.querySelector(`.rail-item[data-nav="${name}"]`);
|
|
1516
|
+
document.querySelectorAll('.rail-item').forEach(r=>{ if(r!==it) r.classList.remove('open'); });
|
|
1517
|
+
if(it) it.classList.add('open');
|
|
781
1518
|
if(name==='settings') loadSettingsData();
|
|
782
1519
|
if(name==='health') loadHealthData();
|
|
783
1520
|
if(name==='connectors') loadConnectors();
|
|
1521
|
+
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// --- Theme (light / dark) ---
|
|
1525
|
+
function syncThemeToggle(){
|
|
1526
|
+
const t=document.documentElement.getAttribute('data-theme')||'dark';
|
|
1527
|
+
const b=document.getElementById('theme-toggle');
|
|
1528
|
+
if(b){ b.textContent = t==='light' ? '☀' : '☾'; b.title = 'Switch to '+(t==='light'?'dark':'light')+' mode'; }
|
|
1529
|
+
}
|
|
1530
|
+
function toggleTheme(){
|
|
1531
|
+
const cur=document.documentElement.getAttribute('data-theme')==='light'?'light':'dark';
|
|
1532
|
+
const next=cur==='light'?'dark':'light';
|
|
1533
|
+
document.documentElement.setAttribute('data-theme',next);
|
|
1534
|
+
try{ localStorage.setItem('omcp-theme',next); }catch(e){}
|
|
1535
|
+
syncThemeToggle();
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// --- Navigation groups (collapsible, persisted) ---
|
|
1539
|
+
function navState(){ try{ return JSON.parse(localStorage.getItem('omcp-nav')||'{}'); }catch(e){ return {}; } }
|
|
1540
|
+
function toggleNavGroup(id){
|
|
1541
|
+
const g=document.querySelector('.rail-grp[data-grp="'+id+'"]'); if(!g) return;
|
|
1542
|
+
const collapsed=g.classList.toggle('collapsed');
|
|
1543
|
+
const s=navState(); s[id]=collapsed;
|
|
1544
|
+
try{ localStorage.setItem('omcp-nav',JSON.stringify(s)); }catch(e){}
|
|
1545
|
+
}
|
|
1546
|
+
function initNav(){
|
|
1547
|
+
const s=navState();
|
|
1548
|
+
document.querySelectorAll('.rail-grp').forEach(g=>{
|
|
1549
|
+
if(s[g.getAttribute('data-grp')]===true) g.classList.add('collapsed');
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
// --- Nav items with sub-points (Connectors, Settings) ---
|
|
1553
|
+
function navToggle(name){
|
|
1554
|
+
const it=document.querySelector('.rail-item[data-nav="'+name+'"]');
|
|
1555
|
+
if(it) it.classList.toggle('open');
|
|
1556
|
+
}
|
|
1557
|
+
function setActiveSub(name, sub){
|
|
1558
|
+
const it=document.querySelector('.rail-item[data-nav="'+name+'"]'); if(!it) return;
|
|
1559
|
+
it.querySelectorAll('.nav-sub').forEach(b=>b.classList.toggle('active', b.getAttribute('data-sub')===sub));
|
|
1560
|
+
}
|
|
1561
|
+
function goTab(name, sub){
|
|
1562
|
+
showPage(name);
|
|
1563
|
+
const el=document.getElementById('tab-'+sub); if(el){
|
|
1564
|
+
const scope=el.closest('.card')||document;
|
|
1565
|
+
scope.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
|
|
1566
|
+
scope.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|
1567
|
+
el.classList.add('active');
|
|
1568
|
+
scope.querySelectorAll('.tab-btn').forEach(b=>{ if((b.getAttribute('onclick')||'').indexOf("showTab('"+sub+"')")>=0) b.classList.add('active'); });
|
|
1569
|
+
if(sub==='metrics') populateMetricsSourceSelect();
|
|
1570
|
+
}
|
|
1571
|
+
setActiveSub(name, sub);
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// --- Header notification feed (collapsible, acknowledgeable) ---
|
|
1575
|
+
function notifAck(){ try{ return JSON.parse(localStorage.getItem('omcp-ack')||'{}'); }catch(e){ return {}; } }
|
|
1576
|
+
function notifAckSave(o){ try{ localStorage.setItem('omcp-ack',JSON.stringify(o)); }catch(e){} }
|
|
1577
|
+
function notifSeen(){ try{ return JSON.parse(localStorage.getItem('omcp-seen')||'{}'); }catch(e){ return {}; } }
|
|
1578
|
+
function notifSeenSave(o){ try{ localStorage.setItem('omcp-seen',JSON.stringify(o)); }catch(e){} }
|
|
1579
|
+
let notifItems=[];
|
|
1580
|
+
function relTime(iso){
|
|
1581
|
+
const d=Date.now()-new Date(iso).getTime(); const m=Math.floor(d/60000);
|
|
1582
|
+
if(m<1) return 'just now'; if(m<60) return m+'m ago';
|
|
1583
|
+
const h=Math.floor(m/60); if(h<24) return h+'h ago'; return Math.floor(h/24)+'d ago';
|
|
1584
|
+
}
|
|
1585
|
+
async function buildNotifications(){
|
|
1586
|
+
const out=[];
|
|
1587
|
+
// Sources unreachable
|
|
1588
|
+
for(const s of (sourcesData||[])) if(s.enabled && s.status!=='up')
|
|
1589
|
+
out.push({id:'src:'+s.name, sev:'bad', text:`Source “${s.name}” is unreachable.`});
|
|
1590
|
+
// Service health
|
|
1591
|
+
try{
|
|
1592
|
+
const h=await(await fetch('/api/health')).json();
|
|
1593
|
+
for(const v of Object.values(h||{})) if(v && v.status!=='healthy')
|
|
1594
|
+
out.push({id:'svc:'+v.service+':'+v.status, sev:v.status==='critical'?'bad':'warn', text:`Service “${v.service}” is ${v.status} (health score ${v.score}).`});
|
|
1595
|
+
}catch(e){}
|
|
1596
|
+
// Gate / entitlement
|
|
1597
|
+
try{
|
|
1598
|
+
const g=await(await fetch('/api/enterprise/status')).json();
|
|
1599
|
+
if(g.mode==='fail-closed') out.push({id:'gate:fc', sev:'bad', text:`Access-control gate is fail-closed${g.reason?': '+g.reason:''}.`});
|
|
1600
|
+
}catch(e){}
|
|
1601
|
+
// Audit chain integrity
|
|
1602
|
+
try{
|
|
1603
|
+
const a=await(await fetch('/api/enterprise/audit?limit=1')).json();
|
|
1604
|
+
if(a.configured && a.chain && a.chain.ok===false) out.push({id:'audit:broken', sev:'bad', text:'Audit log chain integrity check failed.'});
|
|
1605
|
+
}catch(e){}
|
|
1606
|
+
// Stamp first-seen times; prune stale seen entries.
|
|
1607
|
+
const seen=notifSeen(), now=new Date().toISOString(), live={};
|
|
1608
|
+
for(const n of out){ if(!seen[n.id]) seen[n.id]=now; n.ts=seen[n.id]; live[n.id]=seen[n.id]; }
|
|
1609
|
+
notifSeenSave(live);
|
|
1610
|
+
notifItems=out.sort((a,b)=> (a.ts<b.ts?1:-1));
|
|
1611
|
+
renderNotif();
|
|
1612
|
+
}
|
|
1613
|
+
function renderNotif(){
|
|
1614
|
+
const ack=notifAck();
|
|
1615
|
+
const active=notifItems.filter(n=>!ack[n.id]);
|
|
1616
|
+
const cnt=document.getElementById('notif-count'); const list=document.getElementById('notif-list');
|
|
1617
|
+
if(cnt){ if(active.length){ cnt.hidden=false; cnt.textContent=String(active.length); } else cnt.hidden=true; }
|
|
1618
|
+
if(!list) return;
|
|
1619
|
+
list.innerHTML = active.length===0
|
|
1620
|
+
? '<div class="notif-empty">You’re all caught up — no notifications.</div>'
|
|
1621
|
+
: active.map(n=>`<div class="notif-item"><span class="notif-sev ${n.sev}"></span><div class="notif-body"><div class="notif-text">${escHtml(n.text)}</div><div class="notif-time">${relTime(n.ts)}</div></div><button class="notif-ack" title="Mark read" onclick="notifDo('${escHtml(n.id)}')">✓</button></div>`).join('');
|
|
1622
|
+
}
|
|
1623
|
+
function notifDo(id){ const a=notifAck(); a[id]=true; notifAckSave(a); renderNotif(); }
|
|
1624
|
+
function notifAckAll(){ const a=notifAck(); notifItems.forEach(n=>a[n.id]=true); notifAckSave(a); renderNotif(); }
|
|
1625
|
+
function toggleNotif(){
|
|
1626
|
+
const p=document.getElementById('notif-panel'); if(!p) return;
|
|
1627
|
+
p.hidden=!p.hidden;
|
|
1628
|
+
if(!p.hidden){ document.addEventListener('click', notifOutside, true); }
|
|
1629
|
+
else document.removeEventListener('click', notifOutside, true);
|
|
1630
|
+
}
|
|
1631
|
+
function notifOutside(e){
|
|
1632
|
+
const w=document.querySelector('.notif-wrap'); if(w && !w.contains(e.target)){
|
|
1633
|
+
const p=document.getElementById('notif-panel'); if(p) p.hidden=true;
|
|
1634
|
+
document.removeEventListener('click', notifOutside, true);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// --- Inline "?" info popovers + copyable CLI/API blocks ---
|
|
1639
|
+
function infoPop(btn){
|
|
1640
|
+
let p=document.getElementById('info-pop');
|
|
1641
|
+
if(!p){ p=document.createElement('div'); p.id='info-pop'; p.className='popover'; p.hidden=true; document.body.appendChild(p); }
|
|
1642
|
+
if(p._owner===btn && !p.hidden){ p.hidden=true; p._owner=null; return; }
|
|
1643
|
+
p.innerHTML=(btn.dataset.title?`<h4>${escHtml(btn.dataset.title)}</h4>`:'')+`<div>${escHtml(btn.dataset.info||'')}</div>`;
|
|
1644
|
+
p.hidden=false; p._owner=btn;
|
|
1645
|
+
const r=btn.getBoundingClientRect();
|
|
1646
|
+
p.style.left=Math.max(8,Math.min(window.innerWidth-308, r.left))+'px';
|
|
1647
|
+
p.style.top=(r.bottom+8)+'px';
|
|
1648
|
+
setTimeout(()=>document.addEventListener('click', infoOutside, true),0);
|
|
1649
|
+
}
|
|
1650
|
+
function infoOutside(e){
|
|
1651
|
+
const p=document.getElementById('info-pop');
|
|
1652
|
+
if(p && !p.hidden && !p.contains(e.target) && !(e.target.classList&&e.target.classList.contains('info'))){
|
|
1653
|
+
p.hidden=true; p._owner=null; document.removeEventListener('click', infoOutside, true);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
function copyCode(btn){
|
|
1657
|
+
const pre=btn.closest('.codeblock').querySelector('pre');
|
|
1658
|
+
navigator.clipboard.writeText(pre.innerText).then(()=>toast('Copied to clipboard'));
|
|
1659
|
+
}
|
|
1660
|
+
function toggleCode(hd){ hd.closest('.codeblock').classList.toggle('collapsed'); }
|
|
1661
|
+
|
|
1662
|
+
// --- Detail slide-over (clickable elements → inspector) ---
|
|
1663
|
+
function openDrawer(title, html){
|
|
1664
|
+
_edRestore();
|
|
1665
|
+
document.getElementById('drawer-title').textContent=title;
|
|
1666
|
+
document.getElementById('drawer-body').innerHTML=html;
|
|
1667
|
+
document.getElementById('drawer').classList.add('open');
|
|
1668
|
+
document.getElementById('drawer-ov').classList.add('open');
|
|
1669
|
+
document.addEventListener('keydown', drawerEsc);
|
|
1670
|
+
}
|
|
1671
|
+
function closeDrawer(){
|
|
1672
|
+
document.getElementById('drawer').classList.remove('open');
|
|
1673
|
+
document.getElementById('drawer-ov').classList.remove('open');
|
|
1674
|
+
document.removeEventListener('keydown', drawerEsc);
|
|
1675
|
+
_edRestore();
|
|
1676
|
+
}
|
|
1677
|
+
function drawerEsc(e){ if(e.key==='Escape') closeDrawer(); }
|
|
1678
|
+
// --- Edit panels live in the slide-over (clean browse view, focused edit) ---
|
|
1679
|
+
let _edNode=null, _edHost=null;
|
|
1680
|
+
function entEditNew(kind){ entEditOpen(kind, true); }
|
|
1681
|
+
function entEditOpen(kind, seedNew){
|
|
1682
|
+
const id = kind==='cat' ? 'ent-cat-editor' : 'ent-rbac-editor';
|
|
1683
|
+
const node=document.getElementById(id); if(!node) return;
|
|
1684
|
+
const after = seedNew ? (kind==='cat'?catAddProduct:rbacAddRole) : null;
|
|
1685
|
+
if(kind==='cat') entToggleCatEdit(true, after); else entToggleRbacEdit(true, after);
|
|
1686
|
+
_edNode=node; _edHost=node.parentNode;
|
|
1687
|
+
const body=document.getElementById('drawer-body');
|
|
1688
|
+
body.innerHTML=''; body.appendChild(node); node.classList.remove('hidden');
|
|
1689
|
+
document.getElementById('drawer-title').textContent = kind==='cat' ? 'Edit catalog' : 'Edit access policy';
|
|
1690
|
+
const dw=document.getElementById('drawer');
|
|
1691
|
+
dw.classList.add('drawer-wide','open');
|
|
1692
|
+
document.getElementById('drawer-ov').classList.add('open');
|
|
1693
|
+
document.addEventListener('keydown', drawerEsc);
|
|
1694
|
+
}
|
|
1695
|
+
function _edRestore(){
|
|
1696
|
+
if(_edNode&&_edHost){ _edNode.classList.add('hidden'); _edHost.appendChild(_edNode); _edNode=null; _edHost=null; }
|
|
1697
|
+
document.getElementById('drawer').classList.remove('drawer-wide');
|
|
1698
|
+
['ent-rbac-editbtn','ent-cat-editbtn'].forEach(i=>{ const b=document.getElementById(i); if(b) b.classList.remove('hidden'); });
|
|
1699
|
+
}
|
|
1700
|
+
function dwChips(a){ return (a&&a.length?a:['*']).map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''); }
|
|
1701
|
+
function dwSec(lbl, inner){ return `<div class="dw-sec"><div class="dw-lbl">${escHtml(lbl)}</div><div>${inner}</div></div>`; }
|
|
1702
|
+
|
|
1703
|
+
// --- Data Products view (cards / table toggle + search) ---
|
|
1704
|
+
let dpData=null;
|
|
1705
|
+
let healthMap={};
|
|
1706
|
+
let auditMap={};
|
|
1707
|
+
function audDetail(seq){
|
|
1708
|
+
const e=auditMap[seq]; if(!e){ openDrawer('Decision #'+seq,'<div class="empty">No detail.</div>'); return; }
|
|
1709
|
+
const ev=e.event||{}, req=ev.request||{};
|
|
1710
|
+
const tone=ev.allow?'ok':'bad';
|
|
1711
|
+
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>';
|
|
1712
|
+
const html=
|
|
1713
|
+
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>`)+
|
|
1714
|
+
dwSec('Principal & reason', `<table class="dtable"><tbody>
|
|
1715
|
+
<tr><td>Principal</td><td class="mono">${escHtml(ev.principalId||'—')}</td></tr>
|
|
1716
|
+
<tr><td>Timestamp</td><td class="mono">${escHtml(ev.ts||e.ts||'—')}</td></tr>
|
|
1717
|
+
<tr><td>Reason</td><td>${escHtml(ev.reason||'—')}</td></tr></tbody></table>`)+
|
|
1718
|
+
dwSec('Request', `<table class="dtable"><tbody>${reqRows}</tbody></table>`)+
|
|
1719
|
+
dwSec('Chain integrity', `<table class="dtable"><tbody>
|
|
1720
|
+
<tr><td>Entry hash</td><td class="mono" style="word-break:break-all">${escHtml(e.hash||'—')}</td></tr>
|
|
1721
|
+
<tr><td>Prev hash</td><td class="mono" style="word-break:break-all">${escHtml(e.prevHash||'—')}</td></tr></tbody></table>`);
|
|
1722
|
+
openDrawer('Decision #'+seq, html);
|
|
1723
|
+
}
|
|
1724
|
+
function dpView(){ try{ return localStorage.getItem('omcp-dp-view')==='table'?'table':'cards'; }catch(e){ return 'cards'; } }
|
|
1725
|
+
function dpSetView(v){ try{ localStorage.setItem('omcp-dp-view',v); }catch(e){} dpRender(); }
|
|
1726
|
+
function dpOnSearch(){ dpRender(); }
|
|
1727
|
+
function dpRender(){
|
|
1728
|
+
const box=document.getElementById('ent-catalog'); if(!box||!dpData) return;
|
|
1729
|
+
const products=dpData.products||{}, grants=dpData.grants||{};
|
|
1730
|
+
// reverse map: product -> [principals]
|
|
1731
|
+
const grantedTo={};
|
|
1732
|
+
for(const [pr,list] of Object.entries(grants)) (list||[]).forEach(pn=>{ (grantedTo[pn]=grantedTo[pn]||[]).push(pr); });
|
|
1733
|
+
const q=(document.getElementById('dp-q')?document.getElementById('dp-q').value:'').trim().toLowerCase();
|
|
1734
|
+
let entries=Object.entries(products);
|
|
1735
|
+
if(q) entries=entries.filter(([n,p])=>(n+' '+((p&&p.description)||'')).toLowerCase().includes(q));
|
|
1736
|
+
const view=dpView(), total=Object.keys(products).length, shown=entries.length;
|
|
1737
|
+
const chips=a=>(a&&a.length?a:['*']).map(x=>`<span class="chip">${escHtml(x)}</span>`).join('');
|
|
1738
|
+
const bar=`<div class="dp-bar">
|
|
1739
|
+
<span class="dp-count"><b>${shown}</b>${shown!==total?` / ${total}`:''} product${total===1?'':'s'}</span>
|
|
1740
|
+
<span class="dp-search"><input id="dp-q" type="search" placeholder="Search products…" value="${escHtml(q)}" oninput="dpOnSearch()"></span>
|
|
1741
|
+
<span class="dp-spacer"></span>
|
|
1742
|
+
<span class="view-toggle">
|
|
1743
|
+
<button class="${view==='cards'?'active':''}" title="Cards" onclick="dpSetView('cards')">▦ Cards</button>
|
|
1744
|
+
<button class="${view==='table'?'active':''}" title="Table" onclick="dpSetView('table')">☰ Table</button>
|
|
1745
|
+
</span>
|
|
1746
|
+
</div>`;
|
|
1747
|
+
let body;
|
|
1748
|
+
if(shown===0){ body='<div class="empty">No products match the filter.</div>'; }
|
|
1749
|
+
else if(view==='table'){
|
|
1750
|
+
body=`<table class="dtable"><thead><tr><th>Product</th><th>Description</th><th>Sources</th><th>Services</th><th>Tools</th><th>Granted to</th></tr></thead><tbody>`+
|
|
1751
|
+
entries.map(([n,p])=>{ const g=grantedTo[n]||[]; return `<tr data-dp="${escHtml(n)}"><td class="mono">${escHtml(n)}</td><td>${escHtml((p&&p.description)||'—')}</td><td>${chips(p&&p.sources)}</td><td>${chips(p&&p.services)}</td><td>${chips(p&&p.tools)}</td><td>${g.length?`<span title="${escHtml(g.join(', '))}">${g.length} principal${g.length===1?'':'s'}</span>`:'—'}</td></tr>`; }).join('')+
|
|
1752
|
+
`</tbody></table>`;
|
|
1753
|
+
} else {
|
|
1754
|
+
body='<div class="dp-grid">'+entries.map(([n,p])=>{ const g=grantedTo[n]||[];
|
|
1755
|
+
return `<div class="dp-card" data-dp="${escHtml(n)}"><h3>${escHtml(n)}</h3>
|
|
1756
|
+
<div class="dp-desc">${escHtml((p&&p.description)||'No description provided.')}</div>
|
|
1757
|
+
<div class="dp-attr"><span class="lbl">Sources</span>${chips(p&&p.sources)}</div>
|
|
1758
|
+
<div class="dp-attr"><span class="lbl">Services</span>${chips(p&&p.services)}</div>
|
|
1759
|
+
<div class="dp-attr"><span class="lbl">Tools</span>${chips(p&&p.tools)}</div>
|
|
1760
|
+
<div class="dp-foot"><span class="dp-grants">${g.length?`Granted to ${g.length} principal${g.length===1?'':'s'}`:'No grants'}</span></div>
|
|
1761
|
+
</div>`; }).join('')+'</div>';
|
|
1762
|
+
}
|
|
1763
|
+
box.innerHTML=bar+body;
|
|
1764
|
+
box.querySelectorAll('[data-dp]').forEach(el=>el.addEventListener('click',()=>dpDetail(el.getAttribute('data-dp'))));
|
|
1765
|
+
const inp=document.getElementById('dp-q'); if(inp&&q){ inp.focus(); inp.setSelectionRange(q.length,q.length); }
|
|
1766
|
+
}
|
|
1767
|
+
function dpDetail(name){
|
|
1768
|
+
if(!dpData) return;
|
|
1769
|
+
const p=(dpData.products||{})[name]||{};
|
|
1770
|
+
const grantedTo=[]; for(const [pr,list] of Object.entries(dpData.grants||{})) if((list||[]).includes(name)) grantedTo.push(pr);
|
|
1771
|
+
const ex=`curl -X PUT http://localhost:3000/api/enterprise/catalog \\
|
|
1772
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" -H "Content-Type: application/json" \\
|
|
1773
|
+
-d '{"grants":{"<principal>":["${escHtml(name)}"]}}'`;
|
|
1774
|
+
const html=
|
|
1775
|
+
dwSec('Description', `<div style="font-size:var(--fs-sm);color:var(--text-muted)">${escHtml(p.description||'No description provided.')}</div>`)+
|
|
1776
|
+
dwSec('Sources', dwChips(p.sources))+
|
|
1777
|
+
dwSec('Services', dwChips(p.services))+
|
|
1778
|
+
dwSec('Tools', dwChips(p.tools))+
|
|
1779
|
+
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>')+
|
|
1780
|
+
`<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>`;
|
|
1781
|
+
openDrawer(name, html);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// --- Enterprise (read-only governance console) ---
|
|
1785
|
+
const GATE_CLASS={off:'gate-off',active:'gate-active','fail-closed':'gate-failclosed'};
|
|
1786
|
+
async function loadGatePill(){
|
|
1787
|
+
const el=document.getElementById('gate-pill'); if(!el) return;
|
|
1788
|
+
try{
|
|
1789
|
+
const s=await(await fetch('/api/enterprise/status')).json();
|
|
1790
|
+
el.className='gate-pill '+(GATE_CLASS[s.mode]||'gate-unknown');
|
|
1791
|
+
el.textContent='Gate: '+(s.mode||'unknown');
|
|
1792
|
+
el.title=s.reason?('Access-control gate — '+s.reason):'Access-control gate';
|
|
1793
|
+
}catch{ el.className='gate-pill gate-unknown'; el.textContent='Gate: n/a'; }
|
|
1794
|
+
}
|
|
1795
|
+
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>';}
|
|
1796
|
+
async function loadEnterprise(){
|
|
1797
|
+
// Status + entitlement claims
|
|
1798
|
+
try{
|
|
1799
|
+
const s=await(await fetch('/api/enterprise/status')).json();
|
|
1800
|
+
document.getElementById('ent-mode').innerHTML=`<span class="gate-pill ${GATE_CLASS[s.mode]||'gate-unknown'}">${escHtml(s.mode)}</span>`;
|
|
1801
|
+
const rows={ 'Mode':s.mode, 'Active':s.active?'yes':'no' };
|
|
1802
|
+
if(s.reason) rows['Reason']=s.reason;
|
|
1803
|
+
rows['RBAC configured']=s.rbacConfigured?'yes':'no';
|
|
1804
|
+
rows['Catalog configured']=s.catalogConfigured?'yes':'no';
|
|
1805
|
+
rows['Audit configured']=s.auditConfigured?'yes':'no';
|
|
1806
|
+
let html=entKV(rows);
|
|
1807
|
+
if(s.entitlement){
|
|
1808
|
+
const e=s.entitlement, fmt=t=>t?new Date(t*1000).toISOString().replace('T',' ').slice(0,19)+'Z':'—';
|
|
1809
|
+
html+='<div style="margin-top:14px;color:var(--text-dim);font-size:11px;letter-spacing:.08em;text-transform:uppercase">Entitlement claims (signed, non-secret)</div>'+
|
|
1810
|
+
entKV({Subject:e.sub??'—',Tier:e.tier??'—',Features:(e.features||[]).join(', ')||'—',Issued:fmt(e.iat),Expires:fmt(e.exp)});
|
|
1811
|
+
}
|
|
1812
|
+
document.getElementById('ent-status').innerHTML=html;
|
|
1813
|
+
}catch(e){ document.getElementById('ent-status').innerHTML='<div class="empty">Status unavailable.</div>'; }
|
|
1814
|
+
// RBAC policy (read-only)
|
|
1815
|
+
try{
|
|
1816
|
+
const p=await(await fetch('/api/enterprise/policy')).json();
|
|
1817
|
+
const box=document.getElementById('ent-rbac');
|
|
1818
|
+
if(!p.configured){ box.innerHTML='<div class="empty">No RBAC policy configured.</div>'; }
|
|
1819
|
+
else if(p.error){ box.innerHTML=`<div class="empty">Policy unreadable: ${escHtml(p.error)}</div>`; }
|
|
1820
|
+
else {
|
|
1821
|
+
const roles=p.data.roles||{}, binds=p.data.bindings||{};
|
|
1822
|
+
const rt=Object.entries(roles).map(([n,r])=>`<tr><td class="mono">${escHtml(n)}</td><td>${(r.tools||[]).map(x=>`<span class="chip">${escHtml(x)}</span>`).join('')||'—'}</td><td>${r.readOnly?'<span class="pill-yes">read-only</span>':'rw'}</td></tr>`).join('');
|
|
1823
|
+
const bt=Object.entries(binds).map(([k,v])=>`<tr><td class="mono">${escHtml(k)}</td><td>${(v||[]).map(x=>`<span class="chip">${escHtml(x)}</span>`).join('')}</td></tr>`).join('');
|
|
1824
|
+
box.innerHTML=`<table class="dtable"><thead><tr><th>Role</th><th>Tools</th><th>Mode</th></tr></thead><tbody>${rt||'<tr><td colspan=3>—</td></tr>'}</tbody></table>
|
|
1825
|
+
<div style="margin:12px 0 6px;color:var(--text-dim);font-size:10px;letter-spacing:.08em;text-transform:uppercase">Bindings</div>
|
|
1826
|
+
<table class="dtable"><thead><tr><th>Principal</th><th>Roles</th></tr></thead><tbody>${bt||'<tr><td colspan=2>—</td></tr>'}</tbody></table>`;
|
|
1827
|
+
}
|
|
1828
|
+
}catch{ document.getElementById('ent-rbac').innerHTML='<div class="empty">Policy unavailable.</div>'; }
|
|
1829
|
+
// Data Products — rich, toggleable (cards / table) + search
|
|
1830
|
+
try{
|
|
1831
|
+
const c=await(await fetch('/api/enterprise/catalog')).json();
|
|
1832
|
+
const box=document.getElementById('ent-catalog');
|
|
1833
|
+
if(!c.configured){ box.innerHTML='<div class="empty">No product catalog configured.</div>'; dpData=null; }
|
|
1834
|
+
else if(c.error){ box.innerHTML=`<div class="empty">Catalog unreadable: ${escHtml(c.error)}</div>`; dpData=null; }
|
|
1835
|
+
else { dpData=c.data||{products:{},grants:{}}; dpRender(); }
|
|
1836
|
+
}catch{ const b=document.getElementById('ent-catalog'); if(b) b.innerHTML='<div class="empty">Catalog unavailable.</div>'; }
|
|
1837
|
+
// Audit tail + chain integrity
|
|
1838
|
+
try{
|
|
1839
|
+
const a=await(await fetch('/api/enterprise/audit?limit=25')).json();
|
|
1840
|
+
const box=document.getElementById('ent-audit'), chip=document.getElementById('ent-chain');
|
|
1841
|
+
if(!a.configured){ box.innerHTML='<div class="empty">No audit log configured.</div>'; chip.innerHTML=''; }
|
|
1842
|
+
else if(a.error){ box.innerHTML=`<div class="empty">Audit unreadable: ${escHtml(a.error)}</div>`; }
|
|
1843
|
+
else {
|
|
1844
|
+
const ok=a.chain&&a.chain.ok===true;
|
|
1845
|
+
chip.innerHTML=`<span class="gate-pill ${ok?'gate-active':'gate-failclosed'}">chain ${ok?'intact':'broken'}</span>`;
|
|
1846
|
+
auditMap={};
|
|
1847
|
+
const rows=(a.entries||[]).slice().reverse().map(e=>{
|
|
1848
|
+
const ev=e.event||{}; auditMap[e.seq]=e;
|
|
1849
|
+
return `<tr data-aud="${escHtml(String(e.seq))}"><td class="mono">${e.seq}</td><td class="mono">${escHtml(ev.principalId)}</td><td>${escHtml((ev.request||{}).tool)}</td><td>${ev.allow?'<span class="pill-yes">allow</span>':'<span class="pill-no">deny</span>'}</td><td style="color:var(--text-muted)">${escHtml(ev.reason||'')}</td></tr>`;
|
|
1850
|
+
}).join('');
|
|
1851
|
+
box.innerHTML=`<div style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${a.total} total decisions · showing latest ${(a.entries||[]).length} · click a row for detail</div>
|
|
1852
|
+
<table class="dtable"><thead><tr><th>#</th><th>Principal</th><th>Tool</th><th>Decision</th><th>Reason</th></tr></thead><tbody>${rows||'<tr><td colspan=5>no decisions yet</td></tr>'}</tbody></table>`;
|
|
1853
|
+
box.querySelectorAll('[data-aud]').forEach(el=>el.addEventListener('click',()=>audDetail(el.getAttribute('data-aud'))));
|
|
1854
|
+
}
|
|
1855
|
+
}catch{ document.getElementById('ent-audit').innerHTML='<div class="empty">Audit unavailable.</div>'; }
|
|
1856
|
+
}
|
|
1857
|
+
// ---- Minimal YAML for the flat policy/catalog schema (maps, string
|
|
1858
|
+
// arrays, booleans, strings). Optional alternative view to the form. ----
|
|
1859
|
+
function yQuote(s){
|
|
1860
|
+
if(s==='') return '""';
|
|
1861
|
+
if(/^[A-Za-z0-9_.\-\/*]+$/.test(s) && !/^(true|false|null|~)$/i.test(s) && !/^-?\d/.test(s)) return s;
|
|
1862
|
+
return '"'+String(s).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"';
|
|
1863
|
+
}
|
|
1864
|
+
function yamlDump(v, ind){
|
|
1865
|
+
ind=ind||0; const pad=' '.repeat(ind); let out='';
|
|
1866
|
+
if(Array.isArray(v)){
|
|
1867
|
+
if(v.length===0) return ' []\n';
|
|
1868
|
+
out='\n'; for(const it of v) out+=pad+'- '+yQuote(String(it))+'\n'; return out;
|
|
1869
|
+
}
|
|
1870
|
+
if(v && typeof v==='object'){
|
|
1871
|
+
const ks=Object.keys(v); if(ks.length===0) return ' {}\n';
|
|
1872
|
+
out=(ind?'\n':''); for(const k of ks){
|
|
1873
|
+
const val=v[k];
|
|
1874
|
+
if(val && typeof val==='object'){ out+=pad+yQuote(k)+':'+yamlDump(val,ind+2); }
|
|
1875
|
+
else out+=pad+yQuote(k)+': '+(typeof val==='boolean'?String(val):yQuote(String(val)))+'\n';
|
|
1876
|
+
}
|
|
1877
|
+
return out;
|
|
1878
|
+
}
|
|
1879
|
+
return ' '+(typeof v==='boolean'?String(v):yQuote(String(v)))+'\n';
|
|
1880
|
+
}
|
|
1881
|
+
function yUnq(s){ s=s.trim();
|
|
1882
|
+
if(s==='[]') return []; if(s==='{}') return {};
|
|
1883
|
+
if(s==='true') return true; if(s==='false') return false;
|
|
1884
|
+
if(s.length>1 && s[0]==='"' && s[s.length-1]==='"') return s.slice(1,-1).replace(/\\"/g,'"').replace(/\\\\/g,'\\');
|
|
1885
|
+
return s;
|
|
1886
|
+
}
|
|
1887
|
+
function yamlParse(text){
|
|
1888
|
+
const raw=text.replace(/\t/g,' ').split('\n')
|
|
1889
|
+
.filter(l=>l.trim()!=='' && !/^\s*#/.test(l))
|
|
1890
|
+
.map(l=>({ind:l.match(/^ */)[0].length, t:l.trim()}));
|
|
1891
|
+
let i=0;
|
|
1892
|
+
function block(minInd){
|
|
1893
|
+
if(i>=raw.length || raw[i].ind<minInd) return null;
|
|
1894
|
+
const ind=raw[i].ind;
|
|
1895
|
+
if(raw[i].t.startsWith('- ')){
|
|
1896
|
+
const arr=[]; while(i<raw.length && raw[i].ind===ind && raw[i].t.startsWith('- ')){ arr.push(yUnq(raw[i].t.slice(2))); i++; } return arr;
|
|
1897
|
+
}
|
|
1898
|
+
const obj={};
|
|
1899
|
+
while(i<raw.length && raw[i].ind===ind && !raw[i].t.startsWith('- ')){
|
|
1900
|
+
const line=raw[i].t, c=line.indexOf(':');
|
|
1901
|
+
if(c<0) throw new Error('invalid YAML line: '+line);
|
|
1902
|
+
const key=yUnq(line.slice(0,c)), rest=line.slice(c+1).trim();
|
|
1903
|
+
i++;
|
|
1904
|
+
if(rest!=='' ){ obj[key]=yUnq(rest); }
|
|
1905
|
+
else { const child=block(ind+1); obj[key]= child===null ? {} : child; }
|
|
1906
|
+
}
|
|
1907
|
+
return obj;
|
|
1908
|
+
}
|
|
1909
|
+
const r=block(0); if(i<raw.length) throw new Error('unexpected indentation near: '+raw[i].t);
|
|
1910
|
+
return r||{};
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ---- RBAC editor: Form (primary) + JSON / YAML alternative views ----
|
|
1914
|
+
let rbacModel={roles:{},bindings:{},defaultRoles:[]};
|
|
1915
|
+
let rbacCur='form';
|
|
1916
|
+
function entToggleRbacEdit(on, after){
|
|
1917
|
+
const ed=document.getElementById('ent-rbac-editor'), btn=document.getElementById('ent-rbac-editbtn');
|
|
1918
|
+
document.getElementById('ent-rbac-msg').textContent='';
|
|
1919
|
+
if(on){
|
|
1920
|
+
fetch('/api/enterprise/policy').then(r=>r.json()).then(p=>{
|
|
1921
|
+
const d=(p&&p.configured&&p.data)?p.data:{};
|
|
1922
|
+
rbacModel={roles:d.roles||{}, bindings:d.bindings||{}, defaultRoles:d.defaultRoles||[]};
|
|
1923
|
+
rbacCur='form'; rbacSetView('form');
|
|
1924
|
+
if(typeof after==='function') after();
|
|
1925
|
+
});
|
|
1926
|
+
ed.classList.remove('hidden'); btn.classList.add('hidden');
|
|
1927
|
+
} else { ed.classList.add('hidden'); btn.classList.remove('hidden'); }
|
|
1928
|
+
}
|
|
1929
|
+
function csv(a){ return (a||[]).join(', '); }
|
|
1930
|
+
function fromCsv(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Boolean); }
|
|
1931
|
+
function rbacRenderForm(){
|
|
1932
|
+
const m=rbacModel; let h='';
|
|
1933
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Roles</span><button class="fe-add" onclick="rbacAddRole()">+ Add role</button></div>`;
|
|
1934
|
+
const rk=Object.keys(m.roles);
|
|
1935
|
+
h+= rk.length?rk.map(rn=>{ const r=m.roles[rn]||{}; return `<div class="fe-card" data-role>
|
|
1936
|
+
<div class="fe-cardhd"><input type="text" class="r-name fe-grow" value="${escHtml(rn)}" placeholder="role name"><label class="fe-il"><input type="checkbox" class="r-ro" ${r.readOnly?'checked':''}> read-only</label><button class="fe-x" title="Remove role" onclick="this.closest('.fe-card').remove()">×</button></div>
|
|
1937
|
+
<div class="fe-fieldlbl">Tools (comma-separated · * = all)</div><input type="text" class="r-tools" value="${escHtml(csv(r.tools))}" placeholder="query_metrics, get_service_health">
|
|
1938
|
+
<div class="fe-fieldlbl">Sources</div><input type="text" class="r-sources" value="${escHtml(csv(r.sources))}" placeholder="* or prometheus, loki">
|
|
1939
|
+
<div class="fe-fieldlbl">Services</div><input type="text" class="r-services" value="${escHtml(csv(r.services))}" placeholder="* or payment-service">
|
|
1940
|
+
</div>`; }).join(''):'<div class="form-hint">No roles yet.</div>';
|
|
1941
|
+
h+=`</div>`;
|
|
1942
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Bindings (principal → roles)</span><button class="fe-add" onclick="rbacAddBinding()">+ Add binding</button></div>`;
|
|
1943
|
+
const bk=Object.keys(m.bindings);
|
|
1944
|
+
h+= bk.length?bk.map(pn=>`<div class="fe-row" data-bind>
|
|
1945
|
+
<input type="text" class="b-pr fe-grow" value="${escHtml(pn)}" placeholder="principal (API-key name)">
|
|
1946
|
+
<input type="text" class="b-roles fe-grow" value="${escHtml(csv(m.bindings[pn]))}" placeholder="role1, role2">
|
|
1947
|
+
<button class="fe-x" onclick="this.closest('.fe-row').remove()">×</button></div>`).join(''):'<div class="form-hint">No bindings yet.</div>';
|
|
1948
|
+
h+=`</div>`;
|
|
1949
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Default roles (unbound principals)</span></div>
|
|
1950
|
+
<input type="text" id="r-defaults" value="${escHtml(csv(m.defaultRoles))}" placeholder="empty = default-deny"></div>`;
|
|
1951
|
+
document.getElementById('rbac-form').innerHTML=h;
|
|
1952
|
+
}
|
|
1953
|
+
function rbacAddRole(){ rbacCollectForm(); rbacModel.roles['new-role']={tools:[],sources:['*'],services:['*']}; rbacRenderForm(); }
|
|
1954
|
+
function rbacAddBinding(){ rbacCollectForm(); rbacModel.bindings['principal']=[]; rbacRenderForm(); }
|
|
1955
|
+
function rbacCollectForm(){
|
|
1956
|
+
const roles={}, bindings={};
|
|
1957
|
+
document.querySelectorAll('#rbac-form [data-role]').forEach(c=>{
|
|
1958
|
+
const n=c.querySelector('.r-name').value.trim(); if(!n) return;
|
|
1959
|
+
const r={tools:fromCsv(c.querySelector('.r-tools').value), sources:fromCsv(c.querySelector('.r-sources').value), services:fromCsv(c.querySelector('.r-services').value)};
|
|
1960
|
+
if(c.querySelector('.r-ro').checked) r.readOnly=true;
|
|
1961
|
+
roles[n]=r;
|
|
1962
|
+
});
|
|
1963
|
+
document.querySelectorAll('#rbac-form [data-bind]').forEach(c=>{
|
|
1964
|
+
const p=c.querySelector('.b-pr').value.trim(); if(!p) return;
|
|
1965
|
+
bindings[p]=fromCsv(c.querySelector('.b-roles').value);
|
|
1966
|
+
});
|
|
1967
|
+
const dr=document.getElementById('r-defaults');
|
|
1968
|
+
rbacModel={roles,bindings,defaultRoles:dr?fromCsv(dr.value):[]};
|
|
1969
|
+
return rbacModel;
|
|
1970
|
+
}
|
|
1971
|
+
function rbacSyncFrom(view){
|
|
1972
|
+
if(view==='form'){ rbacCollectForm(); return true; }
|
|
1973
|
+
const ta=document.getElementById('ent-rbac-json'); const msg=document.getElementById('ent-rbac-msg');
|
|
1974
|
+
try{
|
|
1975
|
+
const obj = view==='json' ? JSON.parse(ta.value) : yamlParse(ta.value);
|
|
1976
|
+
rbacModel={roles:obj.roles||{}, bindings:obj.bindings||{}, defaultRoles:obj.defaultRoles||[]};
|
|
1977
|
+
return true;
|
|
1978
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Invalid '+view.toUpperCase()+': '+e.message; return false; }
|
|
1979
|
+
}
|
|
1980
|
+
function rbacSetView(v){
|
|
1981
|
+
const form=document.getElementById('rbac-form'), ta=document.getElementById('ent-rbac-json');
|
|
1982
|
+
document.querySelectorAll('#rbac-views button').forEach(b=>b.classList.toggle('active', b.dataset.v===v));
|
|
1983
|
+
if(v==='form'){ form.classList.remove('hidden'); ta.classList.add('hidden'); rbacRenderForm(); }
|
|
1984
|
+
else {
|
|
1985
|
+
form.classList.add('hidden'); ta.classList.remove('hidden');
|
|
1986
|
+
ta.value = v==='json' ? JSON.stringify(rbacModel,null,2) : ('# roles / bindings / defaultRoles\n'+yamlDump(rbacModel));
|
|
1987
|
+
}
|
|
1988
|
+
rbacCur=v;
|
|
1989
|
+
}
|
|
1990
|
+
function rbacView(v){
|
|
1991
|
+
if(v===rbacCur) return;
|
|
1992
|
+
document.getElementById('ent-rbac-msg').textContent='';
|
|
1993
|
+
if(!rbacSyncFrom(rbacCur)) return; // keep current view if it can't be parsed
|
|
1994
|
+
rbacSetView(v);
|
|
1995
|
+
}
|
|
1996
|
+
async function entSaveRbac(){
|
|
1997
|
+
const msg=document.getElementById('ent-rbac-msg');
|
|
1998
|
+
const tok=document.getElementById('ent-admin-token').value.trim();
|
|
1999
|
+
if(!rbacSyncFrom(rbacCur)) return;
|
|
2000
|
+
if(!tok){ msg.style.color='var(--danger)'; msg.textContent='Admin API key required.'; return; }
|
|
2001
|
+
msg.style.color='var(--text-muted)'; msg.textContent='Saving…';
|
|
2002
|
+
try{
|
|
2003
|
+
const r=await fetch('/api/enterprise/policy',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(rbacModel)});
|
|
2004
|
+
const d=await r.json().catch(()=>({}));
|
|
2005
|
+
if(r.ok){ msg.style.color='var(--success)'; msg.textContent='Policy updated.'; toast('RBAC policy saved'); closeDrawer(); loadEnterprise(); }
|
|
2006
|
+
else { msg.style.color='var(--danger)'; msg.textContent='Rejected ('+r.status+'): '+(d.error||'unknown'); }
|
|
2007
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Request failed: '+e.message; }
|
|
2008
|
+
}
|
|
2009
|
+
let catModel={products:{},grants:{},defaultProducts:[]};
|
|
2010
|
+
let catCur='form';
|
|
2011
|
+
function entToggleCatEdit(on, after){
|
|
2012
|
+
const ed=document.getElementById('ent-cat-editor'), btn=document.getElementById('ent-cat-editbtn');
|
|
2013
|
+
document.getElementById('ent-cat-msg').textContent='';
|
|
2014
|
+
if(on){
|
|
2015
|
+
fetch('/api/enterprise/catalog').then(r=>r.json()).then(p=>{
|
|
2016
|
+
const d=(p&&p.configured&&p.data)?p.data:{};
|
|
2017
|
+
catModel={products:d.products||{}, grants:d.grants||{}, defaultProducts:d.defaultProducts||[]};
|
|
2018
|
+
catCur='form'; catSetView('form');
|
|
2019
|
+
if(typeof after==='function') after();
|
|
2020
|
+
});
|
|
2021
|
+
ed.classList.remove('hidden'); btn.classList.add('hidden');
|
|
2022
|
+
} else { ed.classList.add('hidden'); btn.classList.remove('hidden'); }
|
|
2023
|
+
}
|
|
2024
|
+
function catRenderForm(){
|
|
2025
|
+
const m=catModel; let h='';
|
|
2026
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Products</span><button class="fe-add" onclick="catAddProduct()">+ Add product</button></div>`;
|
|
2027
|
+
const pk=Object.keys(m.products);
|
|
2028
|
+
h+= pk.length?pk.map(pn=>{ const p=m.products[pn]||{}; return `<div class="fe-card" data-prod>
|
|
2029
|
+
<div class="fe-cardhd"><input type="text" class="p-name fe-grow" value="${escHtml(pn)}" placeholder="product name"><button class="fe-x" title="Remove product" onclick="this.closest('.fe-card').remove()">×</button></div>
|
|
2030
|
+
<div class="fe-fieldlbl">Description</div><input type="text" class="p-desc" value="${escHtml(p.description||'')}" placeholder="what this product bundles">
|
|
2031
|
+
<div class="fe-fieldlbl">Sources (comma-separated · * = all)</div><input type="text" class="p-sources" value="${escHtml(csv(p.sources))}" placeholder="* or prometheus, loki">
|
|
2032
|
+
<div class="fe-fieldlbl">Services (optional)</div><input type="text" class="p-services" value="${escHtml(csv(p.services))}" placeholder="* or payment-service">
|
|
2033
|
+
</div>`; }).join(''):'<div class="form-hint">No products yet.</div>';
|
|
2034
|
+
h+=`</div>`;
|
|
2035
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Grants (principal → products)</span><button class="fe-add" onclick="catAddGrant()">+ Add grant</button></div>`;
|
|
2036
|
+
const gk=Object.keys(m.grants);
|
|
2037
|
+
h+= gk.length?gk.map(pn=>`<div class="fe-row" data-grant>
|
|
2038
|
+
<input type="text" class="g-pr fe-grow" value="${escHtml(pn)}" placeholder="principal (API-key name)">
|
|
2039
|
+
<input type="text" class="g-prod fe-grow" value="${escHtml(csv(m.grants[pn]))}" placeholder="product1, product2">
|
|
2040
|
+
<button class="fe-x" onclick="this.closest('.fe-row').remove()">×</button></div>`).join(''):'<div class="form-hint">No grants yet.</div>';
|
|
2041
|
+
h+=`</div>`;
|
|
2042
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Default products (ungranted principals)</span></div>
|
|
2043
|
+
<input type="text" id="c-defaults" value="${escHtml(csv(m.defaultProducts))}" placeholder="empty = no default access"></div>`;
|
|
2044
|
+
document.getElementById('cat-form').innerHTML=h;
|
|
2045
|
+
}
|
|
2046
|
+
function catAddProduct(){ catCollectForm(); catModel.products['new-product']={description:'',sources:['*']}; catRenderForm(); }
|
|
2047
|
+
function catAddGrant(){ catCollectForm(); catModel.grants['principal']=[]; catRenderForm(); }
|
|
2048
|
+
function catCollectForm(){
|
|
2049
|
+
const products={}, grants={};
|
|
2050
|
+
document.querySelectorAll('#cat-form [data-prod]').forEach(c=>{
|
|
2051
|
+
const n=c.querySelector('.p-name').value.trim(); if(!n) return;
|
|
2052
|
+
const p={sources:fromCsv(c.querySelector('.p-sources').value)};
|
|
2053
|
+
const desc=c.querySelector('.p-desc').value.trim(); if(desc) p.description=desc;
|
|
2054
|
+
const svc=fromCsv(c.querySelector('.p-services').value); if(svc.length) p.services=svc;
|
|
2055
|
+
products[n]=p;
|
|
2056
|
+
});
|
|
2057
|
+
document.querySelectorAll('#cat-form [data-grant]').forEach(c=>{
|
|
2058
|
+
const p=c.querySelector('.g-pr').value.trim(); if(!p) return;
|
|
2059
|
+
grants[p]=fromCsv(c.querySelector('.g-prod').value);
|
|
2060
|
+
});
|
|
2061
|
+
const dp=document.getElementById('c-defaults');
|
|
2062
|
+
catModel={products,grants,defaultProducts:dp?fromCsv(dp.value):[]};
|
|
2063
|
+
return catModel;
|
|
2064
|
+
}
|
|
2065
|
+
function catSyncFrom(view){
|
|
2066
|
+
if(view==='form'){ catCollectForm(); return true; }
|
|
2067
|
+
const ta=document.getElementById('ent-cat-json'); const msg=document.getElementById('ent-cat-msg');
|
|
2068
|
+
try{
|
|
2069
|
+
const obj = view==='json' ? JSON.parse(ta.value) : yamlParse(ta.value);
|
|
2070
|
+
catModel={products:obj.products||{}, grants:obj.grants||{}, defaultProducts:obj.defaultProducts||[]};
|
|
2071
|
+
return true;
|
|
2072
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Invalid '+view.toUpperCase()+': '+e.message; return false; }
|
|
2073
|
+
}
|
|
2074
|
+
function catSetView(v){
|
|
2075
|
+
const form=document.getElementById('cat-form'), ta=document.getElementById('ent-cat-json');
|
|
2076
|
+
document.querySelectorAll('#cat-views button').forEach(b=>b.classList.toggle('active', b.dataset.v===v));
|
|
2077
|
+
if(v==='form'){ form.classList.remove('hidden'); ta.classList.add('hidden'); catRenderForm(); }
|
|
2078
|
+
else {
|
|
2079
|
+
form.classList.add('hidden'); ta.classList.remove('hidden');
|
|
2080
|
+
ta.value = v==='json' ? JSON.stringify(catModel,null,2) : ('# products / grants / defaultProducts\n'+yamlDump(catModel));
|
|
2081
|
+
}
|
|
2082
|
+
catCur=v;
|
|
2083
|
+
}
|
|
2084
|
+
function catView(v){
|
|
2085
|
+
if(v===catCur) return;
|
|
2086
|
+
document.getElementById('ent-cat-msg').textContent='';
|
|
2087
|
+
if(!catSyncFrom(catCur)) return;
|
|
2088
|
+
catSetView(v);
|
|
2089
|
+
}
|
|
2090
|
+
async function entSaveCat(){
|
|
2091
|
+
const msg=document.getElementById('ent-cat-msg');
|
|
2092
|
+
const tok=document.getElementById('ent-cat-token').value.trim();
|
|
2093
|
+
if(!catSyncFrom(catCur)) return;
|
|
2094
|
+
if(!tok){ msg.style.color='var(--danger)'; msg.textContent='Admin API key required.'; return; }
|
|
2095
|
+
msg.style.color='var(--text-muted)'; msg.textContent='Saving…';
|
|
2096
|
+
try{
|
|
2097
|
+
const r=await fetch('/api/enterprise/catalog',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(catModel)});
|
|
2098
|
+
const d=await r.json().catch(()=>({}));
|
|
2099
|
+
if(r.ok){ msg.style.color='var(--success)'; msg.textContent='Catalog updated.'; toast('Catalog saved'); closeDrawer(); loadEnterprise(); }
|
|
2100
|
+
else { msg.style.color='var(--danger)'; msg.textContent='Rejected ('+r.status+'): '+(d.error||'unknown'); }
|
|
2101
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Request failed: '+e.message; }
|
|
784
2102
|
}
|
|
785
2103
|
|
|
786
2104
|
function escHtml(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
|
@@ -863,6 +2181,8 @@ function showTab(name) {
|
|
|
863
2181
|
if (el) el.classList.add('active');
|
|
864
2182
|
if (event && event.target) event.target.classList.add('active');
|
|
865
2183
|
if(name==='metrics') populateMetricsSourceSelect();
|
|
2184
|
+
const sp = {installed:'connectors',hub:'connectors',upload:'connectors',general:'settings',health:'settings',metrics:'settings'}[name];
|
|
2185
|
+
if(sp) setActiveSub(sp, name);
|
|
866
2186
|
}
|
|
867
2187
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
868
2188
|
|
|
@@ -900,26 +2220,149 @@ function updateStats() {
|
|
|
900
2220
|
svcCtx.textContent = backends.size > 0 ? `across ${backends.size} backend${backends.size===1?'':'s'}` : 'discovered';
|
|
901
2221
|
}
|
|
902
2222
|
|
|
903
|
-
const
|
|
2223
|
+
const sPct = total > 0 ? Math.round((up / total) * 100) : 0;
|
|
2224
|
+
const sTone = total === 0 ? 'tone-warn' : up === total ? 'tone-ok' : up === 0 ? 'tone-bad' : 'tone-warn';
|
|
2225
|
+
renderGauge('gauge-sources', sPct, sTone);
|
|
2226
|
+
|
|
2227
|
+
// Status is now surfaced via the header notification feed, not a badge.
|
|
904
2228
|
const b=document.getElementById('status-badge');
|
|
905
|
-
b
|
|
906
|
-
|
|
2229
|
+
if(b){ const allUp=sourcesData.length>0&&sourcesData.filter(s=>s.enabled).every(s=>s.status==='up');
|
|
2230
|
+
b.textContent=sourcesData.length===0?'No sources':allUp?'All systems operational':'Issues detected';
|
|
2231
|
+
b.className='badge '+(allUp?'badge-ok':'badge-err'); }
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// SVG ring gauge (capacity-style). pct 0..100, tone class on .gauge.
|
|
2235
|
+
function renderGauge(elId, pct, toneClass){
|
|
2236
|
+
const el=document.getElementById(elId); if(!el) return;
|
|
2237
|
+
const p=Math.max(0,Math.min(100,Math.round(pct)));
|
|
2238
|
+
const r=24, c=2*Math.PI*r, off=c*(1-p/100);
|
|
2239
|
+
el.className='gauge '+(toneClass||'');
|
|
2240
|
+
el.innerHTML=
|
|
2241
|
+
'<svg viewBox="0 0 56 56" width="56" height="56" role="img" aria-label="'+p+'%">'+
|
|
2242
|
+
'<circle class="g-track" cx="28" cy="28" r="'+r+'" fill="none" stroke-width="5"/>'+
|
|
2243
|
+
'<circle class="g-fill" cx="28" cy="28" r="'+r+'" fill="none" stroke-width="5" stroke-linecap="round" '+
|
|
2244
|
+
'transform="rotate(-90 28 28)" stroke-dasharray="'+c.toFixed(1)+'" stroke-dashoffset="'+off.toFixed(1)+'"/>'+
|
|
2245
|
+
'<text x="28" y="28" text-anchor="middle" dominant-baseline="central">'+p+'%</text></svg>';
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// Dashboard "Services Healthy" gauge — light poll of the analyzed health API.
|
|
2249
|
+
async function updateHealthStat(){
|
|
2250
|
+
const v=document.getElementById('stat-health'), ctx=document.getElementById('stat-health-ctx');
|
|
2251
|
+
if(!v) return;
|
|
2252
|
+
try{
|
|
2253
|
+
const h=await(await fetch('/api/health')).json();
|
|
2254
|
+
healthMap=h||{};
|
|
2255
|
+
const svcs=Object.values(h||{});
|
|
2256
|
+
const total=svcs.length;
|
|
2257
|
+
const healthy=svcs.filter(s=>s&&s.status==='healthy').length;
|
|
2258
|
+
const pct=total>0?Math.round((healthy/total)*100):0;
|
|
2259
|
+
v.textContent=total>0?healthy+'/'+total:'—';
|
|
2260
|
+
if(ctx) ctx.textContent=total===0?'no services':pct===100?'all healthy':(total-healthy)+' need attention';
|
|
2261
|
+
renderGauge('gauge-health', pct, total===0?'tone-warn':pct===100?'tone-ok':pct>=50?'tone-warn':'tone-bad');
|
|
2262
|
+
renderHealthPanels(svcs);
|
|
2263
|
+
}catch(e){
|
|
2264
|
+
v.textContent='—'; if(ctx) ctx.textContent='unavailable';
|
|
2265
|
+
renderGauge('gauge-health', 0, 'tone-warn');
|
|
2266
|
+
const a=document.getElementById('dash-attention'); if(a) a.innerHTML='<div class="empty">Health data unavailable.</div>';
|
|
2267
|
+
const t=document.getElementById('dash-top'); if(t) t.innerHTML='<div class="empty">Health data unavailable.</div>';
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
function svcTone(s){
|
|
2271
|
+
const st=(s&&s.status)||''; if(st==='critical') return 'bad'; if(st!=='healthy') return 'warn'; return 'ok';
|
|
2272
|
+
}
|
|
2273
|
+
function renderHealthPanels(svcs){
|
|
2274
|
+
const attn=document.getElementById('dash-attention'), top=document.getElementById('dash-top'), cnt=document.getElementById('dash-attn-count');
|
|
2275
|
+
if(attn){
|
|
2276
|
+
const bad=svcs.filter(s=>s&&s.status!=='healthy').sort((a,b)=>(a.score||0)-(b.score||0));
|
|
2277
|
+
if(cnt) cnt.textContent=String(bad.length);
|
|
2278
|
+
attn.innerHTML = bad.length===0
|
|
2279
|
+
? '<div class="empty">All services healthy — nothing needs attention.</div>'
|
|
2280
|
+
: bad.map(s=>{
|
|
2281
|
+
const tn=svcTone(s);
|
|
2282
|
+
const an=(s.anomalies&&s.anomalies[0]&&(s.anomalies[0].metric||s.anomalies[0].description))||(s.correlations&&s.correlations[0])||'no dominant signal';
|
|
2283
|
+
return `<div class="feed-row"><span class="stchip ${tn}">${esc(s.status)}</span><div class="fr-main"><div class="fr-title">${esc(s.service)}</div><div class="fr-sub">score ${esc(s.score)} · ${esc(an)}</div></div></div>`;
|
|
2284
|
+
}).join('');
|
|
2285
|
+
}
|
|
2286
|
+
if(top){
|
|
2287
|
+
const ranked=svcs.slice().sort((a,b)=>(a.score||0)-(b.score||0)).slice(0,8);
|
|
2288
|
+
top.innerHTML = ranked.length===0
|
|
2289
|
+
? '<div class="empty">No services discovered.</div>'
|
|
2290
|
+
: '<div class="rank">'+ranked.map(s=>{
|
|
2291
|
+
const sc=Math.max(0,Math.min(100,Number(s.score)||0)), tn=svcTone(s);
|
|
2292
|
+
return `<div class="rank-row"><span class="rk-name" title="${esc(s.service)}">${esc(s.service)}</span><span class="rk-track"><span class="rk-fill ${tn}" style="width:${sc}%"></span></span><span class="rk-val">${sc}</span></div>`;
|
|
2293
|
+
}).join('')+'</div>';
|
|
2294
|
+
}
|
|
907
2295
|
}
|
|
908
2296
|
|
|
909
2297
|
// --- Render Sources ---
|
|
2298
|
+
function srcView(){ try{ return localStorage.getItem('omcp-src-view')==='table'?'table':'list'; }catch(e){ return 'list'; } }
|
|
2299
|
+
function setSrcView(v){ try{ localStorage.setItem('omcp-src-view',v); }catch(e){} renderSources(); }
|
|
2300
|
+
function srcRow(s){
|
|
2301
|
+
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
2302
|
+
return `<div class="source-row" data-src="${esc(s.name)}"><div class="dot ${sc}"></div><div class="source-info"><div class="name">${esc(s.name)}<span class="tag tag-type">${esc(s.type)}</span>${s.signalType?`<span class="tag tag-${s.signalType}">${s.signalType}</span>`:''}</div><div class="url">${esc(s.url)}</div></div>${s.latencyMs?`<span class="tag-latency">${s.latencyMs}ms</span>`:''}<div class="source-actions" onclick="event.stopPropagation()"><label class="toggle"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" onclick="openEditModal('${esc(s.name)}')">✎</button><button class="btn-icon" onclick="openDeleteConfirm('source','${esc(s.name)}')">🗑</button></div></div>`;
|
|
2303
|
+
}
|
|
910
2304
|
function renderSources() {
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
2305
|
+
const dashHtml = sourcesData.length===0 ? '<div class="empty">No sources configured.</div>' : sourcesData.map(srcRow).join('');
|
|
2306
|
+
document.getElementById('dash-sources').innerHTML=dashHtml;
|
|
2307
|
+
const box=document.getElementById('sources-list'); if(!box) return;
|
|
2308
|
+
if(sourcesData.length===0){ box.innerHTML='<div class="empty">No sources configured.</div>'; return; }
|
|
2309
|
+
const view=srcView();
|
|
2310
|
+
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>
|
|
2311
|
+
<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>`;
|
|
2312
|
+
let body;
|
|
2313
|
+
if(view==='table'){
|
|
2314
|
+
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>`+
|
|
2315
|
+
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('')+
|
|
2316
|
+
`</tbody></table>`;
|
|
2317
|
+
} else {
|
|
2318
|
+
body=sourcesData.map(srcRow).join('');
|
|
2319
|
+
}
|
|
2320
|
+
box.innerHTML=bar+body;
|
|
2321
|
+
box.querySelectorAll('[data-src]').forEach(el=>el.addEventListener('click',()=>srcDetail(el.getAttribute('data-src'))));
|
|
2322
|
+
}
|
|
2323
|
+
function srcDetail(name){
|
|
2324
|
+
const s=sourcesData.find(x=>x.name===name);
|
|
2325
|
+
if(!s){ openDrawer(name,'<div class="empty">Source not found.</div>'); return; }
|
|
2326
|
+
const tone=!s.enabled?'warn':s.status==='up'?'ok':'bad';
|
|
2327
|
+
const svc=(servicesData||[]).filter(x=>(x.sources||[]).includes(s.name)).map(x=>x.name);
|
|
2328
|
+
const html=
|
|
2329
|
+
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>`:''}`)+
|
|
2330
|
+
dwSec('Configuration', `<table class="dtable"><tbody>
|
|
2331
|
+
<tr><td>Type</td><td class="mono">${escHtml(s.type)}</td></tr>
|
|
2332
|
+
<tr><td>Signal type</td><td class="mono">${escHtml(s.signalType||'—')}</td></tr>
|
|
2333
|
+
<tr><td>URL</td><td class="mono" style="word-break:break-all">${escHtml(s.url)}</td></tr>
|
|
2334
|
+
<tr><td>Enabled</td><td>${s.enabled?'yes':'no'}</td></tr></tbody></table>`)+
|
|
2335
|
+
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>')+
|
|
2336
|
+
`<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>`+
|
|
2337
|
+
`<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>`;
|
|
2338
|
+
openDrawer(name, html);
|
|
917
2339
|
}
|
|
918
2340
|
function renderServices() {
|
|
919
|
-
const html = servicesData.length===0 ? '<div class="empty">No services discovered.</div>' : servicesData.map(s=>`<div class="service-row"><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('');
|
|
920
|
-
document.getElementById('services-list').innerHTML=html;
|
|
2341
|
+
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('');
|
|
2342
|
+
const sl=document.getElementById('services-list'); sl.innerHTML=html;
|
|
2343
|
+
sl.querySelectorAll('[data-svc]').forEach(el=>el.addEventListener('click',()=>svcDetail(el.getAttribute('data-svc'))));
|
|
921
2344
|
document.getElementById('dash-services').innerHTML=html;
|
|
922
2345
|
}
|
|
2346
|
+
async function svcDetail(name){
|
|
2347
|
+
let v=healthMap[name];
|
|
2348
|
+
if(!v){ try{ const h=await(await fetch('/api/health')).json(); healthMap=h||{}; v=healthMap[name]; }catch(e){} }
|
|
2349
|
+
if(!v){ openDrawer(name, '<div class="empty">No health data available for this service.</div>'); return; }
|
|
2350
|
+
const tone=v.status==='critical'?'bad':v.status!=='healthy'?'warn':'ok';
|
|
2351
|
+
const m=(v.signals&&v.signals.metrics)||{};
|
|
2352
|
+
const fmtN=x=>x==null?'—':(Math.round(Number(x)*100)/100);
|
|
2353
|
+
const html=
|
|
2354
|
+
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>`)+
|
|
2355
|
+
dwSec('Signals', `<table class="dtable"><tbody>
|
|
2356
|
+
<tr><td>CPU</td><td class="mono">${fmtN(m.cpu)}</td></tr>
|
|
2357
|
+
<tr><td>Memory (MB)</td><td class="mono">${fmtN(m.memory)}</td></tr>
|
|
2358
|
+
<tr><td>Error rate</td><td class="mono">${fmtN(m.errorRate)}</td></tr>
|
|
2359
|
+
<tr><td>Latency p99 (s)</td><td class="mono">${fmtN(m.latencyP99)}</td></tr>
|
|
2360
|
+
<tr><td>Log errors (5m)</td><td class="mono">${fmtN(v.signals&&v.signals.logs&&v.signals.logs.errorRate)}</td></tr></tbody></table>`)+
|
|
2361
|
+
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>')+
|
|
2362
|
+
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>')+
|
|
2363
|
+
`<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>`;
|
|
2364
|
+
openDrawer(name, html);
|
|
2365
|
+
}
|
|
923
2366
|
|
|
924
2367
|
// --- TLS helpers ---
|
|
925
2368
|
function resetTlsFields() {
|
|
@@ -1239,7 +2682,7 @@ async function loadInfo(){
|
|
|
1239
2682
|
// Show the endpoint the user is actually reaching the server through
|
|
1240
2683
|
// (localhost, a port-forward, or an ingress) — not a hardcoded host.
|
|
1241
2684
|
document.getElementById('mcp-url').textContent = window.location.origin + '/mcp';
|
|
1242
|
-
(async()=>{await loadTypes();await refresh();await loadInfo();setInterval(refresh,15000);})();
|
|
2685
|
+
(async()=>{syncThemeToggle();initNav();await loadTypes();await refresh();await loadInfo();await updateHealthStat();await buildNotifications();setInterval(refresh,15000);setInterval(updateHealthStat,15000);setInterval(buildNotifications,15000);})();
|
|
1243
2686
|
</script>
|
|
1244
2687
|
|
|
1245
2688
|
<footer id="info-footer" class="info-footer"><span class="spinner"></span> loading server info…</footer>
|