PyObservability 1.1.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

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.
@@ -1,615 +1,707 @@
1
1
  // app/static/app.js
2
2
  (function () {
3
- // ------------------------------------------------------------
4
- // CONFIG
5
- // ------------------------------------------------------------
6
- const MAX_POINTS = 60;
7
- const targets = window.MONITOR_TARGETS || [];
8
- const DEFAULT_PAGE_SIZE = 15;
9
- const panelSpinners = {};
10
-
11
- // ------------------------------------------------------------
12
- // VISUAL SPINNERS
13
- // ------------------------------------------------------------
14
- function attachSpinners() {
15
- // panels (charts/tables)
16
- document.querySelectorAll(".panel").forEach(box => {
17
- const overlay = document.createElement("div");
18
- overlay.className = "loading-overlay";
19
- overlay.innerHTML = `<div class="spinner"></div>`;
20
- box.style.position = "relative";
21
- box.appendChild(overlay);
22
- panelSpinners[box.id || box.querySelector('table,canvas')?.id || Symbol()] = overlay;
23
- });
3
+ // ------------------------------------------------------------
4
+ // CONFIG
5
+ // ------------------------------------------------------------
6
+ const MAX_POINTS = 60;
7
+ const targets = window.MONITOR_TARGETS || [];
8
+ const DEFAULT_PAGE_SIZE = 15;
9
+ const panelSpinners = {};
10
+
11
+ // ------------------------------------------------------------
12
+ // VISUAL SPINNERS
13
+ // ------------------------------------------------------------
14
+ function attachSpinners() {
15
+ // panels (charts/tables)
16
+ document.querySelectorAll(".panel").forEach(box => {
17
+ const overlay = document.createElement("div");
18
+ overlay.className = "loading-overlay";
19
+ overlay.innerHTML = `<div class="spinner"></div>`;
20
+ box.style.position = "relative";
21
+ box.appendChild(overlay);
22
+ panelSpinners[box.id || box.querySelector('table,canvas')?.id || Symbol()] = overlay;
23
+ });
24
24
 
25
- // meta cards (system/static metrics)
26
- document.querySelectorAll(".meta-card").forEach(card => {
27
- const overlay = document.createElement("div");
28
- overlay.className = "loading-overlay";
29
- overlay.innerHTML = `<div class="spinner"></div>`;
30
- card.style.position = "relative";
31
- card.appendChild(overlay);
32
- panelSpinners[card.querySelector(".meta-value")?.id || Symbol()] = overlay;
33
- });
34
- }
25
+ // meta cards (system/static metrics)
26
+ document.querySelectorAll(".meta-card").forEach(card => {
27
+ const overlay = document.createElement("div");
28
+ overlay.className = "loading-overlay";
29
+ overlay.innerHTML = `<div class="spinner"></div>`;
30
+ card.style.position = "relative";
31
+ card.appendChild(overlay);
32
+ panelSpinners[card.querySelector(".meta-value")?.id || Symbol()] = overlay;
33
+ });
34
+ }
35
35
 
36
- function showAllSpinners() {
37
- Object.keys(panelSpinners).forEach(id => showSpinner(id));
38
- }
36
+ function showAllSpinners() {
37
+ Object.keys(panelSpinners).forEach(id => showSpinner(id));
38
+ }
39
39
 
40
- function hideSpinners() {
41
- document.querySelectorAll(".loading-overlay").forEach(x => {
42
- x.classList.add("hidden");
43
- });
44
- }
45
-
46
- // ------------------------------------------------------------
47
- // DOM REFERENCES
48
- // ------------------------------------------------------------
49
- const nodeSelect = document.getElementById("node-select");
50
- const refreshBtn = document.getElementById("refresh-btn");
51
-
52
- const systemEl = document.getElementById("system");
53
- const ipEl = document.getElementById("ip-info");
54
- const processorEl = document.getElementById("processor");
55
-
56
- const memEl = document.getElementById("memory");
57
- const diskEl = document.getElementById("disk");
58
- const loadEl = document.getElementById("cpuload");
59
-
60
- const cpuAvgCtx = document.getElementById("cpu-avg-chart").getContext("2d");
61
- const memCtx = document.getElementById("mem-chart").getContext("2d");
62
- const loadCtx = document.getElementById("load-chart").getContext("2d");
63
-
64
- const coresGrid = document.getElementById("cores-grid");
65
-
66
- const servicesTable = document.getElementById("services-table");
67
- const servicesTableBody = document.querySelector("#services-table tbody");
68
- const svcFilter = document.getElementById("svc-filter");
69
- const svcGetAll = document.getElementById("get-all-services");
70
-
71
- const processesTable = document.getElementById("processes-table");
72
- const processesTableBody = processesTable.querySelector("tbody");
73
- const procFilter = document.getElementById("proc-filter");
74
-
75
- const dockerTable = document.getElementById("docker-table");
76
- const dockerTableHead = dockerTable.querySelector("thead");
77
- const dockerTableBody = dockerTable.querySelector("tbody");
78
-
79
- const disksTable = document.getElementById("disks-table");
80
- const disksTableHead = disksTable.querySelector("thead");
81
- const disksTableBody = disksTable.querySelector("tbody");
82
-
83
- const pyudiskTable = document.getElementById("pyudisk-table");
84
- const pyudiskTableHead = pyudiskTable.querySelector("thead");
85
- const pyudiskTableBody = pyudiskTable.querySelector("tbody");
86
-
87
- const certsTable = document.getElementById("certificates-table");
88
- const certsTableHead = certsTable.querySelector("thead");
89
- const certsTableBody = certsTable.querySelector("tbody");
90
-
91
- const showCoresCheckbox = document.getElementById("show-cores");
92
-
93
- // ------------------------------------------------------------
94
- // PAGINATION HELPERS
95
- // ------------------------------------------------------------
96
- function createPaginatedTable(tableEl, headEl, bodyEl, pageSize = DEFAULT_PAGE_SIZE) {
97
- const info = document.createElement("div");
98
- info.className = "pagination-info";
99
- tableEl.insertAdjacentElement("beforebegin", info);
100
-
101
- const state = {
102
- data: [],
103
- page: 1,
104
- pageSize
105
- };
40
+ function hideSpinners() {
41
+ document.querySelectorAll(".loading-overlay").forEach(x => {
42
+ x.classList.add("hidden");
43
+ });
44
+ }
106
45
 
107
- const pagination = document.createElement("div");
108
- pagination.className = "pagination";
109
- tableEl.insertAdjacentElement("afterend", pagination);
46
+ // ------------------------------------------------------------
47
+ // DOM REFERENCES
48
+ // ------------------------------------------------------------
49
+ const nodeSelect = document.getElementById("node-select");
50
+ const refreshBtn = document.getElementById("refresh-btn");
110
51
 
111
- function render() {
112
- const rows = state.data;
113
- const pages = Math.ceil(rows.length / state.pageSize) || 1;
114
- state.page = Math.max(1, Math.min(state.page, pages));
52
+ const systemEl = document.getElementById("system");
53
+ const ipEl = document.getElementById("ip-info");
54
+ const processorEl = document.getElementById("processor");
115
55
 
116
- const start = (state.page - 1) * state.pageSize;
117
- const chunk = rows.slice(start, start + state.pageSize);
56
+ const memEl = document.getElementById("memory");
57
+ const diskEl = document.getElementById("disk");
58
+ const loadEl = document.getElementById("cpuload");
118
59
 
119
- info.textContent =
120
- `Showing ${start + 1} to ${Math.min(start + state.pageSize, rows.length)} of ${rows.length} entries`;
60
+ const cpuAvgCtx = document.getElementById("cpu-avg-chart").getContext("2d");
61
+ const memCtx = document.getElementById("mem-chart").getContext("2d");
62
+ const loadCtx = document.getElementById("load-chart").getContext("2d");
121
63
 
122
- bodyEl.innerHTML = "";
123
- chunk.forEach(r => bodyEl.insertAdjacentHTML("beforeend", r));
64
+ const coresGrid = document.getElementById("cores-grid");
124
65
 
125
- renderPagination(pages);
126
- }
66
+ const servicesTable = document.getElementById("services-table");
67
+ const servicesTableBody = document.querySelector("#services-table tbody");
68
+ const svcFilter = document.getElementById("svc-filter");
69
+ const svcGetAll = document.getElementById("get-all-services");
127
70
 
128
- function renderPagination(pages) {
129
- pagination.innerHTML = "";
130
-
131
- const makeBtn = (txt, cb, active = false, disabled = false) => {
132
- const b = document.createElement("button");
133
- b.textContent = txt;
134
- if (active) b.classList.add("active");
135
- if (disabled) {
136
- b.disabled = true;
137
- b.style.opacity = "0.5";
138
- b.style.cursor = "default";
139
- }
140
- b.onclick = disabled ? null : cb;
141
- pagination.appendChild(b);
142
- };
71
+ const processesTable = document.getElementById("processes-table");
72
+ const processesTableBody = processesTable.querySelector("tbody");
73
+ const procFilter = document.getElementById("proc-filter");
74
+
75
+ const dockerTable = document.getElementById("docker-table");
76
+ const dockerTableHead = dockerTable.querySelector("thead");
77
+ const dockerTableBody = dockerTable.querySelector("tbody");
78
+
79
+ const disksTable = document.getElementById("disks-table");
80
+ const disksTableHead = disksTable.querySelector("thead");
81
+ const disksTableBody = disksTable.querySelector("tbody");
82
+
83
+ const pyudiskTable = document.getElementById("pyudisk-table");
84
+ const pyudiskTableHead = pyudiskTable.querySelector("thead");
85
+ const pyudiskTableBody = pyudiskTable.querySelector("tbody");
86
+
87
+ const certsTable = document.getElementById("certificates-table");
88
+ const certsTableHead = certsTable.querySelector("thead");
89
+ const certsTableBody = certsTable.querySelector("tbody");
90
+
91
+ const showCoresCheckbox = document.getElementById("show-cores");
92
+
93
+ // ------------------------------------------------------------
94
+ // PAGINATION HELPERS
95
+ // ------------------------------------------------------------
96
+ function createPaginatedTable(tableEl, headEl, bodyEl, pageSize = DEFAULT_PAGE_SIZE) {
97
+ const info = document.createElement("div");
98
+ info.className = "pagination-info";
99
+ tableEl.insertAdjacentElement("beforebegin", info);
143
100
 
144
- // --- Previous ---
145
- makeBtn("Previous", () => { state.page--; render(); }, false, state.page === 1);
101
+ const state = {
102
+ data: [],
103
+ page: 1,
104
+ pageSize
105
+ };
146
106
 
147
- const maxVisible = 5;
107
+ const pagination = document.createElement("div");
108
+ pagination.className = "pagination";
109
+ tableEl.insertAdjacentElement("afterend", pagination);
148
110
 
149
- if (pages <= maxVisible + 2) {
150
- // Show all
151
- for (let p = 1; p <= pages; p++) {
152
- makeBtn(p, () => { state.page = p; render(); }, p === state.page);
111
+ function render() {
112
+ const rows = state.data;
113
+ const pages = Math.ceil(rows.length / state.pageSize) || 1;
114
+ state.page = Math.max(1, Math.min(state.page, pages));
115
+
116
+ const start = (state.page - 1) * state.pageSize;
117
+ const chunk = rows.slice(start, start + state.pageSize);
118
+
119
+ info.textContent =
120
+ `Showing ${start + 1} to ${Math.min(start + state.pageSize, rows.length)} of ${rows.length} entries`;
121
+
122
+ bodyEl.innerHTML = "";
123
+ chunk.forEach(r => bodyEl.insertAdjacentHTML("beforeend", r));
124
+
125
+ renderPagination(pages);
153
126
  }
154
- } else {
155
- // Big list → use ellipsis
156
- const showLeft = 3;
157
- const showRight = 3;
158
-
159
- if (state.page <= showLeft) {
160
- // First pages
161
- for (let p = 1; p <= showLeft + 1; p++) {
162
- makeBtn(p, () => { state.page = p; render(); }, p === state.page);
163
- }
164
- addEllipsis();
165
- makeBtn(pages, () => { state.page = pages; render(); });
166
- } else if (state.page >= pages - showRight + 1) {
167
- // Last pages
168
- makeBtn(1, () => { state.page = 1; render(); });
169
- addEllipsis();
170
- for (let p = pages - showRight; p <= pages; p++) {
171
- makeBtn(p, () => { state.page = p; render(); }, p === state.page);
172
- }
173
- } else {
174
- // Middle
175
- makeBtn(1, () => { state.page = 1; render(); });
176
- addEllipsis();
177
- for (let p = state.page - 1; p <= state.page + 1; p++) {
178
- makeBtn(p, () => { state.page = p; render(); }, p === state.page);
179
- }
180
- addEllipsis();
181
- makeBtn(pages, () => { state.page = pages; render(); });
127
+
128
+ function renderPagination(pages) {
129
+ pagination.innerHTML = "";
130
+
131
+ const makeBtn = (txt, cb, active = false, disabled = false) => {
132
+ const b = document.createElement("button");
133
+ b.textContent = txt;
134
+ if (active) b.classList.add("active");
135
+ if (disabled) {
136
+ b.disabled = true;
137
+ b.style.opacity = "0.5";
138
+ b.style.cursor = "default";
139
+ }
140
+ b.onclick = disabled ? null : cb;
141
+ pagination.appendChild(b);
142
+ };
143
+
144
+ // --- Previous ---
145
+ makeBtn("Previous", () => {
146
+ state.page--;
147
+ render();
148
+ }, false, state.page === 1);
149
+
150
+ const maxVisible = 5;
151
+
152
+ if (pages <= maxVisible + 2) {
153
+ // Show all
154
+ for (let p = 1; p <= pages; p++) {
155
+ makeBtn(p, () => {
156
+ state.page = p;
157
+ render();
158
+ }, p === state.page);
159
+ }
160
+ } else {
161
+ // Big list → use ellipsis
162
+ const showLeft = 3;
163
+ const showRight = 3;
164
+
165
+ if (state.page <= showLeft) {
166
+ // First pages
167
+ for (let p = 1; p <= showLeft + 1; p++) {
168
+ makeBtn(p, () => {
169
+ state.page = p;
170
+ render();
171
+ }, p === state.page);
172
+ }
173
+ addEllipsis();
174
+ makeBtn(pages, () => {
175
+ state.page = pages;
176
+ render();
177
+ });
178
+ } else if (state.page >= pages - showRight + 1) {
179
+ // Last pages
180
+ makeBtn(1, () => {
181
+ state.page = 1;
182
+ render();
183
+ });
184
+ addEllipsis();
185
+ for (let p = pages - showRight; p <= pages; p++) {
186
+ makeBtn(p, () => {
187
+ state.page = p;
188
+ render();
189
+ }, p === state.page);
190
+ }
191
+ } else {
192
+ // Middle
193
+ makeBtn(1, () => {
194
+ state.page = 1;
195
+ render();
196
+ });
197
+ addEllipsis();
198
+ for (let p = state.page - 1; p <= state.page + 1; p++) {
199
+ makeBtn(p, () => {
200
+ state.page = p;
201
+ render();
202
+ }, p === state.page);
203
+ }
204
+ addEllipsis();
205
+ makeBtn(pages, () => {
206
+ state.page = pages;
207
+ render();
208
+ });
209
+ }
210
+ }
211
+
212
+ // --- Next ---
213
+ makeBtn("Next", () => {
214
+ state.page++;
215
+ render();
216
+ }, false, state.page === pages);
217
+
218
+ function addEllipsis() {
219
+ const e = document.createElement("span");
220
+ e.textContent = "…";
221
+ e.style.padding = "4px 6px";
222
+ pagination.appendChild(e);
223
+ }
182
224
  }
183
- }
184
225
 
185
- // --- Next ---
186
- makeBtn("Next", () => { state.page++; render(); }, false, state.page === pages);
226
+ function setData(arr, columns) {
227
+ headEl.innerHTML = "<tr>" + columns.map(c => `<th>${c}</th>`).join("") + "</tr>";
228
+ state.dataRaw = arr.slice();
229
+ state.columns = columns;
230
+ // Sorting logic
231
+ Array.from(headEl.querySelectorAll("th")).forEach((th, idx) => {
232
+ th.style.cursor = "pointer";
233
+ th.onclick = (e) => {
234
+ // Prevent sort if reset button was clicked
235
+ if (e.target.classList.contains("sort-reset")) return;
236
+ const col = columns[idx];
237
+ if (state.sortCol === col) {
238
+ state.sortAsc = !state.sortAsc;
239
+ } else {
240
+ state.sortCol = col;
241
+ state.sortAsc = true;
242
+ }
243
+ sortAndRender();
244
+ };
245
+ });
246
+
247
+ function sortAndRender() {
248
+ // Rebuild all headers with correct HTML
249
+ headEl.querySelectorAll("th").forEach((th, idx) => {
250
+ const col = state.columns[idx];
251
+ if (state.sortCol === col) {
252
+ th.innerHTML = `${col} <span style="font-size:0.9em">${state.sortAsc ? "▲" : "▼"}</span>&nbsp;<span class="sort-reset" style="cursor:pointer;font-size:0.9em;color:#888;margin-left:8px;" title="Reset sort">⨯</span>`;
253
+ th.querySelector(".sort-reset").onclick = (e) => {
254
+ e.stopPropagation();
255
+ state.sortCol = null;
256
+ state.sortAsc = true;
257
+ state.dataRaw = arr.slice();
258
+ sortAndRender();
259
+ };
260
+ } else {
261
+ th.innerHTML = col;
262
+ }
263
+ });
264
+ if (state.sortCol) {
265
+ state.dataRaw.sort((a, b) => {
266
+ let va = a[state.sortCol], vb = b[state.sortCol];
267
+ let na = parseFloat(va), nb = parseFloat(vb);
268
+ if (!isNaN(na) && !isNaN(nb)) {
269
+ return state.sortAsc ? na - nb : nb - na;
270
+ }
271
+ va = (va ?? "").toString().toLowerCase();
272
+ vb = (vb ?? "").toString().toLowerCase();
273
+ if (va < vb) return state.sortAsc ? -1 : 1;
274
+ if (va > vb) return state.sortAsc ? 1 : -1;
275
+ return 0;
276
+ });
277
+ }
278
+ state.data = state.dataRaw.map(row =>
279
+ "<tr>" + state.columns.map(c => `<td>${row[c] ?? ""}</td>`).join("") + "</tr>"
280
+ );
281
+ render();
282
+ }
283
+ sortAndRender();
284
+ }
187
285
 
188
- function addEllipsis() {
189
- const e = document.createElement("span");
190
- e.textContent = "…";
191
- e.style.padding = "4px 6px";
192
- pagination.appendChild(e);
193
- }
286
+ return {setData};
194
287
  }
195
288
 
196
- function setData(arr, columns) {
197
- if (JSON.stringify(state.data) === JSON.stringify(arr)) {
198
- return; // do not re-render if data didn't change
199
- }
200
- headEl.innerHTML = "<tr>" + columns.map(c => `<th>${c}</th>`).join("") + "</tr>";
201
- state.data = arr.map(row => {
202
- return "<tr>" + columns.map(c => `<td>${row[c] ?? ""}</td>`).join("") + "</tr>";
203
- });
204
- render();
289
+ // Instances for each table
290
+ const PAG_SERVICES = createPaginatedTable(
291
+ servicesTable, servicesTable.querySelector("thead"), servicesTableBody, 5
292
+ );
293
+ const PAG_PROCESSES = createPaginatedTable(
294
+ processesTable, processesTable.querySelector("thead"), processesTableBody
295
+ );
296
+ const PAG_DOCKER = createPaginatedTable(
297
+ dockerTable, dockerTableHead, dockerTableBody
298
+ );
299
+ const PAG_DISKS = createPaginatedTable(
300
+ disksTable, disksTableHead, disksTableBody
301
+ );
302
+ const PAG_PYUDISK = createPaginatedTable(
303
+ pyudiskTable, pyudiskTableHead, pyudiskTableBody
304
+ );
305
+ const PAG_CERTS = createPaginatedTable(
306
+ certsTable, certsTableHead, certsTableBody
307
+ );
308
+
309
+ // ------------------------------------------------------------
310
+ // CHART HELPERS
311
+ // ------------------------------------------------------------
312
+ function makeMainChart(ctx, label) {
313
+ const EMPTY = Array(MAX_POINTS).fill(null);
314
+ const LABELS = Array(MAX_POINTS).fill("");
315
+
316
+ return new Chart(ctx, {
317
+ type: "line",
318
+ data: {
319
+ labels: [...LABELS],
320
+ datasets: [
321
+ {
322
+ label,
323
+ data: [...EMPTY],
324
+ fill: true,
325
+ tension: 0.2,
326
+ cubicInterpolationMode: "monotone",
327
+ pointRadius: 0
328
+ }
329
+ ]
330
+ },
331
+ options: {
332
+ animation: false, responsive: true, maintainAspectRatio: false,
333
+ scales: {x: {display: false}, y: {beginAtZero: true, suggestedMax: 100}},
334
+ plugins: {legend: {display: false}}
335
+ }
336
+ });
205
337
  }
206
338
 
207
- return { setData };
208
- }
209
-
210
- // Instances for each table
211
- const PAG_SERVICES = createPaginatedTable(
212
- servicesTable, servicesTable.querySelector("thead"), servicesTableBody, 5
213
- );
214
- const PAG_PROCESSES = createPaginatedTable(
215
- processesTable, processesTable.querySelector("thead"), processesTableBody
216
- );
217
- const PAG_DOCKER = createPaginatedTable(
218
- dockerTable, dockerTableHead, dockerTableBody
219
- );
220
- const PAG_DISKS = createPaginatedTable(
221
- disksTable, disksTableHead, disksTableBody
222
- );
223
- const PAG_PYUDISK = createPaginatedTable(
224
- pyudiskTable, pyudiskTableHead, pyudiskTableBody
225
- );
226
- const PAG_CERTS = createPaginatedTable(
227
- certsTable, certsTableHead, certsTableBody
228
- );
229
-
230
- // ------------------------------------------------------------
231
- // CHART HELPERS
232
- // ------------------------------------------------------------
233
- function makeMainChart(ctx, label) {
234
- const EMPTY = Array(MAX_POINTS).fill(null);
235
- const LABELS = Array(MAX_POINTS).fill("");
236
-
237
- return new Chart(ctx, {
238
- type: "line",
239
- data: {
240
- labels: [...LABELS],
241
- datasets: [
242
- {
243
- label,
244
- data: [...EMPTY],
245
- fill: true,
246
- tension: 0.2,
247
- cubicInterpolationMode: "monotone",
248
- pointRadius: 0
249
- }
250
- ]
251
- },
252
- options: {
253
- animation: false, responsive: true, maintainAspectRatio: false,
254
- scales: { x: { display: false }, y: { beginAtZero: true, suggestedMax: 100 }},
255
- plugins: { legend: { display: false } }
256
- }
257
- });
258
- }
259
-
260
- function makeCoreSparkline(ctx, coreName) {
261
- const EMPTY_LABELS = Array(MAX_POINTS).fill("");
262
- const EMPTY_DATA = Array(MAX_POINTS).fill(null);
263
-
264
- return new Chart(ctx, {
265
- type: "line",
266
- data: {
267
- labels: [...EMPTY_LABELS],
268
- datasets: [{
269
- label: coreName,
270
- data: [...EMPTY_DATA],
271
- fill: false,
272
- tension: 0.2,
273
- pointRadius: 0
274
- }]
275
- },
276
- options: {
277
- animation: false,
278
- responsive: false,
279
- interaction: false,
280
- events: [],
281
- plugins: { legend: { display: false } },
282
- scales: {
283
- x: { display: false },
284
- y: { display: false, suggestedMax: 100 }
285
- }
286
- }
287
- });
288
- }
339
+ function makeCoreSparkline(ctx, coreName) {
340
+ const EMPTY_LABELS = Array(MAX_POINTS).fill("");
341
+ const EMPTY_DATA = Array(MAX_POINTS).fill(null);
342
+
343
+ return new Chart(ctx, {
344
+ type: "line",
345
+ data: {
346
+ labels: [...EMPTY_LABELS],
347
+ datasets: [{
348
+ label: coreName,
349
+ data: [...EMPTY_DATA],
350
+ fill: false,
351
+ tension: 0.2,
352
+ pointRadius: 0
353
+ }]
354
+ },
355
+ options: {
356
+ animation: false,
357
+ responsive: false,
358
+ interaction: false,
359
+ events: [],
360
+ plugins: {legend: {display: false}},
361
+ scales: {
362
+ x: {display: false},
363
+ y: {display: false, suggestedMax: 100}
364
+ }
365
+ }
366
+ });
367
+ }
289
368
 
290
- const cpuAvgChart = makeMainChart(cpuAvgCtx, "CPU Avg");
291
- const memChart = makeMainChart(memCtx, "Memory %");
292
- const loadChart = makeMainChart(loadCtx, "CPU Load");
369
+ const cpuAvgChart = makeMainChart(cpuAvgCtx, "CPU Avg");
370
+ const memChart = makeMainChart(memCtx, "Memory %");
371
+ const loadChart = makeMainChart(loadCtx, "CPU Load");
293
372
 
294
- // ------------------------------------------------------------
295
- // CORE CHARTS
296
- // ------------------------------------------------------------
297
- const coreMini = {};
373
+ // ------------------------------------------------------------
374
+ // CORE CHARTS
375
+ // ------------------------------------------------------------
376
+ const coreMini = {};
298
377
 
299
- function createCoreChart(coreName) {
300
- const wrapper = document.createElement("div");
301
- wrapper.className = "core-mini";
302
- wrapper.innerHTML = `
378
+ function createCoreChart(coreName) {
379
+ const wrapper = document.createElement("div");
380
+ wrapper.className = "core-mini";
381
+ wrapper.innerHTML = `
303
382
  <div class="label">${coreName}</div>
304
383
  <canvas width="120" height="40"></canvas>
305
384
  <div class="value">—</div>
306
385
  `;
307
- wrapper.style.display = showCoresCheckbox.checked ? "block" : "none";
308
- coresGrid.appendChild(wrapper);
309
-
310
- const canvas = wrapper.querySelector("canvas");
311
- const valEl = wrapper.querySelector(".value");
312
- const chart = makeCoreSparkline(canvas.getContext("2d"), coreName);
313
-
314
- coreMini[coreName] = { chart, el: wrapper, valEl };
315
- return coreMini[coreName];
316
- }
317
-
318
- function getCoreChart(coreName) {
319
- return coreMini[coreName] || createCoreChart(coreName);
320
- }
321
-
322
- function pruneOldCores(latest) {
323
- for (const name of Object.keys(coreMini)) {
324
- if (!latest.includes(name)) {
325
- try { coreMini[name].chart.destroy(); } catch {}
326
- coreMini[name].el.remove();
327
- delete coreMini[name];
328
- }
386
+ wrapper.style.display = showCoresCheckbox.checked ? "block" : "none";
387
+ coresGrid.appendChild(wrapper);
388
+
389
+ const canvas = wrapper.querySelector("canvas");
390
+ const valEl = wrapper.querySelector(".value");
391
+ const chart = makeCoreSparkline(canvas.getContext("2d"), coreName);
392
+
393
+ coreMini[coreName] = {chart, el: wrapper, valEl};
394
+ return coreMini[coreName];
329
395
  }
330
- }
331
-
332
- // ------------------------------------------------------------
333
- // RESET UI
334
- // ------------------------------------------------------------
335
- function resetTables() {
336
- // Clear all table data immediately
337
- PAG_SERVICES.setData([], []);
338
- PAG_PROCESSES.setData([], []);
339
- PAG_DOCKER.setData([], []);
340
- PAG_DISKS.setData([], []);
341
- PAG_PYUDISK.setData([], []);
342
- PAG_CERTS.setData([], []);
343
- }
344
-
345
- function resetUI() {
346
- firstMessage = true;
347
- hideSpinners();
348
- const EMPTY_DATA = Array(MAX_POINTS).fill(null);
349
- const EMPTY_LABELS = Array(MAX_POINTS).fill("");
350
-
351
- const resetChart = chart => {
352
- chart.data.labels = [...EMPTY_LABELS];
353
- chart.data.datasets[0].data = [...EMPTY_DATA];
354
- chart.update();
355
- };
356
396
 
357
- resetChart(cpuAvgChart);
358
- resetChart(memChart);
359
- resetChart(loadChart);
397
+ function getCoreChart(coreName) {
398
+ return coreMini[coreName] || createCoreChart(coreName);
399
+ }
360
400
 
361
- for (const name of Object.keys(coreMini)) {
362
- try { coreMini[name].chart.destroy(); } catch {}
363
- coreMini[name].el.remove();
364
- delete coreMini[name];
401
+ function pruneOldCores(latest) {
402
+ for (const name of Object.keys(coreMini)) {
403
+ if (!latest.includes(name)) {
404
+ try {
405
+ coreMini[name].chart.destroy();
406
+ } catch {
407
+ }
408
+ coreMini[name].el.remove();
409
+ delete coreMini[name];
410
+ }
411
+ }
365
412
  }
366
413
 
367
- systemEl.textContent = "-";
368
- ipEl.textContent = "—";
369
- processorEl.textContent = "—";
370
- memEl.textContent = "—";
371
- diskEl.textContent = "—";
372
- loadEl.textContent = "—";
373
- }
374
-
375
- // ------------------------------------------------------------
376
- // MISC HELPERS
377
- // ------------------------------------------------------------
378
- function pushPoint(chart, value) {
379
- const ts = new Date().toLocaleTimeString();
380
- chart.data.labels.push(ts);
381
- chart.data.datasets[0].data.push(isFinite(value) ? Number(value) : NaN);
382
- if (chart.data.labels.length > MAX_POINTS) {
383
- chart.data.labels.shift();
384
- chart.data.datasets[0].data.shift();
414
+ // ------------------------------------------------------------
415
+ // RESET UI
416
+ // ------------------------------------------------------------
417
+ function resetTables() {
418
+ // Clear all table data immediately
419
+ PAG_SERVICES.setData([], []);
420
+ PAG_PROCESSES.setData([], []);
421
+ PAG_DOCKER.setData([], []);
422
+ PAG_DISKS.setData([], []);
423
+ PAG_PYUDISK.setData([], []);
424
+ PAG_CERTS.setData([], []);
385
425
  }
386
- chart.update("none");
387
- }
388
-
389
- function num(x) { const n = Number(x); return Number.isFinite(n) ? n : null; }
390
- const round2 = x => Number(x).toFixed(2);
391
- const formatBytes = x => {
392
- if (x == null) return "—";
393
- const u = ["B","KB","MB","GB","TB"];
394
- let i = 0, n = Number(x);
395
- while (n > 1024 && i < u.length-1) { n/=1024; i++; }
396
- return n.toFixed(2) + " " + u[i];
397
- };
398
- const objectToString = (...vals) => {
399
- for (const v of vals) {
400
- if (v && typeof v === "object")
401
- return Object.entries(v).map(([a,b])=>`${a}: ${b}`).join("<br>");
402
- if (v != null) return v;
426
+
427
+ function resetUI() {
428
+ firstMessage = true;
429
+ hideSpinners();
430
+ const EMPTY_DATA = Array(MAX_POINTS).fill(null);
431
+ const EMPTY_LABELS = Array(MAX_POINTS).fill("");
432
+
433
+ const resetChart = chart => {
434
+ chart.data.labels = [...EMPTY_LABELS];
435
+ chart.data.datasets[0].data = [...EMPTY_DATA];
436
+ chart.update();
437
+ };
438
+
439
+ resetChart(cpuAvgChart);
440
+ resetChart(memChart);
441
+ resetChart(loadChart);
442
+
443
+ for (const name of Object.keys(coreMini)) {
444
+ try {
445
+ coreMini[name].chart.destroy();
446
+ } catch {
447
+ }
448
+ coreMini[name].el.remove();
449
+ delete coreMini[name];
450
+ }
451
+
452
+ systemEl.textContent = "-";
453
+ ipEl.textContent = "—";
454
+ processorEl.textContent = "—";
455
+ memEl.textContent = "—";
456
+ diskEl.textContent = "—";
457
+ loadEl.textContent = "—";
403
458
  }
404
- return "—";
405
- };
406
-
407
- function showSpinner(panelOrTableId) {
408
- const overlay = panelSpinners[panelOrTableId];
409
- if (overlay) overlay.classList.remove("hidden");
410
- }
411
-
412
- function hideSpinner(panelOrTableId) {
413
- const overlay = panelSpinners[panelOrTableId];
414
- if (overlay) overlay.classList.add("hidden");
415
- }
416
-
417
- // ------------------------------------------------------------
418
- // HANDLE METRICS
419
- // ------------------------------------------------------------
420
- let firstMessage = true;
421
-
422
- function handleMetrics(list) {
423
- if (firstMessage) {
424
- hideSpinners();
425
- firstMessage = false;
459
+
460
+ // ------------------------------------------------------------
461
+ // MISC HELPERS
462
+ // ------------------------------------------------------------
463
+ function pushPoint(chart, value) {
464
+ const ts = new Date().toLocaleTimeString();
465
+ chart.data.labels.push(ts);
466
+ chart.data.datasets[0].data.push(isFinite(value) ? Number(value) : NaN);
467
+ if (chart.data.labels.length > MAX_POINTS) {
468
+ chart.data.labels.shift();
469
+ chart.data.datasets[0].data.shift();
470
+ }
471
+ chart.update("none");
426
472
  }
427
473
 
428
- const now = new Date().toLocaleTimeString();
429
-
430
- for (const host of list) {
431
- if (host.base_url !== selectedBase) continue;
432
- const m = host.metrics || {};
433
-
434
- // ------------ Static fields ------------
435
- systemEl.textContent =
436
- `Node: ${m.node || "-"}\n` +
437
- `OS: ${m.system || "-"}\n` +
438
- `Architecture: ${m.architecture || "-"}\n\n` +
439
- `CPU Cores: ${m.cores || "-"}\n` +
440
- `Up Time: ${m.uptime || "-"}\n`;
441
-
442
- if (m.ip_info)
443
- ipEl.textContent =
444
- `Private: ${m.ip_info.private || "-"}\n\n` +
445
- `Public: ${m.ip_info.public || "-"}`;
446
-
447
- processorEl.textContent =
448
- `CPU: ${m.cpu_name || "-"}\n\n` +
449
- `GPU: ${m.gpu_name || "-"}`;
450
-
451
- if (m.disk_info && m.disk_info[0]) {
452
- const d = m.disk_info[0];
453
- diskEl.textContent =
454
- `Total: ${formatBytes(d.total)}\n` +
455
- `Used: ${formatBytes(d.used)}\n` +
456
- `Free: ${formatBytes(d.free)}`;
457
- }
458
-
459
- if (m.memory_info) {
460
- memEl.textContent =
461
- `Total: ${formatBytes(m.memory_info.total)}\n` +
462
- `Used: ${formatBytes(m.memory_info.used)}\n` +
463
- `Percent: ${round2(m.memory_info.percent)}%`;
464
- pushPoint(memChart, num(m.memory_info.percent));
465
- }
466
-
467
- // ------------ CPU ------------
468
- let avg = null;
469
- if (m.cpu_usage) {
470
- const values = m.cpu_usage.map(num);
471
- avg = values.reduce((a,b)=>a+(b||0),0) / values.length;
472
- pruneOldCores(values.map((_,i)=>"cpu"+(i+1)));
473
-
474
- values.forEach((v,i)=>{
475
- const core = getCoreChart("cpu"+(i+1));
476
-
477
- core.chart.data.labels.push(now);
478
- core.chart.data.datasets[0].data.push(v||0);
479
- if (core.chart.data.labels.length > MAX_POINTS) {
480
- core.chart.data.labels.shift();
481
- core.chart.data.datasets[0].data.shift();
482
- }
483
- core.chart.update({ lazy: true });
484
- core.valEl.textContent = `${(v||0).toFixed(1)}%`;
485
- });
486
- }
487
- if (avg != null) pushPoint(cpuAvgChart, avg);
488
-
489
- // ------------ LOAD ------------
490
- if (m.load_averages) {
491
- const la = m.load_averages;
492
- loadEl.textContent =
493
- `${round2(la.m1)} / ${round2(la.m5)} / ${round2(la.m15)}`;
494
- pushPoint(loadChart, num(la.m1));
495
- }
496
-
497
- // ------------ SERVICES (paginated) ------------
498
- const services = (m.service_stats || m.services || []).filter(s =>
499
- (s.pname || s.Name || "").toLowerCase().includes(
500
- svcFilter.value.trim().toLowerCase()
501
- )
502
- );
503
- if (services.length) {
504
- const columns = ["PID","Name","Status","CPU","Memory","Threads","Open Files"];
505
- const cleaned = services.map(s => ({
506
- PID: s.PID ?? s.pid ?? "",
507
- Name: s.pname ?? s.Name ?? s.name ?? "",
508
- Status: s.Status ?? s.active ?? s.status ?? s.Active ?? "—",
509
- CPU: objectToString(s.CPU, s.cpu),
510
- Memory: objectToString(s.Memory, s.memory),
511
- Threads: s.Threads ?? s.threads ?? "—",
512
- "Open Files": s["Open Files"] ?? s.open_files ?? "—"
513
- }));
514
- PAG_SERVICES.setData(cleaned, columns);
515
- hideSpinner("services-table");
516
- }
517
-
518
- // ------------ PROCESSES (paginated) ------------
519
- const processes = (m.process_stats || []).filter(p =>
520
- (p.Name || "").toLowerCase().includes(
521
- procFilter.value.trim().toLowerCase()
522
- )
523
- );
524
- if (processes.length) {
525
- const columns = ["PID","Name","Status","CPU","Memory","Uptime","Threads","Open Files"];
526
- PAG_PROCESSES.setData(processes, columns);
527
- hideSpinner("processes-table");
528
- }
529
-
530
- // ------------ DOCKER, DISKS, PYUDISK, CERTIFICATES ------------
531
- if (m.docker_stats) {
532
- const cols = Object.keys(m.docker_stats[0] || {});
533
- PAG_DOCKER.setData(m.docker_stats, cols);
534
- hideSpinner("docker-table");
535
- }
536
-
537
- if (m.disks_info) {
538
- const cols = Object.keys(m.disks_info[0] || {});
539
- PAG_DISKS.setData(m.disks_info, cols);
540
- hideSpinner("disks-table");
541
- }
542
-
543
- if (m.pyudisk_stats) {
544
- const cols = Object.keys(m.pyudisk_stats[0] || {});
545
- PAG_PYUDISK.setData(m.pyudisk_stats, cols);
546
- hideSpinner("pyudisk-table");
547
- }
548
-
549
- if (m.certificates) {
550
- const cols = Object.keys(m.certificates[0] || {});
551
- PAG_CERTS.setData(m.certificates, cols);
552
- hideSpinner("certificates-table");
553
- }
474
+ function num(x) {
475
+ const n = Number(x);
476
+ return Number.isFinite(n) ? n : null;
477
+ }
478
+
479
+ const round2 = x => Number(x).toFixed(2);
480
+ const formatBytes = x => {
481
+ if (x == null) return "—";
482
+ const u = ["B", "KB", "MB", "GB", "TB"];
483
+ let i = 0, n = Number(x);
484
+ while (n > 1024 && i < u.length - 1) {
485
+ n /= 1024;
486
+ i++;
487
+ }
488
+ return n.toFixed(2) + " " + u[i];
489
+ };
490
+ const objectToString = (...vals) => {
491
+ for (const v of vals) {
492
+ if (v && typeof v === "object")
493
+ return Object.entries(v).map(([a, b]) => `${a}: ${b}`).join("<br>");
494
+ if (v != null) return v;
495
+ }
496
+ return "—";
497
+ };
498
+
499
+ function showSpinner(panelOrTableId) {
500
+ const overlay = panelSpinners[panelOrTableId];
501
+ if (overlay) overlay.classList.remove("hidden");
554
502
  }
555
- }
556
-
557
- // ------------------------------------------------------------
558
- // EVENT BINDINGS
559
- // ------------------------------------------------------------
560
- targets.forEach(t => {
561
- const opt = document.createElement("option");
562
- opt.value = t.base_url;
563
- opt.textContent = t.name || t.base_url;
564
- nodeSelect.appendChild(opt);
565
- });
566
-
567
- let selectedBase = nodeSelect.value || (targets[0] && targets[0].base_url);
568
- nodeSelect.value = selectedBase;
569
-
570
- nodeSelect.addEventListener("change", () => {
571
- selectedBase = nodeSelect.value;
572
- resetUI();
573
- resetTables();
574
- showAllSpinners();
575
- ws.send(JSON.stringify({ type: "select_target", base_url: selectedBase }));
576
- });
577
-
578
- svcGetAll.addEventListener("change", () => {
579
- ws.send(JSON.stringify({ type: "update_flags", all_services: svcGetAll.checked }));
580
- })
581
-
582
- refreshBtn.addEventListener("click", resetUI);
583
-
584
- showCoresCheckbox.addEventListener("change", () => {
585
- const visible = showCoresCheckbox.checked;
586
- Object.values(coreMini).forEach(c => c.el.style.display = visible ? "block" : "none");
587
- });
588
-
589
- // ------------------------------------------------------------
590
- // WEBSOCKET
591
- // ------------------------------------------------------------
592
- const protocol = location.protocol === "https:" ? "wss" : "ws";
593
- const ws = new WebSocket(`${protocol}://${location.host}/ws`);
594
-
595
- ws.onopen = () => {
596
- ws.send(JSON.stringify({ type: "select_target", base_url: selectedBase }));
597
- };
598
-
599
- ws.onmessage = evt => {
600
- try {
601
- const msg = JSON.parse(evt.data);
602
- if (msg.type === "metrics") handleMetrics(msg.data);
603
- if (msg.type === "error") alert(msg.message);
604
- } catch (err) {
605
- console.error("WS parse error:", err);
503
+
504
+ function hideSpinner(panelOrTableId) {
505
+ const overlay = panelSpinners[panelOrTableId];
506
+ if (overlay) overlay.classList.add("hidden");
606
507
  }
607
- };
608
-
609
- // ------------------------------------------------------------
610
- // INIT
611
- // ------------------------------------------------------------
612
- attachSpinners();
613
- resetUI(); // reset UI, keep spinners visible
614
- showAllSpinners(); // show spinners until first metrics arrive
508
+
509
+ // ------------------------------------------------------------
510
+ // HANDLE METRICS
511
+ // ------------------------------------------------------------
512
+ let firstMessage = true;
513
+
514
+ function handleMetrics(list) {
515
+ if (firstMessage) {
516
+ hideSpinners();
517
+ firstMessage = false;
518
+ }
519
+
520
+ const now = new Date().toLocaleTimeString();
521
+
522
+ for (const host of list) {
523
+ if (host.base_url !== selectedBase) continue;
524
+ const m = host.metrics || {};
525
+
526
+ // ------------ Static fields ------------
527
+ systemEl.textContent =
528
+ `Node: ${m.node || "-"}\n` +
529
+ `OS: ${m.system || "-"}\n` +
530
+ `Architecture: ${m.architecture || "-"}\n\n` +
531
+ `CPU Cores: ${m.cores || "-"}\n` +
532
+ `Up Time: ${m.uptime || "-"}\n`;
533
+
534
+ if (m.ip_info)
535
+ ipEl.textContent =
536
+ `Private: ${m.ip_info.private || "-"}\n\n` +
537
+ `Public: ${m.ip_info.public || "-"}`;
538
+
539
+ processorEl.textContent =
540
+ `CPU: ${m.cpu_name || "-"}\n\n` +
541
+ `GPU: ${m.gpu_name || "-"}`;
542
+
543
+ if (m.disk_info && m.disk_info[0]) {
544
+ const d = m.disk_info[0];
545
+ diskEl.textContent =
546
+ `Total: ${formatBytes(d.total)}\n` +
547
+ `Used: ${formatBytes(d.used)}\n` +
548
+ `Free: ${formatBytes(d.free)}`;
549
+ }
550
+
551
+ if (m.memory_info) {
552
+ memEl.textContent =
553
+ `Total: ${formatBytes(m.memory_info.total)}\n` +
554
+ `Used: ${formatBytes(m.memory_info.used)}\n` +
555
+ `Percent: ${round2(m.memory_info.percent)}%`;
556
+ pushPoint(memChart, num(m.memory_info.percent));
557
+ }
558
+
559
+ // ------------ CPU ------------
560
+ let avg = null;
561
+ if (m.cpu_usage) {
562
+ const values = m.cpu_usage.map(num);
563
+ avg = values.reduce((a, b) => a + (b || 0), 0) / values.length;
564
+ pruneOldCores(values.map((_, i) => "cpu" + (i + 1)));
565
+
566
+ values.forEach((v, i) => {
567
+ const core = getCoreChart("cpu" + (i + 1));
568
+
569
+ core.chart.data.labels.push(now);
570
+ core.chart.data.datasets[0].data.push(v || 0);
571
+ if (core.chart.data.labels.length > MAX_POINTS) {
572
+ core.chart.data.labels.shift();
573
+ core.chart.data.datasets[0].data.shift();
574
+ }
575
+ core.chart.update({lazy: true});
576
+ core.valEl.textContent = `${(v || 0).toFixed(1)}%`;
577
+ });
578
+ }
579
+ if (avg != null) pushPoint(cpuAvgChart, avg);
580
+
581
+ // ------------ LOAD ------------
582
+ if (m.load_averages) {
583
+ const la = m.load_averages;
584
+ loadEl.textContent =
585
+ `${round2(la.m1)} / ${round2(la.m5)} / ${round2(la.m15)}`;
586
+ pushPoint(loadChart, num(la.m1));
587
+ }
588
+
589
+ // ------------ SERVICES (paginated) ------------
590
+ const services = (m.service_stats || m.services || []).filter(s =>
591
+ (s.pname || s.Name || "").toLowerCase().includes(
592
+ svcFilter.value.trim().toLowerCase()
593
+ )
594
+ );
595
+ if (services.length) {
596
+ const columns = ["PID", "Name", "Status", "CPU", "Memory", "Threads", "Open Files"];
597
+ const cleaned = services.map(s => ({
598
+ PID: s.PID ?? s.pid ?? "",
599
+ Name: s.pname ?? s.Name ?? s.name ?? "",
600
+ Status: s.Status ?? s.active ?? s.status ?? s.Active ?? "—",
601
+ CPU: objectToString(s.CPU, s.cpu),
602
+ Memory: objectToString(s.Memory, s.memory),
603
+ Threads: s.Threads ?? s.threads ?? "—",
604
+ "Open Files": s["Open Files"] ?? s.open_files ?? "—"
605
+ }));
606
+ PAG_SERVICES.setData(cleaned, columns);
607
+ hideSpinner("services-table");
608
+ }
609
+
610
+ // ------------ PROCESSES (paginated) ------------
611
+ const processes = (m.process_stats || []).filter(p =>
612
+ (p.Name || "").toLowerCase().includes(
613
+ procFilter.value.trim().toLowerCase()
614
+ )
615
+ );
616
+ if (processes.length) {
617
+ const columns = ["PID", "Name", "Status", "CPU", "Memory", "Uptime", "Threads", "Open Files"];
618
+ PAG_PROCESSES.setData(processes, columns);
619
+ hideSpinner("processes-table");
620
+ }
621
+
622
+ // ------------ DOCKER, DISKS, PYUDISK, CERTIFICATES ------------
623
+ if (m.docker_stats) {
624
+ const cols = Object.keys(m.docker_stats[0] || {});
625
+ PAG_DOCKER.setData(m.docker_stats, cols);
626
+ hideSpinner("docker-table");
627
+ }
628
+
629
+ if (m.disks_info) {
630
+ const cols = Object.keys(m.disks_info[0] || {});
631
+ PAG_DISKS.setData(m.disks_info, cols);
632
+ hideSpinner("disks-table");
633
+ }
634
+
635
+ if (m.pyudisk_stats) {
636
+ const cols = Object.keys(m.pyudisk_stats[0] || {});
637
+ PAG_PYUDISK.setData(m.pyudisk_stats, cols);
638
+ hideSpinner("pyudisk-table");
639
+ }
640
+
641
+ if (m.certificates) {
642
+ const cols = Object.keys(m.certificates[0] || {});
643
+ PAG_CERTS.setData(m.certificates, cols);
644
+ hideSpinner("certificates-table");
645
+ }
646
+ }
647
+ }
648
+
649
+ // ------------------------------------------------------------
650
+ // EVENT BINDINGS
651
+ // ------------------------------------------------------------
652
+ targets.forEach(t => {
653
+ const opt = document.createElement("option");
654
+ opt.value = t.base_url;
655
+ opt.textContent = t.name || t.base_url;
656
+ nodeSelect.appendChild(opt);
657
+ });
658
+
659
+ let selectedBase = nodeSelect.value || (targets[0] && targets[0].base_url);
660
+ nodeSelect.value = selectedBase;
661
+
662
+ nodeSelect.addEventListener("change", () => {
663
+ selectedBase = nodeSelect.value;
664
+ resetUI();
665
+ resetTables();
666
+ showAllSpinners();
667
+ ws.send(JSON.stringify({type: "select_target", base_url: selectedBase}));
668
+ });
669
+
670
+ svcGetAll.addEventListener("change", () => {
671
+ ws.send(JSON.stringify({type: "update_flags", all_services: svcGetAll.checked}));
672
+ })
673
+
674
+ refreshBtn.addEventListener("click", resetUI);
675
+
676
+ showCoresCheckbox.addEventListener("change", () => {
677
+ const visible = showCoresCheckbox.checked;
678
+ Object.values(coreMini).forEach(c => c.el.style.display = visible ? "block" : "none");
679
+ });
680
+
681
+ // ------------------------------------------------------------
682
+ // WEBSOCKET
683
+ // ------------------------------------------------------------
684
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
685
+ const ws = new WebSocket(`${protocol}://${location.host}/ws`);
686
+
687
+ ws.onopen = () => {
688
+ ws.send(JSON.stringify({type: "select_target", base_url: selectedBase}));
689
+ };
690
+
691
+ ws.onmessage = evt => {
692
+ try {
693
+ const msg = JSON.parse(evt.data);
694
+ if (msg.type === "metrics") handleMetrics(msg.data);
695
+ if (msg.type === "error") alert(msg.message);
696
+ } catch (err) {
697
+ console.error("WS parse error:", err);
698
+ }
699
+ };
700
+
701
+ // ------------------------------------------------------------
702
+ // INIT
703
+ // ------------------------------------------------------------
704
+ attachSpinners();
705
+ resetUI(); // reset UI, keep spinners visible
706
+ showAllSpinners(); // show spinners until first metrics arrive
615
707
  })();