@thotischner/observability-mcp 1.5.1 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/sources.yaml +10 -0
- package/dist/connectors/interface.d.ts +11 -1
- package/dist/connectors/interface.js +7 -1
- package/dist/connectors/kubernetes-client.d.ts +3 -0
- package/dist/connectors/kubernetes-client.js +90 -0
- package/dist/connectors/kubernetes-graph.d.ts +73 -0
- package/dist/connectors/kubernetes-graph.js +257 -0
- package/dist/connectors/kubernetes-graph.test.d.ts +1 -0
- package/dist/connectors/kubernetes-graph.test.js +150 -0
- package/dist/connectors/kubernetes.d.ts +52 -0
- package/dist/connectors/kubernetes.js +185 -0
- package/dist/connectors/kubernetes.test.d.ts +1 -0
- package/dist/connectors/kubernetes.test.js +136 -0
- package/dist/connectors/loader.js +6 -0
- package/dist/connectors/topology.test.d.ts +1 -0
- package/dist/connectors/topology.test.js +165 -0
- 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 +152 -6
- package/dist/sdk/index.d.ts +2 -2
- package/dist/sdk/manifest-schema.d.ts +1 -0
- package/dist/sdk/manifest-schema.js +1 -1
- package/dist/tools/get-service-health.js +11 -8
- package/dist/tools/handlers.test.js +31 -0
- package/dist/tools/topology.d.ts +64 -0
- package/dist/tools/topology.js +233 -0
- package/dist/tools/topology.test.d.ts +1 -0
- package/dist/tools/topology.test.js +210 -0
- package/dist/types.d.ts +67 -1
- package/dist/ui/index.html +2333 -67
- package/package.json +3 -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,412 @@
|
|
|
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
|
+
<button class="nav-btn" data-page="topology" onclick="showPage('topology')"><span class="nav-ico">◇</span>Topology</button>
|
|
934
|
+
</div>
|
|
935
|
+
</div>
|
|
936
|
+
<div class="rail-grp" data-grp="catalog">
|
|
937
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('catalog')">Catalog<span class="chev">▾</span></button>
|
|
938
|
+
<div class="rail-grp-body">
|
|
939
|
+
<button class="nav-btn" data-page="products" onclick="showPage('products')"><span class="nav-ico">◫</span>Products</button>
|
|
940
|
+
</div>
|
|
941
|
+
</div>
|
|
942
|
+
<div class="rail-grp" data-grp="governance">
|
|
943
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('governance')">Governance<span class="chev">▾</span></button>
|
|
944
|
+
<div class="rail-grp-body">
|
|
945
|
+
<button class="nav-btn" data-page="access" onclick="showPage('access')"><span class="nav-ico">⛨</span>Access Control</button>
|
|
946
|
+
<button class="nav-btn" data-page="audit" onclick="showPage('audit')"><span class="nav-ico">❒</span>Audit Log</button>
|
|
947
|
+
</div>
|
|
948
|
+
</div>
|
|
949
|
+
<div class="rail-grp" data-grp="system">
|
|
950
|
+
<button class="rail-grp-hd" onclick="toggleNavGroup('system')">System<span class="chev">▾</span></button>
|
|
951
|
+
<div class="rail-grp-body">
|
|
952
|
+
<div class="rail-item" data-nav="connectors">
|
|
953
|
+
<button class="nav-btn nav-parent" onclick="navToggle('connectors')"><span class="nav-ico">⇄</span>Connectors<span class="sub-chev">▾</span></button>
|
|
954
|
+
<div class="rail-sub">
|
|
955
|
+
<button class="nav-sub" data-sub="installed" onclick="goTab('connectors','installed')">Installed</button>
|
|
956
|
+
<button class="nav-sub" data-sub="hub" onclick="goTab('connectors','hub')">Connector Hub</button>
|
|
957
|
+
<button class="nav-sub" data-sub="upload" onclick="goTab('connectors','upload')">Upload Bundle</button>
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
<div class="rail-item" data-nav="settings">
|
|
961
|
+
<button class="nav-btn nav-parent" onclick="navToggle('settings')"><span class="nav-ico">⚙</span>Settings<span class="sub-chev">▾</span></button>
|
|
962
|
+
<div class="rail-sub">
|
|
963
|
+
<button class="nav-sub" data-sub="general" onclick="goTab('settings','general')">General</button>
|
|
964
|
+
<button class="nav-sub" data-sub="health" onclick="goTab('settings','health')">Health Scoring</button>
|
|
965
|
+
<button class="nav-sub" data-sub="metrics" onclick="goTab('settings','metrics')">Custom Metrics</button>
|
|
966
|
+
</div>
|
|
967
|
+
</div>
|
|
968
|
+
<button class="nav-btn" data-page="entitlement" onclick="showPage('entitlement')"><span class="nav-ico">⛁</span>Entitlement</button>
|
|
969
|
+
</div>
|
|
970
|
+
</div>
|
|
971
|
+
</nav>
|
|
972
|
+
<div class="rail-foot" id="rail-foot">loading…</div>
|
|
973
|
+
</aside>
|
|
974
|
+
<header class="masthead">
|
|
498
975
|
<div style="flex:1"></div>
|
|
976
|
+
<div class="notif-wrap">
|
|
977
|
+
<button class="notif-btn" id="notif-btn" aria-label="Notifications" title="Notifications" onclick="toggleNotif()">
|
|
978
|
+
<span class="notif-bell">◔</span><span class="notif-count" id="notif-count" hidden>0</span>
|
|
979
|
+
</button>
|
|
980
|
+
<div class="notif-panel" id="notif-panel" hidden>
|
|
981
|
+
<div class="notif-head"><span>Notifications</span><button class="notif-clear" id="notif-clear" onclick="notifAckAll()">Mark all read</button></div>
|
|
982
|
+
<div id="notif-list"><div class="notif-empty">No notifications</div></div>
|
|
983
|
+
</div>
|
|
984
|
+
</div>
|
|
985
|
+
<button class="theme-toggle" id="theme-toggle" title="Toggle light / dark" aria-label="Toggle theme" onclick="toggleTheme()">◐</button>
|
|
499
986
|
<button class="btn btn-ghost btn-sm" onclick="refresh()">Refresh</button>
|
|
500
|
-
</
|
|
987
|
+
</header>
|
|
501
988
|
|
|
502
989
|
<div class="container">
|
|
503
990
|
<!-- ===== Dashboard ===== -->
|
|
504
991
|
<div class="page active" id="page-dashboard">
|
|
505
|
-
<div class="
|
|
506
|
-
<
|
|
507
|
-
|
|
992
|
+
<div class="page-head">
|
|
993
|
+
<div class="ph-left">
|
|
994
|
+
<div class="breadcrumb">Console / <b>Overview</b></div>
|
|
995
|
+
<h1>Overview</h1>
|
|
996
|
+
</div>
|
|
997
|
+
<div class="ph-actions"><span class="live-indicator" id="dash-live">Live</span></div>
|
|
508
998
|
</div>
|
|
509
999
|
<div class="stats">
|
|
510
1000
|
<div class="stat-card">
|
|
@@ -512,47 +1002,99 @@
|
|
|
512
1002
|
<div class="value" id="stat-sources">-</div>
|
|
513
1003
|
<div class="context" id="stat-sources-ctx">configured</div>
|
|
514
1004
|
</div>
|
|
515
|
-
<div class="stat-card">
|
|
516
|
-
<div class="
|
|
517
|
-
|
|
518
|
-
|
|
1005
|
+
<div class="stat-card has-gauge">
|
|
1006
|
+
<div class="stat-body">
|
|
1007
|
+
<div class="label">Sources Up</div>
|
|
1008
|
+
<div class="value" id="stat-sources-up">-</div>
|
|
1009
|
+
<div class="context" id="stat-sources-up-ctx">connected</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
<span id="gauge-sources" class="gauge"></span>
|
|
519
1012
|
</div>
|
|
520
1013
|
<div class="stat-card">
|
|
521
1014
|
<div class="label">Services</div>
|
|
522
1015
|
<div class="value" id="stat-services">-</div>
|
|
523
1016
|
<div class="context" id="stat-services-ctx">discovered</div>
|
|
524
1017
|
</div>
|
|
525
|
-
<div class="stat-card">
|
|
526
|
-
<div class="
|
|
527
|
-
|
|
528
|
-
|
|
1018
|
+
<div class="stat-card has-gauge">
|
|
1019
|
+
<div class="stat-body">
|
|
1020
|
+
<div class="label">Services Healthy</div>
|
|
1021
|
+
<div class="value" id="stat-health">-</div>
|
|
1022
|
+
<div class="context" id="stat-health-ctx">live</div>
|
|
1023
|
+
</div>
|
|
1024
|
+
<span id="gauge-health" class="gauge"></span>
|
|
529
1025
|
</div>
|
|
530
1026
|
</div>
|
|
531
1027
|
<div class="endpoint-bar">
|
|
532
1028
|
<span>MCP Endpoint: <strong id="mcp-url">http://localhost:3000/mcp</strong></span>
|
|
533
1029
|
<button class="btn btn-ghost btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('mcp-url').textContent);toast('Copied!')">Copy</button>
|
|
534
1030
|
</div>
|
|
1031
|
+
<div class="card">
|
|
1032
|
+
<div class="card-header"><h2>How it works
|
|
1033
|
+
<button class="info" aria-label="Explain the request path"
|
|
1034
|
+
data-title="Request path"
|
|
1035
|
+
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."
|
|
1036
|
+
onclick="infoPop(this)">?</button>
|
|
1037
|
+
</h2></div>
|
|
1038
|
+
<div class="flow">
|
|
1039
|
+
<div class="flow-node"><span class="fn-ic">◇</span><span class="fn-t">Agent</span><span class="fn-s">MCP call</span></div>
|
|
1040
|
+
<span class="flow-arrow"></span>
|
|
1041
|
+
<div class="flow-node"><span class="fn-ic">⛨</span><span class="fn-t">Gate</span><span class="fn-s">entitlement · RBAC · catalog</span></div>
|
|
1042
|
+
<span class="flow-arrow"></span>
|
|
1043
|
+
<div class="flow-node"><span class="fn-ic">⇄</span><span class="fn-t">Connectors</span><span class="fn-s">Prometheus · Loki</span></div>
|
|
1044
|
+
<span class="flow-arrow"></span>
|
|
1045
|
+
<div class="flow-node"><span class="fn-ic">✦</span><span class="fn-t">Analysis</span><span class="fn-s">scored health</span></div>
|
|
1046
|
+
</div>
|
|
1047
|
+
</div>
|
|
535
1048
|
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px;">
|
|
536
1049
|
<div class="card"><div class="card-header"><h2>Sources</h2><button class="btn btn-primary btn-sm" onclick="showPage('sources');openAddModal()">+ Add Source</button></div><div id="dash-sources"><div class="empty">Loading...</div></div></div>
|
|
537
1050
|
<div class="card"><div class="card-header"><h2>Services</h2></div><div id="dash-services"><div class="empty">Loading...</div></div></div>
|
|
538
1051
|
</div>
|
|
1052
|
+
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
|
|
1053
|
+
<div class="card">
|
|
1054
|
+
<div class="card-header"><h2>Needs attention</h2><span id="dash-attn-count" class="tag tag-type">—</span></div>
|
|
1055
|
+
<div id="dash-attention"><div class="empty">Loading…</div></div>
|
|
1056
|
+
</div>
|
|
1057
|
+
<div class="card">
|
|
1058
|
+
<div class="card-header"><h2>Services by health score</h2></div>
|
|
1059
|
+
<div id="dash-top"><div class="empty">Loading…</div></div>
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
539
1062
|
</div>
|
|
540
1063
|
|
|
541
1064
|
<!-- ===== Sources ===== -->
|
|
542
1065
|
<div class="page" id="page-sources">
|
|
1066
|
+
<div class="page-head">
|
|
1067
|
+
<div class="ph-left">
|
|
1068
|
+
<div class="breadcrumb">Console / Observability / <b>Sources</b></div>
|
|
1069
|
+
<h1>Data Sources</h1>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div class="ph-actions"><button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Add Source</button></div>
|
|
1072
|
+
</div>
|
|
543
1073
|
<div class="card">
|
|
544
|
-
<div class="card-header"><h2>
|
|
1074
|
+
<div class="card-header"><h2>All sources</h2></div>
|
|
545
1075
|
<div id="sources-list"><div class="empty">Loading...</div></div>
|
|
546
1076
|
</div>
|
|
547
1077
|
</div>
|
|
548
1078
|
|
|
549
1079
|
<!-- ===== Services ===== -->
|
|
550
1080
|
<div class="page" id="page-services">
|
|
551
|
-
<div class="
|
|
1081
|
+
<div class="page-head">
|
|
1082
|
+
<div class="ph-left">
|
|
1083
|
+
<div class="breadcrumb">Console / Observability / <b>Services</b></div>
|
|
1084
|
+
<h1>Services</h1>
|
|
1085
|
+
</div>
|
|
1086
|
+
</div>
|
|
1087
|
+
<div class="card"><div class="card-header"><h2>Discovered services</h2></div><div id="services-list"><div class="empty">Loading...</div></div></div>
|
|
552
1088
|
</div>
|
|
553
1089
|
|
|
554
1090
|
<!-- ===== Connectors ===== -->
|
|
555
1091
|
<div class="page" id="page-connectors">
|
|
1092
|
+
<div class="page-head">
|
|
1093
|
+
<div class="ph-left">
|
|
1094
|
+
<div class="breadcrumb">Console / System / <b>Connectors</b></div>
|
|
1095
|
+
<h1>Connectors</h1>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
556
1098
|
<div class="card" style="padding:0;">
|
|
557
1099
|
<div class="tabs">
|
|
558
1100
|
<button class="tab-btn active" onclick="showTab('installed')">Installed</button>
|
|
@@ -587,13 +1129,84 @@
|
|
|
587
1129
|
|
|
588
1130
|
<!-- ===== Health ===== -->
|
|
589
1131
|
<div class="page" id="page-health">
|
|
1132
|
+
<div class="page-head">
|
|
1133
|
+
<div class="ph-left">
|
|
1134
|
+
<div class="breadcrumb">Console / Observability / <b>Health</b></div>
|
|
1135
|
+
<h1>Service Health</h1>
|
|
1136
|
+
</div>
|
|
1137
|
+
</div>
|
|
590
1138
|
<div id="health-cards" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;">
|
|
591
1139
|
<div class="empty">Loading health data...</div>
|
|
592
1140
|
</div>
|
|
593
1141
|
</div>
|
|
594
1142
|
|
|
1143
|
+
<!-- ===== Topology ===== -->
|
|
1144
|
+
<div class="page" id="page-topology">
|
|
1145
|
+
<div class="page-head">
|
|
1146
|
+
<div class="ph-left">
|
|
1147
|
+
<div class="breadcrumb">Console / Observability / <b>Topology</b></div>
|
|
1148
|
+
<h1>Infrastructure Topology</h1>
|
|
1149
|
+
</div>
|
|
1150
|
+
<div class="ph-actions">
|
|
1151
|
+
<button class="btn" onclick="loadTopology()">Refresh</button>
|
|
1152
|
+
</div>
|
|
1153
|
+
</div>
|
|
1154
|
+
<div class="tabs">
|
|
1155
|
+
<button class="tab-btn active" onclick="showTopologyTab('summary')">Summary</button>
|
|
1156
|
+
<button class="tab-btn" onclick="showTopologyTab('blast')">Blast radius</button>
|
|
1157
|
+
<button class="tab-btn" onclick="showTopologyTab('graph')">Graph</button>
|
|
1158
|
+
</div>
|
|
1159
|
+
<div id="topology-tab-summary" class="tab-content active">
|
|
1160
|
+
<div class="card">
|
|
1161
|
+
<div class="card-header"><h2>Sources & counts</h2></div>
|
|
1162
|
+
<div id="topology-summary" class="empty">Loading topology...</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
</div>
|
|
1165
|
+
<div id="topology-tab-blast" class="tab-content">
|
|
1166
|
+
<div class="card">
|
|
1167
|
+
<div class="card-header">
|
|
1168
|
+
<h2>Grouped by host <span class="muted" style="font-weight:400; font-size: var(--fs-sm);">(pivots on the <code>RUNS_ON</code> relation)</span></h2>
|
|
1169
|
+
</div>
|
|
1170
|
+
<div style="padding: 0 16px 8px; display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
|
1171
|
+
<label class="muted" style="display:flex; gap:6px; align-items:center; font-size: var(--fs-sm);">
|
|
1172
|
+
Scope filter
|
|
1173
|
+
<select id="topology-scope-filter" style="background:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:4px 8px;">
|
|
1174
|
+
<option value="">All</option>
|
|
1175
|
+
</select>
|
|
1176
|
+
</label>
|
|
1177
|
+
<span class="muted" style="font-size: var(--fs-xs);">Scope = any resource pointed to by an <code>IN_NAMESPACE</code> edge (e.g. k8s namespaces, future: vCenter folders).</span>
|
|
1178
|
+
</div>
|
|
1179
|
+
<div id="topology-by-host" class="empty" style="padding: 0 16px 16px;">Loading...</div>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
<div id="topology-tab-graph" class="tab-content">
|
|
1183
|
+
<div class="card">
|
|
1184
|
+
<div class="card-header">
|
|
1185
|
+
<h2>Layered graph</h2>
|
|
1186
|
+
<span class="muted" style="font-size: var(--fs-xs);">click a resource to inspect · drag to reposition · wheel to zoom · drag the background to pan</span>
|
|
1187
|
+
</div>
|
|
1188
|
+
<div style="display:grid; grid-template-columns: 1fr 340px; gap: 0; border-top: 1px solid var(--border); height: 660px;">
|
|
1189
|
+
<div id="topology-graph-host" style="position:relative; overflow:hidden; background: var(--surface-2); border-right: 1px solid var(--border);">
|
|
1190
|
+
<svg id="topology-graph-svg" style="position:absolute; inset:0; width:100%; height:100%; cursor: grab;"></svg>
|
|
1191
|
+
<div id="topology-graph-legend" style="position:absolute; bottom:10px; left:10px; font-size: var(--fs-xs); padding:8px 10px; background: var(--surface); border:1px solid var(--border); border-radius: 6px; max-width: 75%;"></div>
|
|
1192
|
+
</div>
|
|
1193
|
+
<aside id="topology-inspector" style="background: var(--surface); padding: 16px; overflow-y: auto; font-size: var(--fs-sm);">
|
|
1194
|
+
<div id="topology-inspector-empty" class="empty" style="margin: 24px 0;">Select a resource on the left to inspect its labels, attributes and neighbours.</div>
|
|
1195
|
+
<div id="topology-inspector-body" style="display:none;"></div>
|
|
1196
|
+
</aside>
|
|
1197
|
+
</div>
|
|
1198
|
+
</div>
|
|
1199
|
+
</div>
|
|
1200
|
+
</div>
|
|
1201
|
+
|
|
595
1202
|
<!-- ===== Settings ===== -->
|
|
596
1203
|
<div class="page" id="page-settings">
|
|
1204
|
+
<div class="page-head">
|
|
1205
|
+
<div class="ph-left">
|
|
1206
|
+
<div class="breadcrumb">Console / System / <b>Settings</b></div>
|
|
1207
|
+
<h1>Settings</h1>
|
|
1208
|
+
</div>
|
|
1209
|
+
</div>
|
|
597
1210
|
<div class="card" style="padding:0;">
|
|
598
1211
|
<div class="tabs">
|
|
599
1212
|
<button class="tab-btn active" onclick="showTab('general')">General</button>
|
|
@@ -699,6 +1312,181 @@
|
|
|
699
1312
|
</div>
|
|
700
1313
|
</div>
|
|
701
1314
|
</div>
|
|
1315
|
+
<!-- ===== Governance: Access Control ===== -->
|
|
1316
|
+
<div class="page" id="page-access">
|
|
1317
|
+
<div class="page-head">
|
|
1318
|
+
<div class="ph-left">
|
|
1319
|
+
<div class="breadcrumb">Console / Governance / <b>Access Control</b></div>
|
|
1320
|
+
<h1>Access Control</h1>
|
|
1321
|
+
</div>
|
|
1322
|
+
<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>
|
|
1323
|
+
</div>
|
|
1324
|
+
<div class="card">
|
|
1325
|
+
<div class="card-header"><h2>Roles & bindings
|
|
1326
|
+
<button class="info" aria-label="About roles and bindings"
|
|
1327
|
+
data-title="Roles & bindings"
|
|
1328
|
+
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."
|
|
1329
|
+
onclick="infoPop(this)">?</button>
|
|
1330
|
+
</h2></div>
|
|
1331
|
+
<div id="ent-rbac"><div class="empty">Loading…</div></div>
|
|
1332
|
+
<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>
|
|
1333
|
+
<div class="codeblock collapsed">
|
|
1334
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1335
|
+
<span class="cb-chev">▾</span><span class="cb-title">API · add a role & binding via curl</span>
|
|
1336
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1337
|
+
</div>
|
|
1338
|
+
<pre><span class="tok-cmt"># grant the "viewer" role read-only metrics, bind a principal to it.</span>
|
|
1339
|
+
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1340
|
+
curl -X PUT http://localhost:3000/api/enterprise/policy \
|
|
1341
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
1342
|
+
-H "Content-Type: application/json" \
|
|
1343
|
+
-d '{
|
|
1344
|
+
"roles": {
|
|
1345
|
+
"viewer": { "tools": ["query_metrics","get_service_health"], "readOnly": true }
|
|
1346
|
+
},
|
|
1347
|
+
"bindings": { "alice": ["viewer"] }
|
|
1348
|
+
}'</pre>
|
|
1349
|
+
</div>
|
|
1350
|
+
<div id="ent-rbac-editor" class="hidden" style="margin-top:12px">
|
|
1351
|
+
<div class="ed-bar">
|
|
1352
|
+
<span class="view-toggle" id="rbac-views">
|
|
1353
|
+
<button data-v="form" class="active" onclick="rbacView('form')">Form</button>
|
|
1354
|
+
<button data-v="json" onclick="rbacView('json')">JSON</button>
|
|
1355
|
+
<button data-v="yaml" onclick="rbacView('yaml')">YAML</button>
|
|
1356
|
+
</span>
|
|
1357
|
+
<input type="password" id="ent-admin-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1358
|
+
</div>
|
|
1359
|
+
<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>
|
|
1360
|
+
<div id="rbac-form"></div>
|
|
1361
|
+
<textarea id="ent-rbac-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1362
|
+
<div id="ent-rbac-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1363
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1364
|
+
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1365
|
+
<button class="btn btn-primary btn-sm" onclick="entSaveRbac()">Save policy</button>
|
|
1366
|
+
</div>
|
|
1367
|
+
</div>
|
|
1368
|
+
</div>
|
|
1369
|
+
</div>
|
|
1370
|
+
|
|
1371
|
+
<!-- ===== Catalog: Context Products ===== -->
|
|
1372
|
+
<div class="page" id="page-products">
|
|
1373
|
+
<div class="page-head">
|
|
1374
|
+
<div class="ph-left">
|
|
1375
|
+
<div class="breadcrumb">Console / Catalog / <b>Products</b></div>
|
|
1376
|
+
<h1>Context Products
|
|
1377
|
+
<button class="info" aria-label="What is a context product"
|
|
1378
|
+
data-title="Context products"
|
|
1379
|
+
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)."
|
|
1380
|
+
onclick="infoPop(this)">?</button>
|
|
1381
|
+
</h1>
|
|
1382
|
+
</div>
|
|
1383
|
+
<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>
|
|
1384
|
+
</div>
|
|
1385
|
+
<div class="card" style="background:var(--accent-soft);border-color:transparent">
|
|
1386
|
+
<div style="font-size:var(--fs-sm);color:var(--text);line-height:1.6">
|
|
1387
|
+
<b>The catalog</b> publishes <b>context products</b> — reusable bundles of sources, services and tools —
|
|
1388
|
+
and the <b>grants</b> that decide which principals may consume each product. Browse below as cards or a
|
|
1389
|
+
table; an admin can create or change products via the editor or the API example.
|
|
1390
|
+
</div>
|
|
1391
|
+
</div>
|
|
1392
|
+
<div class="card">
|
|
1393
|
+
<div class="card-header"><h2>Products</h2></div>
|
|
1394
|
+
<div id="ent-catalog"><div class="empty">Loading…</div></div>
|
|
1395
|
+
<div id="ent-cat-editor" class="hidden" style="margin-top:12px">
|
|
1396
|
+
<div class="ed-bar">
|
|
1397
|
+
<span class="view-toggle" id="cat-views">
|
|
1398
|
+
<button data-v="form" class="active" onclick="catView('form')">Form</button>
|
|
1399
|
+
<button data-v="json" onclick="catView('json')">JSON</button>
|
|
1400
|
+
<button data-v="yaml" onclick="catView('yaml')">YAML</button>
|
|
1401
|
+
</span>
|
|
1402
|
+
<input type="password" id="ent-cat-token" class="ed-token" placeholder="Admin API key (Bearer)">
|
|
1403
|
+
</div>
|
|
1404
|
+
<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>
|
|
1405
|
+
<div id="cat-form"></div>
|
|
1406
|
+
<textarea id="ent-cat-json" class="ed-code hidden" rows="16" spellcheck="false"></textarea>
|
|
1407
|
+
<div id="ent-cat-msg" style="margin:8px 0;font-size:12px"></div>
|
|
1408
|
+
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
1409
|
+
<button class="btn btn-ghost btn-sm" onclick="closeDrawer()">Cancel</button>
|
|
1410
|
+
<button class="btn btn-primary btn-sm" onclick="entSaveCat()">Save catalog</button>
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
<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>
|
|
1414
|
+
<div class="codeblock collapsed">
|
|
1415
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1416
|
+
<span class="cb-chev">▾</span><span class="cb-title">API · create a context product via curl</span>
|
|
1417
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1418
|
+
</div>
|
|
1419
|
+
<pre><span class="tok-cmt"># publish a "payments-eu" product and grant it to a principal.</span>
|
|
1420
|
+
<span class="tok-cmt"># needs an admin API key (a principal the current policy grants admin).</span>
|
|
1421
|
+
curl -X PUT http://localhost:3000/api/enterprise/catalog \
|
|
1422
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" \
|
|
1423
|
+
-H "Content-Type: application/json" \
|
|
1424
|
+
-d '{
|
|
1425
|
+
"products": {
|
|
1426
|
+
"payments-eu": {
|
|
1427
|
+
"description": "EU payment-service metrics + logs",
|
|
1428
|
+
"sources": ["prometheus","loki"],
|
|
1429
|
+
"services": ["payment-service"]
|
|
1430
|
+
}
|
|
1431
|
+
},
|
|
1432
|
+
"grants": { "alice": ["payments-eu"] }
|
|
1433
|
+
}'</pre>
|
|
1434
|
+
</div>
|
|
1435
|
+
</div>
|
|
1436
|
+
</div>
|
|
1437
|
+
|
|
1438
|
+
<!-- ===== Governance: Audit Log ===== -->
|
|
1439
|
+
<div class="page" id="page-audit">
|
|
1440
|
+
<div class="page-head">
|
|
1441
|
+
<div class="ph-left">
|
|
1442
|
+
<div class="breadcrumb">Console / Governance / <b>Audit Log</b></div>
|
|
1443
|
+
<h1>Audit Log</h1>
|
|
1444
|
+
</div>
|
|
1445
|
+
<div class="ph-actions"><span id="ent-chain"></span></div>
|
|
1446
|
+
</div>
|
|
1447
|
+
<div class="card">
|
|
1448
|
+
<div class="card-header"><h2>Recent decisions
|
|
1449
|
+
<button class="info" aria-label="About the audit log"
|
|
1450
|
+
data-title="Audit log"
|
|
1451
|
+
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."
|
|
1452
|
+
onclick="infoPop(this)">?</button>
|
|
1453
|
+
</h2></div>
|
|
1454
|
+
<div id="ent-audit"><div class="empty">Loading…</div></div>
|
|
1455
|
+
</div>
|
|
1456
|
+
</div>
|
|
1457
|
+
|
|
1458
|
+
<!-- ===== Governance: Entitlement ===== -->
|
|
1459
|
+
<div class="page" id="page-entitlement">
|
|
1460
|
+
<div class="page-head">
|
|
1461
|
+
<div class="ph-left">
|
|
1462
|
+
<div class="breadcrumb">Console / Governance / <b>Entitlement</b></div>
|
|
1463
|
+
<h1>Entitlement</h1>
|
|
1464
|
+
</div>
|
|
1465
|
+
<div class="ph-actions"><span class="live-indicator" id="ent-live">Read-only</span></div>
|
|
1466
|
+
</div>
|
|
1467
|
+
<div class="card">
|
|
1468
|
+
<div class="card-header"><h2>Access-control gate
|
|
1469
|
+
<button class="info" aria-label="About the gate"
|
|
1470
|
+
data-title="Entitlement gate"
|
|
1471
|
+
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)."
|
|
1472
|
+
onclick="infoPop(this)">?</button>
|
|
1473
|
+
</h2><span id="ent-mode"></span></div>
|
|
1474
|
+
<div id="ent-status"><div class="empty">Loading…</div></div>
|
|
1475
|
+
<div class="codeblock collapsed">
|
|
1476
|
+
<div class="codeblock-hd" onclick="toggleCode(this)">
|
|
1477
|
+
<span class="cb-chev">▾</span><span class="cb-title">CLI · mint a token · API · check the gate</span>
|
|
1478
|
+
<button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
<pre><span class="tok-cmt"># issuer side — sign an entitlement token</span>
|
|
1481
|
+
node enterprise/entitlement/mint.mjs \
|
|
1482
|
+
--key issuer-ed25519.pem \
|
|
1483
|
+
--sub org-acme --features access-control,audit --ttl 365d
|
|
1484
|
+
|
|
1485
|
+
<span class="tok-cmt"># deployment — current gate mode</span>
|
|
1486
|
+
curl -s http://localhost:3000/api/enterprise/status</pre>
|
|
1487
|
+
</div>
|
|
1488
|
+
</div>
|
|
1489
|
+
</div>
|
|
702
1490
|
</div>
|
|
703
1491
|
|
|
704
1492
|
<!-- Source Modal -->
|
|
@@ -764,6 +1552,12 @@
|
|
|
764
1552
|
|
|
765
1553
|
<div class="toast" id="toast-el"></div>
|
|
766
1554
|
|
|
1555
|
+
<div class="drawer-ov" id="drawer-ov" onclick="closeDrawer()"></div>
|
|
1556
|
+
<aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
|
|
1557
|
+
<div class="drawer-hd"><h3 id="drawer-title">Detail</h3><button class="dw-x" aria-label="Close" onclick="closeDrawer()">×</button></div>
|
|
1558
|
+
<div class="drawer-bd" id="drawer-body"></div>
|
|
1559
|
+
</aside>
|
|
1560
|
+
|
|
767
1561
|
<script>
|
|
768
1562
|
let sourcesData=[], servicesData=[], supportedTypes=[], settings={}, healthThresholds={}, defaults={};
|
|
769
1563
|
let deleteTarget=null, deleteType=null;
|
|
@@ -777,10 +1571,595 @@ function showPage(name) {
|
|
|
777
1571
|
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
|
|
778
1572
|
document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
|
|
779
1573
|
document.getElementById('page-'+name).classList.add('active');
|
|
780
|
-
document.querySelector(`.nav-btn[data-page="${name}"]`).classList.add('active');
|
|
1574
|
+
const nb=document.querySelector(`.nav-btn[data-page="${name}"]`); if(nb) nb.classList.add('active');
|
|
1575
|
+
const it=document.querySelector(`.rail-item[data-nav="${name}"]`);
|
|
1576
|
+
document.querySelectorAll('.rail-item').forEach(r=>{ if(r!==it) r.classList.remove('open'); });
|
|
1577
|
+
if(it) it.classList.add('open');
|
|
781
1578
|
if(name==='settings') loadSettingsData();
|
|
782
1579
|
if(name==='health') loadHealthData();
|
|
1580
|
+
if(name==='topology') loadTopology();
|
|
783
1581
|
if(name==='connectors') loadConnectors();
|
|
1582
|
+
if(name==='access'||name==='products'||name==='audit'||name==='entitlement') loadEnterprise();
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// --- Theme (light / dark) ---
|
|
1586
|
+
function syncThemeToggle(){
|
|
1587
|
+
const t=document.documentElement.getAttribute('data-theme')||'dark';
|
|
1588
|
+
const b=document.getElementById('theme-toggle');
|
|
1589
|
+
if(b){ b.textContent = t==='light' ? '☀' : '☾'; b.title = 'Switch to '+(t==='light'?'dark':'light')+' mode'; }
|
|
1590
|
+
}
|
|
1591
|
+
function toggleTheme(){
|
|
1592
|
+
const cur=document.documentElement.getAttribute('data-theme')==='light'?'light':'dark';
|
|
1593
|
+
const next=cur==='light'?'dark':'light';
|
|
1594
|
+
document.documentElement.setAttribute('data-theme',next);
|
|
1595
|
+
try{ localStorage.setItem('omcp-theme',next); }catch(e){}
|
|
1596
|
+
syncThemeToggle();
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// --- Navigation groups (collapsible, persisted) ---
|
|
1600
|
+
function navState(){ try{ return JSON.parse(localStorage.getItem('omcp-nav')||'{}'); }catch(e){ return {}; } }
|
|
1601
|
+
function toggleNavGroup(id){
|
|
1602
|
+
const g=document.querySelector('.rail-grp[data-grp="'+id+'"]'); if(!g) return;
|
|
1603
|
+
const collapsed=g.classList.toggle('collapsed');
|
|
1604
|
+
const s=navState(); s[id]=collapsed;
|
|
1605
|
+
try{ localStorage.setItem('omcp-nav',JSON.stringify(s)); }catch(e){}
|
|
1606
|
+
}
|
|
1607
|
+
function initNav(){
|
|
1608
|
+
const s=navState();
|
|
1609
|
+
document.querySelectorAll('.rail-grp').forEach(g=>{
|
|
1610
|
+
if(s[g.getAttribute('data-grp')]===true) g.classList.add('collapsed');
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
// --- Nav items with sub-points (Connectors, Settings) ---
|
|
1614
|
+
function navToggle(name){
|
|
1615
|
+
const it=document.querySelector('.rail-item[data-nav="'+name+'"]');
|
|
1616
|
+
if(it) it.classList.toggle('open');
|
|
1617
|
+
}
|
|
1618
|
+
function setActiveSub(name, sub){
|
|
1619
|
+
const it=document.querySelector('.rail-item[data-nav="'+name+'"]'); if(!it) return;
|
|
1620
|
+
it.querySelectorAll('.nav-sub').forEach(b=>b.classList.toggle('active', b.getAttribute('data-sub')===sub));
|
|
1621
|
+
}
|
|
1622
|
+
function goTab(name, sub){
|
|
1623
|
+
showPage(name);
|
|
1624
|
+
const el=document.getElementById('tab-'+sub); if(el){
|
|
1625
|
+
const scope=el.closest('.card')||document;
|
|
1626
|
+
scope.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
|
|
1627
|
+
scope.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|
1628
|
+
el.classList.add('active');
|
|
1629
|
+
scope.querySelectorAll('.tab-btn').forEach(b=>{ if((b.getAttribute('onclick')||'').indexOf("showTab('"+sub+"')")>=0) b.classList.add('active'); });
|
|
1630
|
+
if(sub==='metrics') populateMetricsSourceSelect();
|
|
1631
|
+
}
|
|
1632
|
+
setActiveSub(name, sub);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// --- Header notification feed (collapsible, acknowledgeable) ---
|
|
1636
|
+
function notifAck(){ try{ return JSON.parse(localStorage.getItem('omcp-ack')||'{}'); }catch(e){ return {}; } }
|
|
1637
|
+
function notifAckSave(o){ try{ localStorage.setItem('omcp-ack',JSON.stringify(o)); }catch(e){} }
|
|
1638
|
+
function notifSeen(){ try{ return JSON.parse(localStorage.getItem('omcp-seen')||'{}'); }catch(e){ return {}; } }
|
|
1639
|
+
function notifSeenSave(o){ try{ localStorage.setItem('omcp-seen',JSON.stringify(o)); }catch(e){} }
|
|
1640
|
+
let notifItems=[];
|
|
1641
|
+
function relTime(iso){
|
|
1642
|
+
const d=Date.now()-new Date(iso).getTime(); const m=Math.floor(d/60000);
|
|
1643
|
+
if(m<1) return 'just now'; if(m<60) return m+'m ago';
|
|
1644
|
+
const h=Math.floor(m/60); if(h<24) return h+'h ago'; return Math.floor(h/24)+'d ago';
|
|
1645
|
+
}
|
|
1646
|
+
async function buildNotifications(){
|
|
1647
|
+
const out=[];
|
|
1648
|
+
// Sources unreachable
|
|
1649
|
+
for(const s of (sourcesData||[])) if(s.enabled && s.status!=='up')
|
|
1650
|
+
out.push({id:'src:'+s.name, sev:'bad', text:`Source “${s.name}” is unreachable.`});
|
|
1651
|
+
// Service health
|
|
1652
|
+
try{
|
|
1653
|
+
const h=await(await fetch('/api/health')).json();
|
|
1654
|
+
for(const v of Object.values(h||{})) if(v && v.status!=='healthy')
|
|
1655
|
+
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}).`});
|
|
1656
|
+
}catch(e){}
|
|
1657
|
+
// Gate / entitlement
|
|
1658
|
+
try{
|
|
1659
|
+
const g=await(await fetch('/api/enterprise/status')).json();
|
|
1660
|
+
if(g.mode==='fail-closed') out.push({id:'gate:fc', sev:'bad', text:`Access-control gate is fail-closed${g.reason?': '+g.reason:''}.`});
|
|
1661
|
+
}catch(e){}
|
|
1662
|
+
// Audit chain integrity
|
|
1663
|
+
try{
|
|
1664
|
+
const a=await(await fetch('/api/enterprise/audit?limit=1')).json();
|
|
1665
|
+
if(a.configured && a.chain && a.chain.ok===false) out.push({id:'audit:broken', sev:'bad', text:'Audit log chain integrity check failed.'});
|
|
1666
|
+
}catch(e){}
|
|
1667
|
+
// Stamp first-seen times; prune stale seen entries.
|
|
1668
|
+
const seen=notifSeen(), now=new Date().toISOString(), live={};
|
|
1669
|
+
for(const n of out){ if(!seen[n.id]) seen[n.id]=now; n.ts=seen[n.id]; live[n.id]=seen[n.id]; }
|
|
1670
|
+
notifSeenSave(live);
|
|
1671
|
+
notifItems=out.sort((a,b)=> (a.ts<b.ts?1:-1));
|
|
1672
|
+
renderNotif();
|
|
1673
|
+
}
|
|
1674
|
+
function renderNotif(){
|
|
1675
|
+
const ack=notifAck();
|
|
1676
|
+
const active=notifItems.filter(n=>!ack[n.id]);
|
|
1677
|
+
const cnt=document.getElementById('notif-count'); const list=document.getElementById('notif-list');
|
|
1678
|
+
if(cnt){ if(active.length){ cnt.hidden=false; cnt.textContent=String(active.length); } else cnt.hidden=true; }
|
|
1679
|
+
if(!list) return;
|
|
1680
|
+
list.innerHTML = active.length===0
|
|
1681
|
+
? '<div class="notif-empty">You’re all caught up — no notifications.</div>'
|
|
1682
|
+
: 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('');
|
|
1683
|
+
}
|
|
1684
|
+
function notifDo(id){ const a=notifAck(); a[id]=true; notifAckSave(a); renderNotif(); }
|
|
1685
|
+
function notifAckAll(){ const a=notifAck(); notifItems.forEach(n=>a[n.id]=true); notifAckSave(a); renderNotif(); }
|
|
1686
|
+
function toggleNotif(){
|
|
1687
|
+
const p=document.getElementById('notif-panel'); if(!p) return;
|
|
1688
|
+
p.hidden=!p.hidden;
|
|
1689
|
+
if(!p.hidden){ document.addEventListener('click', notifOutside, true); }
|
|
1690
|
+
else document.removeEventListener('click', notifOutside, true);
|
|
1691
|
+
}
|
|
1692
|
+
function notifOutside(e){
|
|
1693
|
+
const w=document.querySelector('.notif-wrap'); if(w && !w.contains(e.target)){
|
|
1694
|
+
const p=document.getElementById('notif-panel'); if(p) p.hidden=true;
|
|
1695
|
+
document.removeEventListener('click', notifOutside, true);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// --- Inline "?" info popovers + copyable CLI/API blocks ---
|
|
1700
|
+
function infoPop(btn){
|
|
1701
|
+
let p=document.getElementById('info-pop');
|
|
1702
|
+
if(!p){ p=document.createElement('div'); p.id='info-pop'; p.className='popover'; p.hidden=true; document.body.appendChild(p); }
|
|
1703
|
+
if(p._owner===btn && !p.hidden){ p.hidden=true; p._owner=null; return; }
|
|
1704
|
+
p.innerHTML=(btn.dataset.title?`<h4>${escHtml(btn.dataset.title)}</h4>`:'')+`<div>${escHtml(btn.dataset.info||'')}</div>`;
|
|
1705
|
+
p.hidden=false; p._owner=btn;
|
|
1706
|
+
const r=btn.getBoundingClientRect();
|
|
1707
|
+
p.style.left=Math.max(8,Math.min(window.innerWidth-308, r.left))+'px';
|
|
1708
|
+
p.style.top=(r.bottom+8)+'px';
|
|
1709
|
+
setTimeout(()=>document.addEventListener('click', infoOutside, true),0);
|
|
1710
|
+
}
|
|
1711
|
+
function infoOutside(e){
|
|
1712
|
+
const p=document.getElementById('info-pop');
|
|
1713
|
+
if(p && !p.hidden && !p.contains(e.target) && !(e.target.classList&&e.target.classList.contains('info'))){
|
|
1714
|
+
p.hidden=true; p._owner=null; document.removeEventListener('click', infoOutside, true);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function copyCode(btn){
|
|
1718
|
+
const pre=btn.closest('.codeblock').querySelector('pre');
|
|
1719
|
+
navigator.clipboard.writeText(pre.innerText).then(()=>toast('Copied to clipboard'));
|
|
1720
|
+
}
|
|
1721
|
+
function toggleCode(hd){ hd.closest('.codeblock').classList.toggle('collapsed'); }
|
|
1722
|
+
|
|
1723
|
+
// --- Detail slide-over (clickable elements → inspector) ---
|
|
1724
|
+
function openDrawer(title, html){
|
|
1725
|
+
_edRestore();
|
|
1726
|
+
document.getElementById('drawer-title').textContent=title;
|
|
1727
|
+
document.getElementById('drawer-body').innerHTML=html;
|
|
1728
|
+
document.getElementById('drawer').classList.add('open');
|
|
1729
|
+
document.getElementById('drawer-ov').classList.add('open');
|
|
1730
|
+
document.addEventListener('keydown', drawerEsc);
|
|
1731
|
+
}
|
|
1732
|
+
function closeDrawer(){
|
|
1733
|
+
document.getElementById('drawer').classList.remove('open');
|
|
1734
|
+
document.getElementById('drawer-ov').classList.remove('open');
|
|
1735
|
+
document.removeEventListener('keydown', drawerEsc);
|
|
1736
|
+
_edRestore();
|
|
1737
|
+
}
|
|
1738
|
+
function drawerEsc(e){ if(e.key==='Escape') closeDrawer(); }
|
|
1739
|
+
// --- Edit panels live in the slide-over (clean browse view, focused edit) ---
|
|
1740
|
+
let _edNode=null, _edHost=null;
|
|
1741
|
+
function entEditNew(kind){ entEditOpen(kind, true); }
|
|
1742
|
+
function entEditOpen(kind, seedNew){
|
|
1743
|
+
const id = kind==='cat' ? 'ent-cat-editor' : 'ent-rbac-editor';
|
|
1744
|
+
const node=document.getElementById(id); if(!node) return;
|
|
1745
|
+
const after = seedNew ? (kind==='cat'?catAddProduct:rbacAddRole) : null;
|
|
1746
|
+
if(kind==='cat') entToggleCatEdit(true, after); else entToggleRbacEdit(true, after);
|
|
1747
|
+
_edNode=node; _edHost=node.parentNode;
|
|
1748
|
+
const body=document.getElementById('drawer-body');
|
|
1749
|
+
body.innerHTML=''; body.appendChild(node); node.classList.remove('hidden');
|
|
1750
|
+
document.getElementById('drawer-title').textContent = kind==='cat' ? 'Edit catalog' : 'Edit access policy';
|
|
1751
|
+
const dw=document.getElementById('drawer');
|
|
1752
|
+
dw.classList.add('drawer-wide','open');
|
|
1753
|
+
document.getElementById('drawer-ov').classList.add('open');
|
|
1754
|
+
document.addEventListener('keydown', drawerEsc);
|
|
1755
|
+
}
|
|
1756
|
+
function _edRestore(){
|
|
1757
|
+
if(_edNode&&_edHost){ _edNode.classList.add('hidden'); _edHost.appendChild(_edNode); _edNode=null; _edHost=null; }
|
|
1758
|
+
document.getElementById('drawer').classList.remove('drawer-wide');
|
|
1759
|
+
['ent-rbac-editbtn','ent-cat-editbtn'].forEach(i=>{ const b=document.getElementById(i); if(b) b.classList.remove('hidden'); });
|
|
1760
|
+
}
|
|
1761
|
+
function dwChips(a){ return (a&&a.length?a:['*']).map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''); }
|
|
1762
|
+
function dwSec(lbl, inner){ return `<div class="dw-sec"><div class="dw-lbl">${escHtml(lbl)}</div><div>${inner}</div></div>`; }
|
|
1763
|
+
|
|
1764
|
+
// --- Data Products view (cards / table toggle + search) ---
|
|
1765
|
+
let dpData=null;
|
|
1766
|
+
let healthMap={};
|
|
1767
|
+
let auditMap={};
|
|
1768
|
+
function audDetail(seq){
|
|
1769
|
+
const e=auditMap[seq]; if(!e){ openDrawer('Decision #'+seq,'<div class="empty">No detail.</div>'); return; }
|
|
1770
|
+
const ev=e.event||{}, req=ev.request||{};
|
|
1771
|
+
const tone=ev.allow?'ok':'bad';
|
|
1772
|
+
const reqRows=Object.keys(req).map(k=>`<tr><td>${escHtml(k)}</td><td class="mono">${escHtml(typeof req[k]==='object'?JSON.stringify(req[k]):String(req[k]))}</td></tr>`).join('')||'<tr><td colspan=2 style="color:var(--text-dim)">no request fields</td></tr>';
|
|
1773
|
+
const html=
|
|
1774
|
+
dwSec('Decision', `<span class="stchip ${tone}">${ev.allow?'allow':'deny'}</span> <span style="color:var(--text-muted);font-size:var(--fs-sm)">sequence #${escHtml(String(e.seq))}</span>`)+
|
|
1775
|
+
dwSec('Principal & reason', `<table class="dtable"><tbody>
|
|
1776
|
+
<tr><td>Principal</td><td class="mono">${escHtml(ev.principalId||'—')}</td></tr>
|
|
1777
|
+
<tr><td>Timestamp</td><td class="mono">${escHtml(ev.ts||e.ts||'—')}</td></tr>
|
|
1778
|
+
<tr><td>Reason</td><td>${escHtml(ev.reason||'—')}</td></tr></tbody></table>`)+
|
|
1779
|
+
dwSec('Request', `<table class="dtable"><tbody>${reqRows}</tbody></table>`)+
|
|
1780
|
+
dwSec('Chain integrity', `<table class="dtable"><tbody>
|
|
1781
|
+
<tr><td>Entry hash</td><td class="mono" style="word-break:break-all">${escHtml(e.hash||'—')}</td></tr>
|
|
1782
|
+
<tr><td>Prev hash</td><td class="mono" style="word-break:break-all">${escHtml(e.prevHash||'—')}</td></tr></tbody></table>`);
|
|
1783
|
+
openDrawer('Decision #'+seq, html);
|
|
1784
|
+
}
|
|
1785
|
+
function dpView(){ try{ return localStorage.getItem('omcp-dp-view')==='table'?'table':'cards'; }catch(e){ return 'cards'; } }
|
|
1786
|
+
function dpSetView(v){ try{ localStorage.setItem('omcp-dp-view',v); }catch(e){} dpRender(); }
|
|
1787
|
+
function dpOnSearch(){ dpRender(); }
|
|
1788
|
+
function dpRender(){
|
|
1789
|
+
const box=document.getElementById('ent-catalog'); if(!box||!dpData) return;
|
|
1790
|
+
const products=dpData.products||{}, grants=dpData.grants||{};
|
|
1791
|
+
// reverse map: product -> [principals]
|
|
1792
|
+
const grantedTo={};
|
|
1793
|
+
for(const [pr,list] of Object.entries(grants)) (list||[]).forEach(pn=>{ (grantedTo[pn]=grantedTo[pn]||[]).push(pr); });
|
|
1794
|
+
const q=(document.getElementById('dp-q')?document.getElementById('dp-q').value:'').trim().toLowerCase();
|
|
1795
|
+
let entries=Object.entries(products);
|
|
1796
|
+
if(q) entries=entries.filter(([n,p])=>(n+' '+((p&&p.description)||'')).toLowerCase().includes(q));
|
|
1797
|
+
const view=dpView(), total=Object.keys(products).length, shown=entries.length;
|
|
1798
|
+
const chips=a=>(a&&a.length?a:['*']).map(x=>`<span class="chip">${escHtml(x)}</span>`).join('');
|
|
1799
|
+
const bar=`<div class="dp-bar">
|
|
1800
|
+
<span class="dp-count"><b>${shown}</b>${shown!==total?` / ${total}`:''} product${total===1?'':'s'}</span>
|
|
1801
|
+
<span class="dp-search"><input id="dp-q" type="search" placeholder="Search products…" value="${escHtml(q)}" oninput="dpOnSearch()"></span>
|
|
1802
|
+
<span class="dp-spacer"></span>
|
|
1803
|
+
<span class="view-toggle">
|
|
1804
|
+
<button class="${view==='cards'?'active':''}" title="Cards" onclick="dpSetView('cards')">▦ Cards</button>
|
|
1805
|
+
<button class="${view==='table'?'active':''}" title="Table" onclick="dpSetView('table')">☰ Table</button>
|
|
1806
|
+
</span>
|
|
1807
|
+
</div>`;
|
|
1808
|
+
let body;
|
|
1809
|
+
if(shown===0){ body='<div class="empty">No products match the filter.</div>'; }
|
|
1810
|
+
else if(view==='table'){
|
|
1811
|
+
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>`+
|
|
1812
|
+
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('')+
|
|
1813
|
+
`</tbody></table>`;
|
|
1814
|
+
} else {
|
|
1815
|
+
body='<div class="dp-grid">'+entries.map(([n,p])=>{ const g=grantedTo[n]||[];
|
|
1816
|
+
return `<div class="dp-card" data-dp="${escHtml(n)}"><h3>${escHtml(n)}</h3>
|
|
1817
|
+
<div class="dp-desc">${escHtml((p&&p.description)||'No description provided.')}</div>
|
|
1818
|
+
<div class="dp-attr"><span class="lbl">Sources</span>${chips(p&&p.sources)}</div>
|
|
1819
|
+
<div class="dp-attr"><span class="lbl">Services</span>${chips(p&&p.services)}</div>
|
|
1820
|
+
<div class="dp-attr"><span class="lbl">Tools</span>${chips(p&&p.tools)}</div>
|
|
1821
|
+
<div class="dp-foot"><span class="dp-grants">${g.length?`Granted to ${g.length} principal${g.length===1?'':'s'}`:'No grants'}</span></div>
|
|
1822
|
+
</div>`; }).join('')+'</div>';
|
|
1823
|
+
}
|
|
1824
|
+
box.innerHTML=bar+body;
|
|
1825
|
+
box.querySelectorAll('[data-dp]').forEach(el=>el.addEventListener('click',()=>dpDetail(el.getAttribute('data-dp'))));
|
|
1826
|
+
const inp=document.getElementById('dp-q'); if(inp&&q){ inp.focus(); inp.setSelectionRange(q.length,q.length); }
|
|
1827
|
+
}
|
|
1828
|
+
function dpDetail(name){
|
|
1829
|
+
if(!dpData) return;
|
|
1830
|
+
const p=(dpData.products||{})[name]||{};
|
|
1831
|
+
const grantedTo=[]; for(const [pr,list] of Object.entries(dpData.grants||{})) if((list||[]).includes(name)) grantedTo.push(pr);
|
|
1832
|
+
const ex=`curl -X PUT http://localhost:3000/api/enterprise/catalog \\
|
|
1833
|
+
-H "Authorization: Bearer <ADMIN_API_KEY>" -H "Content-Type: application/json" \\
|
|
1834
|
+
-d '{"grants":{"<principal>":["${escHtml(name)}"]}}'`;
|
|
1835
|
+
const html=
|
|
1836
|
+
dwSec('Description', `<div style="font-size:var(--fs-sm);color:var(--text-muted)">${escHtml(p.description||'No description provided.')}</div>`)+
|
|
1837
|
+
dwSec('Sources', dwChips(p.sources))+
|
|
1838
|
+
dwSec('Services', dwChips(p.services))+
|
|
1839
|
+
dwSec('Tools', dwChips(p.tools))+
|
|
1840
|
+
dwSec('Granted to ('+grantedTo.length+')', grantedTo.length?grantedTo.map(x=>`<span class="chip">${escHtml(x)}</span>`).join(''):'<span style="color:var(--text-dim);font-size:var(--fs-sm)">No principals granted this product.</span>')+
|
|
1841
|
+
`<div class="codeblock collapsed" style="margin-top:14px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · grant this product</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>${ex}</pre></div>`;
|
|
1842
|
+
openDrawer(name, html);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// --- Enterprise (read-only governance console) ---
|
|
1846
|
+
const GATE_CLASS={off:'gate-off',active:'gate-active','fail-closed':'gate-failclosed'};
|
|
1847
|
+
async function loadGatePill(){
|
|
1848
|
+
const el=document.getElementById('gate-pill'); if(!el) return;
|
|
1849
|
+
try{
|
|
1850
|
+
const s=await(await fetch('/api/enterprise/status')).json();
|
|
1851
|
+
el.className='gate-pill '+(GATE_CLASS[s.mode]||'gate-unknown');
|
|
1852
|
+
el.textContent='Gate: '+(s.mode||'unknown');
|
|
1853
|
+
el.title=s.reason?('Access-control gate — '+s.reason):'Access-control gate';
|
|
1854
|
+
}catch{ el.className='gate-pill gate-unknown'; el.textContent='Gate: n/a'; }
|
|
1855
|
+
}
|
|
1856
|
+
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>';}
|
|
1857
|
+
async function loadEnterprise(){
|
|
1858
|
+
// Status + entitlement claims
|
|
1859
|
+
try{
|
|
1860
|
+
const s=await(await fetch('/api/enterprise/status')).json();
|
|
1861
|
+
document.getElementById('ent-mode').innerHTML=`<span class="gate-pill ${GATE_CLASS[s.mode]||'gate-unknown'}">${escHtml(s.mode)}</span>`;
|
|
1862
|
+
const rows={ 'Mode':s.mode, 'Active':s.active?'yes':'no' };
|
|
1863
|
+
if(s.reason) rows['Reason']=s.reason;
|
|
1864
|
+
rows['RBAC configured']=s.rbacConfigured?'yes':'no';
|
|
1865
|
+
rows['Catalog configured']=s.catalogConfigured?'yes':'no';
|
|
1866
|
+
rows['Audit configured']=s.auditConfigured?'yes':'no';
|
|
1867
|
+
let html=entKV(rows);
|
|
1868
|
+
if(s.entitlement){
|
|
1869
|
+
const e=s.entitlement, fmt=t=>t?new Date(t*1000).toISOString().replace('T',' ').slice(0,19)+'Z':'—';
|
|
1870
|
+
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>'+
|
|
1871
|
+
entKV({Subject:e.sub??'—',Tier:e.tier??'—',Features:(e.features||[]).join(', ')||'—',Issued:fmt(e.iat),Expires:fmt(e.exp)});
|
|
1872
|
+
}
|
|
1873
|
+
document.getElementById('ent-status').innerHTML=html;
|
|
1874
|
+
}catch(e){ document.getElementById('ent-status').innerHTML='<div class="empty">Status unavailable.</div>'; }
|
|
1875
|
+
// RBAC policy (read-only)
|
|
1876
|
+
try{
|
|
1877
|
+
const p=await(await fetch('/api/enterprise/policy')).json();
|
|
1878
|
+
const box=document.getElementById('ent-rbac');
|
|
1879
|
+
if(!p.configured){ box.innerHTML='<div class="empty">No RBAC policy configured.</div>'; }
|
|
1880
|
+
else if(p.error){ box.innerHTML=`<div class="empty">Policy unreadable: ${escHtml(p.error)}</div>`; }
|
|
1881
|
+
else {
|
|
1882
|
+
const roles=p.data.roles||{}, binds=p.data.bindings||{};
|
|
1883
|
+
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('');
|
|
1884
|
+
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('');
|
|
1885
|
+
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>
|
|
1886
|
+
<div style="margin:12px 0 6px;color:var(--text-dim);font-size:10px;letter-spacing:.08em;text-transform:uppercase">Bindings</div>
|
|
1887
|
+
<table class="dtable"><thead><tr><th>Principal</th><th>Roles</th></tr></thead><tbody>${bt||'<tr><td colspan=2>—</td></tr>'}</tbody></table>`;
|
|
1888
|
+
}
|
|
1889
|
+
}catch{ document.getElementById('ent-rbac').innerHTML='<div class="empty">Policy unavailable.</div>'; }
|
|
1890
|
+
// Data Products — rich, toggleable (cards / table) + search
|
|
1891
|
+
try{
|
|
1892
|
+
const c=await(await fetch('/api/enterprise/catalog')).json();
|
|
1893
|
+
const box=document.getElementById('ent-catalog');
|
|
1894
|
+
if(!c.configured){ box.innerHTML='<div class="empty">No product catalog configured.</div>'; dpData=null; }
|
|
1895
|
+
else if(c.error){ box.innerHTML=`<div class="empty">Catalog unreadable: ${escHtml(c.error)}</div>`; dpData=null; }
|
|
1896
|
+
else { dpData=c.data||{products:{},grants:{}}; dpRender(); }
|
|
1897
|
+
}catch{ const b=document.getElementById('ent-catalog'); if(b) b.innerHTML='<div class="empty">Catalog unavailable.</div>'; }
|
|
1898
|
+
// Audit tail + chain integrity
|
|
1899
|
+
try{
|
|
1900
|
+
const a=await(await fetch('/api/enterprise/audit?limit=25')).json();
|
|
1901
|
+
const box=document.getElementById('ent-audit'), chip=document.getElementById('ent-chain');
|
|
1902
|
+
if(!a.configured){ box.innerHTML='<div class="empty">No audit log configured.</div>'; chip.innerHTML=''; }
|
|
1903
|
+
else if(a.error){ box.innerHTML=`<div class="empty">Audit unreadable: ${escHtml(a.error)}</div>`; }
|
|
1904
|
+
else {
|
|
1905
|
+
const ok=a.chain&&a.chain.ok===true;
|
|
1906
|
+
chip.innerHTML=`<span class="gate-pill ${ok?'gate-active':'gate-failclosed'}">chain ${ok?'intact':'broken'}</span>`;
|
|
1907
|
+
auditMap={};
|
|
1908
|
+
const rows=(a.entries||[]).slice().reverse().map(e=>{
|
|
1909
|
+
const ev=e.event||{}; auditMap[e.seq]=e;
|
|
1910
|
+
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>`;
|
|
1911
|
+
}).join('');
|
|
1912
|
+
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>
|
|
1913
|
+
<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>`;
|
|
1914
|
+
box.querySelectorAll('[data-aud]').forEach(el=>el.addEventListener('click',()=>audDetail(el.getAttribute('data-aud'))));
|
|
1915
|
+
}
|
|
1916
|
+
}catch{ document.getElementById('ent-audit').innerHTML='<div class="empty">Audit unavailable.</div>'; }
|
|
1917
|
+
}
|
|
1918
|
+
// ---- Minimal YAML for the flat policy/catalog schema (maps, string
|
|
1919
|
+
// arrays, booleans, strings). Optional alternative view to the form. ----
|
|
1920
|
+
function yQuote(s){
|
|
1921
|
+
if(s==='') return '""';
|
|
1922
|
+
if(/^[A-Za-z0-9_.\-\/*]+$/.test(s) && !/^(true|false|null|~)$/i.test(s) && !/^-?\d/.test(s)) return s;
|
|
1923
|
+
return '"'+String(s).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"';
|
|
1924
|
+
}
|
|
1925
|
+
function yamlDump(v, ind){
|
|
1926
|
+
ind=ind||0; const pad=' '.repeat(ind); let out='';
|
|
1927
|
+
if(Array.isArray(v)){
|
|
1928
|
+
if(v.length===0) return ' []\n';
|
|
1929
|
+
out='\n'; for(const it of v) out+=pad+'- '+yQuote(String(it))+'\n'; return out;
|
|
1930
|
+
}
|
|
1931
|
+
if(v && typeof v==='object'){
|
|
1932
|
+
const ks=Object.keys(v); if(ks.length===0) return ' {}\n';
|
|
1933
|
+
out=(ind?'\n':''); for(const k of ks){
|
|
1934
|
+
const val=v[k];
|
|
1935
|
+
if(val && typeof val==='object'){ out+=pad+yQuote(k)+':'+yamlDump(val,ind+2); }
|
|
1936
|
+
else out+=pad+yQuote(k)+': '+(typeof val==='boolean'?String(val):yQuote(String(val)))+'\n';
|
|
1937
|
+
}
|
|
1938
|
+
return out;
|
|
1939
|
+
}
|
|
1940
|
+
return ' '+(typeof v==='boolean'?String(v):yQuote(String(v)))+'\n';
|
|
1941
|
+
}
|
|
1942
|
+
function yUnq(s){ s=s.trim();
|
|
1943
|
+
if(s==='[]') return []; if(s==='{}') return {};
|
|
1944
|
+
if(s==='true') return true; if(s==='false') return false;
|
|
1945
|
+
if(s.length>1 && s[0]==='"' && s[s.length-1]==='"') return s.slice(1,-1).replace(/\\"/g,'"').replace(/\\\\/g,'\\');
|
|
1946
|
+
return s;
|
|
1947
|
+
}
|
|
1948
|
+
function yamlParse(text){
|
|
1949
|
+
const raw=text.replace(/\t/g,' ').split('\n')
|
|
1950
|
+
.filter(l=>l.trim()!=='' && !/^\s*#/.test(l))
|
|
1951
|
+
.map(l=>({ind:l.match(/^ */)[0].length, t:l.trim()}));
|
|
1952
|
+
let i=0;
|
|
1953
|
+
function block(minInd){
|
|
1954
|
+
if(i>=raw.length || raw[i].ind<minInd) return null;
|
|
1955
|
+
const ind=raw[i].ind;
|
|
1956
|
+
if(raw[i].t.startsWith('- ')){
|
|
1957
|
+
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;
|
|
1958
|
+
}
|
|
1959
|
+
const obj={};
|
|
1960
|
+
while(i<raw.length && raw[i].ind===ind && !raw[i].t.startsWith('- ')){
|
|
1961
|
+
const line=raw[i].t, c=line.indexOf(':');
|
|
1962
|
+
if(c<0) throw new Error('invalid YAML line: '+line);
|
|
1963
|
+
const key=yUnq(line.slice(0,c)), rest=line.slice(c+1).trim();
|
|
1964
|
+
i++;
|
|
1965
|
+
if(rest!=='' ){ obj[key]=yUnq(rest); }
|
|
1966
|
+
else { const child=block(ind+1); obj[key]= child===null ? {} : child; }
|
|
1967
|
+
}
|
|
1968
|
+
return obj;
|
|
1969
|
+
}
|
|
1970
|
+
const r=block(0); if(i<raw.length) throw new Error('unexpected indentation near: '+raw[i].t);
|
|
1971
|
+
return r||{};
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// ---- RBAC editor: Form (primary) + JSON / YAML alternative views ----
|
|
1975
|
+
let rbacModel={roles:{},bindings:{},defaultRoles:[]};
|
|
1976
|
+
let rbacCur='form';
|
|
1977
|
+
function entToggleRbacEdit(on, after){
|
|
1978
|
+
const ed=document.getElementById('ent-rbac-editor'), btn=document.getElementById('ent-rbac-editbtn');
|
|
1979
|
+
document.getElementById('ent-rbac-msg').textContent='';
|
|
1980
|
+
if(on){
|
|
1981
|
+
fetch('/api/enterprise/policy').then(r=>r.json()).then(p=>{
|
|
1982
|
+
const d=(p&&p.configured&&p.data)?p.data:{};
|
|
1983
|
+
rbacModel={roles:d.roles||{}, bindings:d.bindings||{}, defaultRoles:d.defaultRoles||[]};
|
|
1984
|
+
rbacCur='form'; rbacSetView('form');
|
|
1985
|
+
if(typeof after==='function') after();
|
|
1986
|
+
});
|
|
1987
|
+
ed.classList.remove('hidden'); btn.classList.add('hidden');
|
|
1988
|
+
} else { ed.classList.add('hidden'); btn.classList.remove('hidden'); }
|
|
1989
|
+
}
|
|
1990
|
+
function csv(a){ return (a||[]).join(', '); }
|
|
1991
|
+
function fromCsv(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Boolean); }
|
|
1992
|
+
function rbacRenderForm(){
|
|
1993
|
+
const m=rbacModel; let h='';
|
|
1994
|
+
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>`;
|
|
1995
|
+
const rk=Object.keys(m.roles);
|
|
1996
|
+
h+= rk.length?rk.map(rn=>{ const r=m.roles[rn]||{}; return `<div class="fe-card" data-role>
|
|
1997
|
+
<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>
|
|
1998
|
+
<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">
|
|
1999
|
+
<div class="fe-fieldlbl">Sources</div><input type="text" class="r-sources" value="${escHtml(csv(r.sources))}" placeholder="* or prometheus, loki">
|
|
2000
|
+
<div class="fe-fieldlbl">Services</div><input type="text" class="r-services" value="${escHtml(csv(r.services))}" placeholder="* or payment-service">
|
|
2001
|
+
</div>`; }).join(''):'<div class="form-hint">No roles yet.</div>';
|
|
2002
|
+
h+=`</div>`;
|
|
2003
|
+
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>`;
|
|
2004
|
+
const bk=Object.keys(m.bindings);
|
|
2005
|
+
h+= bk.length?bk.map(pn=>`<div class="fe-row" data-bind>
|
|
2006
|
+
<input type="text" class="b-pr fe-grow" value="${escHtml(pn)}" placeholder="principal (API-key name)">
|
|
2007
|
+
<input type="text" class="b-roles fe-grow" value="${escHtml(csv(m.bindings[pn]))}" placeholder="role1, role2">
|
|
2008
|
+
<button class="fe-x" onclick="this.closest('.fe-row').remove()">×</button></div>`).join(''):'<div class="form-hint">No bindings yet.</div>';
|
|
2009
|
+
h+=`</div>`;
|
|
2010
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Default roles (unbound principals)</span></div>
|
|
2011
|
+
<input type="text" id="r-defaults" value="${escHtml(csv(m.defaultRoles))}" placeholder="empty = default-deny"></div>`;
|
|
2012
|
+
document.getElementById('rbac-form').innerHTML=h;
|
|
2013
|
+
}
|
|
2014
|
+
function rbacAddRole(){ rbacCollectForm(); rbacModel.roles['new-role']={tools:[],sources:['*'],services:['*']}; rbacRenderForm(); }
|
|
2015
|
+
function rbacAddBinding(){ rbacCollectForm(); rbacModel.bindings['principal']=[]; rbacRenderForm(); }
|
|
2016
|
+
function rbacCollectForm(){
|
|
2017
|
+
const roles={}, bindings={};
|
|
2018
|
+
document.querySelectorAll('#rbac-form [data-role]').forEach(c=>{
|
|
2019
|
+
const n=c.querySelector('.r-name').value.trim(); if(!n) return;
|
|
2020
|
+
const r={tools:fromCsv(c.querySelector('.r-tools').value), sources:fromCsv(c.querySelector('.r-sources').value), services:fromCsv(c.querySelector('.r-services').value)};
|
|
2021
|
+
if(c.querySelector('.r-ro').checked) r.readOnly=true;
|
|
2022
|
+
roles[n]=r;
|
|
2023
|
+
});
|
|
2024
|
+
document.querySelectorAll('#rbac-form [data-bind]').forEach(c=>{
|
|
2025
|
+
const p=c.querySelector('.b-pr').value.trim(); if(!p) return;
|
|
2026
|
+
bindings[p]=fromCsv(c.querySelector('.b-roles').value);
|
|
2027
|
+
});
|
|
2028
|
+
const dr=document.getElementById('r-defaults');
|
|
2029
|
+
rbacModel={roles,bindings,defaultRoles:dr?fromCsv(dr.value):[]};
|
|
2030
|
+
return rbacModel;
|
|
2031
|
+
}
|
|
2032
|
+
function rbacSyncFrom(view){
|
|
2033
|
+
if(view==='form'){ rbacCollectForm(); return true; }
|
|
2034
|
+
const ta=document.getElementById('ent-rbac-json'); const msg=document.getElementById('ent-rbac-msg');
|
|
2035
|
+
try{
|
|
2036
|
+
const obj = view==='json' ? JSON.parse(ta.value) : yamlParse(ta.value);
|
|
2037
|
+
rbacModel={roles:obj.roles||{}, bindings:obj.bindings||{}, defaultRoles:obj.defaultRoles||[]};
|
|
2038
|
+
return true;
|
|
2039
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Invalid '+view.toUpperCase()+': '+e.message; return false; }
|
|
2040
|
+
}
|
|
2041
|
+
function rbacSetView(v){
|
|
2042
|
+
const form=document.getElementById('rbac-form'), ta=document.getElementById('ent-rbac-json');
|
|
2043
|
+
document.querySelectorAll('#rbac-views button').forEach(b=>b.classList.toggle('active', b.dataset.v===v));
|
|
2044
|
+
if(v==='form'){ form.classList.remove('hidden'); ta.classList.add('hidden'); rbacRenderForm(); }
|
|
2045
|
+
else {
|
|
2046
|
+
form.classList.add('hidden'); ta.classList.remove('hidden');
|
|
2047
|
+
ta.value = v==='json' ? JSON.stringify(rbacModel,null,2) : ('# roles / bindings / defaultRoles\n'+yamlDump(rbacModel));
|
|
2048
|
+
}
|
|
2049
|
+
rbacCur=v;
|
|
2050
|
+
}
|
|
2051
|
+
function rbacView(v){
|
|
2052
|
+
if(v===rbacCur) return;
|
|
2053
|
+
document.getElementById('ent-rbac-msg').textContent='';
|
|
2054
|
+
if(!rbacSyncFrom(rbacCur)) return; // keep current view if it can't be parsed
|
|
2055
|
+
rbacSetView(v);
|
|
2056
|
+
}
|
|
2057
|
+
async function entSaveRbac(){
|
|
2058
|
+
const msg=document.getElementById('ent-rbac-msg');
|
|
2059
|
+
const tok=document.getElementById('ent-admin-token').value.trim();
|
|
2060
|
+
if(!rbacSyncFrom(rbacCur)) return;
|
|
2061
|
+
if(!tok){ msg.style.color='var(--danger)'; msg.textContent='Admin API key required.'; return; }
|
|
2062
|
+
msg.style.color='var(--text-muted)'; msg.textContent='Saving…';
|
|
2063
|
+
try{
|
|
2064
|
+
const r=await fetch('/api/enterprise/policy',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(rbacModel)});
|
|
2065
|
+
const d=await r.json().catch(()=>({}));
|
|
2066
|
+
if(r.ok){ msg.style.color='var(--success)'; msg.textContent='Policy updated.'; toast('RBAC policy saved'); closeDrawer(); loadEnterprise(); }
|
|
2067
|
+
else { msg.style.color='var(--danger)'; msg.textContent='Rejected ('+r.status+'): '+(d.error||'unknown'); }
|
|
2068
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Request failed: '+e.message; }
|
|
2069
|
+
}
|
|
2070
|
+
let catModel={products:{},grants:{},defaultProducts:[]};
|
|
2071
|
+
let catCur='form';
|
|
2072
|
+
function entToggleCatEdit(on, after){
|
|
2073
|
+
const ed=document.getElementById('ent-cat-editor'), btn=document.getElementById('ent-cat-editbtn');
|
|
2074
|
+
document.getElementById('ent-cat-msg').textContent='';
|
|
2075
|
+
if(on){
|
|
2076
|
+
fetch('/api/enterprise/catalog').then(r=>r.json()).then(p=>{
|
|
2077
|
+
const d=(p&&p.configured&&p.data)?p.data:{};
|
|
2078
|
+
catModel={products:d.products||{}, grants:d.grants||{}, defaultProducts:d.defaultProducts||[]};
|
|
2079
|
+
catCur='form'; catSetView('form');
|
|
2080
|
+
if(typeof after==='function') after();
|
|
2081
|
+
});
|
|
2082
|
+
ed.classList.remove('hidden'); btn.classList.add('hidden');
|
|
2083
|
+
} else { ed.classList.add('hidden'); btn.classList.remove('hidden'); }
|
|
2084
|
+
}
|
|
2085
|
+
function catRenderForm(){
|
|
2086
|
+
const m=catModel; let h='';
|
|
2087
|
+
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>`;
|
|
2088
|
+
const pk=Object.keys(m.products);
|
|
2089
|
+
h+= pk.length?pk.map(pn=>{ const p=m.products[pn]||{}; return `<div class="fe-card" data-prod>
|
|
2090
|
+
<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>
|
|
2091
|
+
<div class="fe-fieldlbl">Description</div><input type="text" class="p-desc" value="${escHtml(p.description||'')}" placeholder="what this product bundles">
|
|
2092
|
+
<div class="fe-fieldlbl">Sources (comma-separated · * = all)</div><input type="text" class="p-sources" value="${escHtml(csv(p.sources))}" placeholder="* or prometheus, loki">
|
|
2093
|
+
<div class="fe-fieldlbl">Services (optional)</div><input type="text" class="p-services" value="${escHtml(csv(p.services))}" placeholder="* or payment-service">
|
|
2094
|
+
</div>`; }).join(''):'<div class="form-hint">No products yet.</div>';
|
|
2095
|
+
h+=`</div>`;
|
|
2096
|
+
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>`;
|
|
2097
|
+
const gk=Object.keys(m.grants);
|
|
2098
|
+
h+= gk.length?gk.map(pn=>`<div class="fe-row" data-grant>
|
|
2099
|
+
<input type="text" class="g-pr fe-grow" value="${escHtml(pn)}" placeholder="principal (API-key name)">
|
|
2100
|
+
<input type="text" class="g-prod fe-grow" value="${escHtml(csv(m.grants[pn]))}" placeholder="product1, product2">
|
|
2101
|
+
<button class="fe-x" onclick="this.closest('.fe-row').remove()">×</button></div>`).join(''):'<div class="form-hint">No grants yet.</div>';
|
|
2102
|
+
h+=`</div>`;
|
|
2103
|
+
h+=`<div class="fe-block"><div class="fe-hd"><span class="fe-t">Default products (ungranted principals)</span></div>
|
|
2104
|
+
<input type="text" id="c-defaults" value="${escHtml(csv(m.defaultProducts))}" placeholder="empty = no default access"></div>`;
|
|
2105
|
+
document.getElementById('cat-form').innerHTML=h;
|
|
2106
|
+
}
|
|
2107
|
+
function catAddProduct(){ catCollectForm(); catModel.products['new-product']={description:'',sources:['*']}; catRenderForm(); }
|
|
2108
|
+
function catAddGrant(){ catCollectForm(); catModel.grants['principal']=[]; catRenderForm(); }
|
|
2109
|
+
function catCollectForm(){
|
|
2110
|
+
const products={}, grants={};
|
|
2111
|
+
document.querySelectorAll('#cat-form [data-prod]').forEach(c=>{
|
|
2112
|
+
const n=c.querySelector('.p-name').value.trim(); if(!n) return;
|
|
2113
|
+
const p={sources:fromCsv(c.querySelector('.p-sources').value)};
|
|
2114
|
+
const desc=c.querySelector('.p-desc').value.trim(); if(desc) p.description=desc;
|
|
2115
|
+
const svc=fromCsv(c.querySelector('.p-services').value); if(svc.length) p.services=svc;
|
|
2116
|
+
products[n]=p;
|
|
2117
|
+
});
|
|
2118
|
+
document.querySelectorAll('#cat-form [data-grant]').forEach(c=>{
|
|
2119
|
+
const p=c.querySelector('.g-pr').value.trim(); if(!p) return;
|
|
2120
|
+
grants[p]=fromCsv(c.querySelector('.g-prod').value);
|
|
2121
|
+
});
|
|
2122
|
+
const dp=document.getElementById('c-defaults');
|
|
2123
|
+
catModel={products,grants,defaultProducts:dp?fromCsv(dp.value):[]};
|
|
2124
|
+
return catModel;
|
|
2125
|
+
}
|
|
2126
|
+
function catSyncFrom(view){
|
|
2127
|
+
if(view==='form'){ catCollectForm(); return true; }
|
|
2128
|
+
const ta=document.getElementById('ent-cat-json'); const msg=document.getElementById('ent-cat-msg');
|
|
2129
|
+
try{
|
|
2130
|
+
const obj = view==='json' ? JSON.parse(ta.value) : yamlParse(ta.value);
|
|
2131
|
+
catModel={products:obj.products||{}, grants:obj.grants||{}, defaultProducts:obj.defaultProducts||[]};
|
|
2132
|
+
return true;
|
|
2133
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Invalid '+view.toUpperCase()+': '+e.message; return false; }
|
|
2134
|
+
}
|
|
2135
|
+
function catSetView(v){
|
|
2136
|
+
const form=document.getElementById('cat-form'), ta=document.getElementById('ent-cat-json');
|
|
2137
|
+
document.querySelectorAll('#cat-views button').forEach(b=>b.classList.toggle('active', b.dataset.v===v));
|
|
2138
|
+
if(v==='form'){ form.classList.remove('hidden'); ta.classList.add('hidden'); catRenderForm(); }
|
|
2139
|
+
else {
|
|
2140
|
+
form.classList.add('hidden'); ta.classList.remove('hidden');
|
|
2141
|
+
ta.value = v==='json' ? JSON.stringify(catModel,null,2) : ('# products / grants / defaultProducts\n'+yamlDump(catModel));
|
|
2142
|
+
}
|
|
2143
|
+
catCur=v;
|
|
2144
|
+
}
|
|
2145
|
+
function catView(v){
|
|
2146
|
+
if(v===catCur) return;
|
|
2147
|
+
document.getElementById('ent-cat-msg').textContent='';
|
|
2148
|
+
if(!catSyncFrom(catCur)) return;
|
|
2149
|
+
catSetView(v);
|
|
2150
|
+
}
|
|
2151
|
+
async function entSaveCat(){
|
|
2152
|
+
const msg=document.getElementById('ent-cat-msg');
|
|
2153
|
+
const tok=document.getElementById('ent-cat-token').value.trim();
|
|
2154
|
+
if(!catSyncFrom(catCur)) return;
|
|
2155
|
+
if(!tok){ msg.style.color='var(--danger)'; msg.textContent='Admin API key required.'; return; }
|
|
2156
|
+
msg.style.color='var(--text-muted)'; msg.textContent='Saving…';
|
|
2157
|
+
try{
|
|
2158
|
+
const r=await fetch('/api/enterprise/catalog',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer '+tok},body:JSON.stringify(catModel)});
|
|
2159
|
+
const d=await r.json().catch(()=>({}));
|
|
2160
|
+
if(r.ok){ msg.style.color='var(--success)'; msg.textContent='Catalog updated.'; toast('Catalog saved'); closeDrawer(); loadEnterprise(); }
|
|
2161
|
+
else { msg.style.color='var(--danger)'; msg.textContent='Rejected ('+r.status+'): '+(d.error||'unknown'); }
|
|
2162
|
+
}catch(e){ msg.style.color='var(--danger)'; msg.textContent='Request failed: '+e.message; }
|
|
784
2163
|
}
|
|
785
2164
|
|
|
786
2165
|
function escHtml(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}
|
|
@@ -863,6 +2242,8 @@ function showTab(name) {
|
|
|
863
2242
|
if (el) el.classList.add('active');
|
|
864
2243
|
if (event && event.target) event.target.classList.add('active');
|
|
865
2244
|
if(name==='metrics') populateMetricsSourceSelect();
|
|
2245
|
+
const sp = {installed:'connectors',hub:'connectors',upload:'connectors',general:'settings',health:'settings',metrics:'settings'}[name];
|
|
2246
|
+
if(sp) setActiveSub(sp, name);
|
|
866
2247
|
}
|
|
867
2248
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
|
868
2249
|
|
|
@@ -900,26 +2281,149 @@ function updateStats() {
|
|
|
900
2281
|
svcCtx.textContent = backends.size > 0 ? `across ${backends.size} backend${backends.size===1?'':'s'}` : 'discovered';
|
|
901
2282
|
}
|
|
902
2283
|
|
|
903
|
-
const
|
|
2284
|
+
const sPct = total > 0 ? Math.round((up / total) * 100) : 0;
|
|
2285
|
+
const sTone = total === 0 ? 'tone-warn' : up === total ? 'tone-ok' : up === 0 ? 'tone-bad' : 'tone-warn';
|
|
2286
|
+
renderGauge('gauge-sources', sPct, sTone);
|
|
2287
|
+
|
|
2288
|
+
// Status is now surfaced via the header notification feed, not a badge.
|
|
904
2289
|
const b=document.getElementById('status-badge');
|
|
905
|
-
b
|
|
906
|
-
|
|
2290
|
+
if(b){ const allUp=sourcesData.length>0&&sourcesData.filter(s=>s.enabled).every(s=>s.status==='up');
|
|
2291
|
+
b.textContent=sourcesData.length===0?'No sources':allUp?'All systems operational':'Issues detected';
|
|
2292
|
+
b.className='badge '+(allUp?'badge-ok':'badge-err'); }
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// SVG ring gauge (capacity-style). pct 0..100, tone class on .gauge.
|
|
2296
|
+
function renderGauge(elId, pct, toneClass){
|
|
2297
|
+
const el=document.getElementById(elId); if(!el) return;
|
|
2298
|
+
const p=Math.max(0,Math.min(100,Math.round(pct)));
|
|
2299
|
+
const r=24, c=2*Math.PI*r, off=c*(1-p/100);
|
|
2300
|
+
el.className='gauge '+(toneClass||'');
|
|
2301
|
+
el.innerHTML=
|
|
2302
|
+
'<svg viewBox="0 0 56 56" width="56" height="56" role="img" aria-label="'+p+'%">'+
|
|
2303
|
+
'<circle class="g-track" cx="28" cy="28" r="'+r+'" fill="none" stroke-width="5"/>'+
|
|
2304
|
+
'<circle class="g-fill" cx="28" cy="28" r="'+r+'" fill="none" stroke-width="5" stroke-linecap="round" '+
|
|
2305
|
+
'transform="rotate(-90 28 28)" stroke-dasharray="'+c.toFixed(1)+'" stroke-dashoffset="'+off.toFixed(1)+'"/>'+
|
|
2306
|
+
'<text x="28" y="28" text-anchor="middle" dominant-baseline="central">'+p+'%</text></svg>';
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// Dashboard "Services Healthy" gauge — light poll of the analyzed health API.
|
|
2310
|
+
async function updateHealthStat(){
|
|
2311
|
+
const v=document.getElementById('stat-health'), ctx=document.getElementById('stat-health-ctx');
|
|
2312
|
+
if(!v) return;
|
|
2313
|
+
try{
|
|
2314
|
+
const h=await(await fetch('/api/health')).json();
|
|
2315
|
+
healthMap=h||{};
|
|
2316
|
+
const svcs=Object.values(h||{});
|
|
2317
|
+
const total=svcs.length;
|
|
2318
|
+
const healthy=svcs.filter(s=>s&&s.status==='healthy').length;
|
|
2319
|
+
const pct=total>0?Math.round((healthy/total)*100):0;
|
|
2320
|
+
v.textContent=total>0?healthy+'/'+total:'—';
|
|
2321
|
+
if(ctx) ctx.textContent=total===0?'no services':pct===100?'all healthy':(total-healthy)+' need attention';
|
|
2322
|
+
renderGauge('gauge-health', pct, total===0?'tone-warn':pct===100?'tone-ok':pct>=50?'tone-warn':'tone-bad');
|
|
2323
|
+
renderHealthPanels(svcs);
|
|
2324
|
+
}catch(e){
|
|
2325
|
+
v.textContent='—'; if(ctx) ctx.textContent='unavailable';
|
|
2326
|
+
renderGauge('gauge-health', 0, 'tone-warn');
|
|
2327
|
+
const a=document.getElementById('dash-attention'); if(a) a.innerHTML='<div class="empty">Health data unavailable.</div>';
|
|
2328
|
+
const t=document.getElementById('dash-top'); if(t) t.innerHTML='<div class="empty">Health data unavailable.</div>';
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
function svcTone(s){
|
|
2332
|
+
const st=(s&&s.status)||''; if(st==='critical') return 'bad'; if(st!=='healthy') return 'warn'; return 'ok';
|
|
2333
|
+
}
|
|
2334
|
+
function renderHealthPanels(svcs){
|
|
2335
|
+
const attn=document.getElementById('dash-attention'), top=document.getElementById('dash-top'), cnt=document.getElementById('dash-attn-count');
|
|
2336
|
+
if(attn){
|
|
2337
|
+
const bad=svcs.filter(s=>s&&s.status!=='healthy').sort((a,b)=>(a.score||0)-(b.score||0));
|
|
2338
|
+
if(cnt) cnt.textContent=String(bad.length);
|
|
2339
|
+
attn.innerHTML = bad.length===0
|
|
2340
|
+
? '<div class="empty">All services healthy — nothing needs attention.</div>'
|
|
2341
|
+
: bad.map(s=>{
|
|
2342
|
+
const tn=svcTone(s);
|
|
2343
|
+
const an=(s.anomalies&&s.anomalies[0]&&(s.anomalies[0].metric||s.anomalies[0].description))||(s.correlations&&s.correlations[0])||'no dominant signal';
|
|
2344
|
+
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>`;
|
|
2345
|
+
}).join('');
|
|
2346
|
+
}
|
|
2347
|
+
if(top){
|
|
2348
|
+
const ranked=svcs.slice().sort((a,b)=>(a.score||0)-(b.score||0)).slice(0,8);
|
|
2349
|
+
top.innerHTML = ranked.length===0
|
|
2350
|
+
? '<div class="empty">No services discovered.</div>'
|
|
2351
|
+
: '<div class="rank">'+ranked.map(s=>{
|
|
2352
|
+
const sc=Math.max(0,Math.min(100,Number(s.score)||0)), tn=svcTone(s);
|
|
2353
|
+
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>`;
|
|
2354
|
+
}).join('')+'</div>';
|
|
2355
|
+
}
|
|
907
2356
|
}
|
|
908
2357
|
|
|
909
2358
|
// --- Render Sources ---
|
|
2359
|
+
function srcView(){ try{ return localStorage.getItem('omcp-src-view')==='table'?'table':'list'; }catch(e){ return 'list'; } }
|
|
2360
|
+
function setSrcView(v){ try{ localStorage.setItem('omcp-src-view',v); }catch(e){} renderSources(); }
|
|
2361
|
+
function srcRow(s){
|
|
2362
|
+
const sc=!s.enabled?'dot-disabled':s.status==='up'?'dot-up':'dot-down';
|
|
2363
|
+
return `<div class="source-row" data-src="${esc(s.name)}"><div class="dot ${sc}"></div><div class="source-info"><div class="name">${esc(s.name)}<span class="tag tag-type">${esc(s.type)}</span>${s.signalType?`<span class="tag tag-${s.signalType}">${s.signalType}</span>`:''}</div><div class="url">${esc(s.url)}</div></div>${s.latencyMs?`<span class="tag-latency">${s.latencyMs}ms</span>`:''}<div class="source-actions" onclick="event.stopPropagation()"><label class="toggle"><input type="checkbox" ${s.enabled?'checked':''} onchange="toggleSource('${esc(s.name)}')"><span class="slider"></span></label><button class="btn-icon" onclick="openEditModal('${esc(s.name)}')">✎</button><button class="btn-icon" onclick="openDeleteConfirm('source','${esc(s.name)}')">🗑</button></div></div>`;
|
|
2364
|
+
}
|
|
910
2365
|
function renderSources() {
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
2366
|
+
const dashHtml = sourcesData.length===0 ? '<div class="empty">No sources configured.</div>' : sourcesData.map(srcRow).join('');
|
|
2367
|
+
document.getElementById('dash-sources').innerHTML=dashHtml;
|
|
2368
|
+
const box=document.getElementById('sources-list'); if(!box) return;
|
|
2369
|
+
if(sourcesData.length===0){ box.innerHTML='<div class="empty">No sources configured.</div>'; return; }
|
|
2370
|
+
const view=srcView();
|
|
2371
|
+
const bar=`<div class="dp-bar"><span style="color:var(--text-muted);font-size:12px">${sourcesData.length} source${sourcesData.length===1?'':'s'} · click for detail</span>
|
|
2372
|
+
<span class="view-toggle" style="margin-left:auto"><button class="${view==='list'?'active':''}" onclick="setSrcView('list')">List</button><button class="${view==='table'?'active':''}" onclick="setSrcView('table')">Table</button></span></div>`;
|
|
2373
|
+
let body;
|
|
2374
|
+
if(view==='table'){
|
|
2375
|
+
body=`<table class="dtable"><thead><tr><th>Name</th><th>Type</th><th>Signal</th><th>URL</th><th>Status</th><th>Latency</th></tr></thead><tbody>`+
|
|
2376
|
+
sourcesData.map(s=>`<tr data-src="${esc(s.name)}"><td class="mono">${esc(s.name)}</td><td>${esc(s.type)}</td><td>${s.signalType?esc(s.signalType):'—'}</td><td class="mono" style="color:var(--text-muted)">${esc(s.url)}</td><td>${!s.enabled?'disabled':s.status==='up'?'<span class="pill-yes">up</span>':'<span class="pill-no">down</span>'}</td><td class="mono">${s.latencyMs?s.latencyMs+'ms':'—'}</td></tr>`).join('')+
|
|
2377
|
+
`</tbody></table>`;
|
|
2378
|
+
} else {
|
|
2379
|
+
body=sourcesData.map(srcRow).join('');
|
|
2380
|
+
}
|
|
2381
|
+
box.innerHTML=bar+body;
|
|
2382
|
+
box.querySelectorAll('[data-src]').forEach(el=>el.addEventListener('click',()=>srcDetail(el.getAttribute('data-src'))));
|
|
2383
|
+
}
|
|
2384
|
+
function srcDetail(name){
|
|
2385
|
+
const s=sourcesData.find(x=>x.name===name);
|
|
2386
|
+
if(!s){ openDrawer(name,'<div class="empty">Source not found.</div>'); return; }
|
|
2387
|
+
const tone=!s.enabled?'warn':s.status==='up'?'ok':'bad';
|
|
2388
|
+
const svc=(servicesData||[]).filter(x=>(x.sources||[]).includes(s.name)).map(x=>x.name);
|
|
2389
|
+
const html=
|
|
2390
|
+
dwSec('Status', `<span class="stchip ${tone}">${!s.enabled?'disabled':s.status==='up'?'up':'down'}</span>${s.latencyMs?` <span style="color:var(--text-muted);font-size:var(--fs-sm)">${s.latencyMs}ms</span>`:''}`)+
|
|
2391
|
+
dwSec('Configuration', `<table class="dtable"><tbody>
|
|
2392
|
+
<tr><td>Type</td><td class="mono">${escHtml(s.type)}</td></tr>
|
|
2393
|
+
<tr><td>Signal type</td><td class="mono">${escHtml(s.signalType||'—')}</td></tr>
|
|
2394
|
+
<tr><td>URL</td><td class="mono" style="word-break:break-all">${escHtml(s.url)}</td></tr>
|
|
2395
|
+
<tr><td>Enabled</td><td>${s.enabled?'yes':'no'}</td></tr></tbody></table>`)+
|
|
2396
|
+
dwSec('Services from this source', svc.length?svc.map(n=>`<span class="chip">${escHtml(n)}</span>`).join(' '):'<span style="color:var(--text-dim);font-size:var(--fs-sm)">none discovered</span>')+
|
|
2397
|
+
`<div style="display:flex;gap:8px;margin-top:14px"><button class="btn btn-sm" onclick="closeDrawer();openEditModal('${esc(s.name)}')">Edit source</button><button class="btn btn-ghost btn-sm" onclick="closeDrawer();openDeleteConfirm('source','${esc(s.name)}')">Delete</button></div>`+
|
|
2398
|
+
`<div class="codeblock collapsed" style="margin-top:12px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · all sources</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>curl -s http://localhost:3000/api/sources</pre></div>`;
|
|
2399
|
+
openDrawer(name, html);
|
|
917
2400
|
}
|
|
918
2401
|
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;
|
|
2402
|
+
const html = servicesData.length===0 ? '<div class="empty">No services discovered.</div>' : servicesData.map(s=>`<div class="service-row" data-svc="${esc(s.name)}"><div><span class="name">${esc(s.name)}</span>${s.signalTypes.map(t=>`<span class="tag tag-${t}">${t}</span>`).join('')}</div><span style="color:var(--text2);font-size:12px">${s.sources.join(', ')}</span></div>`).join('');
|
|
2403
|
+
const sl=document.getElementById('services-list'); sl.innerHTML=html;
|
|
2404
|
+
sl.querySelectorAll('[data-svc]').forEach(el=>el.addEventListener('click',()=>svcDetail(el.getAttribute('data-svc'))));
|
|
921
2405
|
document.getElementById('dash-services').innerHTML=html;
|
|
922
2406
|
}
|
|
2407
|
+
async function svcDetail(name){
|
|
2408
|
+
let v=healthMap[name];
|
|
2409
|
+
if(!v){ try{ const h=await(await fetch('/api/health')).json(); healthMap=h||{}; v=healthMap[name]; }catch(e){} }
|
|
2410
|
+
if(!v){ openDrawer(name, '<div class="empty">No health data available for this service.</div>'); return; }
|
|
2411
|
+
const tone=v.status==='critical'?'bad':v.status!=='healthy'?'warn':'ok';
|
|
2412
|
+
const m=(v.signals&&v.signals.metrics)||{};
|
|
2413
|
+
const fmtN=x=>x==null?'—':(Math.round(Number(x)*100)/100);
|
|
2414
|
+
const html=
|
|
2415
|
+
dwSec('Status', `<span class="stchip ${tone}">${escHtml(v.status)}</span> <span style="color:var(--text-muted);font-size:var(--fs-sm)">health score ${escHtml(v.score)}</span>`)+
|
|
2416
|
+
dwSec('Signals', `<table class="dtable"><tbody>
|
|
2417
|
+
<tr><td>CPU</td><td class="mono">${fmtN(m.cpu)}</td></tr>
|
|
2418
|
+
<tr><td>Memory (MB)</td><td class="mono">${fmtN(m.memory)}</td></tr>
|
|
2419
|
+
<tr><td>Error rate</td><td class="mono">${fmtN(m.errorRate)}</td></tr>
|
|
2420
|
+
<tr><td>Latency p99 (s)</td><td class="mono">${fmtN(m.latencyP99)}</td></tr>
|
|
2421
|
+
<tr><td>Log errors (5m)</td><td class="mono">${fmtN(v.signals&&v.signals.logs&&v.signals.logs.errorRate)}</td></tr></tbody></table>`)+
|
|
2422
|
+
dwSec('Anomalies', (v.anomalies&&v.anomalies.length)?v.anomalies.map(a=>`<div class="feed-row"><span class="stchip ${a.severity==='high'?'bad':'warn'}">${escHtml(a.metric)}</span><div class="fr-main"><div class="fr-sub">${escHtml(a.description||'')}</div></div></div>`).join(''):'<span style="color:var(--text-dim);font-size:var(--fs-sm)">None detected.</span>')+
|
|
2423
|
+
dwSec('Correlations', (v.correlations&&v.correlations.length)?v.correlations.map(c=>`<div class="fr-sub" style="margin-bottom:4px">• ${escHtml(c)}</div>`).join(''):'<span style="color:var(--text-dim);font-size:var(--fs-sm)">None.</span>')+
|
|
2424
|
+
`<div class="codeblock collapsed" style="margin-top:6px"><div class="codeblock-hd" onclick="toggleCode(this)"><span class="cb-chev">▾</span><span class="cb-title">API · this service's health</span><button class="codeblock-cp" onclick="event.stopPropagation();copyCode(this)">Copy</button></div><pre>curl -s http://localhost:3000/api/health/${escHtml(name)}</pre></div>`;
|
|
2425
|
+
openDrawer(name, html);
|
|
2426
|
+
}
|
|
923
2427
|
|
|
924
2428
|
// --- TLS helpers ---
|
|
925
2429
|
function resetTlsFields() {
|
|
@@ -1212,6 +2716,768 @@ function renderHealthCards() {
|
|
|
1212
2716
|
}).join('');
|
|
1213
2717
|
}
|
|
1214
2718
|
|
|
2719
|
+
// --- Topology ---
|
|
2720
|
+
let topologyData = null;
|
|
2721
|
+
let topologyInterval = null;
|
|
2722
|
+
let topologyScopeFilter = '';
|
|
2723
|
+
let topologyActiveTab = 'summary';
|
|
2724
|
+
let topologyGraphState = null; // memo: last rendered graph state (positions, view)
|
|
2725
|
+
let topologyLastRevHash = ''; // revisions-of-all-sources signature; re-renders only on change
|
|
2726
|
+
let topologySelectedId = null; // currently selected node id (preserved across renders)
|
|
2727
|
+
|
|
2728
|
+
// --- Generic helpers (no kind/relation hardcoding) ---
|
|
2729
|
+
|
|
2730
|
+
// Walk OWNED_BY edges from startId until no outgoing OWNED_BY remains.
|
|
2731
|
+
// Returns the terminal owner id (or startId if it has no owner). Universal:
|
|
2732
|
+
// k8s pod→rs→deployment, vCenter vm→resource-pool→folder, AWS pod→ASG, etc.
|
|
2733
|
+
function ownershipRoot(startId, ownedByMap){
|
|
2734
|
+
let cur = startId;
|
|
2735
|
+
for (let i = 0; i < 16; i++) { // hard cap defends against cycles
|
|
2736
|
+
const next = ownedByMap.get(cur);
|
|
2737
|
+
if (!next || next === cur) return cur;
|
|
2738
|
+
cur = next;
|
|
2739
|
+
}
|
|
2740
|
+
return cur;
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
// Index edges once per render so views are O(N).
|
|
2744
|
+
function indexTopology(d){
|
|
2745
|
+
const byId = new Map();
|
|
2746
|
+
for (const r of d.resources) byId.set(r.id, r);
|
|
2747
|
+
const runsOn = new Map(); // from → to (assume single host per child)
|
|
2748
|
+
const ownedBy = new Map(); // from → to (single owner walk)
|
|
2749
|
+
const inScope = new Map(); // from → to (single IN_NAMESPACE-style scope per resource)
|
|
2750
|
+
const scopeKindCount = {}; // kind of every IN_NAMESPACE target → count of incoming
|
|
2751
|
+
for (const e of d.edges){
|
|
2752
|
+
if (e.relation === 'RUNS_ON') runsOn.set(e.from, e.to);
|
|
2753
|
+
else if (e.relation === 'OWNED_BY') {
|
|
2754
|
+
// keep only the first OWNED_BY out-edge per source (most connectors emit
|
|
2755
|
+
// one); the walker handles chains via the map.
|
|
2756
|
+
if (!ownedBy.has(e.from)) ownedBy.set(e.from, e.to);
|
|
2757
|
+
} else if (e.relation === 'IN_NAMESPACE') {
|
|
2758
|
+
if (!inScope.has(e.from)) inScope.set(e.from, e.to);
|
|
2759
|
+
const target = byId.get(e.to);
|
|
2760
|
+
if (target) scopeKindCount[target.kind] = (scopeKindCount[target.kind]||0)+1;
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
return { byId, runsOn, ownedBy, inScope, scopeKindCount };
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
async function loadTopology(){
|
|
2767
|
+
try {
|
|
2768
|
+
const r = await fetch('/api/topology');
|
|
2769
|
+
if (!r.ok) throw new Error('http '+r.status);
|
|
2770
|
+
const fresh = await r.json();
|
|
2771
|
+
// Only re-render when topology actually changed. Otherwise the
|
|
2772
|
+
// 5-second poll would rebuild the SVG and wipe the user's selection.
|
|
2773
|
+
const revHash = (fresh.sources || []).map(s => `${s.source}:${s.revision}`).join('|') +
|
|
2774
|
+
`#r=${fresh.resources.length}#e=${fresh.edges.length}`;
|
|
2775
|
+
const isFirst = topologyData == null;
|
|
2776
|
+
topologyData = fresh;
|
|
2777
|
+
if (isFirst || revHash !== topologyLastRevHash) {
|
|
2778
|
+
topologyLastRevHash = revHash;
|
|
2779
|
+
renderTopology();
|
|
2780
|
+
}
|
|
2781
|
+
} catch(e) {
|
|
2782
|
+
topologyData = null;
|
|
2783
|
+
topologyLastRevHash = '';
|
|
2784
|
+
document.getElementById('topology-summary').innerHTML =
|
|
2785
|
+
'<div class="empty">No topology data available. Add a topology-capable source (e.g. <code>kubernetes</code>) under Sources.</div>';
|
|
2786
|
+
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2787
|
+
}
|
|
2788
|
+
if(!topologyInterval) topologyInterval = setInterval(()=>{
|
|
2789
|
+
if(document.getElementById('page-topology').classList.contains('active')) loadTopology();
|
|
2790
|
+
}, 5000);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
function showTopologyTab(name){
|
|
2794
|
+
topologyActiveTab = name;
|
|
2795
|
+
const page = document.getElementById('page-topology');
|
|
2796
|
+
page.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
|
|
2797
|
+
page.querySelectorAll('.tab-content').forEach(c=>c.classList.remove('active'));
|
|
2798
|
+
const btn = Array.from(page.querySelectorAll('.tab-btn')).find(b=>b.textContent.trim().toLowerCase().includes(name)||(name==='blast'&&b.textContent.includes('Blast'))||(name==='graph'&&b.textContent==='Graph')||(name==='summary'&&b.textContent==='Summary'));
|
|
2799
|
+
if (btn) btn.classList.add('active');
|
|
2800
|
+
document.getElementById('topology-tab-'+name).classList.add('active');
|
|
2801
|
+
if (name === 'graph') renderTopologyGraph(); // graph rendering is lazy
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function renderTopology(){
|
|
2805
|
+
const d = topologyData || { sources: [], resources: [], edges: [] };
|
|
2806
|
+
const idx = indexTopology(d);
|
|
2807
|
+
|
|
2808
|
+
// --- Sync scope filter (any IN_NAMESPACE target — generic) ---
|
|
2809
|
+
// Build the option list from actual edge targets, not from a hardcoded kind.
|
|
2810
|
+
const scopeIds = new Set(idx.inScope.values());
|
|
2811
|
+
const scopeRes = Array.from(scopeIds).map(id=>idx.byId.get(id)).filter(Boolean)
|
|
2812
|
+
.sort((a,b)=>a.name.localeCompare(b.name));
|
|
2813
|
+
const sel = document.getElementById('topology-scope-filter');
|
|
2814
|
+
if (sel) {
|
|
2815
|
+
const prev = topologyScopeFilter;
|
|
2816
|
+
sel.innerHTML = '<option value="">All</option>' +
|
|
2817
|
+
scopeRes.map(r=>`<option value="${esc(r.id)}"${prev===r.id?' selected':''}>${esc(r.name)} <span>(${esc(r.kind)})</span></option>`).join('');
|
|
2818
|
+
sel.onchange = (e)=>{ topologyScopeFilter = e.target.value; renderTopology(); };
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// --- Summary tab ---
|
|
2822
|
+
const summary = document.getElementById('topology-summary');
|
|
2823
|
+
if (d.sources.length === 0) {
|
|
2824
|
+
summary.innerHTML = '<div class="empty">No topology-capable sources connected. Add a topology source (e.g. <b>kubernetes</b>) under Sources.</div>';
|
|
2825
|
+
const bh = document.getElementById('topology-by-host'); if (bh) bh.innerHTML = '';
|
|
2826
|
+
renderTopologyGraph(); // shows empty-state legend too
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
const kindCounts = {}, relCounts = {};
|
|
2830
|
+
for (const r of d.resources) kindCounts[r.kind] = (kindCounts[r.kind]||0)+1;
|
|
2831
|
+
for (const e of d.edges) relCounts[e.relation] = (relCounts[e.relation]||0)+1;
|
|
2832
|
+
const srcBadges = d.sources.map(s=>`<span class="badge">${esc(s.source)} <span class="muted">(${esc(s.type)}, rev ${s.revision})</span></span>`).join(' ');
|
|
2833
|
+
const kindRow = Object.entries(kindCounts).sort((a,b)=>b[1]-a[1])
|
|
2834
|
+
.map(([k,v])=>`<div class="hc-metric"><div class="label">${esc(k)}</div><div class="val">${v}</div></div>`).join('');
|
|
2835
|
+
const relRow = Object.entries(relCounts).sort((a,b)=>b[1]-a[1])
|
|
2836
|
+
.map(([k,v])=>`<div class="hc-metric"><div class="label">${esc(k)}</div><div class="val">${v}</div></div>`).join('');
|
|
2837
|
+
summary.innerHTML = `
|
|
2838
|
+
<div style="display:flex; flex-direction:column; gap:12px;">
|
|
2839
|
+
<div><b>Sources:</b> ${srcBadges}</div>
|
|
2840
|
+
<div>
|
|
2841
|
+
<div class="muted" style="margin-bottom:6px;">Resources by kind</div>
|
|
2842
|
+
<div class="hc-metrics">${kindRow}</div>
|
|
2843
|
+
</div>
|
|
2844
|
+
<div>
|
|
2845
|
+
<div class="muted" style="margin-bottom:6px;">Edges by relation</div>
|
|
2846
|
+
<div class="hc-metrics">${relRow}</div>
|
|
2847
|
+
</div>
|
|
2848
|
+
</div>`;
|
|
2849
|
+
|
|
2850
|
+
// --- Blast radius tab (relation-driven on RUNS_ON) ---
|
|
2851
|
+
renderBlastRadius(d, idx);
|
|
2852
|
+
// --- Graph tab (only re-render if it's active to avoid wasted simulation) ---
|
|
2853
|
+
if (topologyActiveTab === 'graph') renderTopologyGraph();
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
function renderBlastRadius(d, idx){
|
|
2857
|
+
const target = document.getElementById('topology-by-host');
|
|
2858
|
+
if (!target) return;
|
|
2859
|
+
|
|
2860
|
+
// Hosts = anything that is the TO of at least one RUNS_ON edge. Generic:
|
|
2861
|
+
// k8s nodes, vCenter hypervisors, NetBox switches, libvirt hosts, …
|
|
2862
|
+
const hostIds = new Set(idx.runsOn.values());
|
|
2863
|
+
if (hostIds.size === 0){
|
|
2864
|
+
target.innerHTML = '<div class="empty">No <code>RUNS_ON</code> edges in the graph yet — blast-radius view appears once topology connectors report which resources run on which hosts.</div>';
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// Optional scope filter applied to the children (not the hosts).
|
|
2869
|
+
const scope = topologyScopeFilter; // resource id, or ''
|
|
2870
|
+
|
|
2871
|
+
const cards = Array.from(hostIds).map(hostId=>{
|
|
2872
|
+
const host = idx.byId.get(hostId);
|
|
2873
|
+
const hostName = host ? host.name : hostId;
|
|
2874
|
+
const hostKind = host ? host.kind : '?';
|
|
2875
|
+
// children that RUN_ON this host
|
|
2876
|
+
let children = [];
|
|
2877
|
+
for (const [fromId, toId] of idx.runsOn.entries()){
|
|
2878
|
+
if (toId !== hostId) continue;
|
|
2879
|
+
if (scope && idx.inScope.get(fromId) !== scope) continue;
|
|
2880
|
+
const c = idx.byId.get(fromId);
|
|
2881
|
+
if (c) children.push(c);
|
|
2882
|
+
}
|
|
2883
|
+
// Group children by their ownership root (terminal OWNED_BY target).
|
|
2884
|
+
const groups = new Map();
|
|
2885
|
+
for (const c of children){
|
|
2886
|
+
const rootId = ownershipRoot(c.id, idx.ownedBy);
|
|
2887
|
+
const arr = groups.get(rootId) || [];
|
|
2888
|
+
arr.push(c);
|
|
2889
|
+
groups.set(rootId, arr);
|
|
2890
|
+
}
|
|
2891
|
+
const rootIds = Array.from(groups.keys()).sort();
|
|
2892
|
+
const sharedNote = rootIds.length > 1
|
|
2893
|
+
? `<div class="hc-correlation">${rootIds.length} ownership roots share this host — blast radius if it fails</div>` : '';
|
|
2894
|
+
const body = rootIds.length === 0
|
|
2895
|
+
? `<div class="empty">No resources on this host${scope?` matching the scope filter`:''}.</div>`
|
|
2896
|
+
: rootIds.map(rid=>{
|
|
2897
|
+
const rootRes = idx.byId.get(rid);
|
|
2898
|
+
const rootLabel = rootRes
|
|
2899
|
+
? `${esc(rootRes.name)} <span class="muted">(${esc(rootRes.kind)})</span>`
|
|
2900
|
+
: `<span class="muted">${esc(rid)}</span>`;
|
|
2901
|
+
const items = groups.get(rid).map(c=>{
|
|
2902
|
+
const attrs = c.attributes || {};
|
|
2903
|
+
// Show up to 2 generic attribute hints — no specific keys assumed.
|
|
2904
|
+
const hints = Object.entries(attrs).filter(([k])=>k!=='uid').slice(0,2)
|
|
2905
|
+
.map(([k,v])=>`${esc(k)}=${esc(String(v))}`).join(' · ');
|
|
2906
|
+
return `<li>
|
|
2907
|
+
<span style="font-family:monospace; font-size:12px;">${esc(c.name)}</span>
|
|
2908
|
+
<span class="muted" style="font-size: 11px;">${esc(c.kind)}${hints?' · '+hints:''}</span>
|
|
2909
|
+
</li>`;
|
|
2910
|
+
}).join('');
|
|
2911
|
+
return `<div style="margin: 8px 0 6px;">
|
|
2912
|
+
<div style="font-weight:600; color:var(--text);">${rootLabel} <span class="muted">— ${groups.get(rid).length} resource${groups.get(rid).length===1?'':'s'}</span></div>
|
|
2913
|
+
<ul style="margin: 4px 0 0 18px; padding:0;">${items}</ul>
|
|
2914
|
+
</div>`;
|
|
2915
|
+
}).join('');
|
|
2916
|
+
return `<div class="card" style="margin-bottom: 12px;">
|
|
2917
|
+
<div class="card-header"><h2 style="font-size:14px;">⬡ ${esc(hostName)} <span class="muted" style="font-weight:400; font-size:12px;">(${esc(hostKind)})</span></h2>
|
|
2918
|
+
<span class="muted" style="font-size:12px;">${children.length} child${children.length===1?'':'ren'}</span>
|
|
2919
|
+
</div>
|
|
2920
|
+
<div style="padding: 0 16px 12px;">
|
|
2921
|
+
${sharedNote}
|
|
2922
|
+
${body}
|
|
2923
|
+
</div>
|
|
2924
|
+
</div>`;
|
|
2925
|
+
}).join('');
|
|
2926
|
+
target.innerHTML = cards;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// --- Layered topology graph (hand-rolled Sugiyama-style, no external deps) ---
|
|
2930
|
+
// Hosts (incoming RUNS_ON targets) anchor the top tier; ownership chains
|
|
2931
|
+
// drop down towards the workload tier at the bottom. IN_NAMESPACE targets
|
|
2932
|
+
// are drawn as tinted background bands rather than edges — drawing them
|
|
2933
|
+
// as edges turns into a star pattern and dominates the view.
|
|
2934
|
+
//
|
|
2935
|
+
// Framework-free to keep the airgapped build path clean
|
|
2936
|
+
// (docs/airgapped-deployment.md). Layout uses one-pass barycenter sorting
|
|
2937
|
+
// for crossing reduction — enough for graphs of a few hundred nodes; for
|
|
2938
|
+
// larger graphs the path forward is full Sugiyama with crossing-min.
|
|
2939
|
+
|
|
2940
|
+
const TOPO_KIND_PALETTE = [
|
|
2941
|
+
'#58a6ff','#3fb950','#f0883e','#a371f7','#f85149',
|
|
2942
|
+
'#79c0ff','#56d364','#ffa657','#d2a8ff','#ff7b72',
|
|
2943
|
+
];
|
|
2944
|
+
const TOPO_REL_STYLE = {
|
|
2945
|
+
RUNS_ON: { stroke: 'var(--text-muted)', dash: '', width: 1.5 },
|
|
2946
|
+
OWNED_BY: { stroke: 'var(--accent, #58a6ff)', dash: '4 4', width: 1.3 },
|
|
2947
|
+
};
|
|
2948
|
+
const TOPO_REL_DEFAULT = { stroke: 'var(--text-muted)', dash: '', width: 1.2 };
|
|
2949
|
+
|
|
2950
|
+
function topoKindColor(kind){
|
|
2951
|
+
let h = 0; for (let i = 0; i < kind.length; i++) h = (h*31 + kind.charCodeAt(i)) | 0;
|
|
2952
|
+
return TOPO_KIND_PALETTE[Math.abs(h) % TOPO_KIND_PALETTE.length];
|
|
2953
|
+
}
|
|
2954
|
+
function topoRelStyle(rel){ return TOPO_REL_STYLE[rel] || TOPO_REL_DEFAULT; }
|
|
2955
|
+
|
|
2956
|
+
function renderTopologyGraph(){
|
|
2957
|
+
const svg = document.getElementById('topology-graph-svg');
|
|
2958
|
+
const host = document.getElementById('topology-graph-host');
|
|
2959
|
+
const legend = document.getElementById('topology-graph-legend');
|
|
2960
|
+
if (!svg || !host) return;
|
|
2961
|
+
const d = topologyData || { resources: [], edges: [] };
|
|
2962
|
+
|
|
2963
|
+
// Reset
|
|
2964
|
+
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
2965
|
+
if (d.resources.length === 0){
|
|
2966
|
+
legend.innerHTML = '<span class="muted">No resources to plot.</span>';
|
|
2967
|
+
return;
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
const idx = indexTopology(d);
|
|
2971
|
+
|
|
2972
|
+
// --- Classify resources --------------------------------------------------
|
|
2973
|
+
// A "scope" is a resource that is targeted by IN_NAMESPACE AND has no
|
|
2974
|
+
// outgoing edges of its own (i.e. a container, not a workload). Drawn as
|
|
2975
|
+
// tinted background bands behind their members, not as graph nodes.
|
|
2976
|
+
// Works generically: k8s namespace, vCenter folder, AWS account, …
|
|
2977
|
+
const scopeTargets = new Set(idx.inScope.values());
|
|
2978
|
+
const outgoingByFrom = {};
|
|
2979
|
+
for (const e of d.edges){
|
|
2980
|
+
(outgoingByFrom[e.from] = outgoingByFrom[e.from] || []).push(e);
|
|
2981
|
+
}
|
|
2982
|
+
const pureScope = new Set();
|
|
2983
|
+
for (const id of scopeTargets){
|
|
2984
|
+
if ((outgoingByFrom[id] || []).length === 0 && !idx.runsOn.has(id)) pureScope.add(id);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
const drawables = d.resources.filter(r=>!pureScope.has(r.id));
|
|
2988
|
+
|
|
2989
|
+
// --- Tier assignment -----------------------------------------------------
|
|
2990
|
+
// Hosts (incoming RUNS_ON) → tier 0 (top).
|
|
2991
|
+
// Other resources → tier = 1 + length of longest OWNED_BY chain TO that
|
|
2992
|
+
// resource (i.e. roots like Deployment sit close to top, deepest leaves
|
|
2993
|
+
// like Pods sit at the bottom). Generic: works for any OWNED_BY chain.
|
|
2994
|
+
const incomingRunsOn = new Set(idx.runsOn.values());
|
|
2995
|
+
// Build outgoingOwnedBy (already in idx as ownedBy: from → to).
|
|
2996
|
+
// Compute longest path from each node BACKWARDS via OWNED_BY (i.e. how
|
|
2997
|
+
// deep it sits below the ownership root). Equivalent: longest chain of
|
|
2998
|
+
// OWNED_BY out-edges starting at this node.
|
|
2999
|
+
const ownDepth = new Map();
|
|
3000
|
+
function computeOwnDepth(id, seen){
|
|
3001
|
+
if (ownDepth.has(id)) return ownDepth.get(id);
|
|
3002
|
+
if (seen.has(id)) return 0; // cycle guard
|
|
3003
|
+
seen.add(id);
|
|
3004
|
+
const next = idx.ownedBy.get(id);
|
|
3005
|
+
const dep = next ? 1 + computeOwnDepth(next, new Set(seen)) : 0;
|
|
3006
|
+
ownDepth.set(id, dep);
|
|
3007
|
+
return dep;
|
|
3008
|
+
}
|
|
3009
|
+
for (const r of drawables) computeOwnDepth(r.id, new Set());
|
|
3010
|
+
|
|
3011
|
+
// Maximum chain depth across non-host resources informs how many tiers
|
|
3012
|
+
// we draw below the host row.
|
|
3013
|
+
let maxChain = 0;
|
|
3014
|
+
for (const r of drawables){
|
|
3015
|
+
if (incomingRunsOn.has(r.id)) continue;
|
|
3016
|
+
const dd = ownDepth.get(r.id) || 0;
|
|
3017
|
+
if (dd > maxChain) maxChain = dd;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
function tierOf(r){
|
|
3021
|
+
if (incomingRunsOn.has(r.id)) return 0; // top: hosts
|
|
3022
|
+
// Below hosts: tier = (maxChain - ownDepth) + 1
|
|
3023
|
+
// So an ownership root (depth=0) sits at tier 1; deepest leaves at tier maxChain+1.
|
|
3024
|
+
return (maxChain - (ownDepth.get(r.id) || 0)) + 1;
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// Bucket resources per tier (deterministic order: by name, then id).
|
|
3028
|
+
const tierMap = new Map();
|
|
3029
|
+
for (const r of drawables){
|
|
3030
|
+
const t = tierOf(r);
|
|
3031
|
+
const arr = tierMap.get(t) || [];
|
|
3032
|
+
arr.push(r);
|
|
3033
|
+
tierMap.set(t, arr);
|
|
3034
|
+
}
|
|
3035
|
+
const tierIndices = Array.from(tierMap.keys()).sort((a,b)=>a-b);
|
|
3036
|
+
|
|
3037
|
+
// --- Barycenter ordering (one-pass) to reduce edge crossings -------------
|
|
3038
|
+
// Place tier 0 alphabetically. For each subsequent tier, order nodes by
|
|
3039
|
+
// the mean x-position of the parents they connect to (RUNS_ON or
|
|
3040
|
+
// OWNED_BY target) in the tier above. Simple but effective for small
|
|
3041
|
+
// graphs and matches what dagre / Sugiyama do as a base step.
|
|
3042
|
+
function parentsOf(r){
|
|
3043
|
+
const parents = [];
|
|
3044
|
+
if (idx.ownedBy.get(r.id)) parents.push(idx.ownedBy.get(r.id));
|
|
3045
|
+
if (idx.runsOn.get(r.id)) parents.push(idx.runsOn.get(r.id));
|
|
3046
|
+
return parents;
|
|
3047
|
+
}
|
|
3048
|
+
// Layout dimensions
|
|
3049
|
+
const W = host.clientWidth || 1000, H = host.clientHeight || 660;
|
|
3050
|
+
const TOP_MARGIN = 36, BOTTOM_MARGIN = 36;
|
|
3051
|
+
const TIER_LABEL_W = 110; // left gutter reserved for tier labels
|
|
3052
|
+
const SIDE_MARGIN = TIER_LABEL_W + 28;
|
|
3053
|
+
const RIGHT_MARGIN = 32;
|
|
3054
|
+
const totalTiers = tierIndices.length;
|
|
3055
|
+
const tierHeight = (H - TOP_MARGIN - BOTTOM_MARGIN) / Math.max(1, totalTiers);
|
|
3056
|
+
const positions = new Map(); // id → {x,y}
|
|
3057
|
+
|
|
3058
|
+
// Initial ordering: alphabetical within tier
|
|
3059
|
+
for (const t of tierIndices) tierMap.get(t).sort((a,b)=>a.name.localeCompare(b.name));
|
|
3060
|
+
|
|
3061
|
+
for (let i = 0; i < tierIndices.length; i++){
|
|
3062
|
+
const t = tierIndices[i];
|
|
3063
|
+
const row = tierMap.get(t);
|
|
3064
|
+
if (i > 0){
|
|
3065
|
+
// Compute barycenter x from already-placed parents.
|
|
3066
|
+
const bary = new Map();
|
|
3067
|
+
for (const r of row){
|
|
3068
|
+
const ps = parentsOf(r).map(pid => positions.get(pid)?.x).filter(x=>x!=null);
|
|
3069
|
+
bary.set(r.id, ps.length ? ps.reduce((a,b)=>a+b,0) / ps.length : Number.POSITIVE_INFINITY);
|
|
3070
|
+
}
|
|
3071
|
+
row.sort((a,b)=>{
|
|
3072
|
+
const ba = bary.get(a.id), bb = bary.get(b.id);
|
|
3073
|
+
if (ba === bb) return a.name.localeCompare(b.name);
|
|
3074
|
+
return ba - bb;
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
const usableW = W - SIDE_MARGIN - RIGHT_MARGIN;
|
|
3078
|
+
const step = row.length > 1 ? usableW / (row.length - 1) : 0;
|
|
3079
|
+
const y = TOP_MARGIN + i * tierHeight + tierHeight/2;
|
|
3080
|
+
for (let j = 0; j < row.length; j++){
|
|
3081
|
+
const x = row.length === 1 ? SIDE_MARGIN + usableW/2 : SIDE_MARGIN + j * step;
|
|
3082
|
+
positions.set(row[j].id, { x, y });
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// Pick a label per tier: the most frequent kind. Falls back to "tier N".
|
|
3087
|
+
function tierLabelFor(rows){
|
|
3088
|
+
if (!rows || rows.length === 0) return '';
|
|
3089
|
+
const counts = {};
|
|
3090
|
+
for (const r of rows) counts[r.kind] = (counts[r.kind] || 0) + 1;
|
|
3091
|
+
return Object.entries(counts).sort((a,b)=>b[1]-a[1])[0][0];
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// --- Scope background bands ---------------------------------------------
|
|
3095
|
+
// For each pure-scope (e.g. namespace), find members and draw a tinted
|
|
3096
|
+
// rectangle behind them. The bands span the union of members' bounding
|
|
3097
|
+
// boxes, so they visually cluster siblings without occluding anything.
|
|
3098
|
+
const scopeColor = (id)=>{
|
|
3099
|
+
const r = idx.byId.get(id);
|
|
3100
|
+
const base = topoKindColor(r ? r.kind : 'scope');
|
|
3101
|
+
// soft alpha + slightly lighter
|
|
3102
|
+
return base.replace(/^#/, '#') + '22'; // append alpha (works because we use hex)
|
|
3103
|
+
};
|
|
3104
|
+
const scopeBands = [];
|
|
3105
|
+
for (const scopeId of pureScope){
|
|
3106
|
+
const members = drawables.filter(r => idx.inScope.get(r.id) === scopeId).map(r => positions.get(r.id)).filter(Boolean);
|
|
3107
|
+
if (members.length === 0) continue;
|
|
3108
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
3109
|
+
for (const p of members){
|
|
3110
|
+
if (p.x < minX) minX = p.x; if (p.x > maxX) maxX = p.x;
|
|
3111
|
+
if (p.y < minY) minY = p.y; if (p.y > maxY) maxY = p.y;
|
|
3112
|
+
}
|
|
3113
|
+
const PADX = 28, PADY = 18;
|
|
3114
|
+
scopeBands.push({
|
|
3115
|
+
id: scopeId,
|
|
3116
|
+
ref: idx.byId.get(scopeId),
|
|
3117
|
+
x: minX - PADX, y: minY - PADY,
|
|
3118
|
+
w: (maxX - minX) + 2*PADX,
|
|
3119
|
+
h: (maxY - minY) + 2*PADY,
|
|
3120
|
+
fill: scopeColor(scopeId),
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// --- Legend (auto-derived) ----------------------------------------------
|
|
3125
|
+
const kinds = Array.from(new Set(drawables.map(r=>r.kind))).sort();
|
|
3126
|
+
const rels = Array.from(new Set(d.edges.map(e=>e.relation).filter(r=>r!=='IN_NAMESPACE'))).sort();
|
|
3127
|
+
legend.innerHTML =
|
|
3128
|
+
'<div style="display:flex; gap:14px; flex-wrap:wrap; align-items:center;">' +
|
|
3129
|
+
'<div><b>Kinds:</b> ' + kinds.map(k=>
|
|
3130
|
+
`<span style="display:inline-flex; gap:4px; align-items:center; margin-right:8px;"><span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:${topoKindColor(k)};"></span>${esc(k)}</span>`
|
|
3131
|
+
).join('') + '</div>' +
|
|
3132
|
+
'<div><b>Edges:</b> ' + rels.map(r=>{
|
|
3133
|
+
const s = topoRelStyle(r);
|
|
3134
|
+
return `<span style="display:inline-flex; gap:4px; align-items:center; margin-right:8px;"><span style="display:inline-block; width:18px; height:0; border-bottom: ${s.width}px ${s.dash?'dashed':'solid'} ${s.stroke};"></span>${esc(r)}</span>`;
|
|
3135
|
+
}).join('') + '</div>' +
|
|
3136
|
+
(pureScope.size > 0 ? '<div class="muted"><b>Scope</b> (e.g. namespaces) shown as tinted backgrounds, not edges.</div>' : '') +
|
|
3137
|
+
'</div>';
|
|
3138
|
+
|
|
3139
|
+
// --- Build SVG -----------------------------------------------------------
|
|
3140
|
+
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
|
3141
|
+
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
3142
|
+
g.setAttribute('id', 'topo-g');
|
|
3143
|
+
let view = { tx: 0, ty: 0, scale: 1 };
|
|
3144
|
+
function applyView(){ g.setAttribute('transform', `translate(${view.tx} ${view.ty}) scale(${view.scale})`); }
|
|
3145
|
+
applyView();
|
|
3146
|
+
svg.appendChild(g);
|
|
3147
|
+
|
|
3148
|
+
// Alternating tier background bands + tier labels in the left gutter.
|
|
3149
|
+
for (let i = 0; i < tierIndices.length; i++){
|
|
3150
|
+
const yTop = TOP_MARGIN + i * tierHeight;
|
|
3151
|
+
if (i % 2 === 1){
|
|
3152
|
+
const band = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
3153
|
+
band.setAttribute('x', '0'); band.setAttribute('y', String(yTop));
|
|
3154
|
+
band.setAttribute('width', String(W)); band.setAttribute('height', String(tierHeight));
|
|
3155
|
+
band.setAttribute('fill', 'var(--text)');
|
|
3156
|
+
band.setAttribute('fill-opacity', '0.025');
|
|
3157
|
+
g.appendChild(band);
|
|
3158
|
+
}
|
|
3159
|
+
// tier separator line
|
|
3160
|
+
if (i > 0){
|
|
3161
|
+
const sep = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
|
3162
|
+
sep.setAttribute('x1', '0'); sep.setAttribute('y1', String(yTop));
|
|
3163
|
+
sep.setAttribute('x2', String(W)); sep.setAttribute('y2', String(yTop));
|
|
3164
|
+
sep.setAttribute('stroke', 'var(--border)');
|
|
3165
|
+
sep.setAttribute('stroke-opacity', '0.5');
|
|
3166
|
+
sep.setAttribute('stroke-dasharray', '2 4');
|
|
3167
|
+
g.appendChild(sep);
|
|
3168
|
+
}
|
|
3169
|
+
const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
3170
|
+
lbl.setAttribute('x', '16'); lbl.setAttribute('y', String(yTop + tierHeight/2 + 3));
|
|
3171
|
+
lbl.setAttribute('font-size', '11');
|
|
3172
|
+
lbl.setAttribute('font-weight', '600');
|
|
3173
|
+
lbl.setAttribute('fill', 'var(--text-muted)');
|
|
3174
|
+
lbl.setAttribute('letter-spacing', '0.08em');
|
|
3175
|
+
lbl.textContent = tierLabelFor(tierMap.get(tierIndices[i])).toUpperCase();
|
|
3176
|
+
g.appendChild(lbl);
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
// Scope bands (drawn over tier bands so members are clearly grouped)
|
|
3180
|
+
scopeBands.sort((a,b)=>(b.w*b.h)-(a.w*a.h));
|
|
3181
|
+
for (const b of scopeBands){
|
|
3182
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
3183
|
+
rect.setAttribute('x', b.x); rect.setAttribute('y', b.y);
|
|
3184
|
+
rect.setAttribute('width', b.w); rect.setAttribute('height', b.h);
|
|
3185
|
+
rect.setAttribute('rx', '14'); rect.setAttribute('ry', '14');
|
|
3186
|
+
rect.setAttribute('fill', b.fill);
|
|
3187
|
+
rect.setAttribute('stroke', topoKindColor(b.ref ? b.ref.kind : 'scope'));
|
|
3188
|
+
rect.setAttribute('stroke-opacity', '0.3');
|
|
3189
|
+
rect.setAttribute('stroke-dasharray', '2 4');
|
|
3190
|
+
g.appendChild(rect);
|
|
3191
|
+
const lbl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
3192
|
+
lbl.setAttribute('x', b.x + 12); lbl.setAttribute('y', b.y + 16);
|
|
3193
|
+
lbl.setAttribute('font-size', '10');
|
|
3194
|
+
lbl.setAttribute('fill', 'var(--text-muted)');
|
|
3195
|
+
lbl.textContent = `${b.ref ? b.ref.name : b.id} (${b.ref ? b.ref.kind : 'scope'})`;
|
|
3196
|
+
g.appendChild(lbl);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// Edges as Bezier paths (smooth vertical flow between tiers).
|
|
3200
|
+
// Skip IN_NAMESPACE since scopes are drawn as bands.
|
|
3201
|
+
const drawEdges = d.edges.filter(e =>
|
|
3202
|
+
e.relation !== 'IN_NAMESPACE' && positions.has(e.from) && positions.has(e.to)
|
|
3203
|
+
);
|
|
3204
|
+
function bezierPath(a, b){
|
|
3205
|
+
// Vertical-bias S-curve; control points pulled toward each endpoint's y
|
|
3206
|
+
// so the line leaves and enters its node vertically. Reads as "flow
|
|
3207
|
+
// downward through tiers" without zig-zags.
|
|
3208
|
+
const cy = (a.y + b.y) / 2;
|
|
3209
|
+
return `M ${a.x} ${a.y} C ${a.x} ${cy}, ${b.x} ${cy}, ${b.x} ${b.y}`;
|
|
3210
|
+
}
|
|
3211
|
+
for (const e of drawEdges){
|
|
3212
|
+
const a = positions.get(e.from), b = positions.get(e.to);
|
|
3213
|
+
const s = topoRelStyle(e.relation);
|
|
3214
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
3215
|
+
path.setAttribute('d', bezierPath(a, b));
|
|
3216
|
+
path.setAttribute('fill', 'none');
|
|
3217
|
+
path.setAttribute('stroke', s.stroke);
|
|
3218
|
+
path.setAttribute('stroke-width', String(s.width));
|
|
3219
|
+
if (s.dash) path.setAttribute('stroke-dasharray', s.dash);
|
|
3220
|
+
path.setAttribute('opacity', '0.85');
|
|
3221
|
+
path.dataset.from = e.from; path.dataset.to = e.to;
|
|
3222
|
+
g.appendChild(path);
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
// Nodes
|
|
3226
|
+
// Compute a per-tier label budget so long names (k8s pod hashes etc.)
|
|
3227
|
+
// don't bleed into each other. Each node "owns" the slot width between
|
|
3228
|
+
// its neighbours in the same row.
|
|
3229
|
+
const labelBudget = new Map(); // id → max chars
|
|
3230
|
+
for (const t of tierIndices){
|
|
3231
|
+
const row = tierMap.get(t);
|
|
3232
|
+
const usableW = W - SIDE_MARGIN - RIGHT_MARGIN;
|
|
3233
|
+
const slot = row.length > 1 ? usableW / row.length : usableW;
|
|
3234
|
+
// ~6.5 px per character at font-size 10
|
|
3235
|
+
const maxChars = Math.max(6, Math.floor(slot / 6.5) - 2);
|
|
3236
|
+
for (const r of row) labelBudget.set(r.id, maxChars);
|
|
3237
|
+
}
|
|
3238
|
+
function truncLabel(name, max){
|
|
3239
|
+
if (name.length <= max) return name;
|
|
3240
|
+
// For k8s-style names like "checkout-7f89d-mdblh" the suffix carries
|
|
3241
|
+
// the identifying entropy; keep the tail.
|
|
3242
|
+
return '…' + name.slice(-(max - 1));
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
const nodeEls = new Map();
|
|
3246
|
+
for (const r of drawables){
|
|
3247
|
+
const p = positions.get(r.id); if (!p) continue;
|
|
3248
|
+
const grp = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
3249
|
+
grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
|
|
3250
|
+
grp.style.cursor = 'grab';
|
|
3251
|
+
grp.dataset.id = r.id;
|
|
3252
|
+
const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
3253
|
+
// Hosts a touch bigger to read as anchors of the layout.
|
|
3254
|
+
const radius = incomingRunsOn.has(r.id) ? 9 : 6.5;
|
|
3255
|
+
c.setAttribute('r', String(radius));
|
|
3256
|
+
c.setAttribute('fill', topoKindColor(r.kind));
|
|
3257
|
+
c.setAttribute('stroke', 'rgba(0,0,0,0.45)');
|
|
3258
|
+
c.setAttribute('stroke-width', '0.8');
|
|
3259
|
+
grp.appendChild(c);
|
|
3260
|
+
// Label centered BELOW the node. A subtle text-stroke in the surface
|
|
3261
|
+
// color acts as a halo so labels stay readable when they cross a
|
|
3262
|
+
// scope band or an edge.
|
|
3263
|
+
const txt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
3264
|
+
txt.setAttribute('x', '0');
|
|
3265
|
+
txt.setAttribute('y', String(radius + 12));
|
|
3266
|
+
txt.setAttribute('font-size', '10');
|
|
3267
|
+
txt.setAttribute('text-anchor', 'middle');
|
|
3268
|
+
txt.setAttribute('fill', 'var(--text)');
|
|
3269
|
+
txt.setAttribute('stroke', 'var(--surface-2)');
|
|
3270
|
+
txt.setAttribute('stroke-width', '3');
|
|
3271
|
+
txt.setAttribute('paint-order', 'stroke');
|
|
3272
|
+
txt.setAttribute('style', 'pointer-events: none;');
|
|
3273
|
+
txt.textContent = truncLabel(r.name, labelBudget.get(r.id) || 16);
|
|
3274
|
+
// SVG <title> = native browser tooltip for the full name on hover.
|
|
3275
|
+
const tip = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
3276
|
+
tip.textContent = r.name;
|
|
3277
|
+
grp.appendChild(tip);
|
|
3278
|
+
grp.appendChild(txt);
|
|
3279
|
+
g.appendChild(grp);
|
|
3280
|
+
nodeEls.set(r.id, { grp, circle: c, radius });
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
// --- Interactions (drag a node, pan background, wheel zoom, click info) --
|
|
3284
|
+
let dragId = null, dragOffset = null;
|
|
3285
|
+
let panning = false, panStart = null;
|
|
3286
|
+
|
|
3287
|
+
function clientToWorld(cx, cy){
|
|
3288
|
+
const rect = svg.getBoundingClientRect();
|
|
3289
|
+
return { x: (cx - rect.left - view.tx) / view.scale, y: (cy - rect.top - view.ty) / view.scale };
|
|
3290
|
+
}
|
|
3291
|
+
function repaintEdgesFor(id){
|
|
3292
|
+
g.querySelectorAll('path[data-from]').forEach(pth=>{
|
|
3293
|
+
if (pth.dataset.from === id || pth.dataset.to === id){
|
|
3294
|
+
const a = positions.get(pth.dataset.from);
|
|
3295
|
+
const b = positions.get(pth.dataset.to);
|
|
3296
|
+
if (a && b) pth.setAttribute('d', bezierPath(a, b));
|
|
3297
|
+
}
|
|
3298
|
+
});
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
svg.onwheel = (ev)=>{
|
|
3302
|
+
ev.preventDefault();
|
|
3303
|
+
const k = ev.deltaY < 0 ? 1.12 : 1/1.12;
|
|
3304
|
+
const rect = svg.getBoundingClientRect();
|
|
3305
|
+
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
|
3306
|
+
view.tx = mx - (mx - view.tx) * k;
|
|
3307
|
+
view.ty = my - (my - view.ty) * k;
|
|
3308
|
+
view.scale *= k;
|
|
3309
|
+
applyView();
|
|
3310
|
+
};
|
|
3311
|
+
svg.onmousedown = (ev)=>{
|
|
3312
|
+
const el = ev.target.closest && ev.target.closest('g[data-id]');
|
|
3313
|
+
if (el){
|
|
3314
|
+
dragId = el.dataset.id;
|
|
3315
|
+
const p = positions.get(dragId);
|
|
3316
|
+
const w2 = clientToWorld(ev.clientX, ev.clientY);
|
|
3317
|
+
dragOffset = { dx: p.x - w2.x, dy: p.y - w2.y };
|
|
3318
|
+
svg.style.cursor = 'grabbing';
|
|
3319
|
+
} else {
|
|
3320
|
+
panning = true;
|
|
3321
|
+
panStart = { x: ev.clientX - view.tx, y: ev.clientY - view.ty };
|
|
3322
|
+
svg.style.cursor = 'grabbing';
|
|
3323
|
+
}
|
|
3324
|
+
};
|
|
3325
|
+
function onMove(ev){
|
|
3326
|
+
if (dragId){
|
|
3327
|
+
const w2 = clientToWorld(ev.clientX, ev.clientY);
|
|
3328
|
+
const p = { x: w2.x + dragOffset.dx, y: w2.y + dragOffset.dy };
|
|
3329
|
+
positions.set(dragId, p);
|
|
3330
|
+
const el = nodeEls.get(dragId);
|
|
3331
|
+
if (el) el.grp.setAttribute('transform', `translate(${p.x} ${p.y})`);
|
|
3332
|
+
repaintEdgesFor(dragId);
|
|
3333
|
+
} else if (panning){
|
|
3334
|
+
view.tx = ev.clientX - panStart.x;
|
|
3335
|
+
view.ty = ev.clientY - panStart.y;
|
|
3336
|
+
applyView();
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
function onUp(){
|
|
3340
|
+
if (dragId){ dragId = null; svg.style.cursor = 'grab'; }
|
|
3341
|
+
if (panning){ panning = false; svg.style.cursor = 'grab'; }
|
|
3342
|
+
}
|
|
3343
|
+
// Re-attach (these handlers are global by design — pointer can leave svg)
|
|
3344
|
+
window.removeEventListener('mousemove', window.__topoMove);
|
|
3345
|
+
window.removeEventListener('mouseup', window.__topoUp);
|
|
3346
|
+
window.__topoMove = onMove; window.__topoUp = onUp;
|
|
3347
|
+
window.addEventListener('mousemove', window.__topoMove);
|
|
3348
|
+
window.addEventListener('mouseup', window.__topoUp);
|
|
3349
|
+
|
|
3350
|
+
// Click: highlight 1-hop neighbours + populate the side inspector.
|
|
3351
|
+
function clearHighlight(){
|
|
3352
|
+
g.querySelectorAll('path[data-from]').forEach(p=>p.setAttribute('opacity','0.85'));
|
|
3353
|
+
g.querySelectorAll('g[data-id]').forEach(n=>{ n.style.opacity = '1'; });
|
|
3354
|
+
nodeEls.forEach(({circle})=>{ circle.setAttribute('stroke','rgba(0,0,0,0.45)'); circle.setAttribute('stroke-width','0.8'); });
|
|
3355
|
+
}
|
|
3356
|
+
function selectResource(id){
|
|
3357
|
+
const ref = idx.byId.get(id);
|
|
3358
|
+
if (!ref) return;
|
|
3359
|
+
topologySelectedId = id;
|
|
3360
|
+
const neighbours = new Set([id]);
|
|
3361
|
+
const incoming = []; const outgoing = [];
|
|
3362
|
+
for (const e of drawEdges){
|
|
3363
|
+
if (e.from === id){ neighbours.add(e.to); outgoing.push(e); }
|
|
3364
|
+
if (e.to === id){ neighbours.add(e.from); incoming.push(e); }
|
|
3365
|
+
}
|
|
3366
|
+
g.querySelectorAll('path[data-from]').forEach(p=>{
|
|
3367
|
+
const on = neighbours.has(p.dataset.from) && neighbours.has(p.dataset.to);
|
|
3368
|
+
p.setAttribute('opacity', on ? '1' : '0.1');
|
|
3369
|
+
});
|
|
3370
|
+
g.querySelectorAll('g[data-id]').forEach(n=>{
|
|
3371
|
+
n.style.opacity = neighbours.has(n.dataset.id) ? '1' : '0.25';
|
|
3372
|
+
});
|
|
3373
|
+
const myEl = nodeEls.get(id);
|
|
3374
|
+
if (myEl){ myEl.circle.setAttribute('stroke', 'var(--accent, #58a6ff)'); myEl.circle.setAttribute('stroke-width','2.5'); }
|
|
3375
|
+
renderInspector(ref, incoming, outgoing);
|
|
3376
|
+
}
|
|
3377
|
+
svg.onclick = (ev)=>{
|
|
3378
|
+
const el = ev.target.closest && ev.target.closest('g[data-id]');
|
|
3379
|
+
if (!el){
|
|
3380
|
+
topologySelectedId = null;
|
|
3381
|
+
clearHighlight();
|
|
3382
|
+
showInspectorEmpty();
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3385
|
+
selectResource(el.dataset.id);
|
|
3386
|
+
};
|
|
3387
|
+
|
|
3388
|
+
function showInspectorEmpty(){
|
|
3389
|
+
document.getElementById('topology-inspector-empty').style.display = '';
|
|
3390
|
+
document.getElementById('topology-inspector-body').style.display = 'none';
|
|
3391
|
+
}
|
|
3392
|
+
function renderInspector(ref, incoming, outgoing){
|
|
3393
|
+
document.getElementById('topology-inspector-empty').style.display = 'none';
|
|
3394
|
+
const body = document.getElementById('topology-inspector-body');
|
|
3395
|
+
body.style.display = '';
|
|
3396
|
+
const labelEntries = Object.entries(ref.labels || {});
|
|
3397
|
+
const attrEntries = Object.entries(ref.attributes || {});
|
|
3398
|
+
function kvList(entries){
|
|
3399
|
+
if (entries.length === 0) return '<div class="muted" style="font-size: var(--fs-xs);">(none)</div>';
|
|
3400
|
+
return '<div style="display:grid; grid-template-columns: max-content 1fr; gap: 4px 10px; font-size: var(--fs-xs);">' +
|
|
3401
|
+
entries.map(([k,v])=>`<div class="muted" style="font-weight:600;">${esc(k)}</div><div style="font-family: 'JetBrains Mono', ui-monospace, monospace; word-break: break-all;">${esc(String(v))}</div>`).join('') +
|
|
3402
|
+
'</div>';
|
|
3403
|
+
}
|
|
3404
|
+
function neighbourList(edges, dir){
|
|
3405
|
+
if (edges.length === 0) return '<div class="muted" style="font-size: var(--fs-xs);">(none)</div>';
|
|
3406
|
+
return '<ul style="margin:0; padding-left: 0; list-style: none; display:flex; flex-direction:column; gap:4px;">' +
|
|
3407
|
+
edges.map(e=>{
|
|
3408
|
+
const otherId = dir === 'in' ? e.from : e.to;
|
|
3409
|
+
const other = idx.byId.get(otherId);
|
|
3410
|
+
const otherKind = other ? other.kind : '?';
|
|
3411
|
+
const otherName = other ? other.name : otherId;
|
|
3412
|
+
const c = topoKindColor(otherKind);
|
|
3413
|
+
return `<li style="display:flex; gap:6px; align-items:center; font-size: var(--fs-xs);">
|
|
3414
|
+
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:${c}; flex-shrink:0;"></span>
|
|
3415
|
+
<span style="color: var(--text-muted); width: 78px; flex-shrink:0;">${esc(e.relation)}</span>
|
|
3416
|
+
<a href="#" data-jump="${esc(otherId)}" style="color: var(--accent, #58a6ff); text-decoration:none; word-break: break-all;">${esc(otherName)}</a>
|
|
3417
|
+
<span class="muted" style="flex-shrink:0;">(${esc(otherKind)})</span>
|
|
3418
|
+
</li>`;
|
|
3419
|
+
}).join('') + '</ul>';
|
|
3420
|
+
}
|
|
3421
|
+
body.innerHTML = `
|
|
3422
|
+
<div style="display:flex; align-items:flex-start; gap:8px;">
|
|
3423
|
+
<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:${topoKindColor(ref.kind)}; margin-top:5px; flex-shrink:0;"></span>
|
|
3424
|
+
<div style="min-width:0; flex:1;">
|
|
3425
|
+
<div style="font-weight:600; font-size: var(--fs-md); color: var(--text); word-break: break-all;">${esc(ref.name)}</div>
|
|
3426
|
+
<div style="display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top: 4px;">
|
|
3427
|
+
<span class="badge">${esc(ref.kind)}</span>
|
|
3428
|
+
<span class="muted" style="font-size: var(--fs-xs);">source: ${esc(ref.source)}</span>
|
|
3429
|
+
</div>
|
|
3430
|
+
</div>
|
|
3431
|
+
</div>
|
|
3432
|
+
<div style="margin-top: 10px; font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 10px; color: var(--text-muted); word-break: break-all;">${esc(ref.id)}</div>
|
|
3433
|
+
|
|
3434
|
+
<div style="margin-top: 16px;">
|
|
3435
|
+
<div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Labels</div>
|
|
3436
|
+
${kvList(labelEntries)}
|
|
3437
|
+
</div>
|
|
3438
|
+
|
|
3439
|
+
<div style="margin-top: 16px;">
|
|
3440
|
+
<div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Attributes</div>
|
|
3441
|
+
${kvList(attrEntries.filter(([k])=>k!=='uid'))}
|
|
3442
|
+
</div>
|
|
3443
|
+
|
|
3444
|
+
<div style="margin-top: 16px;">
|
|
3445
|
+
<div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Outgoing (${outgoing.length})</div>
|
|
3446
|
+
${neighbourList(outgoing, 'out')}
|
|
3447
|
+
</div>
|
|
3448
|
+
|
|
3449
|
+
<div style="margin-top: 12px;">
|
|
3450
|
+
<div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Incoming (${incoming.length})</div>
|
|
3451
|
+
${neighbourList(incoming, 'in')}
|
|
3452
|
+
</div>
|
|
3453
|
+
|
|
3454
|
+
<div style="margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
3455
|
+
<div style="text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 6px;">Linked telemetry</div>
|
|
3456
|
+
<div class="muted" style="font-size: var(--fs-xs);">No metrics or logs linked to this resource. When demo workloads run inside the cluster and emit Prometheus/Loki signals under matching service labels, charts will appear here.</div>
|
|
3457
|
+
</div>
|
|
3458
|
+
`;
|
|
3459
|
+
// Wire neighbour links to navigate inside the graph.
|
|
3460
|
+
body.querySelectorAll('a[data-jump]').forEach(a=>{
|
|
3461
|
+
a.addEventListener('click', (ev)=>{
|
|
3462
|
+
ev.preventDefault();
|
|
3463
|
+
selectResource(a.dataset.jump);
|
|
3464
|
+
});
|
|
3465
|
+
});
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
// Re-apply selection across re-renders. If the previously-selected
|
|
3469
|
+
// resource still exists, keep the highlight + inspector pinned to it
|
|
3470
|
+
// so a poll-driven refresh doesn't drag the user back to the empty
|
|
3471
|
+
// state. If it's gone (deleted), fall back to empty.
|
|
3472
|
+
if (topologySelectedId && idx.byId.has(topologySelectedId)){
|
|
3473
|
+
selectResource(topologySelectedId);
|
|
3474
|
+
} else {
|
|
3475
|
+
topologySelectedId = null;
|
|
3476
|
+
showInspectorEmpty();
|
|
3477
|
+
}
|
|
3478
|
+
topologyGraphState = { positions, view };
|
|
3479
|
+
}
|
|
3480
|
+
|
|
1215
3481
|
// --- Utils ---
|
|
1216
3482
|
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
|
1217
3483
|
async function refresh(){await Promise.all([loadSources(),loadServices()]);}
|
|
@@ -1239,7 +3505,7 @@ async function loadInfo(){
|
|
|
1239
3505
|
// Show the endpoint the user is actually reaching the server through
|
|
1240
3506
|
// (localhost, a port-forward, or an ingress) — not a hardcoded host.
|
|
1241
3507
|
document.getElementById('mcp-url').textContent = window.location.origin + '/mcp';
|
|
1242
|
-
(async()=>{await loadTypes();await refresh();await loadInfo();setInterval(refresh,15000);})();
|
|
3508
|
+
(async()=>{syncThemeToggle();initNav();await loadTypes();await refresh();await loadInfo();await updateHealthStat();await buildNotifications();setInterval(refresh,15000);setInterval(updateHealthStat,15000);setInterval(buildNotifications,15000);})();
|
|
1243
3509
|
</script>
|
|
1244
3510
|
|
|
1245
3511
|
<footer id="info-footer" class="info-footer"><span class="spinner"></span> loading server info…</footer>
|