PyObservability 1.3.0__py3-none-any.whl → 1.4.1__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.
- pyobservability/config/settings.py +1 -0
- pyobservability/main.py +3 -3
- pyobservability/monitor.py +4 -5
- pyobservability/static/app.js +413 -10
- pyobservability/static/styles.css +148 -0
- pyobservability/templates/index.html +72 -0
- pyobservability/transport.py +151 -2
- pyobservability/version.py +1 -1
- {pyobservability-1.3.0.dist-info → pyobservability-1.4.1.dist-info}/METADATA +1 -1
- pyobservability-1.4.1.dist-info/RECORD +16 -0
- pyobservability-1.3.0.dist-info/RECORD +0 -16
- {pyobservability-1.3.0.dist-info → pyobservability-1.4.1.dist-info}/WHEEL +0 -0
- {pyobservability-1.3.0.dist-info → pyobservability-1.4.1.dist-info}/entry_points.txt +0 -0
- {pyobservability-1.3.0.dist-info → pyobservability-1.4.1.dist-info}/licenses/LICENSE +0 -0
- {pyobservability-1.3.0.dist-info → pyobservability-1.4.1.dist-info}/top_level.txt +0 -0
|
@@ -51,6 +51,7 @@ def detailed_log_config(filename: str | None = None, debug: bool = False) -> Dic
|
|
|
51
51
|
"uvicorn": {"handlers": ["default"], "level": level},
|
|
52
52
|
"uvicorn.error": {"handlers": ["default"], "level": level, "propagate": False},
|
|
53
53
|
"uvicorn.access": {"handlers": ["default"], "level": level, "propagate": False},
|
|
54
|
+
"uvicorn.default": {"handlers": ["default"], "level": level, "propagate": False},
|
|
54
55
|
},
|
|
55
56
|
"root": {"handlers": ["default"], "level": level},
|
|
56
57
|
}
|
pyobservability/main.py
CHANGED
|
@@ -2,7 +2,7 @@ import logging
|
|
|
2
2
|
import pathlib
|
|
3
3
|
import warnings
|
|
4
4
|
from datetime import datetime
|
|
5
|
-
from typing import Dict
|
|
5
|
+
from typing import Any, Dict
|
|
6
6
|
|
|
7
7
|
import uiauth
|
|
8
8
|
import uvicorn
|
|
@@ -39,7 +39,7 @@ async def index(request: Request):
|
|
|
39
39
|
TemplateResponse:
|
|
40
40
|
Rendered HTML template with targets and version.
|
|
41
41
|
"""
|
|
42
|
-
args = dict(request=request, targets=settings.env.targets, version=__version__)
|
|
42
|
+
args: Dict[str, Any] = dict(request=request, targets=settings.env.targets, version=__version__)
|
|
43
43
|
if settings.env.username and settings.env.password:
|
|
44
44
|
args["logout"] = uiauth.enums.APIEndpoints.fastapi_logout.value
|
|
45
45
|
return templates.TemplateResponse("index.html", args)
|
|
@@ -49,7 +49,7 @@ async def health() -> Dict[str, str]:
|
|
|
49
49
|
"""Health check endpoint.
|
|
50
50
|
|
|
51
51
|
Returns:
|
|
52
|
-
|
|
52
|
+
Dict[str, str]:
|
|
53
53
|
Health status.
|
|
54
54
|
"""
|
|
55
55
|
return {"status": "ok"}
|
pyobservability/monitor.py
CHANGED
|
@@ -174,7 +174,7 @@ class Monitor:
|
|
|
174
174
|
"ts": asyncio.get_event_loop().time(),
|
|
175
175
|
"data": [
|
|
176
176
|
{
|
|
177
|
-
"name": self.
|
|
177
|
+
"name": self.name,
|
|
178
178
|
"base_url": self.base_url,
|
|
179
179
|
"metrics": payload,
|
|
180
180
|
}
|
|
@@ -188,13 +188,12 @@ class Monitor:
|
|
|
188
188
|
_ = q.get_nowait()
|
|
189
189
|
q.put_nowait(result)
|
|
190
190
|
except Exception as err:
|
|
191
|
-
LOGGER.debug("Stream error for %s: %s", self.base_url, err)
|
|
192
191
|
if errors.get(self.base_url):
|
|
193
192
|
if errors[self.base_url] < 10:
|
|
193
|
+
LOGGER.debug("Stream error for %s: %s", self.base_url, err)
|
|
194
194
|
errors[self.base_url] += 1
|
|
195
195
|
else:
|
|
196
|
-
LOGGER.error(err
|
|
197
|
-
LOGGER.error("%s exceeded error threshold.", self.base_url)
|
|
196
|
+
LOGGER.error("Stream error for %s: %s", self.base_url, err)
|
|
198
197
|
|
|
199
198
|
# notify subscribers before stopping
|
|
200
199
|
error_msg = {
|
|
@@ -209,7 +208,7 @@ class Monitor:
|
|
|
209
208
|
except asyncio.QueueFull as warn:
|
|
210
209
|
LOGGER.warning(warn)
|
|
211
210
|
_ = q.get_nowait()
|
|
212
|
-
q.put_nowait(
|
|
211
|
+
q.put_nowait(error_msg)
|
|
213
212
|
await self.stop()
|
|
214
213
|
return
|
|
215
214
|
else:
|
pyobservability/static/app.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ------------------------------------------------------------
|
|
6
6
|
const MAX_POINTS = 60;
|
|
7
7
|
const targets = window.MONITOR_TARGETS || [];
|
|
8
|
-
const DEFAULT_PAGE_SIZE =
|
|
8
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
9
9
|
const panelSpinners = {};
|
|
10
10
|
|
|
11
11
|
// ------------------------------------------------------------
|
|
@@ -117,11 +117,26 @@
|
|
|
117
117
|
const chunk = rows.slice(start, start + state.pageSize);
|
|
118
118
|
|
|
119
119
|
info.textContent =
|
|
120
|
-
`Showing ${start + 1} to ${Math.min(start + state.pageSize, rows.length)} of ${rows.length} entries`;
|
|
120
|
+
`Showing ${rows.length ? start + 1 : 0} to ${rows.length ? Math.min(start + state.pageSize, rows.length) : 0} of ${rows.length} entries`;
|
|
121
121
|
|
|
122
122
|
bodyEl.innerHTML = "";
|
|
123
123
|
chunk.forEach(r => bodyEl.insertAdjacentHTML("beforeend", r));
|
|
124
124
|
|
|
125
|
+
const fillerCount = Math.max(0, state.pageSize - chunk.length);
|
|
126
|
+
const shouldPad = state.page > 1 && fillerCount > 0;
|
|
127
|
+
if (shouldPad) {
|
|
128
|
+
const colCount = state.columns?.length || headEl.querySelectorAll("th").length || 1;
|
|
129
|
+
for (let i = 0; i < fillerCount; i++) {
|
|
130
|
+
const fillerRow = document.createElement("tr");
|
|
131
|
+
fillerRow.className = "placeholder-row";
|
|
132
|
+
for (let c = 0; c < colCount; c++) {
|
|
133
|
+
const cell = document.createElement("td");
|
|
134
|
+
cell.innerHTML = " ";
|
|
135
|
+
fillerRow.appendChild(cell);
|
|
136
|
+
}
|
|
137
|
+
bodyEl.appendChild(fillerRow);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
125
140
|
renderPagination(pages);
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -370,6 +385,336 @@
|
|
|
370
385
|
const memChart = makeMainChart(memCtx, "Memory %");
|
|
371
386
|
const loadChart = makeMainChart(loadCtx, "CPU Load");
|
|
372
387
|
|
|
388
|
+
// Unified metrics: three parallel charts (memory, CPU, disk)
|
|
389
|
+
const unifiedPanel = document.getElementById("unified-panel");
|
|
390
|
+
const unifiedLegend = document.getElementById("unified-legend");
|
|
391
|
+
const unifiedMemCtx = document.getElementById("unified-mem-chart").getContext("2d");
|
|
392
|
+
const unifiedCpuCtx = document.getElementById("unified-cpu-chart").getContext("2d");
|
|
393
|
+
const unifiedDiskCtx = document.getElementById("unified-disk-chart").getContext("2d");
|
|
394
|
+
|
|
395
|
+
// Unified tables DOM references
|
|
396
|
+
const unifiedServicesTable = document.getElementById("unified-services-table");
|
|
397
|
+
const unifiedServicesHead = unifiedServicesTable?.querySelector("thead");
|
|
398
|
+
const unifiedServicesBody = unifiedServicesTable?.querySelector("tbody");
|
|
399
|
+
|
|
400
|
+
const unifiedProcessesTable = document.getElementById("unified-processes-table");
|
|
401
|
+
const unifiedProcessesHead = unifiedProcessesTable?.querySelector("thead");
|
|
402
|
+
const unifiedProcessesBody = unifiedProcessesTable?.querySelector("tbody");
|
|
403
|
+
|
|
404
|
+
const unifiedDockerTable = document.getElementById("unified-docker-table");
|
|
405
|
+
const unifiedDockerHead = unifiedDockerTable?.querySelector("thead");
|
|
406
|
+
const unifiedDockerBody = unifiedDockerTable?.querySelector("tbody");
|
|
407
|
+
|
|
408
|
+
const unifiedDisksTable = document.getElementById("unified-disks-table");
|
|
409
|
+
const unifiedDisksHead = unifiedDisksTable?.querySelector("thead");
|
|
410
|
+
const unifiedDisksBody = unifiedDisksTable?.querySelector("tbody");
|
|
411
|
+
|
|
412
|
+
const unifiedPyudiskTable = document.getElementById("unified-pyudisk-table");
|
|
413
|
+
const unifiedPyudiskHead = unifiedPyudiskTable?.querySelector("thead");
|
|
414
|
+
const unifiedPyudiskBody = unifiedPyudiskTable?.querySelector("tbody");
|
|
415
|
+
|
|
416
|
+
const unifiedCertsTable = document.getElementById("unified-certificates-table");
|
|
417
|
+
const unifiedCertsHead = unifiedCertsTable?.querySelector("thead");
|
|
418
|
+
const unifiedCertsBody = unifiedCertsTable?.querySelector("tbody");
|
|
419
|
+
|
|
420
|
+
// Paginated unified tables
|
|
421
|
+
const PAG_UNIFIED_SERVICES = unifiedServicesTable && createPaginatedTable(
|
|
422
|
+
unifiedServicesTable, unifiedServicesHead, unifiedServicesBody
|
|
423
|
+
);
|
|
424
|
+
const PAG_UNIFIED_PROCESSES = unifiedProcessesTable && createPaginatedTable(
|
|
425
|
+
unifiedProcessesTable, unifiedProcessesHead, unifiedProcessesBody
|
|
426
|
+
);
|
|
427
|
+
const PAG_UNIFIED_DOCKER = unifiedDockerTable && createPaginatedTable(
|
|
428
|
+
unifiedDockerTable, unifiedDockerHead, unifiedDockerBody
|
|
429
|
+
);
|
|
430
|
+
const PAG_UNIFIED_DISKS = unifiedDisksTable && createPaginatedTable(
|
|
431
|
+
unifiedDisksTable, unifiedDisksHead, unifiedDisksBody
|
|
432
|
+
);
|
|
433
|
+
const PAG_UNIFIED_PYUDISK = unifiedPyudiskTable && createPaginatedTable(
|
|
434
|
+
unifiedPyudiskTable, unifiedPyudiskHead, unifiedPyudiskBody
|
|
435
|
+
);
|
|
436
|
+
const PAG_UNIFIED_CERTS = unifiedCertsTable && createPaginatedTable(
|
|
437
|
+
unifiedCertsTable, unifiedCertsHead, unifiedCertsBody
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
let unifiedNodes = [];
|
|
441
|
+
// TODO: Update colorPalette to use contrasting colors
|
|
442
|
+
const colorPalette = ["#63b3ff", "#ff99c8", "#7dd3fc", "#fbbf24", "#a3e635", "#f87171", "#c084fc", "#38bdf8"];
|
|
443
|
+
const nodeColor = {};
|
|
444
|
+
const unifiedCharts = {memory: null, cpu: null, disk: null};
|
|
445
|
+
|
|
446
|
+
function normalizeNodes(nodes) {
|
|
447
|
+
return nodes
|
|
448
|
+
.filter(node => node.base_url && node.base_url !== "*")
|
|
449
|
+
.sort((a, b) => (a.name || a.base_url).localeCompare(b.name || b.base_url));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function assignColors(nodes) {
|
|
453
|
+
nodes.forEach((node, idx) => {
|
|
454
|
+
nodeColor[node.base_url] = colorPalette[idx % colorPalette.length];
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderLegend(nodes) {
|
|
459
|
+
unifiedLegend.innerHTML = "";
|
|
460
|
+
nodes.forEach(node => {
|
|
461
|
+
const item = document.createElement("div");
|
|
462
|
+
item.className = "unified-legend-item";
|
|
463
|
+
item.innerHTML = `<span class="legend-dot" style="background:${nodeColor[node.base_url]}"></span>${node.name || node.base_url}`;
|
|
464
|
+
unifiedLegend.appendChild(item);
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function makeUnifiedChart(ctx, nodes) {
|
|
469
|
+
return new Chart(ctx, {
|
|
470
|
+
type: "line",
|
|
471
|
+
data: {
|
|
472
|
+
labels: Array(MAX_POINTS).fill(""),
|
|
473
|
+
datasets: nodes.map(node => ({
|
|
474
|
+
label: node.name || node.base_url,
|
|
475
|
+
meta: {base_url: node.base_url},
|
|
476
|
+
data: Array(MAX_POINTS).fill(null),
|
|
477
|
+
borderColor: nodeColor[node.base_url],
|
|
478
|
+
backgroundColor: `${nodeColor[node.base_url]}33`,
|
|
479
|
+
borderWidth: 2,
|
|
480
|
+
fill: false,
|
|
481
|
+
tension: 0.3,
|
|
482
|
+
pointRadius: 0,
|
|
483
|
+
}))
|
|
484
|
+
},
|
|
485
|
+
options: {
|
|
486
|
+
animation: false,
|
|
487
|
+
responsive: true,
|
|
488
|
+
maintainAspectRatio: false,
|
|
489
|
+
scales: {
|
|
490
|
+
x: {display: false},
|
|
491
|
+
y: {beginAtZero: true, suggestedMax: 100}
|
|
492
|
+
},
|
|
493
|
+
plugins: {legend: {display: false}}
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function setUnifiedMode(enabled) {
|
|
499
|
+
document.body.classList.toggle("unified-mode", enabled);
|
|
500
|
+
if (!enabled) {
|
|
501
|
+
unifiedPanel.classList.add("hidden");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function ensureUnifiedChart(metrics) {
|
|
506
|
+
const nodes = normalizeNodes(metrics);
|
|
507
|
+
if (!nodes.length) return false;
|
|
508
|
+
const changed =
|
|
509
|
+
nodes.length !== unifiedNodes.length ||
|
|
510
|
+
nodes.some((node, idx) => unifiedNodes[idx]?.base_url !== node.base_url);
|
|
511
|
+
|
|
512
|
+
if (changed) {
|
|
513
|
+
// Destroy any existing unified charts
|
|
514
|
+
Object.keys(unifiedCharts).forEach(key => {
|
|
515
|
+
if (unifiedCharts[key]) {
|
|
516
|
+
unifiedCharts[key].destroy();
|
|
517
|
+
unifiedCharts[key] = null;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
assignColors(nodes);
|
|
521
|
+
unifiedCharts.memory = makeUnifiedChart(unifiedMemCtx, nodes);
|
|
522
|
+
unifiedCharts.cpu = makeUnifiedChart(unifiedCpuCtx, nodes);
|
|
523
|
+
unifiedCharts.disk = makeUnifiedChart(unifiedDiskCtx, nodes);
|
|
524
|
+
unifiedNodes = nodes;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
unifiedPanel.classList.remove("hidden");
|
|
528
|
+
renderLegend(nodes);
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function sampleForMetric(host, metric) {
|
|
533
|
+
if (!host.metrics) return null;
|
|
534
|
+
if (metric === "memory") return host.metrics.memory_info?.percent ?? null;
|
|
535
|
+
if (metric === "cpu") {
|
|
536
|
+
const values = (host.metrics.cpu_usage || [])
|
|
537
|
+
.map(v => Number(v))
|
|
538
|
+
.filter(Number.isFinite);
|
|
539
|
+
if (!values.length) return null;
|
|
540
|
+
return values.reduce((a, b) => a + b, 0) / values.length;
|
|
541
|
+
}
|
|
542
|
+
if (metric === "disk") {
|
|
543
|
+
const m = host.metrics;
|
|
544
|
+
if (m.disk_info && m.disk_info[0]) {
|
|
545
|
+
const m = host.metrics;
|
|
546
|
+
const agg = aggregateDiskInfo(m.disk_info);
|
|
547
|
+
return agg.percent;
|
|
548
|
+
}
|
|
549
|
+
return host.metrics.disk_info?.[0]?.percent ?? null;
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function updateUnified(metrics) {
|
|
555
|
+
const ts = new Date().toLocaleTimeString();
|
|
556
|
+
const metricKeys = ["memory", "cpu", "disk"];
|
|
557
|
+
metricKeys.forEach(metric => {
|
|
558
|
+
const chart = unifiedCharts[metric];
|
|
559
|
+
if (!chart) return;
|
|
560
|
+
|
|
561
|
+
chart.data.labels.push(ts);
|
|
562
|
+
if (chart.data.labels.length > MAX_POINTS) {
|
|
563
|
+
chart.data.labels.shift();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
chart.data.datasets.forEach(ds => {
|
|
567
|
+
const host = metrics.find(h => h.base_url === ds.meta.base_url);
|
|
568
|
+
const value = host ? sampleForMetric(host, metric) : null;
|
|
569
|
+
ds.data.push(value);
|
|
570
|
+
if (ds.data.length > MAX_POINTS) ds.data.shift();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
chart.update("none");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// --- Unified tables aggregation ---
|
|
577
|
+
// Helper to get display name for node
|
|
578
|
+
const getNodeLabel = (host) => host.name || host.base_url || "";
|
|
579
|
+
|
|
580
|
+
// Services
|
|
581
|
+
if (PAG_UNIFIED_SERVICES) {
|
|
582
|
+
const svcRows = [];
|
|
583
|
+
metrics.forEach(host => {
|
|
584
|
+
if (!host.metrics) return;
|
|
585
|
+
const m = host.metrics;
|
|
586
|
+
const label = getNodeLabel(host);
|
|
587
|
+
const services = (m.service_stats || m.services || []).filter(s =>
|
|
588
|
+
(s.pname || s.Name || "").toLowerCase().includes(
|
|
589
|
+
svcFilter.value.trim().toLowerCase()
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
services.forEach(s => {
|
|
593
|
+
svcRows.push({
|
|
594
|
+
Node: label,
|
|
595
|
+
PID: s.PID ?? s.pid ?? "",
|
|
596
|
+
Name: s.pname ?? s.Name ?? s.name ?? "",
|
|
597
|
+
Status: s.Status ?? s.active ?? s.status ?? s.Active ?? "4",
|
|
598
|
+
CPU: objectToString(s.CPU, s.cpu),
|
|
599
|
+
Memory: objectToString(s.Memory, s.memory),
|
|
600
|
+
Threads: s.Threads ?? s.threads ?? "4",
|
|
601
|
+
"Open Files": s["Open Files"] ?? s.open_files ?? "4"
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
const svcCols = ["Node", "PID", "Name", "Status", "CPU", "Memory", "Threads", "Open Files"];
|
|
606
|
+
PAG_UNIFIED_SERVICES.setData(svcRows, svcCols);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Processes
|
|
610
|
+
if (PAG_UNIFIED_PROCESSES) {
|
|
611
|
+
const procRows = [];
|
|
612
|
+
const procColsSet = new Set(["Node", "PID", "Name", "Status", "CPU", "Memory", "Uptime", "Threads", "Open Files"]);
|
|
613
|
+
metrics.forEach(host => {
|
|
614
|
+
if (!host.metrics) return;
|
|
615
|
+
const m = host.metrics;
|
|
616
|
+
const label = getNodeLabel(host);
|
|
617
|
+
const processes = (m.process_stats || []).filter(p =>
|
|
618
|
+
(p.Name || "").toLowerCase().includes(
|
|
619
|
+
procFilter.value.trim().toLowerCase()
|
|
620
|
+
)
|
|
621
|
+
);
|
|
622
|
+
processes.forEach(p => {
|
|
623
|
+
const row = {Node: label};
|
|
624
|
+
Object.entries(p).forEach(([k, v]) => {
|
|
625
|
+
procColsSet.add(k);
|
|
626
|
+
row[k] = v;
|
|
627
|
+
});
|
|
628
|
+
procRows.push(row);
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
const procCols = Array.from(procColsSet);
|
|
632
|
+
PAG_UNIFIED_PROCESSES.setData(procRows, procCols);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Docker
|
|
636
|
+
if (PAG_UNIFIED_DOCKER) {
|
|
637
|
+
const dockerRows = [];
|
|
638
|
+
const dockerColsSet = new Set(["Node"]);
|
|
639
|
+
metrics.forEach(host => {
|
|
640
|
+
if (!host.metrics || !Array.isArray(host.metrics.docker_stats)) return;
|
|
641
|
+
const label = getNodeLabel(host);
|
|
642
|
+
host.metrics.docker_stats.forEach(s => {
|
|
643
|
+
const row = {Node: label};
|
|
644
|
+
Object.entries(s).forEach(([k, v]) => {
|
|
645
|
+
dockerColsSet.add(k);
|
|
646
|
+
row[k] = v;
|
|
647
|
+
});
|
|
648
|
+
dockerRows.push(row);
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
const dockerCols = Array.from(dockerColsSet);
|
|
652
|
+
PAG_UNIFIED_DOCKER.setData(dockerRows, dockerCols);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Disks
|
|
656
|
+
if (PAG_UNIFIED_DISKS) {
|
|
657
|
+
const diskRows = [];
|
|
658
|
+
const diskColsSet = new Set(["Node"]);
|
|
659
|
+
metrics.forEach(host => {
|
|
660
|
+
if (!host.metrics || !Array.isArray(host.metrics.disks_info)) return;
|
|
661
|
+
const label = getNodeLabel(host);
|
|
662
|
+
host.metrics.disks_info.forEach(d => {
|
|
663
|
+
const row = {Node: label};
|
|
664
|
+
Object.entries(d).forEach(([k, v]) => {
|
|
665
|
+
if (k === "Node") return;
|
|
666
|
+
diskColsSet.add(k);
|
|
667
|
+
row[k] = v;
|
|
668
|
+
});
|
|
669
|
+
diskRows.push(row);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
const diskCols = Array.from(diskColsSet);
|
|
673
|
+
PAG_UNIFIED_DISKS.setData(diskRows, diskCols);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// PyUdisk
|
|
677
|
+
if (PAG_UNIFIED_PYUDISK) {
|
|
678
|
+
const pyuRows = [];
|
|
679
|
+
const pyuColsSet = new Set(["Node"]);
|
|
680
|
+
metrics.forEach(host => {
|
|
681
|
+
if (!host.metrics || !Array.isArray(host.metrics.pyudisk_stats)) return;
|
|
682
|
+
const label = getNodeLabel(host);
|
|
683
|
+
host.metrics.pyudisk_stats.forEach(pyu => {
|
|
684
|
+
const row = {Node: label};
|
|
685
|
+
Object.entries(pyu).forEach(([k, v]) => {
|
|
686
|
+
if (k === "Mountpoint") return;
|
|
687
|
+
pyuColsSet.add(k);
|
|
688
|
+
row[k] = v;
|
|
689
|
+
});
|
|
690
|
+
pyuRows.push(row);
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
const pyuCols = Array.from(pyuColsSet);
|
|
694
|
+
PAG_UNIFIED_PYUDISK.setData(pyuRows, pyuCols);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Certificates
|
|
698
|
+
if (PAG_UNIFIED_CERTS) {
|
|
699
|
+
const certRows = [];
|
|
700
|
+
const certColsSet = new Set(["Node"]);
|
|
701
|
+
metrics.forEach(host => {
|
|
702
|
+
if (!host.metrics || !Array.isArray(host.metrics.certificates)) return;
|
|
703
|
+
const label = getNodeLabel(host);
|
|
704
|
+
host.metrics.certificates.forEach(c => {
|
|
705
|
+
const row = {Node: label};
|
|
706
|
+
Object.entries(c).forEach(([k, v]) => {
|
|
707
|
+
certColsSet.add(k);
|
|
708
|
+
row[k] = v;
|
|
709
|
+
});
|
|
710
|
+
certRows.push(row);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
const certCols = Array.from(certColsSet);
|
|
714
|
+
PAG_UNIFIED_CERTS.setData(certRows, certCols);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
373
718
|
// ------------------------------------------------------------
|
|
374
719
|
// CORE CHARTS
|
|
375
720
|
// ------------------------------------------------------------
|
|
@@ -398,9 +743,9 @@
|
|
|
398
743
|
return coreMini[coreName] || createCoreChart(coreName);
|
|
399
744
|
}
|
|
400
745
|
|
|
401
|
-
function pruneOldCores(
|
|
746
|
+
function pruneOldCores(keep) {
|
|
402
747
|
for (const name of Object.keys(coreMini)) {
|
|
403
|
-
if (!
|
|
748
|
+
if (!keep.includes(name)) {
|
|
404
749
|
try {
|
|
405
750
|
coreMini[name].chart.destroy();
|
|
406
751
|
} catch {
|
|
@@ -422,6 +767,12 @@
|
|
|
422
767
|
PAG_DISKS.setData([], []);
|
|
423
768
|
PAG_PYUDISK.setData([], []);
|
|
424
769
|
PAG_CERTS.setData([], []);
|
|
770
|
+
if (PAG_UNIFIED_SERVICES) PAG_UNIFIED_SERVICES.setData([], []);
|
|
771
|
+
if (PAG_UNIFIED_PROCESSES) PAG_UNIFIED_PROCESSES.setData([], []);
|
|
772
|
+
if (PAG_UNIFIED_DOCKER) PAG_UNIFIED_DOCKER.setData([], []);
|
|
773
|
+
if (PAG_UNIFIED_DISKS) PAG_UNIFIED_DISKS.setData([], []);
|
|
774
|
+
if (PAG_UNIFIED_PYUDISK) PAG_UNIFIED_PYUDISK.setData([], []);
|
|
775
|
+
if (PAG_UNIFIED_CERTS) PAG_UNIFIED_CERTS.setData([], []);
|
|
425
776
|
}
|
|
426
777
|
|
|
427
778
|
function resetUI() {
|
|
@@ -440,11 +791,23 @@
|
|
|
440
791
|
resetChart(memChart);
|
|
441
792
|
resetChart(loadChart);
|
|
442
793
|
|
|
794
|
+
// Reset unified charts as well
|
|
795
|
+
Object.keys(unifiedCharts).forEach(key => {
|
|
796
|
+
const chart = unifiedCharts[key];
|
|
797
|
+
if (chart) {
|
|
798
|
+
try {
|
|
799
|
+
chart.destroy();
|
|
800
|
+
} catch {}
|
|
801
|
+
unifiedCharts[key] = null;
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
unifiedNodes = [];
|
|
805
|
+
unifiedPanel.classList.add("hidden");
|
|
806
|
+
|
|
443
807
|
for (const name of Object.keys(coreMini)) {
|
|
444
808
|
try {
|
|
445
809
|
coreMini[name].chart.destroy();
|
|
446
|
-
} catch {
|
|
447
|
-
}
|
|
810
|
+
} catch {}
|
|
448
811
|
coreMini[name].el.remove();
|
|
449
812
|
delete coreMini[name];
|
|
450
813
|
}
|
|
@@ -460,6 +823,19 @@
|
|
|
460
823
|
// ------------------------------------------------------------
|
|
461
824
|
// MISC HELPERS
|
|
462
825
|
// ------------------------------------------------------------
|
|
826
|
+
function aggregateDiskInfo(diskInfo) {
|
|
827
|
+
let totalDisk = 0;
|
|
828
|
+
let usedDisk = 0;
|
|
829
|
+
let freeDisk = 0;
|
|
830
|
+
diskInfo.forEach(d => {
|
|
831
|
+
totalDisk += num(d.total || 0);
|
|
832
|
+
usedDisk += num(d.used || 0);
|
|
833
|
+
freeDisk += num(d.free || 0);
|
|
834
|
+
});
|
|
835
|
+
let percentDisk = totalDisk > 0 ? (usedDisk / totalDisk) * 100 : 0.0;
|
|
836
|
+
return {"total": totalDisk, "used": usedDisk, "free": freeDisk, "percent": percentDisk};
|
|
837
|
+
}
|
|
838
|
+
|
|
463
839
|
function pushPoint(chart, value) {
|
|
464
840
|
const ts = new Date().toLocaleTimeString();
|
|
465
841
|
chart.data.labels.push(ts);
|
|
@@ -519,6 +895,16 @@
|
|
|
519
895
|
|
|
520
896
|
const now = new Date().toLocaleTimeString();
|
|
521
897
|
|
|
898
|
+
if (selectedBase === "*") {
|
|
899
|
+
if (ensureUnifiedChart(list)) {
|
|
900
|
+
setUnifiedMode(true);
|
|
901
|
+
updateUnified(list);
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
setUnifiedMode(false);
|
|
907
|
+
|
|
522
908
|
for (const host of list) {
|
|
523
909
|
if (host.base_url !== selectedBase) continue;
|
|
524
910
|
const m = host.metrics || {};
|
|
@@ -541,17 +927,19 @@
|
|
|
541
927
|
`GPU: ${m.gpu_name || "-"}`;
|
|
542
928
|
|
|
543
929
|
if (m.disk_info && m.disk_info[0]) {
|
|
544
|
-
const
|
|
930
|
+
const agg = aggregateDiskInfo(m.disk_info);
|
|
545
931
|
diskEl.textContent =
|
|
546
|
-
`Total: ${formatBytes(
|
|
547
|
-
`Used: ${formatBytes(
|
|
548
|
-
`Free: ${formatBytes(
|
|
932
|
+
`Total: ${formatBytes(agg.total)}\n` +
|
|
933
|
+
`Used: ${formatBytes(agg.used)}\n` +
|
|
934
|
+
`Free: ${formatBytes(agg.free)}\n` +
|
|
935
|
+
`Percent: ${round2(agg.percent)}%`;
|
|
549
936
|
}
|
|
550
937
|
|
|
551
938
|
if (m.memory_info) {
|
|
552
939
|
memEl.textContent =
|
|
553
940
|
`Total: ${formatBytes(m.memory_info.total)}\n` +
|
|
554
941
|
`Used: ${formatBytes(m.memory_info.used)}\n` +
|
|
942
|
+
`Free: ${formatBytes(m.memory_info.free)}\n` +
|
|
555
943
|
`Percent: ${round2(m.memory_info.percent)}%`;
|
|
556
944
|
pushPoint(memChart, num(m.memory_info.percent));
|
|
557
945
|
}
|
|
@@ -644,11 +1032,25 @@
|
|
|
644
1032
|
hideSpinner("certificates-table");
|
|
645
1033
|
}
|
|
646
1034
|
}
|
|
1035
|
+
|
|
1036
|
+
// When not in unified ("*") mode, ensure unified panel is hidden and charts cleared
|
|
1037
|
+
unifiedPanel.classList.add("hidden");
|
|
1038
|
+
unifiedNodes = [];
|
|
1039
|
+
Object.keys(unifiedCharts).forEach(key => {
|
|
1040
|
+
if (unifiedCharts[key]) {
|
|
1041
|
+
unifiedCharts[key].destroy();
|
|
1042
|
+
unifiedCharts[key] = null;
|
|
1043
|
+
}
|
|
1044
|
+
});
|
|
647
1045
|
}
|
|
648
1046
|
|
|
649
1047
|
// ------------------------------------------------------------
|
|
650
1048
|
// EVENT BINDINGS
|
|
651
1049
|
// ------------------------------------------------------------
|
|
1050
|
+
targets.push({
|
|
1051
|
+
base_url: "*",
|
|
1052
|
+
name: "*"
|
|
1053
|
+
});
|
|
652
1054
|
targets.forEach(t => {
|
|
653
1055
|
const opt = document.createElement("option");
|
|
654
1056
|
opt.value = t.base_url;
|
|
@@ -664,6 +1066,7 @@
|
|
|
664
1066
|
resetUI();
|
|
665
1067
|
resetTables();
|
|
666
1068
|
showAllSpinners();
|
|
1069
|
+
if (selectedBase !== "*") unifiedPanel.classList.add("hidden");
|
|
667
1070
|
ws.send(JSON.stringify({type: "select_target", base_url: selectedBase}));
|
|
668
1071
|
});
|
|
669
1072
|
|
|
@@ -201,6 +201,16 @@ html, body {
|
|
|
201
201
|
text-align: left;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
.table tbody tr.placeholder-row td {
|
|
205
|
+
color: transparent;
|
|
206
|
+
border-bottom: 1px solid transparent;
|
|
207
|
+
padding: 8px;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.table tbody tr.placeholder-row td::after {
|
|
211
|
+
content: "";
|
|
212
|
+
}
|
|
213
|
+
|
|
204
214
|
.pre {
|
|
205
215
|
background: rgba(255, 255, 255, 0.02);
|
|
206
216
|
padding: 8px;
|
|
@@ -265,6 +275,10 @@ input#proc-filter {
|
|
|
265
275
|
.meta-row {
|
|
266
276
|
grid-template-columns: 1fr;
|
|
267
277
|
}
|
|
278
|
+
|
|
279
|
+
.unified-grid {
|
|
280
|
+
grid-template-columns: 1fr;
|
|
281
|
+
}
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
/* ---------------- SPINNER ------------------- */
|
|
@@ -324,3 +338,137 @@ input#proc-filter {
|
|
|
324
338
|
margin-bottom: 4px;
|
|
325
339
|
opacity: 0.85;
|
|
326
340
|
}
|
|
341
|
+
|
|
342
|
+
.panel-helper {
|
|
343
|
+
margin: 0 0 8px;
|
|
344
|
+
font-size: 12px;
|
|
345
|
+
color: var(--muted);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.metric-switch {
|
|
349
|
+
display: inline-flex;
|
|
350
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
351
|
+
border-radius: 4px;
|
|
352
|
+
overflow: hidden;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.metric-btn {
|
|
356
|
+
background: transparent;
|
|
357
|
+
color: inherit;
|
|
358
|
+
border: none;
|
|
359
|
+
padding: 6px 12px;
|
|
360
|
+
font-size: 12px;
|
|
361
|
+
cursor: pointer;
|
|
362
|
+
transition: background 0.2s, color 0.2s;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.metric-btn:not(.active):hover {
|
|
366
|
+
background: rgba(255, 255, 255, 0.05);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.metric-btn.active {
|
|
370
|
+
background: var(--accent);
|
|
371
|
+
color: #020617;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.unified-header {
|
|
375
|
+
align-items: center;
|
|
376
|
+
justify-content: center;
|
|
377
|
+
gap: 12px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.unified-title-group {
|
|
381
|
+
display: flex;
|
|
382
|
+
flex-direction: column;
|
|
383
|
+
align-items: center;
|
|
384
|
+
justify-content: center;
|
|
385
|
+
text-align: center;
|
|
386
|
+
gap: 2px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.unified-indicator {
|
|
390
|
+
font-size: 12px;
|
|
391
|
+
text-transform: uppercase;
|
|
392
|
+
letter-spacing: 0.05em;
|
|
393
|
+
color: var(--muted);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.unified-legend {
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-wrap: wrap;
|
|
399
|
+
gap: 8px;
|
|
400
|
+
margin-top: 10px;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.unified-legend-item {
|
|
404
|
+
display: flex;
|
|
405
|
+
align-items: center;
|
|
406
|
+
gap: 6px;
|
|
407
|
+
padding: 4px 8px;
|
|
408
|
+
border-radius: 4px;
|
|
409
|
+
background: rgba(255, 255, 255, 0.05);
|
|
410
|
+
font-size: 12px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.legend-dot {
|
|
414
|
+
width: 10px;
|
|
415
|
+
height: 10px;
|
|
416
|
+
border-radius: 50%;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/* New unified-grid layout for three unified charts */
|
|
420
|
+
.unified-grid {
|
|
421
|
+
display: grid;
|
|
422
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
423
|
+
gap: 12px;
|
|
424
|
+
margin-top: 8px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.unified-metric-panel {
|
|
428
|
+
background: var(--panel);
|
|
429
|
+
border-radius: 8px;
|
|
430
|
+
padding: 8px 8px 4px;
|
|
431
|
+
box-shadow: 0 4px 12px rgba(2, 6, 23, 0.6);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.unified-metric-panel .panel-header h3 {
|
|
435
|
+
font-size: 13px;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.unified-tables {
|
|
439
|
+
margin-top: 12px;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.unified-tables-title {
|
|
443
|
+
margin: 0 0 8px;
|
|
444
|
+
font-size: 13px;
|
|
445
|
+
color: var(--muted);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.unified-tables-grid {
|
|
449
|
+
display: flex;
|
|
450
|
+
flex-direction: column;
|
|
451
|
+
gap: 12px;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.unified-table-panel {
|
|
455
|
+
display: flex;
|
|
456
|
+
flex-direction: column;
|
|
457
|
+
gap: 8px;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.unified-table-panel .table {
|
|
461
|
+
font-size: 12px;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
body.unified-mode .meta-row,
|
|
465
|
+
body.unified-mode .charts-row > :not(#unified-panel),
|
|
466
|
+
body.unified-mode .details-row,
|
|
467
|
+
body.unified-mode .tables-row,
|
|
468
|
+
body.unified-mode footer {
|
|
469
|
+
display: none !important;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
body.unified-mode #unified-panel {
|
|
473
|
+
flex: 1;
|
|
474
|
+
}
|
|
@@ -75,6 +75,78 @@
|
|
|
75
75
|
<div class="panel-header"><h3>Memory (%)</h3></div>
|
|
76
76
|
<canvas id="mem-chart" class="chart"></canvas>
|
|
77
77
|
</div>
|
|
78
|
+
|
|
79
|
+
<div class="panel hidden" id="unified-panel">
|
|
80
|
+
<div class="panel-header unified-header">
|
|
81
|
+
<div class="unified-title-group">
|
|
82
|
+
<h3>Aggregated Metrics</h3>
|
|
83
|
+
<span class="unified-indicator">Holistic node comparison</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
{# <p class="panel-helper">Select "All nodes" (*) to compare every host.</p> #}
|
|
87
|
+
<div class="unified-grid">
|
|
88
|
+
<div class="unified-metric-panel">
|
|
89
|
+
<div class="panel-header"><h3>Memory (%)</h3></div>
|
|
90
|
+
<canvas id="unified-mem-chart" class="chart"></canvas>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="unified-metric-panel">
|
|
93
|
+
<div class="panel-header"><h3>CPU Avg (%)</h3></div>
|
|
94
|
+
<canvas id="unified-cpu-chart" class="chart"></canvas>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="unified-metric-panel">
|
|
97
|
+
<div class="panel-header"><h3>Disk (%)</h3></div>
|
|
98
|
+
<canvas id="unified-disk-chart" class="chart"></canvas>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
{# TODO: Make the following tables into multiple div containers #}
|
|
102
|
+
<div id="unified-legend" class="unified-legend"></div>
|
|
103
|
+
<div class="unified-tables">
|
|
104
|
+
<div class="unified-tables-grid">
|
|
105
|
+
<div class="panel unified-table-panel">
|
|
106
|
+
<div class="panel-header"><h3>Services</h3></div>
|
|
107
|
+
<table class="table" id="unified-services-table">
|
|
108
|
+
<thead></thead>
|
|
109
|
+
<tbody></tbody>
|
|
110
|
+
</table>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="panel unified-table-panel">
|
|
113
|
+
<div class="panel-header"><h3>Processes</h3></div>
|
|
114
|
+
<table class="table" id="unified-processes-table">
|
|
115
|
+
<thead></thead>
|
|
116
|
+
<tbody></tbody>
|
|
117
|
+
</table>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="panel unified-table-panel">
|
|
120
|
+
<div class="panel-header"><h3>Docker Containers</h3></div>
|
|
121
|
+
<table class="table" id="unified-docker-table">
|
|
122
|
+
<thead></thead>
|
|
123
|
+
<tbody></tbody>
|
|
124
|
+
</table>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="panel unified-table-panel">
|
|
127
|
+
<div class="panel-header"><h3>Disks</h3></div>
|
|
128
|
+
<table class="table" id="unified-disks-table">
|
|
129
|
+
<thead></thead>
|
|
130
|
+
<tbody></tbody>
|
|
131
|
+
</table>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="panel unified-table-panel">
|
|
134
|
+
<div class="panel-header"><h3>PyUdisk Metrics</h3></div>
|
|
135
|
+
<table class="table" id="unified-pyudisk-table">
|
|
136
|
+
<thead></thead>
|
|
137
|
+
<tbody></tbody>
|
|
138
|
+
</table>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="panel unified-table-panel">
|
|
141
|
+
<div class="panel-header"><h3>Certificates</h3></div>
|
|
142
|
+
<table class="table" id="unified-certificates-table">
|
|
143
|
+
<thead></thead>
|
|
144
|
+
<tbody></tbody>
|
|
145
|
+
</table>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
78
150
|
</section>
|
|
79
151
|
|
|
80
152
|
<section class="details-row">
|
pyobservability/transport.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
|
+
from typing import Dict, List
|
|
4
5
|
|
|
5
6
|
from fastapi import WebSocket, WebSocketDisconnect
|
|
6
7
|
|
|
@@ -22,6 +23,99 @@ async def _forward_metrics(websocket: WebSocket, q: asyncio.Queue) -> None:
|
|
|
22
23
|
await websocket.send_json(payload)
|
|
23
24
|
|
|
24
25
|
|
|
26
|
+
def _normalize_targets() -> List[Dict[str, str]]:
|
|
27
|
+
"""Return configuration targets sorted so legend colors are consistent."""
|
|
28
|
+
return sorted(settings.env.targets, key=lambda t: t["name"].lower())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _forward_metrics_multi(
|
|
32
|
+
websocket: WebSocket,
|
|
33
|
+
queues: List[asyncio.Queue],
|
|
34
|
+
targets: List[Dict[str, str]],
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Fan-in metrics from multiple monitors and emit once every node updates.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
websocket: FastAPI WebSocket connection.
|
|
40
|
+
queues: List of asyncio.Queues from multiple monitors.
|
|
41
|
+
targets: List of target configurations corresponding to the queues.
|
|
42
|
+
|
|
43
|
+
Notes:
|
|
44
|
+
This is resilient to individual node failures.
|
|
45
|
+
If a monitor starts emitting an ``error`` payload (e.g. exceeded error threshold),
|
|
46
|
+
that node is marked as failed and removed from the unified stream,
|
|
47
|
+
so that the remaining nodes keep updating in the UI.
|
|
48
|
+
"""
|
|
49
|
+
# Track active (non-failed) indices
|
|
50
|
+
active = [True] * len(queues)
|
|
51
|
+
|
|
52
|
+
# Latest payload per node keyed by base_url
|
|
53
|
+
latest: Dict[str, dict] = {}
|
|
54
|
+
try:
|
|
55
|
+
while True:
|
|
56
|
+
# If all nodes have failed, stop the unified stream loop.
|
|
57
|
+
if not any(active):
|
|
58
|
+
LOGGER.info("All targets in unified stream have failed; stopping multi-node forwarder")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Wait for at least one payload from any active queue.
|
|
62
|
+
# We gather one payload per active queue in a "round", but we do
|
|
63
|
+
# not block forever on failed queues because we mark them inactive
|
|
64
|
+
# as soon as they send an error.
|
|
65
|
+
for idx, q in enumerate(queues):
|
|
66
|
+
if not active[idx]:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
payload = await q.get()
|
|
70
|
+
|
|
71
|
+
# Handle error payloads from monitors: mark this node as failed
|
|
72
|
+
# and notify the UI once, then skip it from future
|
|
73
|
+
# rounds so the rest of the nodes continue streaming.
|
|
74
|
+
if isinstance(payload, dict) and payload.get("type") == "error":
|
|
75
|
+
base_url = targets[idx]["base_url"]
|
|
76
|
+
LOGGER.warning("Unified stream: target %s reported error and will be skipped", base_url)
|
|
77
|
+
|
|
78
|
+
# Forward the error to the websocket so the UI can show it.
|
|
79
|
+
try:
|
|
80
|
+
await websocket.send_json(payload)
|
|
81
|
+
except Exception as send_err: # pragma: no cover - defensive
|
|
82
|
+
LOGGER.debug("Failed to send error payload to WS: %s", send_err)
|
|
83
|
+
|
|
84
|
+
# Mark this target as inactive and remove its latest sample
|
|
85
|
+
active[idx] = False
|
|
86
|
+
latest.pop(base_url, None)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
# Normal metrics payload: remember latest per base_url
|
|
90
|
+
base_url = targets[idx]["base_url"]
|
|
91
|
+
latest[base_url] = payload
|
|
92
|
+
|
|
93
|
+
# Build merged message from all currently active targets that have
|
|
94
|
+
# produced at least one metrics payload.
|
|
95
|
+
merged = {
|
|
96
|
+
"type": "metrics",
|
|
97
|
+
"ts": asyncio.get_event_loop().time(),
|
|
98
|
+
"data": [],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for idx, target in enumerate(targets):
|
|
102
|
+
if not active[idx]:
|
|
103
|
+
continue
|
|
104
|
+
sample = latest.get(target["base_url"])
|
|
105
|
+
if not sample:
|
|
106
|
+
continue
|
|
107
|
+
merged["data"].extend(sample.get("data", []))
|
|
108
|
+
|
|
109
|
+
# If we have no data (e.g. new round before any active node
|
|
110
|
+
# produced metrics), skip sending to avoid spamming empty payloads.
|
|
111
|
+
if not merged["data"]:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
await websocket.send_json(merged)
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
LOGGER.debug("Unified stream task cancelled")
|
|
117
|
+
|
|
118
|
+
|
|
25
119
|
async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
26
120
|
"""Websocket endpoint to handle observability data streaming.
|
|
27
121
|
|
|
@@ -32,6 +126,10 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
32
126
|
|
|
33
127
|
monitor: Monitor | None = None
|
|
34
128
|
q: asyncio.Queue | None = None
|
|
129
|
+
monitors: List[Monitor] = []
|
|
130
|
+
queues: List[asyncio.Queue] = []
|
|
131
|
+
multi_task: asyncio.Task | None = None
|
|
132
|
+
forward_task: asyncio.Task | None = None
|
|
35
133
|
|
|
36
134
|
try:
|
|
37
135
|
while True:
|
|
@@ -42,6 +140,15 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
42
140
|
await monitor.update_flags(
|
|
43
141
|
all_services=data.get("all_services", False),
|
|
44
142
|
)
|
|
143
|
+
elif monitors:
|
|
144
|
+
await asyncio.gather(
|
|
145
|
+
*(
|
|
146
|
+
mon.update_flags(
|
|
147
|
+
all_services=data.get("all_services", False),
|
|
148
|
+
)
|
|
149
|
+
for mon in monitors
|
|
150
|
+
)
|
|
151
|
+
)
|
|
45
152
|
continue
|
|
46
153
|
|
|
47
154
|
# -------------------------------------------
|
|
@@ -50,10 +157,42 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
50
157
|
if data.get("type") == "select_target":
|
|
51
158
|
base_url = data["base_url"]
|
|
52
159
|
|
|
53
|
-
# stop old monitor
|
|
160
|
+
# stop old monitor / tasks
|
|
161
|
+
if forward_task:
|
|
162
|
+
LOGGER.info("Stopping previous forwarder task")
|
|
163
|
+
forward_task.cancel()
|
|
164
|
+
forward_task = None
|
|
165
|
+
|
|
54
166
|
if monitor:
|
|
167
|
+
LOGGER.info("Stopping previous monitor task")
|
|
55
168
|
monitor.unsubscribe(q)
|
|
56
169
|
await monitor.stop()
|
|
170
|
+
monitor = None
|
|
171
|
+
q = None
|
|
172
|
+
|
|
173
|
+
if multi_task:
|
|
174
|
+
LOGGER.info("Stopping previous multi-target forwarder task")
|
|
175
|
+
multi_task.cancel()
|
|
176
|
+
multi_task = None
|
|
177
|
+
|
|
178
|
+
LOGGER.info("Unsubscribing from previous monitors") if monitor else None
|
|
179
|
+
for idx, mon in enumerate(monitors):
|
|
180
|
+
mon.unsubscribe(queues[idx])
|
|
181
|
+
await mon.stop()
|
|
182
|
+
|
|
183
|
+
monitors.clear()
|
|
184
|
+
queues.clear()
|
|
185
|
+
|
|
186
|
+
if base_url == "*":
|
|
187
|
+
LOGGER.info("Gathering metrics for all targets in unified stream")
|
|
188
|
+
targets = _normalize_targets()
|
|
189
|
+
for target in targets:
|
|
190
|
+
mon = Monitor(target)
|
|
191
|
+
await mon.start()
|
|
192
|
+
monitors.append(mon)
|
|
193
|
+
queues.append(mon.subscribe())
|
|
194
|
+
multi_task = asyncio.create_task(_forward_metrics_multi(websocket, queues, targets))
|
|
195
|
+
continue
|
|
57
196
|
|
|
58
197
|
if target := settings.targets_by_url.get(base_url):
|
|
59
198
|
LOGGER.info("Gathering metrics for: %s", target["name"])
|
|
@@ -69,14 +208,24 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
|
|
|
69
208
|
q = monitor.subscribe()
|
|
70
209
|
|
|
71
210
|
# start forwarding metrics
|
|
72
|
-
asyncio.create_task(_forward_metrics(websocket, q))
|
|
211
|
+
forward_task = asyncio.create_task(_forward_metrics(websocket, q))
|
|
73
212
|
except WebSocketDisconnect:
|
|
74
213
|
pass
|
|
75
214
|
except Exception as err:
|
|
76
215
|
LOGGER.error("WS error: %s", err)
|
|
77
216
|
|
|
78
217
|
# cleanup
|
|
218
|
+
if forward_task:
|
|
219
|
+
forward_task.cancel()
|
|
220
|
+
|
|
79
221
|
if monitor:
|
|
80
222
|
if q:
|
|
81
223
|
monitor.unsubscribe(q)
|
|
82
224
|
await monitor.stop()
|
|
225
|
+
|
|
226
|
+
if multi_task:
|
|
227
|
+
multi_task.cancel()
|
|
228
|
+
|
|
229
|
+
for idx, mon in enumerate(monitors):
|
|
230
|
+
mon.unsubscribe(queues[idx])
|
|
231
|
+
await mon.stop()
|
pyobservability/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.
|
|
1
|
+
__version__ = "1.4.1"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pyobservability/__init__.py,sha256=yVBLyTohBiBKp0Otyl04IggPh8mhg3Er25u6eFyxMto,2618
|
|
2
|
+
pyobservability/main.py,sha256=EJ49ENDnKy07jKHQ0IYEVmsyc_4O3uxCHpn1faQD5VM,4534
|
|
3
|
+
pyobservability/monitor.py,sha256=i_Xf_DB-qLOp1b9wryekjwHIM8AnMrGTkuEg7e08bcM,7539
|
|
4
|
+
pyobservability/transport.py,sha256=S-84mgf-9yMj0H7VSAmueW9yosX_1XxdyNJC2EuQHQQ,8493
|
|
5
|
+
pyobservability/version.py,sha256=BqOI5y46o1G1RWC9bF1DPL-YM68lGYPmZt1pn6FZFZs,22
|
|
6
|
+
pyobservability/config/enums.py,sha256=EhvD9kB5EMW3ARxr5KmISmf-rP3D4IKqOIjw6Tb8SB8,294
|
|
7
|
+
pyobservability/config/settings.py,sha256=ylhiT0SARHuzkyon_1otgsO74AfA6aiUKp5uczZQj08,5980
|
|
8
|
+
pyobservability/static/app.js,sha256=l6m8mwEU4z5d2wQF7Fa6tFITW3HX3M5RaLhBJ0jFXrM,43322
|
|
9
|
+
pyobservability/static/styles.css,sha256=DRJ4kw-LDlXMcQXxFd8cEDuDC_ZfwZgARAjn0zDWwRk,8172
|
|
10
|
+
pyobservability/templates/index.html,sha256=Z_r1Gq0QNxEwTL4_2NPQ1cKqLpbQoJC88E5leyjo07s,10786
|
|
11
|
+
pyobservability-1.4.1.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
|
|
12
|
+
pyobservability-1.4.1.dist-info/METADATA,sha256=QUIDftx5D229DYD03NsspRYP2Imp0u_rVVlzW4-UPHo,6537
|
|
13
|
+
pyobservability-1.4.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
pyobservability-1.4.1.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
|
|
15
|
+
pyobservability-1.4.1.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
|
|
16
|
+
pyobservability-1.4.1.dist-info/RECORD,,
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
pyobservability/__init__.py,sha256=yVBLyTohBiBKp0Otyl04IggPh8mhg3Er25u6eFyxMto,2618
|
|
2
|
-
pyobservability/main.py,sha256=6Ozyxi-XVq9ojkrTEjw9oYWJaW49JJZ-ezBNzEM951Y,4503
|
|
3
|
-
pyobservability/monitor.py,sha256=Y38zDJFPDbQqz_2jQcNmJBnRpeobC_SV2uhN-13e9RU,7591
|
|
4
|
-
pyobservability/transport.py,sha256=zHLAodX20bKkPOuacKjzv1Dqj3JbNAB75o1ABwzum0U,2534
|
|
5
|
-
pyobservability/version.py,sha256=F5mW07pSyGrqDNY2Ehr-UpDzpBtN-FsYU0QGZWf6PJE,22
|
|
6
|
-
pyobservability/config/enums.py,sha256=EhvD9kB5EMW3ARxr5KmISmf-rP3D4IKqOIjw6Tb8SB8,294
|
|
7
|
-
pyobservability/config/settings.py,sha256=l1xtD8teYS5ozXWm6JqpyyrcQsu95Syjs02xsd0m6MI,5886
|
|
8
|
-
pyobservability/static/app.js,sha256=ngeSsp8mCkF6XqZpXhwqv-EtNI8NmrMYxNgQtaKB4WE,26675
|
|
9
|
-
pyobservability/static/styles.css,sha256=4-VCDhzv_FnrvUffETTHkNnyGOFDUJU0qz2eLHCz_QI,5569
|
|
10
|
-
pyobservability/templates/index.html,sha256=OvFDydHMquBtFr_2cy3iXP0I-pax6_RKBJinVVEIzQY,7193
|
|
11
|
-
pyobservability-1.3.0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
|
|
12
|
-
pyobservability-1.3.0.dist-info/METADATA,sha256=FJSF4L10qUjlQMYDKxJmuHrRkWmSLmOConJB5MKufOY,6537
|
|
13
|
-
pyobservability-1.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
-
pyobservability-1.3.0.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
|
|
15
|
-
pyobservability-1.3.0.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
|
|
16
|
-
pyobservability-1.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|