PyObservability 1.4.1__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.
- pyobservability/config/enums.py +1 -0
- pyobservability/config/settings.py +5 -0
- pyobservability/kuma.py +126 -0
- pyobservability/main.py +59 -16
- pyobservability/static/app.js +181 -14
- pyobservability/static/styles.css +72 -0
- pyobservability/templates/index.html +37 -2
- pyobservability/version.py +1 -1
- {pyobservability-1.4.1.dist-info → pyobservability-2.0.0.dist-info}/METADATA +13 -3
- pyobservability-2.0.0.dist-info/RECORD +17 -0
- {pyobservability-1.4.1.dist-info → pyobservability-2.0.0.dist-info}/WHEEL +1 -1
- pyobservability-1.4.1.dist-info/RECORD +0 -16
- {pyobservability-1.4.1.dist-info → pyobservability-2.0.0.dist-info}/entry_points.txt +0 -0
- {pyobservability-1.4.1.dist-info → pyobservability-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {pyobservability-1.4.1.dist-info → pyobservability-2.0.0.dist-info}/top_level.txt +0 -0
pyobservability/config/enums.py
CHANGED
|
@@ -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
|
|
pyobservability/kuma.py
ADDED
|
@@ -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
|
|
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
|
-
|
|
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)
|
pyobservability/static/app.js
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
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
|
// ------------------------------------------------------------
|
|
@@ -320,6 +330,11 @@
|
|
|
320
330
|
const PAG_CERTS = createPaginatedTable(
|
|
321
331
|
certsTable, certsTableHead, certsTableBody
|
|
322
332
|
);
|
|
333
|
+
const PAG_ENDPOINTS = createPaginatedTable(
|
|
334
|
+
endpointsTable,
|
|
335
|
+
endpointsTableHead,
|
|
336
|
+
endpointsTableBody
|
|
337
|
+
);
|
|
323
338
|
|
|
324
339
|
// ------------------------------------------------------------
|
|
325
340
|
// CHART HELPERS
|
|
@@ -351,6 +366,32 @@
|
|
|
351
366
|
});
|
|
352
367
|
}
|
|
353
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
|
+
|
|
354
395
|
function makeCoreSparkline(ctx, coreName) {
|
|
355
396
|
const EMPTY_LABELS = Array(MAX_POINTS).fill("");
|
|
356
397
|
const EMPTY_DATA = Array(MAX_POINTS).fill(null);
|
|
@@ -438,8 +479,16 @@
|
|
|
438
479
|
);
|
|
439
480
|
|
|
440
481
|
let unifiedNodes = [];
|
|
441
|
-
|
|
442
|
-
|
|
482
|
+
const colorPalette = [
|
|
483
|
+
"#ff0000",
|
|
484
|
+
"#ffff00",
|
|
485
|
+
"#00ff00",
|
|
486
|
+
"#0066ff",
|
|
487
|
+
"#b300ff",
|
|
488
|
+
"#ff7f00",
|
|
489
|
+
"#8b4513",
|
|
490
|
+
"#7f7f7f"
|
|
491
|
+
];
|
|
443
492
|
const nodeColor = {};
|
|
444
493
|
const unifiedCharts = {memory: null, cpu: null, disk: null};
|
|
445
494
|
|
|
@@ -1082,29 +1131,147 @@
|
|
|
1082
1131
|
});
|
|
1083
1132
|
|
|
1084
1133
|
// ------------------------------------------------------------
|
|
1085
|
-
//
|
|
1134
|
+
// TAB MANAGEMENT
|
|
1086
1135
|
// ------------------------------------------------------------
|
|
1087
|
-
const
|
|
1088
|
-
const
|
|
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
|
+
);
|
|
1089
1148
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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 = '';
|
|
1093
1197
|
|
|
1094
|
-
ws.onmessage = evt => {
|
|
1095
1198
|
try {
|
|
1096
|
-
const
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
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"]);
|
|
1099
1207
|
} catch (err) {
|
|
1100
|
-
console.error("
|
|
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>';
|
|
1101
1211
|
}
|
|
1102
|
-
}
|
|
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
|
+
// ------------------------------------------------------------
|
|
1103
1265
|
|
|
1104
1266
|
// ------------------------------------------------------------
|
|
1105
1267
|
// INIT
|
|
1106
1268
|
// ------------------------------------------------------------
|
|
1269
|
+
renderEndpoints();
|
|
1107
1270
|
attachSpinners();
|
|
1108
1271
|
resetUI(); // reset UI, keep spinners visible
|
|
1109
1272
|
showAllSpinners(); // show spinners until first metrics arrive
|
|
1273
|
+
|
|
1274
|
+
// Initialize nodes view by default
|
|
1275
|
+
document.body.classList.add('nodes-view');
|
|
1276
|
+
initWebSocket();
|
|
1110
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 {
|
|
@@ -472,3 +505,42 @@ body.unified-mode footer {
|
|
|
472
505
|
body.unified-mode #unified-panel {
|
|
473
506
|
flex: 1;
|
|
474
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>
|
|
@@ -98,7 +105,6 @@
|
|
|
98
105
|
<canvas id="unified-disk-chart" class="chart"></canvas>
|
|
99
106
|
</div>
|
|
100
107
|
</div>
|
|
101
|
-
{# TODO: Make the following tables into multiple div containers #}
|
|
102
108
|
<div id="unified-legend" class="unified-legend"></div>
|
|
103
109
|
<div class="unified-tables">
|
|
104
110
|
<div class="unified-tables-grid">
|
|
@@ -250,10 +256,32 @@
|
|
|
250
256
|
</table>
|
|
251
257
|
</div>
|
|
252
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>
|
|
253
269
|
</section>
|
|
254
270
|
</section>
|
|
255
271
|
</main>
|
|
256
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
|
+
|
|
257
285
|
<footer class="footer">
|
|
258
286
|
<div><code>PyObservability: v{{ version }}</code><br>
|
|
259
287
|
<a href="https://github.com/thevickypedia/PyObservability" target="_blank">https://github.com/thevickypedia/PyObservability</a>
|
|
@@ -264,6 +292,13 @@
|
|
|
264
292
|
// inject server-side targets
|
|
265
293
|
window.MONITOR_TARGETS = {{ targets | tojson }};
|
|
266
294
|
</script>
|
|
295
|
+
|
|
296
|
+
{% if service_map is not none %}
|
|
297
|
+
<script>
|
|
298
|
+
window.SERVICE_MAP = {{ service_map | tojson }};
|
|
299
|
+
</script>
|
|
300
|
+
{% endif %}
|
|
301
|
+
|
|
267
302
|
<script>
|
|
268
303
|
const logoutEndpoint = "{{ logout }}";
|
|
269
304
|
|
pyobservability/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "
|
|
1
|
+
__version__ = "2.0.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyObservability
|
|
3
|
-
Version:
|
|
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.
|
|
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:
|
|
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
|
© 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,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=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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|