PyObservability 1.4.0__py3-none-any.whl → 2.0.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.
@@ -9,6 +9,7 @@ class APIEndpoints(StrEnum):
9
9
  """
10
10
 
11
11
  health = "/health"
12
+ kuma = "/kuma"
12
13
  root = "/"
13
14
  ws = "/ws"
14
15
 
@@ -132,6 +132,11 @@ class EnvConfig(PydanticEnvConfig):
132
132
  password: str | None = Field(None, validation_alias=alias_choices("PASSWORD"))
133
133
  timeout: PositiveInt = Field(300, validation_alias=alias_choices("TIMEOUT"))
134
134
 
135
+ kuma_url: str | None = None
136
+ kuma_username: str | None = None
137
+ kuma_password: str | None = None
138
+ kuma_timeout: PositiveInt = 5
139
+
135
140
  class Config:
136
141
  """Environment variables configuration."""
137
142
 
@@ -0,0 +1,126 @@
1
+ import logging
2
+ import time
3
+ from collections import defaultdict
4
+ from typing import Any, Dict, List
5
+ from urllib.parse import urlparse
6
+
7
+ import socketio
8
+
9
+ from pyobservability.config import settings
10
+
11
+ LOGGER = logging.getLogger("uvicorn.default")
12
+
13
+
14
+ class UptimeKumaClient:
15
+ """Client to interact with Uptime Kuma server via Socket.IO.
16
+
17
+ >>> UptimeKumaClient
18
+
19
+ """
20
+
21
+ def __init__(self):
22
+ """Initialize the Uptime Kuma client."""
23
+ self.sio = socketio.Client()
24
+ self.monitors = {}
25
+ self.logged_in = False
26
+
27
+ self.sio.on("monitorList", self._on_monitor_list)
28
+
29
+ def _on_monitor_list(self, data):
30
+ """Handle incoming monitor list from Uptime Kuma server."""
31
+ LOGGER.debug("Received monitor list from Uptime Kuma server.")
32
+ self.monitors = data
33
+
34
+ def connect(self):
35
+ """Connect to the Uptime Kuma server via Socket.IO."""
36
+ LOGGER.debug("Connecting to Uptime Kuma server at %s", settings.env.kuma_url)
37
+ self.sio.connect(settings.env.kuma_url)
38
+
39
+ def login(self):
40
+ """Log in to the Uptime Kuma server."""
41
+ result = {"ok": False}
42
+
43
+ def callback(resp):
44
+ """Callback to handle login response."""
45
+ result.update(resp or {"ok": False})
46
+
47
+ self.sio.emit(
48
+ "login",
49
+ {
50
+ "username": settings.env.kuma_username,
51
+ "password": settings.env.kuma_password,
52
+ "token": "",
53
+ },
54
+ callback=callback,
55
+ )
56
+
57
+ end = time.time() + settings.env.kuma_timeout
58
+ while not result.get("ok") and time.time() < end:
59
+ time.sleep(0.05)
60
+
61
+ if not result.get("ok"):
62
+ raise RuntimeError("Uptime Kuma login failed")
63
+
64
+ self.logged_in = True
65
+
66
+ def get_monitors(self):
67
+ """Retrieve monitors from the Uptime Kuma server."""
68
+ if not self.sio.connected:
69
+ self.connect()
70
+
71
+ if not self.logged_in:
72
+ self.login()
73
+
74
+ end = time.time() + settings.env.kuma_timeout
75
+ while not self.monitors and time.time() < end:
76
+ time.sleep(0.05)
77
+
78
+ if not self.monitors:
79
+ raise RuntimeError("No monitors received")
80
+
81
+ return self.monitors
82
+
83
+
84
+ async def extract_monitors(payload: Dict[int, Dict[str, Any]]) -> List[Dict[str, Any]]:
85
+ """Convert raw API payload into a list of dicts with name, url, tag_names, host.
86
+
87
+ Args:
88
+ payload: Raw payload from Uptime Kuma server.
89
+
90
+ Returns:
91
+ List[Dict[str, Any]]:
92
+ List of monitors with relevant fields.
93
+ """
94
+ monitors = []
95
+
96
+ grouped = {}
97
+ for monitor in payload.values():
98
+ if children_ids := monitor.get("childrenIDs"):
99
+ for child in children_ids:
100
+ grouped[child] = monitor.get("name")
101
+
102
+ for monitor in payload.values():
103
+ url = monitor.get("url").replace("host.docker.internal", urlparse(settings.env.kuma_url).hostname)
104
+ host = urlparse(url).hostname if url else None
105
+ if not host:
106
+ continue
107
+ monitors.append(
108
+ {
109
+ "name": monitor.get("name"),
110
+ "parent": grouped.get(monitor.get("id")),
111
+ "url": url,
112
+ "host": host,
113
+ "tag_names": [tag.get("name") for tag in monitor.get("tags", []) if "name" in tag],
114
+ }
115
+ )
116
+ return monitors
117
+
118
+
119
+ async def group_by_host(monitors: List[Dict[str, Any]]) -> Dict[str, Any]:
120
+ """Group monitors by host."""
121
+ grouped = defaultdict(list)
122
+
123
+ for monitor in monitors:
124
+ grouped[monitor["host"]].append(monitor)
125
+
126
+ return dict(grouped)
pyobservability/main.py CHANGED
@@ -2,16 +2,18 @@ import logging
2
2
  import pathlib
3
3
  import warnings
4
4
  from datetime import datetime
5
- from typing import Any, Dict
5
+ from http import HTTPStatus
6
+ from typing import Dict
6
7
 
7
8
  import uiauth
8
9
  import uvicorn
9
- from fastapi import FastAPI, Request
10
+ from fastapi import FastAPI, HTTPException, Request
10
11
  from fastapi.routing import APIRoute, APIWebSocketRoute
11
12
  from fastapi.staticfiles import StaticFiles
12
13
  from fastapi.templating import Jinja2Templates
13
14
 
14
15
  from pyobservability.config import enums, settings
16
+ from pyobservability.kuma import UptimeKumaClient, extract_monitors, group_by_host
15
17
  from pyobservability.transport import websocket_endpoint
16
18
  from pyobservability.version import __version__
17
19
 
@@ -39,12 +41,33 @@ async def index(request: Request):
39
41
  TemplateResponse:
40
42
  Rendered HTML template with targets and version.
41
43
  """
42
- args: Dict[str, Any] = dict(request=request, targets=settings.env.targets, version=__version__)
44
+ kuma_data = {} if all((settings.env.kuma_url, settings.env.kuma_username, settings.env.kuma_password)) else None
45
+ args = dict(request=request, service_map=kuma_data, targets=settings.env.targets, version=__version__)
43
46
  if settings.env.username and settings.env.password:
44
47
  args["logout"] = uiauth.enums.APIEndpoints.fastapi_logout.value
45
48
  return templates.TemplateResponse("index.html", args)
46
49
 
47
50
 
51
+ async def kuma():
52
+ """Kuma endpoint to retrieve monitors from Kuma server.
53
+
54
+ Returns:
55
+ Dict[str, Any]:
56
+ Grouped monitors by host from Kuma server.
57
+ """
58
+ try:
59
+ kuma_data = UptimeKumaClient().get_monitors()
60
+ LOGGER.info("Retrieved payload from kuma server.")
61
+ except RuntimeError:
62
+ raise HTTPException(
63
+ status_code=HTTPStatus.SERVICE_UNAVAILABLE.real,
64
+ detail="Unable to retrieve data from kuma server.",
65
+ )
66
+ json_monitors = await extract_monitors(kuma_data)
67
+ LOGGER.info("Extracted JSON monitors from kuma payload.")
68
+ return await group_by_host(json_monitors)
69
+
70
+
48
71
  async def health() -> Dict[str, str]:
49
72
  """Health check endpoint.
50
73
 
@@ -65,25 +88,35 @@ def include_routes() -> None:
65
88
  include_in_schema=False,
66
89
  ),
67
90
  )
91
+ kuma_enabled = all((settings.env.kuma_url, settings.env.kuma_username, settings.env.kuma_password))
68
92
  if all((settings.env.username, settings.env.password)):
93
+ auth_endpoints = [
94
+ uiauth.Parameters(
95
+ path=enums.APIEndpoints.root,
96
+ function=index,
97
+ methods=[uiauth.enums.APIMethods.GET],
98
+ ),
99
+ uiauth.Parameters(
100
+ path=enums.APIEndpoints.ws,
101
+ function=websocket_endpoint,
102
+ route=APIWebSocketRoute,
103
+ ),
104
+ ]
105
+ if kuma_enabled:
106
+ auth_endpoints.append(
107
+ uiauth.Parameters(
108
+ path=enums.APIEndpoints.kuma,
109
+ function=kuma,
110
+ methods=[uiauth.enums.APIMethods.GET],
111
+ )
112
+ )
69
113
  uiauth.protect(
70
114
  app=PyObservability,
71
115
  username=settings.env.username,
72
116
  password=settings.env.password,
73
117
  timeout=settings.env.timeout,
74
118
  custom_logger=LOGGER,
75
- params=[
76
- uiauth.Parameters(
77
- path=enums.APIEndpoints.root,
78
- function=index,
79
- methods=[uiauth.enums.APIMethods.GET],
80
- ),
81
- uiauth.Parameters(
82
- path=enums.APIEndpoints.ws,
83
- function=websocket_endpoint,
84
- route=APIWebSocketRoute,
85
- ),
86
- ],
119
+ params=auth_endpoints,
87
120
  )
88
121
  else:
89
122
  warnings.warn("\n\tRunning PyObservability without any protection.", UserWarning)
@@ -99,10 +132,20 @@ def include_routes() -> None:
99
132
  APIWebSocketRoute(
100
133
  path=enums.APIEndpoints.ws,
101
134
  endpoint=websocket_endpoint,
102
- )
135
+ ),
103
136
  )
137
+ if kuma_enabled:
138
+ PyObservability.routes.append(
139
+ APIRoute(
140
+ path=enums.APIEndpoints.kuma,
141
+ endpoint=kuma,
142
+ methods=["GET"],
143
+ include_in_schema=False,
144
+ ),
145
+ )
104
146
 
105
147
 
148
+ # noinspection PyTypeChecker
106
149
  def start(**kwargs) -> None:
107
150
  """Start the FastAPI app with Uvicorn server."""
108
151
  settings.env = settings.env_loader(**kwargs)
@@ -5,9 +5,15 @@
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
+ // Tab management
12
+ let currentTab = 'nodes';
13
+ let ws = null;
14
+ let kumaMapData = null;
15
+ let kumaMapLoaded = false;
16
+
11
17
  // ------------------------------------------------------------
12
18
  // VISUAL SPINNERS
13
19
  // ------------------------------------------------------------
@@ -88,6 +94,10 @@
88
94
  const certsTableHead = certsTable.querySelector("thead");
89
95
  const certsTableBody = certsTable.querySelector("tbody");
90
96
 
97
+ const endpointsTable = document.getElementById("endpoints-table");
98
+ const endpointsTableHead = endpointsTable.querySelector("thead");
99
+ const endpointsTableBody = endpointsTable.querySelector("tbody");
100
+
91
101
  const showCoresCheckbox = document.getElementById("show-cores");
92
102
 
93
103
  // ------------------------------------------------------------
@@ -117,11 +127,26 @@
117
127
  const chunk = rows.slice(start, start + state.pageSize);
118
128
 
119
129
  info.textContent =
120
- `Showing ${start + 1} to ${Math.min(start + state.pageSize, rows.length)} of ${rows.length} entries`;
130
+ `Showing ${rows.length ? start + 1 : 0} to ${rows.length ? Math.min(start + state.pageSize, rows.length) : 0} of ${rows.length} entries`;
121
131
 
122
132
  bodyEl.innerHTML = "";
123
133
  chunk.forEach(r => bodyEl.insertAdjacentHTML("beforeend", r));
124
134
 
135
+ const fillerCount = Math.max(0, state.pageSize - chunk.length);
136
+ const shouldPad = state.page > 1 && fillerCount > 0;
137
+ if (shouldPad) {
138
+ const colCount = state.columns?.length || headEl.querySelectorAll("th").length || 1;
139
+ for (let i = 0; i < fillerCount; i++) {
140
+ const fillerRow = document.createElement("tr");
141
+ fillerRow.className = "placeholder-row";
142
+ for (let c = 0; c < colCount; c++) {
143
+ const cell = document.createElement("td");
144
+ cell.innerHTML = "&nbsp;";
145
+ fillerRow.appendChild(cell);
146
+ }
147
+ bodyEl.appendChild(fillerRow);
148
+ }
149
+ }
125
150
  renderPagination(pages);
126
151
  }
127
152
 
@@ -305,6 +330,11 @@
305
330
  const PAG_CERTS = createPaginatedTable(
306
331
  certsTable, certsTableHead, certsTableBody
307
332
  );
333
+ const PAG_ENDPOINTS = createPaginatedTable(
334
+ endpointsTable,
335
+ endpointsTableHead,
336
+ endpointsTableBody
337
+ );
308
338
 
309
339
  // ------------------------------------------------------------
310
340
  // CHART HELPERS
@@ -336,6 +366,32 @@
336
366
  });
337
367
  }
338
368
 
369
+ function normalizeServiceMap(serviceMap) {
370
+ const rows = [];
371
+ Object.entries(serviceMap || {}).forEach(([host, services]) => {
372
+ services.forEach(svc => {
373
+ rows.push({
374
+ Node: host,
375
+ Name: svc.name || "",
376
+ Parent: svc.parent || "—",
377
+ Tags: (svc.tag_names || []).join(", "),
378
+ URL: `<a href="${svc.url}" target="_blank">${svc.url}</a>`
379
+ });
380
+ });
381
+ });
382
+ return rows;
383
+ }
384
+
385
+ function renderEndpoints() {
386
+ if (!window.SERVICE_MAP) return;
387
+
388
+ const rows = normalizeServiceMap(window.SERVICE_MAP);
389
+ const columns = ["Node", "Name", "Parent", "Tags", "URL"];
390
+
391
+ PAG_ENDPOINTS.setData(rows, columns);
392
+ hideSpinner("endpoints-table");
393
+ }
394
+
339
395
  function makeCoreSparkline(ctx, coreName) {
340
396
  const EMPTY_LABELS = Array(MAX_POINTS).fill("");
341
397
  const EMPTY_DATA = Array(MAX_POINTS).fill(null);
@@ -377,8 +433,62 @@
377
433
  const unifiedCpuCtx = document.getElementById("unified-cpu-chart").getContext("2d");
378
434
  const unifiedDiskCtx = document.getElementById("unified-disk-chart").getContext("2d");
379
435
 
436
+ // Unified tables DOM references
437
+ const unifiedServicesTable = document.getElementById("unified-services-table");
438
+ const unifiedServicesHead = unifiedServicesTable?.querySelector("thead");
439
+ const unifiedServicesBody = unifiedServicesTable?.querySelector("tbody");
440
+
441
+ const unifiedProcessesTable = document.getElementById("unified-processes-table");
442
+ const unifiedProcessesHead = unifiedProcessesTable?.querySelector("thead");
443
+ const unifiedProcessesBody = unifiedProcessesTable?.querySelector("tbody");
444
+
445
+ const unifiedDockerTable = document.getElementById("unified-docker-table");
446
+ const unifiedDockerHead = unifiedDockerTable?.querySelector("thead");
447
+ const unifiedDockerBody = unifiedDockerTable?.querySelector("tbody");
448
+
449
+ const unifiedDisksTable = document.getElementById("unified-disks-table");
450
+ const unifiedDisksHead = unifiedDisksTable?.querySelector("thead");
451
+ const unifiedDisksBody = unifiedDisksTable?.querySelector("tbody");
452
+
453
+ const unifiedPyudiskTable = document.getElementById("unified-pyudisk-table");
454
+ const unifiedPyudiskHead = unifiedPyudiskTable?.querySelector("thead");
455
+ const unifiedPyudiskBody = unifiedPyudiskTable?.querySelector("tbody");
456
+
457
+ const unifiedCertsTable = document.getElementById("unified-certificates-table");
458
+ const unifiedCertsHead = unifiedCertsTable?.querySelector("thead");
459
+ const unifiedCertsBody = unifiedCertsTable?.querySelector("tbody");
460
+
461
+ // Paginated unified tables
462
+ const PAG_UNIFIED_SERVICES = unifiedServicesTable && createPaginatedTable(
463
+ unifiedServicesTable, unifiedServicesHead, unifiedServicesBody
464
+ );
465
+ const PAG_UNIFIED_PROCESSES = unifiedProcessesTable && createPaginatedTable(
466
+ unifiedProcessesTable, unifiedProcessesHead, unifiedProcessesBody
467
+ );
468
+ const PAG_UNIFIED_DOCKER = unifiedDockerTable && createPaginatedTable(
469
+ unifiedDockerTable, unifiedDockerHead, unifiedDockerBody
470
+ );
471
+ const PAG_UNIFIED_DISKS = unifiedDisksTable && createPaginatedTable(
472
+ unifiedDisksTable, unifiedDisksHead, unifiedDisksBody
473
+ );
474
+ const PAG_UNIFIED_PYUDISK = unifiedPyudiskTable && createPaginatedTable(
475
+ unifiedPyudiskTable, unifiedPyudiskHead, unifiedPyudiskBody
476
+ );
477
+ const PAG_UNIFIED_CERTS = unifiedCertsTable && createPaginatedTable(
478
+ unifiedCertsTable, unifiedCertsHead, unifiedCertsBody
479
+ );
480
+
380
481
  let unifiedNodes = [];
381
- const colorPalette = ["#63b3ff", "#ff99c8", "#7dd3fc", "#fbbf24", "#a3e635", "#f87171", "#c084fc", "#38bdf8"];
482
+ const colorPalette = [
483
+ "#ff0000",
484
+ "#ffff00",
485
+ "#00ff00",
486
+ "#0066ff",
487
+ "#b300ff",
488
+ "#ff7f00",
489
+ "#8b4513",
490
+ "#7f7f7f"
491
+ ];
382
492
  const nodeColor = {};
383
493
  const unifiedCharts = {memory: null, cpu: null, disk: null};
384
494
 
@@ -511,6 +621,147 @@
511
621
 
512
622
  chart.update("none");
513
623
  });
624
+
625
+ // --- Unified tables aggregation ---
626
+ // Helper to get display name for node
627
+ const getNodeLabel = (host) => host.name || host.base_url || "";
628
+
629
+ // Services
630
+ if (PAG_UNIFIED_SERVICES) {
631
+ const svcRows = [];
632
+ metrics.forEach(host => {
633
+ if (!host.metrics) return;
634
+ const m = host.metrics;
635
+ const label = getNodeLabel(host);
636
+ const services = (m.service_stats || m.services || []).filter(s =>
637
+ (s.pname || s.Name || "").toLowerCase().includes(
638
+ svcFilter.value.trim().toLowerCase()
639
+ )
640
+ );
641
+ services.forEach(s => {
642
+ svcRows.push({
643
+ Node: label,
644
+ PID: s.PID ?? s.pid ?? "",
645
+ Name: s.pname ?? s.Name ?? s.name ?? "",
646
+ Status: s.Status ?? s.active ?? s.status ?? s.Active ?? "4",
647
+ CPU: objectToString(s.CPU, s.cpu),
648
+ Memory: objectToString(s.Memory, s.memory),
649
+ Threads: s.Threads ?? s.threads ?? "4",
650
+ "Open Files": s["Open Files"] ?? s.open_files ?? "4"
651
+ });
652
+ });
653
+ });
654
+ const svcCols = ["Node", "PID", "Name", "Status", "CPU", "Memory", "Threads", "Open Files"];
655
+ PAG_UNIFIED_SERVICES.setData(svcRows, svcCols);
656
+ }
657
+
658
+ // Processes
659
+ if (PAG_UNIFIED_PROCESSES) {
660
+ const procRows = [];
661
+ const procColsSet = new Set(["Node", "PID", "Name", "Status", "CPU", "Memory", "Uptime", "Threads", "Open Files"]);
662
+ metrics.forEach(host => {
663
+ if (!host.metrics) return;
664
+ const m = host.metrics;
665
+ const label = getNodeLabel(host);
666
+ const processes = (m.process_stats || []).filter(p =>
667
+ (p.Name || "").toLowerCase().includes(
668
+ procFilter.value.trim().toLowerCase()
669
+ )
670
+ );
671
+ processes.forEach(p => {
672
+ const row = {Node: label};
673
+ Object.entries(p).forEach(([k, v]) => {
674
+ procColsSet.add(k);
675
+ row[k] = v;
676
+ });
677
+ procRows.push(row);
678
+ });
679
+ });
680
+ const procCols = Array.from(procColsSet);
681
+ PAG_UNIFIED_PROCESSES.setData(procRows, procCols);
682
+ }
683
+
684
+ // Docker
685
+ if (PAG_UNIFIED_DOCKER) {
686
+ const dockerRows = [];
687
+ const dockerColsSet = new Set(["Node"]);
688
+ metrics.forEach(host => {
689
+ if (!host.metrics || !Array.isArray(host.metrics.docker_stats)) return;
690
+ const label = getNodeLabel(host);
691
+ host.metrics.docker_stats.forEach(s => {
692
+ const row = {Node: label};
693
+ Object.entries(s).forEach(([k, v]) => {
694
+ dockerColsSet.add(k);
695
+ row[k] = v;
696
+ });
697
+ dockerRows.push(row);
698
+ });
699
+ });
700
+ const dockerCols = Array.from(dockerColsSet);
701
+ PAG_UNIFIED_DOCKER.setData(dockerRows, dockerCols);
702
+ }
703
+
704
+ // Disks
705
+ if (PAG_UNIFIED_DISKS) {
706
+ const diskRows = [];
707
+ const diskColsSet = new Set(["Node"]);
708
+ metrics.forEach(host => {
709
+ if (!host.metrics || !Array.isArray(host.metrics.disks_info)) return;
710
+ const label = getNodeLabel(host);
711
+ host.metrics.disks_info.forEach(d => {
712
+ const row = {Node: label};
713
+ Object.entries(d).forEach(([k, v]) => {
714
+ if (k === "Node") return;
715
+ diskColsSet.add(k);
716
+ row[k] = v;
717
+ });
718
+ diskRows.push(row);
719
+ });
720
+ });
721
+ const diskCols = Array.from(diskColsSet);
722
+ PAG_UNIFIED_DISKS.setData(diskRows, diskCols);
723
+ }
724
+
725
+ // PyUdisk
726
+ if (PAG_UNIFIED_PYUDISK) {
727
+ const pyuRows = [];
728
+ const pyuColsSet = new Set(["Node"]);
729
+ metrics.forEach(host => {
730
+ if (!host.metrics || !Array.isArray(host.metrics.pyudisk_stats)) return;
731
+ const label = getNodeLabel(host);
732
+ host.metrics.pyudisk_stats.forEach(pyu => {
733
+ const row = {Node: label};
734
+ Object.entries(pyu).forEach(([k, v]) => {
735
+ if (k === "Mountpoint") return;
736
+ pyuColsSet.add(k);
737
+ row[k] = v;
738
+ });
739
+ pyuRows.push(row);
740
+ });
741
+ });
742
+ const pyuCols = Array.from(pyuColsSet);
743
+ PAG_UNIFIED_PYUDISK.setData(pyuRows, pyuCols);
744
+ }
745
+
746
+ // Certificates
747
+ if (PAG_UNIFIED_CERTS) {
748
+ const certRows = [];
749
+ const certColsSet = new Set(["Node"]);
750
+ metrics.forEach(host => {
751
+ if (!host.metrics || !Array.isArray(host.metrics.certificates)) return;
752
+ const label = getNodeLabel(host);
753
+ host.metrics.certificates.forEach(c => {
754
+ const row = {Node: label};
755
+ Object.entries(c).forEach(([k, v]) => {
756
+ certColsSet.add(k);
757
+ row[k] = v;
758
+ });
759
+ certRows.push(row);
760
+ });
761
+ });
762
+ const certCols = Array.from(certColsSet);
763
+ PAG_UNIFIED_CERTS.setData(certRows, certCols);
764
+ }
514
765
  }
515
766
 
516
767
  // ------------------------------------------------------------
@@ -565,6 +816,12 @@
565
816
  PAG_DISKS.setData([], []);
566
817
  PAG_PYUDISK.setData([], []);
567
818
  PAG_CERTS.setData([], []);
819
+ if (PAG_UNIFIED_SERVICES) PAG_UNIFIED_SERVICES.setData([], []);
820
+ if (PAG_UNIFIED_PROCESSES) PAG_UNIFIED_PROCESSES.setData([], []);
821
+ if (PAG_UNIFIED_DOCKER) PAG_UNIFIED_DOCKER.setData([], []);
822
+ if (PAG_UNIFIED_DISKS) PAG_UNIFIED_DISKS.setData([], []);
823
+ if (PAG_UNIFIED_PYUDISK) PAG_UNIFIED_PYUDISK.setData([], []);
824
+ if (PAG_UNIFIED_CERTS) PAG_UNIFIED_CERTS.setData([], []);
568
825
  }
569
826
 
570
827
  function resetUI() {
@@ -825,20 +1082,15 @@
825
1082
  }
826
1083
  }
827
1084
 
828
- if (selectedBase === "*") {
829
- if (ensureUnifiedChart(list)) {
830
- updateUnified(list);
1085
+ // When not in unified ("*") mode, ensure unified panel is hidden and charts cleared
1086
+ unifiedPanel.classList.add("hidden");
1087
+ unifiedNodes = [];
1088
+ Object.keys(unifiedCharts).forEach(key => {
1089
+ if (unifiedCharts[key]) {
1090
+ unifiedCharts[key].destroy();
1091
+ unifiedCharts[key] = null;
831
1092
  }
832
- } else {
833
- unifiedPanel.classList.add("hidden");
834
- unifiedNodes = [];
835
- Object.keys(unifiedCharts).forEach(key => {
836
- if (unifiedCharts[key]) {
837
- unifiedCharts[key].destroy();
838
- unifiedCharts[key] = null;
839
- }
840
- });
841
- }
1093
+ });
842
1094
  }
843
1095
 
844
1096
  // ------------------------------------------------------------
@@ -879,29 +1131,147 @@
879
1131
  });
880
1132
 
881
1133
  // ------------------------------------------------------------
882
- // WEBSOCKET
1134
+ // TAB MANAGEMENT
883
1135
  // ------------------------------------------------------------
884
- const protocol = location.protocol === "https:" ? "wss" : "ws";
885
- const ws = new WebSocket(`${protocol}://${location.host}/ws`);
1136
+ const nodesTab = document.getElementById("nodes-tab");
1137
+ const kumaTab = document.getElementById("kuma-tab");
1138
+ const kumaMainTable = document.getElementById("kuma-main-table");
1139
+ const kumaMainThead = kumaMainTable.querySelector("thead");
1140
+ const kumaMainTbody = kumaMainTable.querySelector("tbody");
1141
+ const kumaSearchInput = document.getElementById("kuma-search-input");
1142
+ const controlsDiv = document.querySelector(".controls");
1143
+
1144
+ // Create paginated table for kuma using existing infrastructure
1145
+ const PAG_KUMA_TAB = createPaginatedTable(
1146
+ kumaMainTable, kumaMainThead, kumaMainTbody, 20
1147
+ );
886
1148
 
887
- ws.onopen = () => {
888
- ws.send(JSON.stringify({type: "select_target", base_url: selectedBase}));
889
- };
1149
+ let allKumaRows = [];
1150
+
1151
+ function initWebSocket() {
1152
+ if (ws) return;
1153
+
1154
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
1155
+ ws = new WebSocket(`${protocol}://${location.host}/ws`);
1156
+
1157
+ ws.onopen = () => {
1158
+ ws.send(JSON.stringify({type: "select_target", base_url: selectedBase}));
1159
+ };
1160
+
1161
+ ws.onmessage = evt => {
1162
+ try {
1163
+ const msg = JSON.parse(evt.data);
1164
+ if (msg.type === "metrics") handleMetrics(msg.data);
1165
+ if (msg.type === "error") alert(msg.message);
1166
+ } catch (err) {
1167
+ console.error("WS parse error:", err);
1168
+ }
1169
+ };
1170
+
1171
+ ws.onerror = (err) => {
1172
+ console.error("WebSocket error:", err);
1173
+ };
1174
+
1175
+ ws.onclose = () => {
1176
+ console.log("WebSocket closed");
1177
+ ws = null;
1178
+ };
1179
+ }
1180
+
1181
+ function closeWebSocket() {
1182
+ if (ws && ws.readyState === WebSocket.OPEN) {
1183
+ ws.close();
1184
+ ws = null;
1185
+ }
1186
+ }
1187
+
1188
+ async function loadKumaMap() {
1189
+ if (kumaMapLoaded) {
1190
+ PAG_KUMA_TAB.setData(allKumaRows, ["Node", "Name", "Parent", "Tags", "URL"]);
1191
+ return;
1192
+ }
1193
+
1194
+ // Show loading state
1195
+ kumaMainThead.innerHTML = '<tr><th colspan="5">Loading...</th></tr>';
1196
+ kumaMainTbody.innerHTML = '';
890
1197
 
891
- ws.onmessage = evt => {
892
1198
  try {
893
- const msg = JSON.parse(evt.data);
894
- if (msg.type === "metrics") handleMetrics(msg.data);
895
- if (msg.type === "error") alert(msg.message);
1199
+ const response = await fetch('/kuma');
1200
+ if (!response.ok) throw new Error('Failed to fetch service map');
1201
+
1202
+ kumaMapData = await response.json();
1203
+ kumaMapLoaded = true;
1204
+
1205
+ allKumaRows = normalizeServiceMap(kumaMapData);
1206
+ PAG_KUMA_TAB.setData(allKumaRows, ["Node", "Name", "Parent", "Tags", "URL"]);
896
1207
  } catch (err) {
897
- console.error("WS parse error:", err);
1208
+ console.error("Error loading Kuma map:", err);
1209
+ kumaMainThead.innerHTML = '<tr><th>Error</th></tr>';
1210
+ kumaMainTbody.innerHTML = '<tr><td>Error loading Kuma endpoints. Please try again.</td></tr>';
898
1211
  }
899
- };
1212
+ }
1213
+
1214
+ kumaSearchInput.addEventListener('input', (e) => {
1215
+ const searchTerm = e.target.value.toLowerCase();
1216
+
1217
+ if (!searchTerm) {
1218
+ PAG_KUMA_TAB.setData(allKumaRows, ["Node", "Name", "Parent", "Tags", "URL"]);
1219
+ } else {
1220
+ const filtered = allKumaRows.filter(row =>
1221
+ row.Node.toLowerCase().includes(searchTerm) ||
1222
+ row.Name.toLowerCase().includes(searchTerm) ||
1223
+ row.Parent.toLowerCase().includes(searchTerm) ||
1224
+ row.Tags.toLowerCase().includes(searchTerm) ||
1225
+ row.URL.toLowerCase().includes(searchTerm)
1226
+ );
1227
+ PAG_KUMA_TAB.setData(filtered, ["Node", "Name", "Parent", "Tags", "URL"]);
1228
+ }
1229
+ });
1230
+
1231
+ function switchToNodesTab() {
1232
+ currentTab = 'nodes';
1233
+ nodesTab.classList.add('active');
1234
+ kumaTab.classList.remove('active');
1235
+ document.body.classList.remove('kuma-view');
1236
+ document.body.classList.add('nodes-view');
1237
+
1238
+ controlsDiv.classList.remove('invisible');
1239
+
1240
+ closeWebSocket();
1241
+ initWebSocket();
1242
+ }
1243
+
1244
+ function switchToKumaTab() {
1245
+ currentTab = 'kuma';
1246
+ kumaTab.classList.add('active');
1247
+ nodesTab.classList.remove('active');
1248
+ document.body.classList.add('kuma-view');
1249
+ document.body.classList.remove('nodes-view');
1250
+
1251
+ controlsDiv.classList.add('invisible');
1252
+
1253
+ closeWebSocket();
1254
+ loadKumaMap();
1255
+ }
1256
+
1257
+ if (nodesTab && kumaTab) {
1258
+ nodesTab.addEventListener('click', switchToNodesTab);
1259
+ kumaTab.addEventListener('click', switchToKumaTab);
1260
+ }
1261
+
1262
+ // ------------------------------------------------------------
1263
+ // WEBSOCKET (Managed by tabs)
1264
+ // ------------------------------------------------------------
900
1265
 
901
1266
  // ------------------------------------------------------------
902
1267
  // INIT
903
1268
  // ------------------------------------------------------------
1269
+ renderEndpoints();
904
1270
  attachSpinners();
905
1271
  resetUI(); // reset UI, keep spinners visible
906
1272
  showAllSpinners(); // show spinners until first metrics arrive
1273
+
1274
+ // Initialize nodes view by default
1275
+ document.body.classList.add('nodes-view');
1276
+ initWebSocket();
907
1277
  })();
@@ -32,6 +32,34 @@ html, body {
32
32
  border-bottom: 1px solid rgba(255, 255, 255, 0.02);
33
33
  }
34
34
 
35
+ .tab-navigation {
36
+ display: flex;
37
+ gap: 12px;
38
+ flex: 1;
39
+ justify-content: center;
40
+ }
41
+
42
+ .tab-btn {
43
+ background: transparent;
44
+ color: var(--muted);
45
+ border: none;
46
+ padding: 10px 32px;
47
+ font-size: 16px;
48
+ font-weight: 600;
49
+ cursor: pointer;
50
+ border-bottom: 3px solid transparent;
51
+ transition: all 0.2s;
52
+ }
53
+
54
+ .tab-btn:hover {
55
+ color: #e6eef8;
56
+ }
57
+
58
+ .tab-btn.active {
59
+ color: var(--accent);
60
+ border-bottom-color: var(--accent);
61
+ }
62
+
35
63
  .brand {
36
64
  font-weight: 700;
37
65
  font-size: 18px;
@@ -41,6 +69,11 @@ html, body {
41
69
  display: flex;
42
70
  gap: 8px;
43
71
  align-items: center;
72
+ min-width: 300px; /* Maintain space even when hidden */
73
+ }
74
+
75
+ .controls.invisible {
76
+ visibility: hidden;
44
77
  }
45
78
 
46
79
  .controls select, .controls button {
@@ -201,6 +234,16 @@ html, body {
201
234
  text-align: left;
202
235
  }
203
236
 
237
+ .table tbody tr.placeholder-row td {
238
+ color: transparent;
239
+ border-bottom: 1px solid transparent;
240
+ padding: 8px;
241
+ }
242
+
243
+ .table tbody tr.placeholder-row td::after {
244
+ content: "";
245
+ }
246
+
204
247
  .pre {
205
248
  background: rgba(255, 255, 255, 0.02);
206
249
  padding: 8px;
@@ -425,6 +468,32 @@ input#proc-filter {
425
468
  font-size: 13px;
426
469
  }
427
470
 
471
+ .unified-tables {
472
+ margin-top: 12px;
473
+ }
474
+
475
+ .unified-tables-title {
476
+ margin: 0 0 8px;
477
+ font-size: 13px;
478
+ color: var(--muted);
479
+ }
480
+
481
+ .unified-tables-grid {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ }
486
+
487
+ .unified-table-panel {
488
+ display: flex;
489
+ flex-direction: column;
490
+ gap: 8px;
491
+ }
492
+
493
+ .unified-table-panel .table {
494
+ font-size: 12px;
495
+ }
496
+
428
497
  body.unified-mode .meta-row,
429
498
  body.unified-mode .charts-row > :not(#unified-panel),
430
499
  body.unified-mode .details-row,
@@ -436,3 +505,42 @@ body.unified-mode footer {
436
505
  body.unified-mode #unified-panel {
437
506
  flex: 1;
438
507
  }
508
+
509
+ /* Tab view controls */
510
+ body.kuma-view .meta-row,
511
+ body.kuma-view .charts-row,
512
+ body.kuma-view .details-row,
513
+ body.kuma-view .tables-row {
514
+ display: none !important;
515
+ }
516
+
517
+ body.kuma-view #kuma-content {
518
+ display: block !important;
519
+ }
520
+
521
+ body.nodes-view #kuma-content {
522
+ display: none !important;
523
+ }
524
+
525
+ #kuma-content {
526
+ display: none;
527
+ }
528
+
529
+ #kuma-content .panel {
530
+ max-width: 1400px;
531
+ margin: 0 auto;
532
+ }
533
+
534
+ .kuma-search {
535
+ margin-bottom: 12px;
536
+ }
537
+
538
+ .kuma-search input {
539
+ width: 100%;
540
+ padding: 10px 16px;
541
+ background: var(--glass);
542
+ border: 1px solid rgba(255, 255, 255, 0.08);
543
+ color: inherit;
544
+ border-radius: 6px;
545
+ font-size: 14px;
546
+ }
@@ -19,8 +19,15 @@
19
19
  <header class="topbar">
20
20
  <div class="brand">Node Monitor</div>
21
21
 
22
+ {% if service_map is not none %}
23
+ <div class="tab-navigation">
24
+ <button id="nodes-tab" class="tab-btn active">Nodes</button>
25
+ <button id="kuma-tab" class="tab-btn">Uptime Kuma</button>
26
+ </div>
27
+ {% endif %}
28
+
22
29
  <div class="controls">
23
- <label for="node-select">Node:</label>
30
+ <label for="node-select" id="node-select-label">Node:</label>
24
31
  <select id="node-select" aria-label="Select node"></select>
25
32
  <button id="refresh-btn" title="Force refresh">Refresh</button>
26
33
  <button id="logout" title="Logout" onclick="logout()">Logout</button>
@@ -99,6 +106,52 @@
99
106
  </div>
100
107
  </div>
101
108
  <div id="unified-legend" class="unified-legend"></div>
109
+ <div class="unified-tables">
110
+ <div class="unified-tables-grid">
111
+ <div class="panel unified-table-panel">
112
+ <div class="panel-header"><h3>Services</h3></div>
113
+ <table class="table" id="unified-services-table">
114
+ <thead></thead>
115
+ <tbody></tbody>
116
+ </table>
117
+ </div>
118
+ <div class="panel unified-table-panel">
119
+ <div class="panel-header"><h3>Processes</h3></div>
120
+ <table class="table" id="unified-processes-table">
121
+ <thead></thead>
122
+ <tbody></tbody>
123
+ </table>
124
+ </div>
125
+ <div class="panel unified-table-panel">
126
+ <div class="panel-header"><h3>Docker Containers</h3></div>
127
+ <table class="table" id="unified-docker-table">
128
+ <thead></thead>
129
+ <tbody></tbody>
130
+ </table>
131
+ </div>
132
+ <div class="panel unified-table-panel">
133
+ <div class="panel-header"><h3>Disks</h3></div>
134
+ <table class="table" id="unified-disks-table">
135
+ <thead></thead>
136
+ <tbody></tbody>
137
+ </table>
138
+ </div>
139
+ <div class="panel unified-table-panel">
140
+ <div class="panel-header"><h3>PyUdisk Metrics</h3></div>
141
+ <table class="table" id="unified-pyudisk-table">
142
+ <thead></thead>
143
+ <tbody></tbody>
144
+ </table>
145
+ </div>
146
+ <div class="panel unified-table-panel">
147
+ <div class="panel-header"><h3>Certificates</h3></div>
148
+ <table class="table" id="unified-certificates-table">
149
+ <thead></thead>
150
+ <tbody></tbody>
151
+ </table>
152
+ </div>
153
+ </div>
154
+ </div>
102
155
  </div>
103
156
  </section>
104
157
 
@@ -203,10 +256,32 @@
203
256
  </table>
204
257
  </div>
205
258
  </div>
259
+
260
+ <div class="panel">
261
+ <div class="panel-header"><h3>Kuma Endpoints</h3></div>
262
+ <div class="panel-body">
263
+ <table class="table" id="endpoints-table">
264
+ <thead></thead>
265
+ <tbody></tbody>
266
+ </table>
267
+ </div>
268
+ </div>
206
269
  </section>
207
270
  </section>
208
271
  </main>
209
272
 
273
+ <div id="kuma-content" class="container">
274
+ <section class="panel">
275
+ <div class="kuma-search">
276
+ <input type="text" id="kuma-search-input" placeholder="Search Kuma endpoints by node, name, parent, or tags...">
277
+ </div>
278
+ <table class="table" id="kuma-main-table">
279
+ <thead></thead>
280
+ <tbody></tbody>
281
+ </table>
282
+ </section>
283
+ </div>
284
+
210
285
  <footer class="footer">
211
286
  <div><code>PyObservability: v{{ version }}</code><br>
212
287
  <a href="https://github.com/thevickypedia/PyObservability" target="_blank">https://github.com/thevickypedia/PyObservability</a>
@@ -217,6 +292,13 @@
217
292
  // inject server-side targets
218
293
  window.MONITOR_TARGETS = {{ targets | tojson }};
219
294
  </script>
295
+
296
+ {% if service_map is not none %}
297
+ <script>
298
+ window.SERVICE_MAP = {{ service_map | tojson }};
299
+ </script>
300
+ {% endif %}
301
+
220
302
  <script>
221
303
  const logoutEndpoint = "{{ logout }}";
222
304
 
@@ -1 +1 @@
1
- __version__ = "1.4.0"
1
+ __version__ = "2.0.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyObservability
3
- Version: 1.4.0
3
+ Version: 2.0.0
4
4
  Summary: Lightweight OS-agnostic observability UI for PyNinja
5
5
  Author-email: Vignesh Rao <svignesh1793@gmail.com>
6
6
  License: MIT License
@@ -42,13 +42,16 @@ Requires-Python: >=3.11
42
42
  Description-Content-Type: text/markdown
43
43
  License-File: LICENSE
44
44
  Requires-Dist: aiohttp==3.13.*
45
- Requires-Dist: fastapi==0.122.*
45
+ Requires-Dist: fastapi==0.128.*
46
46
  Requires-Dist: FastAPI-UI-Auth==0.2.1
47
47
  Requires-Dist: Jinja2==3.1.*
48
48
  Requires-Dist: pydantic==2.12.*
49
49
  Requires-Dist: pydantic-settings==2.12.*
50
50
  Requires-Dist: python-dotenv==1.2.*
51
- Requires-Dist: uvicorn[standard]==0.38.*
51
+ Requires-Dist: python-socketio==5.16.*
52
+ Requires-Dist: requests==2.32.*
53
+ Requires-Dist: uvicorn[standard]==0.40.*
54
+ Requires-Dist: websocket-client==1.9.*
52
55
  Provides-Extra: dev
53
56
  Requires-Dist: pre-commit; extra == "dev"
54
57
  Dynamic: license-file
@@ -140,6 +143,13 @@ docker run \
140
143
  - **LOGS_PATH** - Directory path to store log files if `LOG` is set to `file`.
141
144
  - **LOG_CONFIG** - Path to a custom logging configuration file.
142
145
 
146
+ **Uptime Kuma**
147
+ > Uptime Kuma integration can be enabled by setting the following environment variables.
148
+ - **KUMA_URL** - Base URL of the Uptime Kuma server.
149
+ - **KUMA_USERNAME** - Username to authenticate with Uptime Kuma.
150
+ - **KUMA_PASSWORD** - Password to authenticate with Uptime Kuma.
151
+ - **KUMA_TIMEOUT** - Timeout (in seconds) for Uptime Kuma authentication. Defaults to 5s.
152
+
143
153
  ## License & copyright
144
154
 
145
155
  &copy; Vignesh Rao
@@ -0,0 +1,17 @@
1
+ pyobservability/__init__.py,sha256=yVBLyTohBiBKp0Otyl04IggPh8mhg3Er25u6eFyxMto,2618
2
+ pyobservability/kuma.py,sha256=RL6ZsK6ZE3vUufsUQ3r4gnH8uka0k8ARd41GP5YozbU,3634
3
+ pyobservability/main.py,sha256=EbP6xkOvj2cV1cOEmmq6E6QsLOhbvKI_jdpfcWjSz-M,6124
4
+ pyobservability/monitor.py,sha256=i_Xf_DB-qLOp1b9wryekjwHIM8AnMrGTkuEg7e08bcM,7539
5
+ pyobservability/transport.py,sha256=S-84mgf-9yMj0H7VSAmueW9yosX_1XxdyNJC2EuQHQQ,8493
6
+ pyobservability/version.py,sha256=_7OlQdbVkK4jad0CLdpI0grT-zEAb-qgFmH5mFzDXiA,22
7
+ pyobservability/config/enums.py,sha256=rQZh2Q4-9ItQTQgxsYjqw-jNePpWHhvQUrbrQJBG5CI,313
8
+ pyobservability/config/settings.py,sha256=aCz1tZEg8fUA5gHCCDN_m3fgrH1axz2fvdalj4bszGs,6121
9
+ pyobservability/static/app.js,sha256=lE-Oybq50FNr_0SrZJFiJihiXyIcyhw7-SNbGA81ySQ,48675
10
+ pyobservability/static/styles.css,sha256=P6Xg-IAXO3WNeBLGH9Q5HAdNeDMRbFcM5P_cq60Jf00,9405
11
+ pyobservability/templates/index.html,sha256=WQNOT_uHfAvVqOnYc1olYdyrjM11tDpeKwTkbPQ853Q,11834
12
+ pyobservability-2.0.0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
13
+ pyobservability-2.0.0.dist-info/METADATA,sha256=hu_7k6rRxUXitpfLhkAB6GZxMYQNDeqGaQAHJDKa0Ss,7026
14
+ pyobservability-2.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
15
+ pyobservability-2.0.0.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
16
+ pyobservability-2.0.0.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
17
+ pyobservability-2.0.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,16 +0,0 @@
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=8UhoYEXHs1Oai7BW_ExBmuwWnRI-yMG_u1fQAXMizHQ,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=lel2ZZH-ifjehzy2d-5UQxOhnBIHxla05xjokW1nXXA,33919
9
- pyobservability/static/styles.css,sha256=0Vagj7nDac27JC0M870V3yqc1XN4rB5pDYuy4zjit3c,7618
10
- pyobservability/templates/index.html,sha256=JdGn7Bg9w-4zzcMzX78p0q5760ao7sioaqB1s_8d0Fs,8421
11
- pyobservability-1.4.0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
12
- pyobservability-1.4.0.dist-info/METADATA,sha256=DHbIRiPU0Vi_5PjsT0k5NgMInO8_YgY5azP9fGQM8VQ,6537
13
- pyobservability-1.4.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
14
- pyobservability-1.4.0.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
15
- pyobservability-1.4.0.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
16
- pyobservability-1.4.0.dist-info/RECORD,,