@thotischner/observability-mcp 1.3.4 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/cli/index.d.ts +2 -0
  2. package/dist/cli/index.js +370 -0
  3. package/dist/cli/lib.d.ts +95 -0
  4. package/dist/cli/lib.js +185 -0
  5. package/dist/cli/lib.test.d.ts +1 -0
  6. package/dist/cli/lib.test.js +134 -0
  7. package/dist/config/loader.test.js +3 -3
  8. package/dist/connectors/hub.d.ts +48 -0
  9. package/dist/connectors/hub.js +51 -0
  10. package/dist/connectors/hub.test.d.ts +1 -0
  11. package/dist/connectors/hub.test.js +52 -0
  12. package/dist/connectors/install.d.ts +24 -0
  13. package/dist/connectors/install.js +100 -0
  14. package/dist/connectors/install.test.d.ts +1 -0
  15. package/dist/connectors/install.test.js +58 -0
  16. package/dist/connectors/loader.d.ts +48 -0
  17. package/dist/connectors/loader.js +222 -0
  18. package/dist/connectors/loki.js +14 -6
  19. package/dist/connectors/loki.test.js +27 -0
  20. package/dist/connectors/registry.d.ts +3 -0
  21. package/dist/connectors/registry.js +16 -16
  22. package/dist/connectors/tls.test.js +3 -3
  23. package/dist/connectors/verify.d.ts +19 -0
  24. package/dist/connectors/verify.js +87 -0
  25. package/dist/connectors/verify.test.d.ts +1 -0
  26. package/dist/connectors/verify.test.js +63 -0
  27. package/dist/index.js +389 -26
  28. package/dist/metrics/instrument-connector.d.ts +8 -0
  29. package/dist/metrics/instrument-connector.js +41 -0
  30. package/dist/metrics/self.d.ts +12 -0
  31. package/dist/metrics/self.js +61 -0
  32. package/dist/openapi.d.ts +2 -0
  33. package/dist/openapi.js +186 -0
  34. package/dist/sdk/index.d.ts +52 -0
  35. package/dist/sdk/index.js +13 -0
  36. package/dist/sdk/manifest-schema.d.ts +28 -0
  37. package/dist/sdk/manifest-schema.js +47 -0
  38. package/dist/sdk/manifest-schema.test.d.ts +1 -0
  39. package/dist/sdk/manifest-schema.test.js +50 -0
  40. package/dist/tools/get-service-health.js +3 -2
  41. package/dist/ui/index.html +687 -115
  42. package/dist/util/sanitize.d.ts +1 -0
  43. package/dist/util/sanitize.js +6 -0
  44. package/package.json +21 -8
@@ -4,122 +4,482 @@
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
+ <link rel="preconnect" href="https://rsms.me/">
8
+ <link rel="stylesheet" href="https://rsms.me/inter/inter.css">
9
+ <style>
10
+ @supports (font-variation-settings: normal) {
11
+ :root { font-family: 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
12
+ }
13
+ </style>
7
14
  <style>
8
15
  :root {
9
- --bg: #0f1117; --surface: #161b22; --surface2: #1c2128; --border: #30363d;
10
- --text: #e1e4e8; --text2: #8b949e; --accent: #58a6ff; --green: #3fb950;
11
- --red: #f85149; --yellow: #d29922; --purple: #d2a8ff; --radius: 8px;
12
- }
13
- * { margin: 0; padding: 0; box-sizing: border-box; }
14
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
15
- .header { background: var(--surface); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; gap: 16px; }
16
- .header h1 { font-size: 18px; font-weight: 600; }
17
- .badge { padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; }
18
- .badge-ok { background: var(--green); color: #fff; }
19
- .badge-err { background: var(--red); color: #fff; }
20
- .nav { display: flex; gap: 2px; margin-left: 24px; }
21
- .nav-btn { background: none; border: none; color: var(--text2); padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
22
- .nav-btn:hover { background: var(--surface2); color: var(--text); }
23
- .nav-btn.active { background: var(--surface2); color: var(--accent); }
24
- .container { max-width: 1200px; margin: 0 auto; padding: 24px; }
25
- .page { display: none; } .page.active { display: block; }
26
- .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
27
- .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; text-align: center; }
28
- .stat-card .value { font-size: 28px; font-weight: 700; color: var(--accent); }
29
- .stat-card .label { font-size: 12px; color: var(--text2); margin-top: 4px; }
30
- .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
31
- .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
32
- .card-header h2 { font-size: 15px; font-weight: 600; }
33
- .source-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; transition: border-color 0.15s; }
34
- .source-row:hover { border-color: var(--accent); }
35
- .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
36
- .dot-up { background: var(--green); box-shadow: 0 0 6px var(--green); }
37
- .dot-down { background: var(--red); box-shadow: 0 0 6px var(--red); }
38
- .dot-disabled { background: var(--text2); }
16
+ /* Surface neutral slate, three tiers of elevation */
17
+ --bg: #0b0d12;
18
+ --surface: #11141b;
19
+ --surface-2: #161a23;
20
+ --surface-3: #1c212c;
21
+ --border: rgba(255,255,255,0.06);
22
+ --border-strong: rgba(255,255,255,0.10);
23
+
24
+ /* Text four levels */
25
+ --text: #e8ecf2;
26
+ --text-muted: #a1a8b3;
27
+ --text-dim: #6b7280;
28
+ --text-inv: #0b0d12;
29
+
30
+ /* One accent refined cyan-blue, not GitHub blue */
31
+ --accent: #4f8cff;
32
+ --accent-2: #6ea3ff;
33
+ --accent-soft: rgba(79,140,255,0.10);
34
+ --accent-ring: rgba(79,140,255,0.35);
35
+
36
+ /* Semantic desaturated, professional */
37
+ --success: #4ade80;
38
+ --success-soft: rgba(74,222,128,0.10);
39
+ --warning: #f5b341;
40
+ --warning-soft: rgba(245,179,65,0.10);
41
+ --danger: #ef5b6e;
42
+ --danger-soft: rgba(239,91,110,0.10);
43
+ --info: #a78bfa;
44
+ --info-soft: rgba(167,139,250,0.10);
45
+
46
+ /* Legacy aliases so nothing breaks during the rolling refactor */
47
+ --text2: var(--text-muted);
48
+ --surface2: var(--surface-2);
49
+ --green: var(--success);
50
+ --red: var(--danger);
51
+ --yellow: var(--warning);
52
+ --purple: var(--info);
53
+
54
+ /* Type scale (Major-Third-ish) */
55
+ --fs-xs: 11px;
56
+ --fs-sm: 12px;
57
+ --fs-md: 13px;
58
+ --fs-lg: 14px;
59
+ --fs-xl: 16px;
60
+ --fs-2xl: 20px;
61
+ --fs-3xl: 28px;
62
+ --fs-4xl: 36px;
63
+
64
+ /* Spacing — 4-based scale */
65
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
66
+ --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px;
67
+
68
+ /* Radii */
69
+ --radius-sm: 6px;
70
+ --radius: 8px;
71
+ --radius-lg: 12px;
72
+ --radius-pill: 999px;
73
+
74
+ /* Elevation */
75
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
76
+ --shadow: 0 4px 12px rgba(0,0,0,0.25), 0 0 0 1px var(--border);
77
+ --shadow-lg: 0 12px 32px rgba(0,0,0,0.35), 0 0 0 1px var(--border-strong);
78
+
79
+ /* Motion */
80
+ --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
81
+ --t-fast: 120ms;
82
+ }
83
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
84
+ html { -webkit-text-size-adjust: 100%; }
85
+ body {
86
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
87
+ font-feature-settings: 'cv11', 'ss01', 'ss03';
88
+ font-size: var(--fs-md);
89
+ line-height: 1.5;
90
+ background: var(--bg);
91
+ color: var(--text);
92
+ -webkit-font-smoothing: antialiased;
93
+ text-rendering: optimizeLegibility;
94
+ }
95
+ code, .mono, pre { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-ligatures: none; }
96
+ a { color: var(--accent); text-decoration: none; }
97
+ a:hover { color: var(--accent-2); }
98
+ :focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; border-radius: 4px; }
99
+
100
+ .header {
101
+ background: linear-gradient(180deg, var(--surface), var(--surface) 70%, rgba(0,0,0,0.0));
102
+ border-bottom: 1px solid var(--border);
103
+ padding: var(--sp-3) var(--sp-6);
104
+ display: flex; align-items: center; gap: var(--sp-4);
105
+ position: sticky; top: 0; z-index: 20;
106
+ backdrop-filter: saturate(160%) blur(8px);
107
+ }
108
+ .header h1 { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; }
109
+ .header h1::before {
110
+ content: ''; display: inline-block; width: 14px; height: 14px;
111
+ margin-right: 10px; vertical-align: -2px; border-radius: 3px;
112
+ background: linear-gradient(135deg, var(--accent), var(--info));
113
+ box-shadow: 0 0 0 1px var(--border-strong) inset;
114
+ }
115
+ .badge {
116
+ display: inline-flex; align-items: center; gap: 6px;
117
+ padding: 3px 10px;
118
+ border-radius: var(--radius-pill);
119
+ font-size: var(--fs-xs); font-weight: 600;
120
+ letter-spacing: 0.02em;
121
+ }
122
+ .badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; box-shadow: 0 0 6px currentColor; }
123
+ .badge-ok { background: var(--success-soft); color: var(--success); border: 1px solid rgba(74,222,128,0.25); }
124
+ .badge-err { background: var(--danger-soft); color: var(--danger); border: 1px solid rgba(239,91,110,0.30); }
125
+ .hidden { display: none !important; }
126
+
127
+ .nav { display: flex; gap: 2px; margin-left: var(--sp-6); }
128
+ .nav-btn {
129
+ background: none; border: none;
130
+ color: var(--text-muted);
131
+ padding: 6px 14px;
132
+ border-radius: var(--radius-sm);
133
+ cursor: pointer;
134
+ font-size: var(--fs-md); font-weight: 500;
135
+ transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
136
+ }
137
+ .nav-btn:hover { background: var(--surface-2); color: var(--text); }
138
+ .nav-btn.active { background: var(--surface-2); color: var(--text); box-shadow: inset 0 -2px 0 0 var(--accent); }
139
+ .container { max-width: 1240px; margin: 0 auto; padding: var(--sp-6); }
140
+ .page { display: none; } .page.active { display: block; animation: fadeIn 180ms var(--ease); }
141
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
142
+
143
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-4); margin-bottom: var(--sp-6); }
144
+ .stat-card {
145
+ background: var(--surface);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius);
148
+ padding: var(--sp-5);
149
+ position: relative; overflow: hidden;
150
+ text-align: left;
151
+ transition: border-color var(--t-fast) var(--ease);
152
+ }
153
+ .stat-card:hover { border-color: var(--border-strong); }
154
+ .stat-card::after {
155
+ content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
156
+ background: linear-gradient(180deg, var(--accent), transparent);
157
+ opacity: 0.5;
158
+ }
159
+ .stat-card .label {
160
+ font-size: var(--fs-xs); color: var(--text-muted);
161
+ text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500;
162
+ margin-bottom: var(--sp-2);
163
+ }
164
+ .stat-card .value {
165
+ font-size: var(--fs-3xl); font-weight: 600; letter-spacing: -0.02em;
166
+ color: var(--text); font-variant-numeric: tabular-nums; line-height: 1.1;
167
+ }
168
+ .stat-card .context {
169
+ font-size: var(--fs-xs); color: var(--text-muted);
170
+ margin-top: var(--sp-2);
171
+ font-variant-numeric: tabular-nums;
172
+ }
173
+ .stat-card .context.good { color: var(--success, #3fb950); }
174
+ .stat-card .context.warn { color: var(--warn, #d29922); }
175
+
176
+ .live-indicator {
177
+ display: inline-flex; align-items: center; gap: var(--sp-2);
178
+ font-size: var(--fs-xs); color: var(--text-muted);
179
+ text-transform: uppercase; letter-spacing: 0.08em;
180
+ }
181
+ .live-indicator::before {
182
+ content: ''; width: 8px; height: 8px; border-radius: 50%;
183
+ background: var(--success, #3fb950);
184
+ box-shadow: 0 0 0 0 rgba(63, 185, 80, 0.5);
185
+ animation: live-pulse 2s var(--ease) infinite;
186
+ }
187
+ @keyframes live-pulse {
188
+ 0% { box-shadow: 0 0 0 0 rgba(63, 185, 80, 0.4); }
189
+ 70% { box-shadow: 0 0 0 6px rgba(63, 185, 80, 0); }
190
+ 100% { box-shadow: 0 0 0 0 rgba(63, 185, 80, 0); }
191
+ }
192
+ .dashboard-meta {
193
+ display: flex; align-items: baseline; justify-content: space-between;
194
+ gap: var(--sp-4); margin-bottom: var(--sp-4);
195
+ }
196
+ .dashboard-meta h1 {
197
+ font-size: var(--fs-lg); font-weight: 600; color: var(--text);
198
+ letter-spacing: -0.01em; margin: 0;
199
+ }
200
+ .card {
201
+ background: var(--surface);
202
+ border: 1px solid var(--border);
203
+ border-radius: var(--radius);
204
+ padding: var(--sp-5);
205
+ margin-bottom: var(--sp-4);
206
+ }
207
+ .card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); }
208
+ .card-header h2 { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.01em; color: var(--text); }
209
+ .source-row {
210
+ display: flex; align-items: center; gap: var(--sp-3);
211
+ padding: var(--sp-3) var(--sp-4);
212
+ border: 1px solid var(--border);
213
+ border-radius: var(--radius-sm);
214
+ margin-bottom: var(--sp-2);
215
+ background: var(--surface);
216
+ transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
217
+ }
218
+ .source-row:hover { background: var(--surface-2); border-color: var(--border-strong); }
219
+ .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; position: relative; }
220
+ .dot-up { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
221
+ .dot-up::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; background: var(--success); opacity: 0.4; animation: pulse 2s var(--ease) infinite; }
222
+ @keyframes pulse { 0%, 100% { opacity: 0.4; transform: scale(1); } 50% { opacity: 0; transform: scale(1.8); } }
223
+ .dot-down { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
224
+ .dot-disabled { background: var(--text-dim); box-shadow: 0 0 0 3px rgba(107,114,128,0.15); }
39
225
  .source-info { flex: 1; min-width: 0; }
40
- .source-info .name { font-weight: 600; font-size: 14px; }
41
- .source-info .url { color: var(--text2); font-size: 12px; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
42
- .source-actions { display: flex; gap: 6px; flex-shrink: 0; }
43
- .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; margin-left: 6px; }
44
- .tag-metrics { color: var(--accent); background: #1f6feb1a; border: 1px solid #1f6feb33; }
45
- .tag-logs { color: var(--purple); background: #8957e51a; border: 1px solid #8957e533; }
46
- .tag-type { color: var(--text2); background: var(--surface2); border: 1px solid var(--border); }
47
- .tag-latency { color: var(--text2); font-size: 11px; }
48
- .service-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 6px; }
49
- .service-row .name { font-weight: 600; font-size: 14px; }
50
- .btn { padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); transition: all 0.15s; }
51
- .btn-primary { background: #238636; border-color: #238636; color: #fff; } .btn-primary:hover { background: #2ea043; }
52
- .btn-ghost { background: none; color: var(--text2); } .btn-ghost:hover { background: var(--surface2); color: var(--text); }
53
- .btn-danger { background: none; color: var(--red); border-color: transparent; } .btn-danger:hover { background: #f851491a; }
54
- .btn-sm { padding: 4px 10px; font-size: 12px; }
55
- .btn-icon { background: none; border: none; color: var(--text2); cursor: pointer; padding: 4px 8px; border-radius: 4px; font-size: 16px; } .btn-icon:hover { background: var(--surface2); color: var(--text); }
56
- .toggle { position: relative; width: 36px; height: 20px; cursor: pointer; }
226
+ .source-info .name { font-weight: 600; font-size: var(--fs-lg); letter-spacing: -0.01em; }
227
+ .source-info .url {
228
+ color: var(--text-muted); font-size: var(--fs-sm);
229
+ font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-ligatures: none;
230
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
231
+ }
232
+ .source-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
233
+ .tag {
234
+ display: inline-flex; align-items: center; gap: 4px;
235
+ padding: 2px 8px;
236
+ border-radius: var(--radius-pill);
237
+ font-size: var(--fs-xs); font-weight: 500;
238
+ letter-spacing: 0.02em;
239
+ margin-left: 6px;
240
+ }
241
+ .tag-metrics { color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(79,140,255,0.25); }
242
+ .tag-logs { color: var(--info); background: var(--info-soft); border: 1px solid rgba(167,139,250,0.25); }
243
+ .tag-type { color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border-strong); text-transform: capitalize; }
244
+ .tag-latency { color: var(--text-muted); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
245
+ .service-row {
246
+ display: flex; align-items: center; justify-content: space-between;
247
+ padding: var(--sp-3) var(--sp-4);
248
+ border: 1px solid var(--border);
249
+ border-radius: var(--radius-sm);
250
+ margin-bottom: var(--sp-2);
251
+ background: var(--surface);
252
+ transition: background var(--t-fast) var(--ease);
253
+ }
254
+ .service-row:hover { background: var(--surface-2); }
255
+ .service-row .name { font-weight: 600; font-size: var(--fs-lg); }
256
+ .btn {
257
+ padding: 7px 14px;
258
+ border-radius: var(--radius-sm);
259
+ font: 500 var(--fs-md)/1 inherit;
260
+ cursor: pointer;
261
+ border: 1px solid var(--border-strong);
262
+ background: var(--surface-2);
263
+ color: var(--text);
264
+ transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
265
+ display: inline-flex; align-items: center; gap: 6px;
266
+ }
267
+ .btn:hover { background: var(--surface-3); border-color: rgba(255,255,255,0.18); }
268
+ .btn:active { transform: translateY(1px); }
269
+ .btn-primary {
270
+ background: var(--accent);
271
+ border-color: var(--accent);
272
+ color: var(--text-inv);
273
+ font-weight: 600;
274
+ }
275
+ .btn-primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
276
+ .btn-ghost { background: none; border-color: transparent; color: var(--text-muted); }
277
+ .btn-ghost:hover { background: var(--surface-2); color: var(--text); border-color: transparent; }
278
+ .btn-danger { background: none; color: var(--danger); border-color: transparent; }
279
+ .btn-danger:hover { background: var(--danger-soft); border-color: transparent; }
280
+ .btn-sm { padding: 4px 10px; font-size: var(--fs-sm); }
281
+ .btn-icon { background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 4px 8px; border-radius: var(--radius-sm); font-size: var(--fs-xl); transition: color var(--t-fast) var(--ease), background var(--t-fast) var(--ease); }
282
+ .btn-icon:hover { background: var(--surface-2); color: var(--text); }
283
+ .toggle { position: relative; width: 38px; height: 22px; cursor: pointer; flex-shrink: 0; }
57
284
  .toggle input { display: none; }
58
- .toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 10px; transition: 0.2s; }
59
- .toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px; background: var(--text); border-radius: 50%; transition: 0.2s; }
60
- .toggle input:checked + .slider { background: var(--green); }
61
- .toggle input:checked + .slider::before { transform: translateX(16px); }
62
- .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; }
63
- .modal-overlay.open { display: flex; }
64
- .modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; width: 520px; max-width: 90vw; max-height: 90vh; overflow-y: auto; }
65
- .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border); }
66
- .modal-header h3 { font-size: 16px; font-weight: 600; }
67
- .modal-body { padding: 20px; }
68
- .modal-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); }
69
- .form-group { margin-bottom: 16px; }
70
- .form-group label { display: block; font-size: 13px; font-weight: 500; color: var(--text2); margin-bottom: 6px; }
71
- .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; font-family: inherit; }
72
- .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); }
73
- .form-group select { appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; }
74
- .form-group textarea { resize: vertical; min-height: 80px; font-family: monospace; font-size: 13px; }
75
- .form-hint { font-size: 11px; color: var(--text2); margin-top: 4px; }
76
- .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
77
- .form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
78
- .test-result { padding: 10px 14px; border-radius: 6px; font-size: 13px; margin-top: 12px; display: none; }
285
+ .toggle .slider {
286
+ position: absolute; inset: 0;
287
+ background: var(--surface-3);
288
+ border: 1px solid var(--border-strong);
289
+ border-radius: var(--radius-pill);
290
+ transition: background var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease);
291
+ }
292
+ .toggle .slider::before {
293
+ content: ''; position: absolute;
294
+ width: 16px; height: 16px; left: 2px; top: 2px;
295
+ background: var(--text);
296
+ border-radius: 50%;
297
+ transition: transform var(--t-fast) var(--ease), background var(--t-fast) var(--ease);
298
+ box-shadow: 0 1px 2px rgba(0,0,0,0.4);
299
+ }
300
+ .toggle input:checked + .slider { background: var(--accent); border-color: var(--accent); }
301
+ .toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
302
+ .toggle:hover .slider { border-color: rgba(255,255,255,0.18); }
303
+ .modal-overlay {
304
+ display: none; position: fixed; inset: 0;
305
+ background: rgba(8,10,15,0.72);
306
+ backdrop-filter: blur(4px);
307
+ z-index: 100; align-items: center; justify-content: center;
308
+ }
309
+ .modal-overlay.open { display: flex; animation: fadeIn 140ms var(--ease); }
310
+ .modal {
311
+ background: var(--surface);
312
+ border: 1px solid var(--border-strong);
313
+ border-radius: var(--radius-lg);
314
+ box-shadow: var(--shadow-lg);
315
+ width: 560px; max-width: 92vw; max-height: 88vh;
316
+ overflow-y: auto;
317
+ animation: modalIn 180ms var(--ease);
318
+ }
319
+ @keyframes modalIn { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
320
+ .modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
321
+ .modal-header h3 { font-size: var(--fs-xl); font-weight: 600; letter-spacing: -0.01em; }
322
+ .modal-body { padding: var(--sp-5); }
323
+ .modal-footer { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-4) var(--sp-5); border-top: 1px solid var(--border); background: var(--surface); position: sticky; bottom: 0; }
324
+ .form-group { margin-bottom: var(--sp-4); }
325
+ .form-group label { display: block; font-size: var(--fs-sm); font-weight: 500; color: var(--text-muted); margin-bottom: 6px; letter-spacing: 0.02em; }
326
+ .form-group input, .form-group select, .form-group textarea {
327
+ width: 100%; padding: 9px 12px;
328
+ background: var(--bg);
329
+ border: 1px solid var(--border-strong);
330
+ border-radius: var(--radius-sm);
331
+ color: var(--text);
332
+ font: var(--fs-lg)/1.4 inherit;
333
+ transition: border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease);
334
+ }
335
+ .form-group input:hover, .form-group select:hover, .form-group textarea:hover { border-color: rgba(255,255,255,0.16); }
336
+ .form-group input:focus, .form-group select:focus, .form-group textarea:focus {
337
+ outline: none;
338
+ border-color: var(--accent);
339
+ box-shadow: 0 0 0 3px var(--accent-ring);
340
+ }
341
+ .form-group select {
342
+ appearance: none;
343
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23a1a8b3'%3E%3Cpath d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
344
+ background-repeat: no-repeat; background-position: right 12px center;
345
+ padding-right: 36px;
346
+ }
347
+ .form-group textarea { resize: vertical; min-height: 96px; font: var(--fs-md)/1.55 'JetBrains Mono', ui-monospace, monospace; }
348
+ .form-hint { font-size: var(--fs-xs); color: var(--text-dim); margin-top: 6px; line-height: 1.5; }
349
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
350
+ .form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: var(--sp-3); }
351
+ .test-result { padding: 10px 14px; border-radius: var(--radius-sm); font-size: var(--fs-md); margin-top: var(--sp-3); display: none; }
79
352
  .test-result.show { display: block; }
80
- .test-result.success { background: #238636aa; border: 1px solid var(--green); }
81
- .test-result.failure { background: #f851491a; border: 1px solid var(--red); color: var(--red); }
82
- .endpoint-bar { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 16px; font-family: monospace; font-size: 13px; color: var(--accent); margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; }
83
- .empty { color: var(--text2); text-align: center; padding: 32px; }
84
- .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
353
+ .test-result.success { background: var(--success-soft); border: 1px solid rgba(74,222,128,0.30); color: var(--success); }
354
+ .test-result.failure { background: var(--danger-soft); border: 1px solid rgba(239,91,110,0.35); color: var(--danger); }
355
+ .endpoint-bar {
356
+ background: var(--surface);
357
+ border: 1px solid var(--border);
358
+ border-radius: var(--radius);
359
+ padding: 10px 16px;
360
+ font: var(--fs-md)/1 'JetBrains Mono', ui-monospace, monospace;
361
+ color: var(--accent);
362
+ margin-bottom: var(--sp-6);
363
+ display: flex; align-items: center; justify-content: space-between;
364
+ }
365
+ .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 { color: var(--text-dim); text-align: center; padding: var(--sp-8) var(--sp-4); font-size: var(--fs-md); }
367
+ .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; }
85
368
  @keyframes spin { to { transform: rotate(360deg); } }
86
369
  /* Tabs inside settings */
87
- .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
88
- .tab-btn { background: none; border: none; border-bottom: 2px solid transparent; color: var(--text2); padding: 10px 18px; cursor: pointer; font-size: 13px; font-weight: 500; }
370
+ .tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: var(--sp-5); }
371
+ .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); }
89
372
  .tab-btn:hover { color: var(--text); }
90
- .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
373
+ .tab-btn.active { color: var(--text); border-bottom-color: var(--accent); }
91
374
  .tab-content { display: none; } .tab-content.active { display: block; }
92
375
  /* Threshold cards */
93
- .threshold-group { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
94
- .threshold-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 14px; }
95
- .threshold-card h4 { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
376
+ .threshold-group { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); margin-bottom: var(--sp-5); }
377
+ .threshold-card { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-4); }
378
+ .threshold-card h4 { font-size: var(--fs-md); font-weight: 600; margin-bottom: var(--sp-3); letter-spacing: 0.01em; }
96
379
  /* Metric table */
97
380
  .metric-table { width: 100%; border-collapse: collapse; }
98
- .metric-table th { text-align: left; font-size: 12px; color: var(--text2); font-weight: 500; padding: 6px 10px; border-bottom: 1px solid var(--border); }
99
- .metric-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); font-size: 13px; }
100
- .metric-table .query { font-family: monospace; font-size: 11px; color: var(--text2); max-width: 400px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
101
- .metric-table tr:hover td { background: var(--surface2); }
102
- .toast { position: fixed; bottom: 24px; right: 24px; background: var(--green); color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 13px; font-weight: 500; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 200; }
103
- .toast.show { opacity: 1; }
381
+ .metric-table th { text-align: left; font-size: var(--fs-xs); color: var(--text-muted); font-weight: 500; padding: 8px 10px; border-bottom: 1px solid var(--border-strong); text-transform: uppercase; letter-spacing: 0.06em; }
382
+ .metric-table td { padding: 10px; border-bottom: 1px solid var(--border); font-size: var(--fs-md); }
383
+ .metric-table .query { font-family: 'JetBrains Mono', ui-monospace, monospace; font-variant-ligatures: none; font-size: var(--fs-xs); color: var(--text-muted); max-width: 400px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
384
+ .metric-table tr { transition: background var(--t-fast) var(--ease); }
385
+ .metric-table tbody tr:hover td { background: var(--surface-2); }
386
+ .toast {
387
+ position: fixed; bottom: var(--sp-6); right: var(--sp-6);
388
+ background: var(--surface);
389
+ color: var(--text);
390
+ padding: 12px 18px;
391
+ border-radius: var(--radius);
392
+ border: 1px solid var(--border-strong);
393
+ box-shadow: var(--shadow);
394
+ font-size: var(--fs-md); font-weight: 500;
395
+ display: flex; align-items: center; gap: 10px;
396
+ opacity: 0; transform: translateY(8px);
397
+ transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease);
398
+ pointer-events: none; z-index: 200;
399
+ }
400
+ .toast::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
401
+ .toast.show { opacity: 1; transform: none; }
402
+ .toast.toast-error::before { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
104
403
  /* Health cards */
105
- .health-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; }
106
- .health-card .hc-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
107
- .health-card .hc-name { font-size: 16px; font-weight: 600; }
108
- .health-card .hc-score { font-size: 28px; font-weight: 700; }
109
- .health-card .hc-score.healthy { color: var(--green); }
110
- .health-card .hc-score.degraded { color: var(--yellow); }
111
- .health-card .hc-score.critical { color: var(--red); }
112
- .health-card .hc-status { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
113
- .hc-status.healthy { background: #23863633; color: var(--green); }
114
- .hc-status.degraded { background: #d2992233; color: var(--yellow); }
115
- .hc-status.critical { background: #f8514933; color: var(--red); }
116
- .hc-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
117
- .hc-metric { background: var(--bg); border-radius: 6px; padding: 8px 10px; }
118
- .hc-metric .label { font-size: 11px; color: var(--text2); }
119
- .hc-metric .val { font-size: 15px; font-weight: 600; margin-top: 2px; }
120
- .hc-anomalies { margin-top: 12px; }
121
- .hc-anomaly { background: #f851491a; border: 1px solid #f8514933; border-radius: 6px; padding: 8px 10px; margin-top: 6px; font-size: 12px; color: var(--red); }
122
- .hc-correlation { background: #d2a8ff1a; border: 1px solid #8957e533; border-radius: 6px; padding: 8px 10px; margin-top: 6px; font-size: 12px; color: var(--purple); }
404
+ .health-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-5); transition: border-color var(--t-fast) var(--ease); }
405
+ .health-card:hover { border-color: var(--border-strong); }
406
+ .health-card .hc-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); gap: var(--sp-3); }
407
+ .health-card .hc-name { font-size: var(--fs-xl); font-weight: 600; letter-spacing: -0.01em; }
408
+ .health-card .hc-score { font-size: var(--fs-3xl); font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; line-height: 1; }
409
+ .health-card .hc-score.healthy { color: var(--success); }
410
+ .health-card .hc-score.degraded { color: var(--warning); }
411
+ .health-card .hc-score.critical { color: var(--danger); }
412
+ .health-card .hc-spark {
413
+ display: block; width: 100%; height: 36px;
414
+ margin: var(--sp-1) 0 var(--sp-3); opacity: 0.85;
415
+ }
416
+ .health-card .hc-spark.healthy .spark-fill { fill: rgba(63, 185, 80, 0.12); }
417
+ .health-card .hc-spark.healthy .spark-line { stroke: var(--success); }
418
+ .health-card .hc-spark.degraded .spark-fill { fill: rgba(210, 153, 34, 0.12); }
419
+ .health-card .hc-spark.degraded .spark-line { stroke: var(--warning); }
420
+ .health-card .hc-spark.critical .spark-fill { fill: rgba(248, 81, 73, 0.12); }
421
+ .health-card .hc-spark.critical .spark-line { stroke: var(--danger); }
422
+ .health-card .hc-spark .spark-line { stroke-width: 1.5; fill: none; }
423
+ .health-card .hc-spark .spark-dot { fill: currentColor; }
424
+ .health-card .hc-spark .spark-empty {
425
+ stroke: var(--border); stroke-dasharray: 3 3; stroke-width: 1; fill: none;
426
+ }
427
+ .health-card .hc-status {
428
+ display: inline-flex; align-items: center; gap: 6px;
429
+ padding: 3px 10px;
430
+ border-radius: var(--radius-pill);
431
+ font-size: var(--fs-xs); font-weight: 600;
432
+ text-transform: uppercase; letter-spacing: 0.06em;
433
+ }
434
+ .hc-status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
435
+ .hc-status.healthy { background: var(--success-soft); color: var(--success); border: 1px solid rgba(74,222,128,0.25); }
436
+ .hc-status.degraded { background: var(--warning-soft); color: var(--warning); border: 1px solid rgba(245,179,65,0.30); }
437
+ .hc-status.critical { background: var(--danger-soft); color: var(--danger); border: 1px solid rgba(239,91,110,0.35); }
438
+ .hc-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); margin-top: var(--sp-3); }
439
+ .hc-metric { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; }
440
+ .hc-metric .label { font-size: var(--fs-xs); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 500; }
441
+ .hc-metric .val { font-size: var(--fs-lg); font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }
442
+ .hc-anomalies { margin-top: var(--sp-3); }
443
+ .hc-anomaly {
444
+ background: var(--danger-soft);
445
+ border: 1px solid rgba(239,91,110,0.30);
446
+ border-left: 3px solid var(--danger);
447
+ border-radius: var(--radius-sm);
448
+ padding: 10px 12px; margin-top: 6px;
449
+ font-size: var(--fs-sm); color: var(--text); line-height: 1.5;
450
+ }
451
+ .hc-correlation {
452
+ background: var(--info-soft);
453
+ border: 1px solid rgba(167,139,250,0.25);
454
+ border-left: 3px solid var(--info);
455
+ border-radius: var(--radius-sm);
456
+ padding: 10px 12px; margin-top: 6px;
457
+ font-size: var(--fs-sm); color: var(--text); line-height: 1.5;
458
+ }
459
+ .info-footer {
460
+ max-width: 1240px; margin: var(--sp-8) auto var(--sp-6);
461
+ padding: var(--sp-4) var(--sp-6);
462
+ border-top: 1px solid var(--border);
463
+ color: var(--text-muted);
464
+ font-size: var(--fs-xs);
465
+ display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3);
466
+ letter-spacing: 0.02em;
467
+ }
468
+ .info-footer code { color: var(--text); font-size: var(--fs-xs); }
469
+ .info-footer strong { color: var(--text); font-weight: 600; }
470
+ .footer-sep { color: var(--text-dim); }
471
+ .footer-plugins { display: inline-flex; flex-wrap: wrap; gap: var(--sp-1); }
472
+ .footer-pill {
473
+ display: inline-flex; align-items: center; gap: 4px;
474
+ padding: 2px 8px;
475
+ background: var(--surface-2);
476
+ border: 1px solid var(--border-strong);
477
+ border-radius: var(--radius-pill);
478
+ font-size: var(--fs-xs);
479
+ color: var(--text);
480
+ }
481
+ .footer-pill-meta { color: var(--text-muted); font-weight: 400; }
482
+
123
483
  @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; } }
124
484
  </style>
125
485
  </head>
@@ -131,6 +491,7 @@
131
491
  <button class="nav-btn active" data-page="dashboard" onclick="showPage('dashboard')">Dashboard</button>
132
492
  <button class="nav-btn" data-page="sources" onclick="showPage('sources')">Sources</button>
133
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>
134
495
  <button class="nav-btn" data-page="health" onclick="showPage('health')">Health</button>
135
496
  <button class="nav-btn" data-page="settings" onclick="showPage('settings')">Settings</button>
136
497
  </div>
@@ -141,11 +502,31 @@
141
502
  <div class="container">
142
503
  <!-- ===== Dashboard ===== -->
143
504
  <div class="page active" id="page-dashboard">
505
+ <div class="dashboard-meta">
506
+ <h1>Overview</h1>
507
+ <span class="live-indicator" id="dash-live">Live</span>
508
+ </div>
144
509
  <div class="stats">
145
- <div class="stat-card"><div class="value" id="stat-sources">-</div><div class="label">Data Sources</div></div>
146
- <div class="stat-card"><div class="value" id="stat-sources-up">-</div><div class="label">Sources UP</div></div>
147
- <div class="stat-card"><div class="value" id="stat-services">-</div><div class="label">Services</div></div>
148
- <div class="stat-card"><div class="value" id="stat-tools">6</div><div class="label">MCP Tools</div></div>
510
+ <div class="stat-card">
511
+ <div class="label">Data Sources</div>
512
+ <div class="value" id="stat-sources">-</div>
513
+ <div class="context" id="stat-sources-ctx">configured</div>
514
+ </div>
515
+ <div class="stat-card">
516
+ <div class="label">Sources Up</div>
517
+ <div class="value" id="stat-sources-up">-</div>
518
+ <div class="context" id="stat-sources-up-ctx">connected</div>
519
+ </div>
520
+ <div class="stat-card">
521
+ <div class="label">Services</div>
522
+ <div class="value" id="stat-services">-</div>
523
+ <div class="context" id="stat-services-ctx">discovered</div>
524
+ </div>
525
+ <div class="stat-card">
526
+ <div class="label">MCP Tools</div>
527
+ <div class="value" id="stat-tools">6</div>
528
+ <div class="context">available</div>
529
+ </div>
149
530
  </div>
150
531
  <div class="endpoint-bar">
151
532
  <span>MCP Endpoint: <strong id="mcp-url">http://localhost:3000/mcp</strong></span>
@@ -170,6 +551,40 @@
170
551
  <div class="card"><div class="card-header"><h2>Discovered Services</h2></div><div id="services-list"><div class="empty">Loading...</div></div></div>
171
552
  </div>
172
553
 
554
+ <!-- ===== Connectors ===== -->
555
+ <div class="page" id="page-connectors">
556
+ <div class="card" style="padding:0;">
557
+ <div class="tabs">
558
+ <button class="tab-btn active" onclick="showTab('installed')">Installed</button>
559
+ <button class="tab-btn" onclick="showTab('hub')">Connector Hub</button>
560
+ <button class="tab-btn" onclick="showTab('upload')">Upload bundle</button>
561
+ </div>
562
+
563
+ <!-- Installed Tab -->
564
+ <div class="tab-content active" id="tab-installed" style="padding:20px;">
565
+ <div class="card-header" style="margin-bottom:12px"><h2>Installed connectors</h2><button class="btn btn-ghost btn-sm" onclick="loadConnectors()">Refresh</button></div>
566
+ <div id="conn-installed"><div class="empty">Loading...</div></div>
567
+ </div>
568
+
569
+ <!-- Connector Hub Tab -->
570
+ <div class="tab-content" id="tab-hub" style="padding:20px;">
571
+ <div class="card-header" style="margin-bottom:12px"><h2>Available from the Connector Hub</h2>
572
+ <a class="btn btn-ghost btn-sm" href="https://thotischner.github.io/observability-mcp/hub/" target="_blank" rel="noopener">Open Hub ↗</a></div>
573
+ <div id="conn-hub"><div class="empty">Loading...</div></div>
574
+ </div>
575
+
576
+ <!-- Upload bundle Tab -->
577
+ <div class="tab-content" id="tab-upload" style="padding:20px;">
578
+ <div class="card-header" style="margin-bottom:12px"><h2>Upload a connector bundle</h2></div>
579
+ <p class="empty" style="margin:0 0 12px">Install a signed connector <code>.tgz</code> directly — handy for air-gapped environments. The bundle is always verified against the configured trust root before it is loaded.</p>
580
+ <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
581
+ <input type="file" id="conn-upload-file" accept=".tgz,.tar.gz,application/octet-stream">
582
+ <button class="btn btn-primary btn-sm" onclick="uploadConnector(this)">Upload &amp; install</button>
583
+ </div>
584
+ </div>
585
+ </div>
586
+ </div>
587
+
173
588
  <!-- ===== Health ===== -->
174
589
  <div class="page" id="page-health">
175
590
  <div id="health-cards" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px;">
@@ -365,12 +780,88 @@ function showPage(name) {
365
780
  document.querySelector(`.nav-btn[data-page="${name}"]`).classList.add('active');
366
781
  if(name==='settings') loadSettingsData();
367
782
  if(name==='health') loadHealthData();
783
+ if(name==='connectors') loadConnectors();
784
+ }
785
+
786
+ function escHtml(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
787
+
788
+ async function loadConnectors(){
789
+ const inst=document.getElementById('conn-installed');
790
+ const hub=document.getElementById('conn-hub');
791
+ try {
792
+ const d=await(await fetch('/api/connectors')).json();
793
+ const cs=(d.connectors||[]);
794
+ inst.innerHTML = cs.length ? cs.map(c=>{
795
+ const caps=Object.entries(c.capabilities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'—';
796
+ return `<div class="card" style="margin:0 0 10px">
797
+ <div class="card-header"><h2 style="font-size:15px">${escHtml(c.displayName)}
798
+ <span class="badge">${escHtml(c.source)}</span>
799
+ ${c.version?`<span class="badge">v${escHtml(c.version)}</span>`:''}</h2></div>
800
+ <div style="color:var(--text-muted);font-size:13px">${escHtml(c.description)||'<em>no description</em>'}</div>
801
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">type <code>${escHtml(c.name)}</code> · signals ${(c.signalTypes||[]).map(escHtml).join(', ')||'—'} · ${escHtml(caps)}</div>
802
+ </div>`;
803
+ }).join('') : '<div class="empty">No connectors loaded.</div>';
804
+ } catch(e){ inst.innerHTML='<div class="empty">Failed to load connectors.</div>'; }
805
+
806
+ try {
807
+ const d=await(await fetch('/api/hub/catalog')).json();
808
+ if(d.error){ hub.innerHTML=`<div class="empty">Hub catalog unreachable (${escHtml(d.url)}).<br>Set <code>HUB_CATALOG_URL</code> for a mirror. ${escHtml(d.error)}</div>`; return; }
809
+ const cs=(d.connectors||[]);
810
+ hub.innerHTML = cs.length ? cs.map(c=>{
811
+ const v=(c.versions&&c.versions[0])||{};
812
+ const ver=c.latest||v.version||'';
813
+ const status = c.installed
814
+ ? `<span class="badge badge-ok">installed${c.installedVersion?` v${escHtml(c.installedVersion)}`:''}</span>`
815
+ : (c.builtin?'<span class="badge">builtin</span>':'<span class="badge">available</span>');
816
+ const cmd = c.builtin
817
+ ? `# ${escHtml(c.displayName)} ships in the server image — no install needed.`
818
+ : `curl -fsSL -o plugin-signing.pub.pem https://raw.githubusercontent.com/ThoTischner/observability-mcp/main/docs/plugin-signing.pub.pem\nomcp plugin install ${escHtml(c.name)}@${escHtml(ver)} --trust-root plugin-signing.pub.pem`;
819
+ const id='ci-'+c.name;
820
+ return `<div class="card" style="margin:0 0 10px">
821
+ <div class="card-header"><h2 style="font-size:15px">${escHtml(c.displayName)}
822
+ <span class="badge">${escHtml(c.tier)}</span> ${status}</h2>
823
+ ${c.installed||c.builtin?'':`<span style="display:flex;gap:6px"><button class="btn btn-primary btn-sm" onclick="installConnector('${escHtml(c.name)}',this)">Install</button><button class="btn btn-ghost btn-sm" onclick="document.getElementById('${id}').classList.toggle('hidden')">CLI…</button></span>`}</div>
824
+ <div style="color:var(--text-muted);font-size:13px">${escHtml(c.description)}</div>
825
+ <div style="font-size:12px;color:var(--text-muted);margin-top:6px">type <code>${escHtml(c.name)}</code> · signals ${(c.signalTypes||[]).map(escHtml).join(', ')||'—'} · latest <code>${escHtml(ver)||'—'}</code></div>
826
+ <pre id="${id}" class="hidden" style="margin-top:8px;background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;overflow:auto">${escHtml(cmd)}</pre>
827
+ </div>`;
828
+ }).join('') : '<div class="empty">Catalog empty.</div>';
829
+ } catch(e){ hub.innerHTML='<div class="empty">Failed to reach the hub catalog.</div>'; }
830
+ }
831
+
832
+ async function installConnector(name, btn){
833
+ if(btn){ btn.disabled=true; btn.textContent='Installing…'; }
834
+ try {
835
+ const r=await fetch('/api/connectors/install',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
836
+ const d=await r.json().catch(()=>({}));
837
+ if(r.ok){ toast(`Installed ${name}${d.version?(' v'+d.version):''}`); loadConnectors(); loadTypes(); }
838
+ else if(r.status===403){ toast('UI install disabled — use the CLI tab, or set ENABLE_UI_INSTALL=true + PLUGIN_TRUST_ROOT.'); if(btn){btn.disabled=false;btn.textContent='Install';} }
839
+ else { toast('Install failed: '+(d.error||r.status)); if(btn){btn.disabled=false;btn.textContent='Install';} }
840
+ } catch(e){ toast('Install error: '+e.message); if(btn){btn.disabled=false;btn.textContent='Install';} }
841
+ }
842
+ async function uploadConnector(btn){
843
+ const inp=document.getElementById('conn-upload-file');
844
+ const f=inp&&inp.files&&inp.files[0];
845
+ if(!f){ toast('Choose a connector .tgz first'); return; }
846
+ if(btn){ btn.disabled=true; btn.textContent='Uploading…'; }
847
+ try {
848
+ const r=await fetch('/api/connectors/upload',{method:'POST',headers:{'Content-Type':'application/octet-stream'},body:f});
849
+ const d=await r.json().catch(()=>({}));
850
+ if(r.ok){ toast(`Installed ${d.name||'connector'}${d.version?(' v'+d.version):''}`); inp.value=''; loadConnectors(); loadTypes(); }
851
+ else if(r.status===403){ toast('UI install disabled — set ENABLE_UI_INSTALL=true + PLUGIN_TRUST_ROOT.'); }
852
+ else { toast('Upload failed: '+(d.error||r.status)); }
853
+ } catch(e){ toast('Upload error: '+e.message); }
854
+ finally { if(btn){btn.disabled=false;btn.textContent='Upload & install';} }
368
855
  }
369
856
  function showTab(name) {
370
- document.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
371
- document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
372
- document.getElementById('tab-'+name).classList.add('active');
373
- event.target.classList.add('active');
857
+ // Scope to the tab group's card so independent tab sets (Settings,
858
+ // Connectors) don't reset each other.
859
+ const scope = (event && event.target.closest('.card')) || document;
860
+ scope.querySelectorAll('.tab-content').forEach(t=>t.classList.remove('active'));
861
+ scope.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
862
+ const el = document.getElementById('tab-'+name);
863
+ if (el) el.classList.add('active');
864
+ if (event && event.target) event.target.classList.add('active');
374
865
  if(name==='metrics') populateMetricsSourceSelect();
375
866
  }
376
867
  function closeModal(id) { document.getElementById(id).classList.remove('open'); }
@@ -386,9 +877,29 @@ async function loadSettingsData() {
386
877
  ]); populateSettingsForm(); populateHealthForm(); populateMetricsSourceSelect(); } catch(e){ console.error(e); }
387
878
  }
388
879
  function updateStats() {
389
- document.getElementById('stat-sources').textContent=sourcesData.length;
390
- document.getElementById('stat-sources-up').textContent=sourcesData.filter(s=>s.status==='up').length;
391
- document.getElementById('stat-services').textContent=servicesData.length;
880
+ const total = sourcesData.length;
881
+ const up = sourcesData.filter(s=>s.status==='up').length;
882
+ const enabled = sourcesData.filter(s=>s.enabled).length;
883
+ document.getElementById('stat-sources').textContent = total;
884
+ document.getElementById('stat-sources-up').textContent = up;
885
+ document.getElementById('stat-services').textContent = servicesData.length;
886
+
887
+ const srcCtx = document.getElementById('stat-sources-ctx');
888
+ if (srcCtx) srcCtx.textContent = enabled === total ? `${enabled} enabled` : `${enabled}/${total} enabled`;
889
+
890
+ const upCtx = document.getElementById('stat-sources-up-ctx');
891
+ if (upCtx) {
892
+ upCtx.textContent = total === 0 ? 'no sources' : `${up}/${total} connected`;
893
+ upCtx.classList.toggle('good', total > 0 && up === total);
894
+ upCtx.classList.toggle('warn', total > 0 && up < total);
895
+ }
896
+
897
+ const svcCtx = document.getElementById('stat-services-ctx');
898
+ if (svcCtx) {
899
+ const backends = new Set(servicesData.map(s => s.source || s.sourceName).filter(Boolean));
900
+ svcCtx.textContent = backends.size > 0 ? `across ${backends.size} backend${backends.size===1?'':'s'}` : 'discovered';
901
+ }
902
+
392
903
  const allUp=sourcesData.length>0&&sourcesData.filter(s=>s.enabled).every(s=>s.status==='up');
393
904
  const b=document.getElementById('status-badge');
394
905
  b.textContent=sourcesData.length===0?'No sources':allUp?'All systems operational':'Issues detected';
@@ -631,6 +1142,39 @@ function saveMetric() {
631
1142
  // --- Health Dashboard ---
632
1143
  let healthData={};
633
1144
  let healthInterval=null;
1145
+ // Score history per service, kept client-side. The server doesn't yet
1146
+ // expose a per-service score timeseries — this gives an at-a-glance trend
1147
+ // for the last ~7.5 minutes (30 points × 15s refresh).
1148
+ const SPARK_MAX = 30;
1149
+ const scoreHistory = {};
1150
+ function pushScore(name, score) {
1151
+ if (typeof score !== 'number') return;
1152
+ const arr = scoreHistory[name] || (scoreHistory[name] = []);
1153
+ arr.push(score);
1154
+ if (arr.length > SPARK_MAX) arr.shift();
1155
+ }
1156
+ function sparkSvg(name, status) {
1157
+ const pts = scoreHistory[name] || [];
1158
+ const w = 100, h = 36, pad = 2;
1159
+ if (pts.length < 2) {
1160
+ // Placeholder dashed midline until we have ≥2 samples.
1161
+ return `<svg class="hc-spark ${status}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">`
1162
+ + `<line class="spark-empty" x1="0" y1="${h/2}" x2="${w}" y2="${h/2}"/></svg>`;
1163
+ }
1164
+ const min = 0, max = 100;
1165
+ const step = (w - pad*2) / (pts.length - 1);
1166
+ const y = v => h - pad - ((v - min) / (max - min)) * (h - pad*2);
1167
+ const coords = pts.map((v, i) => `${pad + i*step},${y(v)}`);
1168
+ const line = coords.join(' ');
1169
+ const area = `M${coords[0]} L${line.split(' ').join(' L')} L${pad + (pts.length-1)*step},${h-pad} L${pad},${h-pad} Z`;
1170
+ const last = coords[coords.length - 1].split(',');
1171
+ return `<svg class="hc-spark ${status}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">`
1172
+ + `<path class="spark-fill" d="${area}"/>`
1173
+ + `<polyline class="spark-line" points="${line}"/>`
1174
+ + `<circle class="spark-dot" cx="${last[0]}" cy="${last[1]}" r="1.8"/>`
1175
+ + `</svg>`;
1176
+ }
1177
+
634
1178
  async function loadHealthData() {
635
1179
  try {
636
1180
  healthData=await(await fetch('/api/health')).json();
@@ -647,11 +1191,13 @@ function renderHealthCards() {
647
1191
  const s=h.status||'healthy';
648
1192
  const m=h.signals?.metrics||{};
649
1193
  const l=h.signals?.logs||{};
1194
+ pushScore(name, h.score);
650
1195
  return `<div class="health-card">
651
1196
  <div class="hc-header">
652
1197
  <div><span class="hc-name">${esc(name)}</span> <span class="hc-status ${s}">${s}</span></div>
653
1198
  <span class="hc-score ${s}">${h.score ?? '-'}</span>
654
1199
  </div>
1200
+ ${sparkSvg(name, s)}
655
1201
  <div class="hc-metrics">
656
1202
  <div class="hc-metric"><div class="label">CPU</div><div class="val">${typeof m.cpu==='number'?m.cpu.toFixed(1)+'%':'-'}</div></div>
657
1203
  <div class="hc-metric"><div class="label">Memory</div><div class="val">${typeof m.memory==='number'?m.memory.toFixed(0)+' MB':'-'}</div></div>
@@ -669,7 +1215,33 @@ function renderHealthCards() {
669
1215
  // --- Utils ---
670
1216
  function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
671
1217
  async function refresh(){await Promise.all([loadSources(),loadServices()]);}
672
- (async()=>{await loadTypes();await refresh();setInterval(refresh,15000);})();
1218
+
1219
+ async function loadInfo(){
1220
+ try{
1221
+ const r=await fetch('/api/info'); if(!r.ok) return;
1222
+ const d=await r.json();
1223
+ const footer=document.getElementById('info-footer');
1224
+ if(!footer) return;
1225
+ const plugins=(d.plugins||[]).map(p=>`<span class="footer-pill">${esc(p.name)}<span class="footer-pill-meta">${esc(p.source)}${p.version?` · ${esc(p.version)}`:''}</span></span>`).join('');
1226
+ const sha=d.build&&d.build.commit?` · <code class="mono">${esc(d.build.commit.slice(0,7))}</code>`:'';
1227
+ footer.innerHTML=`
1228
+ <span><strong>${esc(d.name)}</strong> v${esc(d.version)}${sha}</span>
1229
+ <span class="footer-sep">·</span>
1230
+ <span>MCP ${esc(d.mcpProtocolVersion)}</span>
1231
+ <span class="footer-sep">·</span>
1232
+ <span>${esc(d.runtime?d.runtime.node:'')} ${esc(d.runtime?d.runtime.platform+'/'+d.runtime.arch:'')}</span>
1233
+ <span class="footer-sep">·</span>
1234
+ <span class="footer-plugins">${plugins||'<em>no plugins loaded</em>'}</span>
1235
+ `;
1236
+ }catch{/* server too old or /api/info disabled */}
1237
+ }
1238
+
1239
+ // Show the endpoint the user is actually reaching the server through
1240
+ // (localhost, a port-forward, or an ingress) — not a hardcoded host.
1241
+ document.getElementById('mcp-url').textContent = window.location.origin + '/mcp';
1242
+ (async()=>{await loadTypes();await refresh();await loadInfo();setInterval(refresh,15000);})();
673
1243
  </script>
1244
+
1245
+ <footer id="info-footer" class="info-footer"><span class="spinner"></span>&nbsp;loading server info…</footer>
674
1246
  </body>
675
1247
  </html>