PyObservability 0.0.0a0__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.
File without changes
@@ -0,0 +1,44 @@
1
+ # app/main.py
2
+ import asyncio
3
+ import pathlib
4
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.templating import Jinja2Templates
7
+ from pyobservability.monitor import Monitor
8
+ import os
9
+ from dotenv import load_dotenv
10
+ load_dotenv()
11
+
12
+ app = FastAPI(title="Monitor UI")
13
+ root = pathlib.Path(__file__).parent
14
+ templates_dir = root / "templates"
15
+ static_dir = root / "static"
16
+ templates = Jinja2Templates(directory=templates_dir)
17
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
18
+
19
+ monitor = Monitor(poll_interval=float(os.getenv("POLL_INTERVAL", 2)))
20
+
21
+ @app.get("/")
22
+ async def index(request: Request):
23
+ # pass configured targets to the template so frontend can prebuild UI
24
+ return templates.TemplateResponse("index.html", {"request": request, "targets": monitor.targets})
25
+
26
+ @app.websocket("/ws")
27
+ async def websocket_endpoint(websocket: WebSocket):
28
+ await websocket.accept()
29
+ await monitor.start()
30
+ q = monitor.subscribe()
31
+ try:
32
+ while True:
33
+ payload = await q.get()
34
+ # send as JSON text
35
+ await websocket.send_json(payload)
36
+ except WebSocketDisconnect:
37
+ monitor.unsubscribe(q)
38
+ except Exception:
39
+ monitor.unsubscribe(q)
40
+ try:
41
+ await websocket.close()
42
+ except:
43
+ pass
44
+ await monitor.stop()
@@ -0,0 +1,234 @@
1
+ # app/monitor.py
2
+
3
+ import asyncio
4
+ import os
5
+ import json
6
+ from typing import Any, Dict, List
7
+ import aiohttp
8
+ from asyncio import CancelledError
9
+
10
+
11
+ ###############################################################################
12
+ # ENDPOINT DEFINITIONS (PyNinja Correct)
13
+ ###############################################################################
14
+
15
+ ENDPOINTS = {
16
+ "ip": {
17
+ "path": "/get-ip",
18
+ "params": {"public": "false"},
19
+ },
20
+ "cpu": {
21
+ "path": "/get-cpu",
22
+ "params": {"interval": 2, "per_cpu": "true"},
23
+ },
24
+ "cpu_load": {
25
+ "path": "/get-cpu-load",
26
+ "params": {},
27
+ },
28
+ "gpu": {
29
+ "path": "/get-processor",
30
+ "params": {},
31
+ },
32
+ "memory": {
33
+ "path": "/get-memory",
34
+ "params": {},
35
+ },
36
+ "disk": {
37
+ "path": "/get-disk-utilization",
38
+ "params": {"path": "/"},
39
+ },
40
+ "disks": {
41
+ "path": "/get-all-disks",
42
+ "params": {},
43
+ },
44
+ "services": {
45
+ "path": "/get-all-services",
46
+ "params": {},
47
+ },
48
+ "docker_stats": {
49
+ "path": "/get-docker-stats",
50
+ "params": {},
51
+ },
52
+ "certificates": {
53
+ "path": "/get-certificates",
54
+ "params": {},
55
+ }
56
+ }
57
+
58
+
59
+ ###############################################################################
60
+ # LOAD TARGETS FROM ENV
61
+ ###############################################################################
62
+
63
+ def load_targets_from_env() -> List[Dict[str, Any]]:
64
+ raw = os.getenv("MONITOR_TARGETS", "[]")
65
+
66
+ try:
67
+ data = json.loads(raw)
68
+ except Exception:
69
+ data = [raw] if raw else []
70
+
71
+ parsed = []
72
+
73
+ for entry in data:
74
+ if isinstance(entry, str):
75
+ parsed.append({"name": entry, "base_url": entry, "apikey": None})
76
+ elif isinstance(entry, dict):
77
+ parsed.append({
78
+ "name": entry.get("name") or entry["base_url"],
79
+ "base_url": entry["base_url"],
80
+ "apikey": entry.get("apikey")
81
+ })
82
+
83
+ return parsed
84
+
85
+
86
+ ###############################################################################
87
+ # MONITOR CLASS
88
+ ###############################################################################
89
+
90
+ class Monitor:
91
+
92
+ def __init__(self, poll_interval: float = 2.0):
93
+ self.targets = load_targets_from_env()
94
+ self.poll_interval = float(os.getenv("POLL_INTERVAL", poll_interval))
95
+ self.sessions: Dict[str, aiohttp.ClientSession] = {}
96
+ self._ws_subscribers: List[asyncio.Queue] = []
97
+ self._task = None
98
+ self._stop = asyncio.Event()
99
+
100
+ ############################################################################
101
+ # LIFECYCLE
102
+ ############################################################################
103
+ async def start(self):
104
+ for t in self.targets:
105
+ self.sessions[t["base_url"]] = aiohttp.ClientSession()
106
+ self._task = asyncio.create_task(self._run_loop())
107
+
108
+ async def stop(self):
109
+ self._stop.set()
110
+ if self._task:
111
+ self._task.cancel()
112
+ try:
113
+ await self._task
114
+ except CancelledError:
115
+ pass
116
+
117
+ for sess in self.sessions.values():
118
+ await sess.close()
119
+
120
+ ############################################################################
121
+ # SUBSCRIBE / UNSUBSCRIBE
122
+ ############################################################################
123
+ def subscribe(self) -> asyncio.Queue:
124
+ q = asyncio.Queue(maxsize=10)
125
+ self._ws_subscribers.append(q)
126
+ return q
127
+
128
+ def unsubscribe(self, q: asyncio.Queue):
129
+ try:
130
+ self._ws_subscribers.remove(q)
131
+ except ValueError:
132
+ pass
133
+
134
+ ############################################################################
135
+ # FETCH WRAPPER
136
+ ############################################################################
137
+ async def _fetch(self, session, base_url, ep, apikey=None, params=None):
138
+ url = base_url.rstrip("/") + ep
139
+ headers = {"accept": "application/json"}
140
+ if apikey:
141
+ headers["Authorization"] = f"Bearer {apikey}"
142
+
143
+ try:
144
+ async with session.get(url, headers=headers, params=params, timeout=10) as resp:
145
+ if resp.status == 200:
146
+ try:
147
+ return await resp.json()
148
+ except Exception:
149
+ return {"detail": await resp.text()}
150
+ return {"error": f"HTTP {resp.status}"}
151
+ except Exception as e:
152
+ return {"error": str(e)}
153
+
154
+ ############################################################################
155
+ # PER-TARGET POLLING
156
+ ############################################################################
157
+ async def _poll_target(self, target: Dict[str, Any]) -> Dict[str, Any]:
158
+ base = target["base_url"]
159
+ apikey = target.get("apikey")
160
+ session = self.sessions[base]
161
+
162
+ result = {
163
+ "name": target["name"],
164
+ "base_url": base,
165
+ "metrics": {}
166
+ }
167
+
168
+ # Fire ALL requests concurrently
169
+ tasks = {}
170
+
171
+ for key, cfg in ENDPOINTS.items():
172
+ tasks[key] = asyncio.create_task(
173
+ self._fetch(
174
+ session,
175
+ base,
176
+ cfg["path"],
177
+ apikey=apikey,
178
+ params=cfg["params"]
179
+ )
180
+ )
181
+
182
+ # Wait for all endpoints
183
+ raw_results = await asyncio.gather(*tasks.values(), return_exceptions=True)
184
+
185
+ for (key, _), resp in zip(tasks.items(), raw_results):
186
+ if isinstance(resp, Exception):
187
+ result["metrics"][key] = {"error": str(resp)}
188
+ continue
189
+ if isinstance(resp, dict):
190
+ result["metrics"][key] = resp.get("detail", resp)
191
+ else:
192
+ # raw string / number / list / etc
193
+ result["metrics"][key] = resp
194
+
195
+ return result
196
+
197
+ ############################################################################
198
+ # POLL ALL HOSTS
199
+ ############################################################################
200
+ async def _poll_all(self) -> List[Dict[str, Any]]:
201
+ tasks = [self._poll_target(t) for t in self.targets]
202
+ results = await asyncio.gather(*tasks, return_exceptions=True)
203
+ out = []
204
+ for r in results:
205
+ if isinstance(r, Exception):
206
+ out.append({"error": str(r)})
207
+ else:
208
+ out.append(r)
209
+ return out
210
+
211
+ ############################################################################
212
+ # MAIN LOOP
213
+ ############################################################################
214
+ async def _run_loop(self):
215
+ while not self._stop.is_set():
216
+ metrics = await self._poll_all()
217
+
218
+ payload = {
219
+ "type": "metrics",
220
+ "ts": asyncio.get_event_loop().time(),
221
+ "data": metrics
222
+ }
223
+
224
+ for q in list(self._ws_subscribers):
225
+ try:
226
+ q.put_nowait(payload)
227
+ except asyncio.QueueFull:
228
+ try:
229
+ _ = q.get_nowait()
230
+ q.put_nowait(payload)
231
+ except Exception:
232
+ pass
233
+
234
+ await asyncio.sleep(self.poll_interval)
@@ -0,0 +1,392 @@
1
+ // app/static/app.js
2
+ (function () {
3
+ // ------------------------------------------------------------
4
+ // CONFIG
5
+ // ------------------------------------------------------------
6
+ const MAX_POINTS = 60;
7
+ const targets = window.MONITOR_TARGETS || [];
8
+
9
+ // ------------------------------------------------------------
10
+ // DOM REFERENCES
11
+ // ------------------------------------------------------------
12
+ const nodeSelect = document.getElementById("node-select");
13
+ const refreshBtn = document.getElementById("refresh-btn");
14
+
15
+ const ipEl = document.getElementById("ip");
16
+ const gpuEl = document.getElementById("gpu");
17
+ const memEl = document.getElementById("memory");
18
+ const diskEl = document.getElementById("disk");
19
+ const loadEl = document.getElementById("cpuload");
20
+
21
+ const cpuAvgCtx = document.getElementById("cpu-avg-chart").getContext("2d");
22
+ const memCtx = document.getElementById("mem-chart").getContext("2d");
23
+ const loadCtx = document.getElementById("load-chart").getContext("2d");
24
+
25
+ const coresGrid = document.getElementById("cores-grid");
26
+
27
+ const servicesTableBody = document.querySelector("#services-table tbody");
28
+ const svcFilter = document.getElementById("svc-filter");
29
+
30
+ const dockerStatsEl = document.getElementById("docker-stats");
31
+ const containersList = document.getElementById("containers-list");
32
+
33
+ const disksTableBody = document.querySelector("#disks-table tbody");
34
+ const certsEl = document.getElementById("certificates");
35
+
36
+ const showCoresCheckbox = document.getElementById("show-cores");
37
+
38
+ // ------------------------------------------------------------
39
+ // CHART HELPERS
40
+ // ------------------------------------------------------------
41
+ function makeMainChart(ctx, label) {
42
+ const EMPTY = Array(MAX_POINTS).fill(null);
43
+ const LABELS = Array(MAX_POINTS).fill("");
44
+
45
+ return new Chart(ctx, {
46
+ type: "line",
47
+ data: {
48
+ labels: [...LABELS],
49
+ datasets: [
50
+ {
51
+ label,
52
+ data: [...EMPTY],
53
+ fill: true,
54
+ tension: 0.2,
55
+ cubicInterpolationMode: "monotone",
56
+ pointRadius: 0
57
+ }
58
+ ]
59
+ },
60
+ options: {
61
+ animation: false,
62
+ responsive: true,
63
+ maintainAspectRatio: false,
64
+ spanGaps: false,
65
+ scales: {
66
+ x: { display: false },
67
+ y: {
68
+ beginAtZero: true,
69
+ suggestedMax: 100
70
+ }
71
+ },
72
+ plugins: {
73
+ legend: { display: false }
74
+ }
75
+ }
76
+ });
77
+ }
78
+
79
+ function makeCoreSparkline(ctx, coreName) {
80
+ const EMPTY_LABELS = Array(MAX_POINTS).fill("");
81
+ const EMPTY_DATA = Array(MAX_POINTS).fill(null);
82
+
83
+ return new Chart(ctx, {
84
+ type: "line",
85
+ data: {
86
+ labels: [...EMPTY_LABELS],
87
+ datasets: [{
88
+ label: coreName,
89
+ data: [...EMPTY_DATA],
90
+ fill: false,
91
+ tension: 0.2,
92
+ pointRadius: 0
93
+ }]
94
+ },
95
+ options: {
96
+ animation: false,
97
+ responsive: false,
98
+ interaction: false,
99
+ events: [],
100
+ spanGaps: false,
101
+ plugins: { legend: { display: false } },
102
+ scales: {
103
+ x: { display: false },
104
+ y: { display: false, suggestedMax: 100 }
105
+ }
106
+ }
107
+ });
108
+ }
109
+
110
+ const cpuAvgChart = makeMainChart(cpuAvgCtx, "CPU Avg");
111
+ const memChart = makeMainChart(memCtx, "Memory %");
112
+ const loadChart = makeMainChart(loadCtx, "CPU Load");
113
+
114
+ // ------------------------------------------------------------
115
+ // CORE SPARKLINE STATE
116
+ // ------------------------------------------------------------
117
+ const coreMini = {};
118
+
119
+ function createCoreChart(coreName) {
120
+ const wrapper = document.createElement("div");
121
+ wrapper.className = "core-mini";
122
+ wrapper.innerHTML = `
123
+ <div class="label">${coreName}</div>
124
+ <canvas width="120" height="40"></canvas>
125
+ <div class="value">—</div>
126
+ `;
127
+ coresGrid.appendChild(wrapper);
128
+
129
+ const canvas = wrapper.querySelector("canvas");
130
+ const valEl = wrapper.querySelector(".value");
131
+ const chart = makeCoreSparkline(canvas.getContext("2d"), coreName);
132
+
133
+ coreMini[coreName] = { chart, el: wrapper, valEl };
134
+ return coreMini[coreName];
135
+ }
136
+
137
+ function getCoreChart(coreName) {
138
+ return coreMini[coreName] || createCoreChart(coreName);
139
+ }
140
+
141
+ function pruneOldCores(latest) {
142
+ for (const name of Object.keys(coreMini)) {
143
+ if (!latest.includes(name)) {
144
+ try { coreMini[name].chart.destroy(); } catch {}
145
+ coreMini[name].el.remove();
146
+ delete coreMini[name];
147
+ }
148
+ }
149
+ }
150
+
151
+ // ------------------------------------------------------------
152
+ // RESET UI
153
+ // ------------------------------------------------------------
154
+ function resetUI() {
155
+ // Pre-fill charts with right-anchored null buffers
156
+ const EMPTY_DATA = Array(MAX_POINTS).fill(null);
157
+ const EMPTY_LABELS = Array(MAX_POINTS).fill("");
158
+
159
+ function resetChart(chart) {
160
+ chart.data.labels = [...EMPTY_LABELS];
161
+ chart.data.datasets[0].data = [...EMPTY_DATA];
162
+ chart.update();
163
+ }
164
+
165
+ // Reset main charts (CPU Avg, Memory %, CPU Load)
166
+ resetChart(cpuAvgChart);
167
+ resetChart(memChart);
168
+ resetChart(loadChart);
169
+
170
+ // Remove all per-core mini charts
171
+ for (const name of Object.keys(coreMini)) {
172
+ try { coreMini[name].chart.destroy(); } catch {}
173
+ coreMini[name].el.remove();
174
+ delete coreMini[name];
175
+ }
176
+
177
+ // Reset static UI fields
178
+ ipEl.textContent = "—";
179
+ gpuEl.textContent = "—";
180
+ memEl.textContent = "—";
181
+ diskEl.textContent = "—";
182
+ loadEl.textContent = "—";
183
+
184
+ servicesTableBody.innerHTML = "";
185
+ dockerStatsEl.textContent = "—";
186
+ containersList.innerHTML = "";
187
+ disksTableBody.innerHTML = "";
188
+ certsEl.textContent = "—";
189
+ }
190
+
191
+ // ------------------------------------------------------------
192
+ // HELPERS
193
+ // ------------------------------------------------------------
194
+ function pushPoint(chart, value) {
195
+ const ts = new Date().toLocaleTimeString();
196
+ chart.data.labels.push(ts);
197
+ chart.data.datasets[0].data.push(isFinite(value) ? Number(value) : NaN);
198
+
199
+ if (chart.data.labels.length > MAX_POINTS) {
200
+ chart.data.labels.shift();
201
+ chart.data.datasets[0].data.shift();
202
+ }
203
+
204
+ chart.update("none");
205
+ }
206
+
207
+ function num(x) {
208
+ const n = Number(x);
209
+ return Number.isFinite(n) ? n : null;
210
+ }
211
+
212
+ // ------------------------------------------------------------
213
+ // METRICS HANDLER
214
+ // ------------------------------------------------------------
215
+ function handleMetrics(list) {
216
+ const now = new Date().toLocaleTimeString();
217
+
218
+ for (const host of list) {
219
+ if (host.base_url !== selectedBase) continue;
220
+ const m = host.metrics || {};
221
+
222
+ // ------------------- BASIC INFO -------------------
223
+ ipEl.textContent = m.ip?.ip || m.ip || "—";
224
+ gpuEl.textContent = JSON.stringify(m.gpu ?? "—", null, 2);
225
+ diskEl.textContent = m.disk ? JSON.stringify(m.disk, null, 0) : "—";
226
+
227
+ // ------------------- MEMORY -------------------
228
+ if (m.memory) {
229
+ const used = m.memory.ram_used || m.memory.used || "";
230
+ const percent = m.memory.ram_usage ?? m.memory.usage ?? m.memory.percent ?? "—";
231
+ memEl.textContent = `used: ${used} (${percent}%)`;
232
+ pushPoint(memChart, num(percent));
233
+ }
234
+
235
+ // ------------------- CPU -------------------
236
+ let avg = null;
237
+
238
+ if (m.cpu) {
239
+ const detail = m.cpu.detail || m.cpu;
240
+
241
+ if (typeof detail === "object") {
242
+ const names = Object.keys(detail);
243
+ pruneOldCores(names);
244
+
245
+ const values = [];
246
+
247
+ for (const [core, val] of Object.entries(detail)) {
248
+ const v = num(val);
249
+ values.push(v);
250
+
251
+ const c = getCoreChart(core);
252
+ c.chart.data.labels.push(now);
253
+ c.chart.data.datasets[0].data.push(v ?? 0);
254
+
255
+ if (c.chart.data.labels.length > MAX_POINTS) {
256
+ c.chart.data.labels.shift();
257
+ c.chart.data.datasets[0].data.shift();
258
+ }
259
+
260
+ c.chart.update("none");
261
+ c.valEl.textContent = `${(v ?? 0).toFixed(1)}%`;
262
+ }
263
+
264
+ avg = values.reduce((a, b) => a + (b ?? 0), 0) / values.length;
265
+ }
266
+ else if (typeof detail === "number") {
267
+ avg = detail;
268
+ }
269
+ }
270
+
271
+ if (avg != null) pushPoint(cpuAvgChart, avg);
272
+
273
+ // ------------------- CPU LOAD -------------------
274
+ if (m.cpu_load) {
275
+ const load = m.cpu_load.detail || m.cpu_load;
276
+ if (typeof load === "object") {
277
+ const m1 = load.m1 ?? load[0];
278
+ const m5 = load.m5 ?? load[1];
279
+ const m15 = load.m15 ?? load[2];
280
+
281
+ loadEl.textContent = `${m1} / ${m5} / ${m15}`;
282
+ pushPoint(loadChart, num(m1) ?? 0);
283
+ } else {
284
+ loadEl.textContent = load;
285
+ pushPoint(loadChart, num(load));
286
+ }
287
+ }
288
+
289
+ // ------------------- SERVICES -------------------
290
+ if (Array.isArray(m.services)) {
291
+ const filter = svcFilter.value.trim().toLowerCase();
292
+ servicesTableBody.innerHTML = "";
293
+
294
+ for (const s of m.services) {
295
+ const name = s.pname || s.label || s.name || "";
296
+
297
+ if (filter && !name.toLowerCase().includes(filter)) continue;
298
+
299
+ const tr = document.createElement("tr");
300
+ tr.innerHTML = `
301
+ <td>${s.pid ?? s.PID ?? ""}</td>
302
+ <td>${name}</td>
303
+ <td>${s.status ?? "—"}</td>
304
+ <td>${s.cpu ? JSON.stringify(s.cpu) : "—"}</td>
305
+ <td>${s.memory ? (s.memory.rss || s.memory.pfaults || JSON.stringify(s.memory)) : "—"}</td>
306
+ `;
307
+ servicesTableBody.appendChild(tr);
308
+ }
309
+ }
310
+
311
+ // ------------------- DOCKER -------------------
312
+ if (m.docker_stats)
313
+ dockerStatsEl.textContent = JSON.stringify(m.docker_stats, null, 2);
314
+
315
+ if (Array.isArray(m.containers)) {
316
+ containersList.innerHTML = "";
317
+ for (const c of m.containers) {
318
+ const li = document.createElement("li");
319
+ li.textContent = c["Container Name"] || c.name || JSON.stringify(c);
320
+ containersList.appendChild(li);
321
+ }
322
+ }
323
+
324
+ // ------------------- DISKS -------------------
325
+ if (Array.isArray(m.disks)) {
326
+ disksTableBody.innerHTML = "";
327
+ for (const d of m.disks) {
328
+ const tr = document.createElement("tr");
329
+ tr.innerHTML = `
330
+ <td>${d.name || d.device_id || ""}</td>
331
+ <td>${d.size || d.total || ""}</td>
332
+ <td>${(d.mountpoints || []).join(", ")}</td>
333
+ `;
334
+ disksTableBody.appendChild(tr);
335
+ }
336
+ }
337
+
338
+ // ------------------- CERTIFICATES -------------------
339
+ if (m.certificates) {
340
+ certsEl.textContent = JSON.stringify(m.certificates, null, 2);
341
+ }
342
+ }
343
+ }
344
+
345
+ // ------------------------------------------------------------
346
+ // EVENT BINDINGS
347
+ // ------------------------------------------------------------
348
+ targets.forEach(t => {
349
+ const opt = document.createElement("option");
350
+ opt.value = t.base_url;
351
+ opt.textContent = t.name || t.base_url;
352
+ nodeSelect.appendChild(opt);
353
+ });
354
+
355
+ let selectedBase =
356
+ nodeSelect.value || (targets[0] && targets[0].base_url);
357
+ nodeSelect.value = selectedBase;
358
+
359
+ nodeSelect.addEventListener("change", () => {
360
+ selectedBase = nodeSelect.value;
361
+ resetUI();
362
+ });
363
+
364
+ refreshBtn.addEventListener("click", resetUI);
365
+
366
+ showCoresCheckbox.addEventListener("change", () => {
367
+ const visible = showCoresCheckbox.checked;
368
+ Object.values(coreMini).forEach(c => {
369
+ c.el.style.display = visible ? "block" : "none";
370
+ });
371
+ });
372
+
373
+ // ------------------------------------------------------------
374
+ // WEBSOCKET
375
+ // ------------------------------------------------------------
376
+ const protocol = location.protocol === "https:" ? "wss" : "ws";
377
+ const ws = new WebSocket(`${protocol}://${location.host}/ws`);
378
+
379
+ ws.onmessage = evt => {
380
+ try {
381
+ const msg = JSON.parse(evt.data);
382
+ if (msg.type === "metrics") handleMetrics(msg.data);
383
+ } catch (err) {
384
+ console.error("WS parse error:", err);
385
+ }
386
+ };
387
+
388
+ // ------------------------------------------------------------
389
+ // INIT
390
+ // ------------------------------------------------------------
391
+ resetUI();
392
+ })();
@@ -0,0 +1,60 @@
1
+ :root {
2
+ --bg: #0b1220;
3
+ --panel: #0f1724;
4
+ --muted: #9fb0d7;
5
+ --accent: #63b3ff;
6
+ --glass: rgba(255,255,255,0.03);
7
+ }
8
+
9
+ * { box-sizing: border-box; }
10
+ html,body { height:100%; margin:0; font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; background:var(--bg); color:#e6eef8; }
11
+
12
+ .topbar {
13
+ display:flex; align-items:center; justify-content:space-between;
14
+ padding:12px 20px; background:linear-gradient(90deg,#071028,#0b1530); border-bottom:1px solid rgba(255,255,255,0.02);
15
+ }
16
+ .brand { font-weight:700; font-size:18px; }
17
+ .controls { display:flex; gap:8px; align-items:center; }
18
+
19
+ .controls select, .controls button { background:var(--panel); color:inherit; border:1px solid rgba(255,255,255,0.04); padding:6px 8px; border-radius:6px; }
20
+ .controls label { font-size:14px; color:var(--muted); margin-right:6px; }
21
+
22
+ .container { padding:18px; display:flex; flex-direction:column; gap:16px; max-width:1200px; margin:0 auto; }
23
+
24
+ .meta-row { display:flex; gap:12px; }
25
+ .meta-card { background:var(--panel); padding:12px; border-radius:8px; width:1fr; min-width:120px; flex:1; box-shadow:0 4px 12px rgba(2,6,23,0.6); }
26
+ .meta-title { font-size:12px; color:var(--muted); }
27
+ .meta-value { margin-top:6px; font-weight:600; font-size:16px; }
28
+ .meta-value.pre { white-space:pre-wrap; font-family:monospace; font-size:13px; }
29
+
30
+ .charts-row { display:flex; gap:12px; }
31
+ .panel { background:var(--panel); border-radius:8px; padding:12px; box-shadow:0 6px 18px rgba(2,6,23,0.6); flex:1; }
32
+ .panel.wide { flex:2; }
33
+ .panel.narrow { flex:0.8; }
34
+ .panel-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; }
35
+ .panel-header h3 { margin:0; font-size:14px; }
36
+ .panel-actions { font-size:13px; color:var(--muted); }
37
+
38
+ .chart { height:220px !important; width:100% !important; }
39
+ .chart-small { height:120px !important; width:100% !important; }
40
+
41
+ .details-row { display:flex; gap:12px; }
42
+ .cores-grid { display:grid; grid-template-columns: repeat(auto-fit,minmax(120px,1fr)); gap:8px; width:100%; overflow:hidden; }
43
+ .core-mini { background:var(--glass); padding:6px; border-radius:6px; text-align:center; font-size:12px; }
44
+ .core-mini .label { color:var(--muted); font-size:11px; }
45
+ .core-mini .value { font-weight:700; margin-top:6px; }
46
+
47
+ .tables-row { display:grid; grid-template-columns: repeat(2, 1fr); gap:12px; }
48
+ .table { width:100%; border-collapse:collapse; font-size:13px; }
49
+ .table th, .table td { padding:8px; border-bottom:1px solid rgba(255,255,255,0.03); text-align:left; }
50
+ .pre { background:rgba(255,255,255,0.02); padding:8px; border-radius:6px; overflow:auto; white-space:pre-wrap; font-family:monospace; font-size:13px; }
51
+ .list { padding-left:14px; margin:8px 0; }
52
+ .footer { padding:12px 20px; font-size:12px; color:var(--muted); text-align:center; border-top:1px solid rgba(255,255,255,0.02); margin-top:20px; }
53
+ .service-controls { display:flex; gap:8px; padding-bottom:8px; }
54
+ input#svc-filter { flex:1; padding:6px 8px; background:var(--panel); border:1px solid rgba(255,255,255,0.04); color:inherit; border-radius:6px; }
55
+
56
+ @media (max-width:900px) {
57
+ .tables-row { grid-template-columns: 1fr; }
58
+ .charts-row { flex-direction:column; }
59
+ .details-row { flex-direction:column; }
60
+ }
@@ -0,0 +1,129 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Node Monitor — Single Node View</title>
7
+
8
+ <link rel="stylesheet" href="/static/styles.css" />
9
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
10
+ </head>
11
+ <body>
12
+ <header class="topbar">
13
+ <div class="brand">Node Monitor</div>
14
+
15
+ <div class="controls">
16
+ <label for="node-select">Node:</label>
17
+ <select id="node-select" aria-label="Select node"></select>
18
+ <button id="refresh-btn" title="Force refresh">Refresh</button>
19
+ </div>
20
+ </header>
21
+
22
+ <main class="container">
23
+ <section class="meta-row">
24
+ <div class="meta-card">
25
+ <div class="meta-title">IP</div>
26
+ <div id="ip" class="meta-value">—</div>
27
+ </div>
28
+
29
+ <div class="meta-card">
30
+ <div class="meta-title">GPU / CPU Model</div>
31
+ <div id="gpu" class="meta-value pre">—</div>
32
+ </div>
33
+
34
+ <div class="meta-card">
35
+ <div class="meta-title">CPU Load (1/5/15)</div>
36
+ <div id="cpuload" class="meta-value">—</div>
37
+ </div>
38
+
39
+ <div class="meta-card">
40
+ <div class="meta-title">Disk ( / )</div>
41
+ <div id="disk" class="meta-value pre">—</div>
42
+ </div>
43
+
44
+ <div class="meta-card">
45
+ <div class="meta-title">Memory</div>
46
+ <div id="memory" class="meta-value pre">—</div>
47
+ </div>
48
+ </section>
49
+
50
+ <section class="charts-row">
51
+ <div class="panel">
52
+ <div class="panel-header">
53
+ <h3>CPU (Average)</h3>
54
+ <div class="panel-actions">
55
+ <label><input type="checkbox" id="show-cores" /> Show per-core</label>
56
+ </div>
57
+ </div>
58
+ <canvas id="cpu-avg-chart" class="chart"></canvas>
59
+ </div>
60
+
61
+ <div class="panel">
62
+ <div class="panel-header"><h3>Memory (%)</h3></div>
63
+ <canvas id="mem-chart" class="chart"></canvas>
64
+ </div>
65
+ </section>
66
+
67
+ <section class="details-row">
68
+ <div class="panel wide">
69
+ <div class="panel-header"><h3>Per-Core (sparklines)</h3></div>
70
+ <div id="cores-grid" class="cores-grid"></div>
71
+ </div>
72
+
73
+ <div class="panel narrow">
74
+ <div class="panel-header"><h3>CPU Load History</h3></div>
75
+ <canvas id="load-chart" class="chart-small"></canvas>
76
+ </div>
77
+ </section>
78
+
79
+ <section class="tables-row">
80
+ <div class="panel">
81
+ <div class="panel-header"><h3>Services</h3></div>
82
+ <div class="panel-body service-controls">
83
+ <input id="svc-filter" placeholder="filter service name..." />
84
+ </div>
85
+ <table class="table" id="services-table">
86
+ <thead><tr><th>PID</th><th>Name</th><th>Status</th><th>CPU</th><th>Memory</th></tr></thead>
87
+ <tbody></tbody>
88
+ </table>
89
+ </div>
90
+
91
+ <div class="panel">
92
+ <div class="panel-header"><h3>Docker / Containers</h3></div>
93
+ <div class="panel-body">
94
+ <pre id="docker-stats" class="pre"></pre>
95
+ <h4>Containers</h4>
96
+ <ul id="containers-list" class="list"></ul>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="panel">
101
+ <div class="panel-header"><h3>Disks</h3></div>
102
+ <div class="panel-body">
103
+ <table class="table" id="disks-table">
104
+ <thead><tr><th>Name</th><th>Size</th><th>Mounts</th></tr></thead>
105
+ <tbody></tbody>
106
+ </table>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="panel">
111
+ <div class="panel-header"><h3>Certificates</h3></div>
112
+ <div class="panel-body">
113
+ <pre id="certificates" class="pre">—</pre>
114
+ </div>
115
+ </div>
116
+ </section>
117
+ </main>
118
+
119
+ <footer class="footer">
120
+ <div>OpenAPI: <code>/static/openapi.json</code> (uploaded file available at <code>/mnt/data/openapi.json</code>)</div>
121
+ </footer>
122
+
123
+ <script>
124
+ // inject server-side targets
125
+ window.MONITOR_TARGETS = {{ targets | tojson }};
126
+ </script>
127
+ <script src="/static/app.js"></script>
128
+ </body>
129
+ </html>
@@ -0,0 +1 @@
1
+ __version__ = "0.0.0a0"
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: PyObservability
3
+ Version: 0.0.0a0
4
+ Summary: Lightweight OS-agnostic observability UI for PyNinja
5
+ Author-email: Vignesh Rao <svignesh1793@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 TheVickypedia
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/thevickypedia/PyObservability
29
+ Project-URL: Docs, https://thevickypedia.github.io/PyObservability
30
+ Project-URL: Source, https://github.com/thevickypedia/PyObservability
31
+ Project-URL: Bug Tracker, https://github.com/thevickypedia/PyObservability/issues
32
+ Project-URL: Release Notes, https://github.com/thevickypedia/PyObservability/blob/main/release_notes.rst
33
+ Keywords: PyObservability,observability,system-monitor,PyNinja
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Development Status :: 3 - Alpha
37
+ Classifier: Operating System :: MacOS :: MacOS X
38
+ Classifier: Operating System :: Microsoft :: Windows
39
+ Classifier: Operating System :: POSIX :: Linux
40
+ Classifier: Topic :: System :: Monitoring
41
+ Requires-Python: >=3.11
42
+ Description-Content-Type: text/markdown
43
+ License-File: LICENSE
44
+ Requires-Dist: fastapi
45
+ Requires-Dist: uvicorn[standard]
46
+ Requires-Dist: aiohttp
47
+ Requires-Dist: jinja2
48
+ Requires-Dist: python-dotenv
49
+ Provides-Extra: dev
50
+ Requires-Dist: sphinx==5.1.1; extra == "dev"
51
+ Requires-Dist: pre-commit; extra == "dev"
52
+ Requires-Dist: recommonmark; extra == "dev"
53
+ Requires-Dist: gitverse; extra == "dev"
54
+ Dynamic: license-file
55
+
56
+ # PyObservability
@@ -0,0 +1,12 @@
1
+ pyobservability/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pyobservability/main.py,sha256=yegGcq_OTHGIeJOiJecxnNSBYQ_Hf2MRjJs4KgNZsrQ,1385
3
+ pyobservability/monitor.py,sha256=H_8-c5_S0QNWlGnMktEm1Z64T8HK2nXQ803V1scr6Ok,7393
4
+ pyobservability/version.py,sha256=MVtVCT-b01z6f8XUlOsWWZynQ9Wv001Mk-_erJHty6Y,24
5
+ pyobservability/static/app.js,sha256=dLxyB25ttKaSE1RLpCaqeLXRRwcWyre0icMDZUV6CvU,12351
6
+ pyobservability/static/styles.css,sha256=lUQ74-frlBuNcf6u_8KHUKcNsdMmb0vSxBBaDLPwGEI,3236
7
+ pyobservability/templates/index.html,sha256=6x_fCqTcgcA7eInq49VMsoAGW-Ks80k0by8LeP1rkvI,3988
8
+ pyobservability-0.0.0a0.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
9
+ pyobservability-0.0.0a0.dist-info/METADATA,sha256=E_g2OFzx8C103vvcVThDjaVvJjpBsj86Y9caIH4EZ6E,2663
10
+ pyobservability-0.0.0a0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ pyobservability-0.0.0a0.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
12
+ pyobservability-0.0.0a0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 TheVickypedia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pyobservability