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.
@@ -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
- dict:
52
+ Dict[str, str]:
53
53
  Health status.
54
54
  """
55
55
  return {"status": "ok"}
@@ -174,7 +174,7 @@ class Monitor:
174
174
  "ts": asyncio.get_event_loop().time(),
175
175
  "data": [
176
176
  {
177
- "name": self.base_url,
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.with_traceback())
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(result)
211
+ q.put_nowait(error_msg)
213
212
  await self.stop()
214
213
  return
215
214
  else:
@@ -5,7 +5,7 @@
5
5
  // ------------------------------------------------------------
6
6
  const MAX_POINTS = 60;
7
7
  const targets = window.MONITOR_TARGETS || [];
8
- const DEFAULT_PAGE_SIZE = 15;
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 = "&nbsp;";
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(latest) {
746
+ function pruneOldCores(keep) {
402
747
  for (const name of Object.keys(coreMini)) {
403
- if (!latest.includes(name)) {
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 d = m.disk_info[0];
930
+ const agg = aggregateDiskInfo(m.disk_info);
545
931
  diskEl.textContent =
546
- `Total: ${formatBytes(d.total)}\n` +
547
- `Used: ${formatBytes(d.used)}\n` +
548
- `Free: ${formatBytes(d.free)}`;
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">
@@ -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()
@@ -1 +1 @@
1
- __version__ = "1.3.0"
1
+ __version__ = "1.4.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyObservability
3
- Version: 1.3.0
3
+ Version: 1.4.1
4
4
  Summary: Lightweight OS-agnostic observability UI for PyNinja
5
5
  Author-email: Vignesh Rao <svignesh1793@gmail.com>
6
6
  License: MIT License
@@ -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,,