PyObservability 0.0.0a0__py3-none-any.whl → 0.0.0a2__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/__init__.py +68 -0
- pyobservability/config/settings.py +108 -0
- pyobservability/main.py +60 -16
- pyobservability/monitor.py +30 -66
- pyobservability/static/app.js +51 -17
- pyobservability/static/styles.css +1 -1
- pyobservability/templates/index.html +4 -3
- pyobservability/version.py +1 -1
- {pyobservability-0.0.0a0.dist-info → pyobservability-0.0.0a2.dist-info}/METADATA +8 -6
- pyobservability-0.0.0a2.dist-info/RECORD +14 -0
- pyobservability-0.0.0a2.dist-info/entry_points.txt +2 -0
- pyobservability-0.0.0a0.dist-info/RECORD +0 -12
- {pyobservability-0.0.0a0.dist-info → pyobservability-0.0.0a2.dist-info}/WHEEL +0 -0
- {pyobservability-0.0.0a0.dist-info → pyobservability-0.0.0a2.dist-info}/licenses/LICENSE +0 -0
- {pyobservability-0.0.0a0.dist-info → pyobservability-0.0.0a2.dist-info}/top_level.txt +0 -0
pyobservability/__init__.py
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Module for packaging."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from pyobservability.main import start # noqa: F401
|
|
6
|
+
from pyobservability.version import __version__
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _cli() -> None:
|
|
10
|
+
"""Starter function to invoke the observability UI via CLI commands.
|
|
11
|
+
|
|
12
|
+
**Flags**
|
|
13
|
+
- ``--version | -V``: Prints the version.
|
|
14
|
+
- ``--help | -H``: Prints the help section.
|
|
15
|
+
- ``--env | -E <path>``: Filepath to load environment variables.
|
|
16
|
+
|
|
17
|
+
**Commands**
|
|
18
|
+
``start``: Initiates the PyObservability as a regular script.
|
|
19
|
+
"""
|
|
20
|
+
assert sys.argv[0].endswith("pyobservability"), "Invalid commandline trigger!!"
|
|
21
|
+
options = {
|
|
22
|
+
"--version | -V": "Prints the version.",
|
|
23
|
+
"--help | -H": "Prints the help section.",
|
|
24
|
+
"--env | -E <path>": "Filepath to load environment variables.",
|
|
25
|
+
"start": "Initiates the PyObservability as a regular script.",
|
|
26
|
+
}
|
|
27
|
+
# weird way to increase spacing to keep all values monotonic
|
|
28
|
+
_longest_key = len(max(options.keys()))
|
|
29
|
+
_pretext = "\n\t* "
|
|
30
|
+
choices = _pretext + _pretext.join(
|
|
31
|
+
f"{k} {'·' * (_longest_key - len(k) + 8)}→ {v}".expandtabs() for k, v in options.items()
|
|
32
|
+
)
|
|
33
|
+
args = [arg.lower() for arg in sys.argv[1:]]
|
|
34
|
+
try:
|
|
35
|
+
assert len(args) > 1
|
|
36
|
+
except (IndexError, AttributeError, AssertionError):
|
|
37
|
+
print(f"Cannot proceed without a valid arbitrary command. Please choose from {choices}")
|
|
38
|
+
exit(1)
|
|
39
|
+
env_file = None
|
|
40
|
+
if any(arg in args for arg in ["version", "--version", "-v"]):
|
|
41
|
+
print(f"PyObservability: {__version__}")
|
|
42
|
+
exit(0)
|
|
43
|
+
elif any(arg in args for arg in ["help", "--help", "-h"]):
|
|
44
|
+
print(f"Usage: pyobservability [arbitrary-command]\nOptions (and corresponding behavior):{choices}")
|
|
45
|
+
exit(0)
|
|
46
|
+
elif any(arg in args for arg in ["env", "--env", "E", "-e"]):
|
|
47
|
+
extra_index = next(
|
|
48
|
+
(index for index, arg in enumerate(args) if arg in ["env", "--env", "E", "-e"]),
|
|
49
|
+
None,
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
env_file = sys.argv[extra_index + 2]
|
|
53
|
+
except (IndexError, TypeError):
|
|
54
|
+
print("Cannot proceed without a valid extra environment file path.")
|
|
55
|
+
exit(1)
|
|
56
|
+
elif any(arg in args for arg in ("start",)):
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
print(f"Unknown Option: {sys.argv[1]}\nArbitrary commands must be one of {choices}")
|
|
60
|
+
exit(1)
|
|
61
|
+
if any(arg in args for arg in ("start",)):
|
|
62
|
+
start(env_file=env_file)
|
|
63
|
+
else:
|
|
64
|
+
print(
|
|
65
|
+
"Insufficient Arguments:\n\tNo command received to initiate the PyObservability. "
|
|
66
|
+
f"Please choose from {choices}"
|
|
67
|
+
)
|
|
68
|
+
exit(1)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pathlib
|
|
3
|
+
import socket
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, HttpUrl, PositiveInt
|
|
8
|
+
from pydantic_settings import BaseSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PydanticEnvConfig(BaseSettings):
|
|
12
|
+
"""Pydantic BaseSettings with custom order for loading environment variables.
|
|
13
|
+
|
|
14
|
+
>>> PydanticEnvConfig
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def settings_customise_sources(
|
|
20
|
+
cls,
|
|
21
|
+
settings_cls,
|
|
22
|
+
init_settings,
|
|
23
|
+
env_settings,
|
|
24
|
+
dotenv_settings,
|
|
25
|
+
file_secret_settings,
|
|
26
|
+
):
|
|
27
|
+
"""Order: dotenv, env, init, secrets files."""
|
|
28
|
+
return dotenv_settings, env_settings, init_settings, file_secret_settings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MonitorTarget(BaseModel):
|
|
32
|
+
name: str
|
|
33
|
+
base_url: HttpUrl
|
|
34
|
+
apikey: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EnvConfig(PydanticEnvConfig):
|
|
38
|
+
"""Configuration settings for the server.
|
|
39
|
+
|
|
40
|
+
>>> EnvConfig
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
host: str = socket.gethostbyname("localhost") or "0.0.0.0"
|
|
45
|
+
port: PositiveInt = 8080
|
|
46
|
+
|
|
47
|
+
monitor_targets: List[MonitorTarget]
|
|
48
|
+
poll_interval: PositiveInt = 3
|
|
49
|
+
|
|
50
|
+
class Config:
|
|
51
|
+
"""Environment variables configuration."""
|
|
52
|
+
|
|
53
|
+
env_prefix = ""
|
|
54
|
+
extra = "forbid"
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_env_file(cls, filename: pathlib.Path) -> "EnvConfig":
|
|
58
|
+
"""Create an instance of EnvConfig from environment file.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
filename: Name of the env file.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
EnvConfig:
|
|
65
|
+
Loads the ``EnvConfig`` model.
|
|
66
|
+
"""
|
|
67
|
+
# noinspection PyArgumentList
|
|
68
|
+
return cls(_env_file=filename)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def env_loader(**kwargs) -> EnvConfig:
|
|
72
|
+
"""Loads environment variables based on filetypes or kwargs.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
EnvConfig:
|
|
76
|
+
Returns a reference to the ``EnvConfig`` object.
|
|
77
|
+
"""
|
|
78
|
+
# Default to .env if no kwargs were passed
|
|
79
|
+
if not kwargs:
|
|
80
|
+
return EnvConfig.from_env_file(".env")
|
|
81
|
+
# Look for the kwarg env_file and process accordingly
|
|
82
|
+
if env_file := kwargs.get("env_file"):
|
|
83
|
+
env_file = pathlib.Path(env_file)
|
|
84
|
+
assert env_file.is_file(), f"\n\tenv_file: [{env_file.resolve()!r}] does not exist"
|
|
85
|
+
if env_file.suffix.lower() == ".json":
|
|
86
|
+
with env_file.open() as stream:
|
|
87
|
+
env_data = json.load(stream)
|
|
88
|
+
return EnvConfig(**{k.lower(): v for k, v in env_data.items()})
|
|
89
|
+
elif env_file.suffix.lower() in (".yaml", ".yml"):
|
|
90
|
+
with env_file.open() as stream:
|
|
91
|
+
env_data = yaml.load(stream, yaml.FullLoader)
|
|
92
|
+
return EnvConfig(**{k.lower(): v for k, v in env_data.items()})
|
|
93
|
+
elif not env_file.suffix or env_file.suffix.lower() in (
|
|
94
|
+
".text",
|
|
95
|
+
".txt",
|
|
96
|
+
".env",
|
|
97
|
+
"",
|
|
98
|
+
):
|
|
99
|
+
return EnvConfig.from_env_file(env_file)
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"\n\tUnsupported format for {env_file!r}, " "can be one of (.json, .yaml, .yml, .txt, .text, .env)"
|
|
103
|
+
)
|
|
104
|
+
# Load env config with regular kwargs
|
|
105
|
+
return EnvConfig(**kwargs)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
env: EnvConfig
|
pyobservability/main.py
CHANGED
|
@@ -1,30 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
import asyncio
|
|
1
|
+
import logging
|
|
3
2
|
import pathlib
|
|
4
|
-
|
|
3
|
+
|
|
4
|
+
import uvicorn
|
|
5
|
+
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
|
6
|
+
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
5
7
|
from fastapi.staticfiles import StaticFiles
|
|
6
8
|
from fastapi.templating import Jinja2Templates
|
|
9
|
+
|
|
10
|
+
from pyobservability.config import settings
|
|
7
11
|
from pyobservability.monitor import Monitor
|
|
8
|
-
import os
|
|
9
|
-
from dotenv import load_dotenv
|
|
10
|
-
load_dotenv()
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
14
|
+
|
|
15
|
+
PyObservability = FastAPI(title="PyObservability")
|
|
16
|
+
PyObservability.__name__ = "PyObservability"
|
|
17
|
+
PyObservability.description = "Observability page for nodes running PyNinja"
|
|
18
|
+
|
|
13
19
|
root = pathlib.Path(__file__).parent
|
|
14
20
|
templates_dir = root / "templates"
|
|
15
|
-
static_dir = root / "static"
|
|
16
21
|
templates = Jinja2Templates(directory=templates_dir)
|
|
17
|
-
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
static_dir = root / "static"
|
|
24
|
+
PyObservability.mount("/static", StaticFiles(directory=static_dir), name="static")
|
|
25
|
+
|
|
20
26
|
|
|
21
|
-
@app.get("/")
|
|
22
27
|
async def index(request: Request):
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
"""Pass configured targets to the template so frontend can prebuild UI.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
request: FastAPI request object.
|
|
32
|
+
"""
|
|
33
|
+
return templates.TemplateResponse("index.html", {"request": request, "targets": settings.env.monitor_targets})
|
|
34
|
+
|
|
25
35
|
|
|
26
|
-
@app.websocket("/ws")
|
|
27
36
|
async def websocket_endpoint(websocket: WebSocket):
|
|
37
|
+
"""Websocket endpoint to render the metrics.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
websocket: FastAPI websocket object.
|
|
41
|
+
"""
|
|
42
|
+
monitor = Monitor(targets=settings.env.monitor_targets, poll_interval=settings.env.poll_interval)
|
|
28
43
|
await websocket.accept()
|
|
29
44
|
await monitor.start()
|
|
30
45
|
q = monitor.subscribe()
|
|
@@ -39,6 +54,35 @@ async def websocket_endpoint(websocket: WebSocket):
|
|
|
39
54
|
monitor.unsubscribe(q)
|
|
40
55
|
try:
|
|
41
56
|
await websocket.close()
|
|
42
|
-
except:
|
|
43
|
-
|
|
57
|
+
except Exception as err:
|
|
58
|
+
LOGGER.warning(err)
|
|
44
59
|
await monitor.stop()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
PyObservability.routes.append(
|
|
63
|
+
APIRoute(
|
|
64
|
+
path="/", # enums.APIEndpoints.root,
|
|
65
|
+
endpoint=index,
|
|
66
|
+
methods=["GET"],
|
|
67
|
+
include_in_schema=False,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
PyObservability.routes.append(
|
|
71
|
+
APIWebSocketRoute(
|
|
72
|
+
path="/ws",
|
|
73
|
+
endpoint=websocket_endpoint,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def start(**kwargs):
|
|
79
|
+
settings.env = settings.env_loader(**kwargs)
|
|
80
|
+
settings.env.monitor_targets = [
|
|
81
|
+
{k: str(v) for k, v in target.model_dump().items()} for target in settings.env.monitor_targets
|
|
82
|
+
]
|
|
83
|
+
uvicorn_args = dict(
|
|
84
|
+
host=settings.env.host,
|
|
85
|
+
port=settings.env.port,
|
|
86
|
+
app=PyObservability,
|
|
87
|
+
)
|
|
88
|
+
uvicorn.run(**uvicorn_args)
|
pyobservability/monitor.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# app/monitor.py
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import logging
|
|
5
|
+
from asyncio import CancelledError
|
|
6
6
|
from typing import Any, Dict, List
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
7
9
|
import aiohttp
|
|
8
|
-
from asyncio import CancelledError
|
|
9
10
|
|
|
11
|
+
LOGGER = logging.getLogger("uvicorn.default")
|
|
10
12
|
|
|
11
13
|
###############################################################################
|
|
12
14
|
# ENDPOINT DEFINITIONS (PyNinja Correct)
|
|
@@ -52,46 +54,20 @@ ENDPOINTS = {
|
|
|
52
54
|
"certificates": {
|
|
53
55
|
"path": "/get-certificates",
|
|
54
56
|
"params": {},
|
|
55
|
-
}
|
|
57
|
+
},
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
|
|
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
61
|
###############################################################################
|
|
87
62
|
# MONITOR CLASS
|
|
88
63
|
###############################################################################
|
|
89
64
|
|
|
65
|
+
|
|
90
66
|
class Monitor:
|
|
91
67
|
|
|
92
|
-
def __init__(self, poll_interval: float
|
|
93
|
-
self.targets =
|
|
94
|
-
self.poll_interval =
|
|
68
|
+
def __init__(self, targets: List[Dict[str, str]], poll_interval: float):
|
|
69
|
+
self.targets = targets
|
|
70
|
+
self.poll_interval = poll_interval
|
|
95
71
|
self.sessions: Dict[str, aiohttp.ClientSession] = {}
|
|
96
72
|
self._ws_subscribers: List[asyncio.Queue] = []
|
|
97
73
|
self._task = None
|
|
@@ -101,8 +77,8 @@ class Monitor:
|
|
|
101
77
|
# LIFECYCLE
|
|
102
78
|
############################################################################
|
|
103
79
|
async def start(self):
|
|
104
|
-
for
|
|
105
|
-
self.sessions[
|
|
80
|
+
for target in self.targets:
|
|
81
|
+
self.sessions[target["base_url"]] = aiohttp.ClientSession()
|
|
106
82
|
self._task = asyncio.create_task(self._run_loop())
|
|
107
83
|
|
|
108
84
|
async def stop(self):
|
|
@@ -134,49 +110,40 @@ class Monitor:
|
|
|
134
110
|
############################################################################
|
|
135
111
|
# FETCH WRAPPER
|
|
136
112
|
############################################################################
|
|
137
|
-
async def _fetch(self, session, base_url, ep,
|
|
113
|
+
async def _fetch(self, session, base_url, ep, headers: Dict[str, str], params=None):
|
|
138
114
|
url = base_url.rstrip("/") + ep
|
|
139
|
-
headers = {"accept": "application/json"}
|
|
140
|
-
if apikey:
|
|
141
|
-
headers["Authorization"] = f"Bearer {apikey}"
|
|
142
|
-
|
|
143
115
|
try:
|
|
144
116
|
async with session.get(url, headers=headers, params=params, timeout=10) as resp:
|
|
145
117
|
if resp.status == 200:
|
|
146
118
|
try:
|
|
147
119
|
return await resp.json()
|
|
148
|
-
except Exception:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
120
|
+
except Exception as err:
|
|
121
|
+
LOGGER.debug(err)
|
|
122
|
+
return "NO DATA"
|
|
123
|
+
parsed = urlparse(url)
|
|
124
|
+
LOGGER.debug("Exception on '%s' - [%d]: %s", parsed.path, resp.status, await resp.text())
|
|
125
|
+
return "NO DATA"
|
|
126
|
+
except Exception as err:
|
|
127
|
+
LOGGER.debug(err)
|
|
128
|
+
return "NO DATA"
|
|
153
129
|
|
|
154
130
|
############################################################################
|
|
155
131
|
# PER-TARGET POLLING
|
|
156
132
|
############################################################################
|
|
157
133
|
async def _poll_target(self, target: Dict[str, Any]) -> Dict[str, Any]:
|
|
158
134
|
base = target["base_url"]
|
|
159
|
-
apikey = target
|
|
135
|
+
apikey = target["apikey"]
|
|
160
136
|
session = self.sessions[base]
|
|
137
|
+
headers = {"Accept": "application/json", "Authorization": f"Bearer {apikey}"}
|
|
161
138
|
|
|
162
|
-
result = {
|
|
163
|
-
"name": target["name"],
|
|
164
|
-
"base_url": base,
|
|
165
|
-
"metrics": {}
|
|
166
|
-
}
|
|
139
|
+
result = {"name": target["name"], "base_url": base, "metrics": {}}
|
|
167
140
|
|
|
168
141
|
# Fire ALL requests concurrently
|
|
169
142
|
tasks = {}
|
|
170
143
|
|
|
171
144
|
for key, cfg in ENDPOINTS.items():
|
|
172
145
|
tasks[key] = asyncio.create_task(
|
|
173
|
-
self._fetch(
|
|
174
|
-
session,
|
|
175
|
-
base,
|
|
176
|
-
cfg["path"],
|
|
177
|
-
apikey=apikey,
|
|
178
|
-
params=cfg["params"]
|
|
179
|
-
)
|
|
146
|
+
self._fetch(session, base, cfg["path"], headers=headers, params=cfg["params"])
|
|
180
147
|
)
|
|
181
148
|
|
|
182
149
|
# Wait for all endpoints
|
|
@@ -184,7 +151,7 @@ class Monitor:
|
|
|
184
151
|
|
|
185
152
|
for (key, _), resp in zip(tasks.items(), raw_results):
|
|
186
153
|
if isinstance(resp, Exception):
|
|
187
|
-
result["metrics"][key] =
|
|
154
|
+
result["metrics"][key] = "NO DATA"
|
|
188
155
|
continue
|
|
189
156
|
if isinstance(resp, dict):
|
|
190
157
|
result["metrics"][key] = resp.get("detail", resp)
|
|
@@ -198,11 +165,12 @@ class Monitor:
|
|
|
198
165
|
# POLL ALL HOSTS
|
|
199
166
|
############################################################################
|
|
200
167
|
async def _poll_all(self) -> List[Dict[str, Any]]:
|
|
201
|
-
tasks = [self._poll_target(
|
|
168
|
+
tasks = [self._poll_target(target) for target in self.targets]
|
|
202
169
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
203
170
|
out = []
|
|
204
171
|
for r in results:
|
|
205
172
|
if isinstance(r, Exception):
|
|
173
|
+
LOGGER.error("%s", r)
|
|
206
174
|
out.append({"error": str(r)})
|
|
207
175
|
else:
|
|
208
176
|
out.append(r)
|
|
@@ -215,11 +183,7 @@ class Monitor:
|
|
|
215
183
|
while not self._stop.is_set():
|
|
216
184
|
metrics = await self._poll_all()
|
|
217
185
|
|
|
218
|
-
payload = {
|
|
219
|
-
"type": "metrics",
|
|
220
|
-
"ts": asyncio.get_event_loop().time(),
|
|
221
|
-
"data": metrics
|
|
222
|
-
}
|
|
186
|
+
payload = {"type": "metrics", "ts": asyncio.get_event_loop().time(), "data": metrics}
|
|
223
187
|
|
|
224
188
|
for q in list(self._ws_subscribers):
|
|
225
189
|
try:
|
pyobservability/static/app.js
CHANGED
|
@@ -27,8 +27,9 @@
|
|
|
27
27
|
const servicesTableBody = document.querySelector("#services-table tbody");
|
|
28
28
|
const svcFilter = document.getElementById("svc-filter");
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
const
|
|
30
|
+
const dockerTable = document.getElementById("docker-table");
|
|
31
|
+
const dockerTableHead = dockerTable.querySelector("thead");
|
|
32
|
+
const dockerTableBody = dockerTable.querySelector("tbody");
|
|
32
33
|
|
|
33
34
|
const disksTableBody = document.querySelector("#disks-table tbody");
|
|
34
35
|
const certsEl = document.getElementById("certificates");
|
|
@@ -182,8 +183,8 @@
|
|
|
182
183
|
loadEl.textContent = "—";
|
|
183
184
|
|
|
184
185
|
servicesTableBody.innerHTML = "";
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
dockerTableHead.innerHTML = "";
|
|
187
|
+
dockerTableBody.innerHTML = "";
|
|
187
188
|
disksTableBody.innerHTML = "";
|
|
188
189
|
certsEl.textContent = "—";
|
|
189
190
|
}
|
|
@@ -209,6 +210,19 @@
|
|
|
209
210
|
return Number.isFinite(n) ? n : null;
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
function formatStringOrObject(x) {
|
|
214
|
+
if (x == null) return "—";
|
|
215
|
+
if (typeof x === "string" || typeof x === "number") return x;
|
|
216
|
+
return Object.entries(x)
|
|
217
|
+
.map(([k,v]) => `${k}: ${v}`)
|
|
218
|
+
.join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function round2(x) {
|
|
222
|
+
const n = Number(x);
|
|
223
|
+
return Number.isFinite(n) ? n.toFixed(2) : "—";
|
|
224
|
+
}
|
|
225
|
+
|
|
212
226
|
// ------------------------------------------------------------
|
|
213
227
|
// METRICS HANDLER
|
|
214
228
|
// ------------------------------------------------------------
|
|
@@ -221,14 +235,22 @@
|
|
|
221
235
|
|
|
222
236
|
// ------------------- BASIC INFO -------------------
|
|
223
237
|
ipEl.textContent = m.ip?.ip || m.ip || "—";
|
|
224
|
-
gpuEl.textContent =
|
|
225
|
-
|
|
238
|
+
gpuEl.textContent = formatStringOrObject(m.gpu ?? m.cpu ?? "—");
|
|
239
|
+
if (m.disk) {
|
|
240
|
+
const total = m.disk.total ?? "—";
|
|
241
|
+
const used = m.disk.used ?? "—";
|
|
242
|
+
const free = m.disk.free ?? "—";
|
|
243
|
+
diskEl.textContent = `Total: ${total}\nUsed: ${used}\nFree: ${free}`;
|
|
244
|
+
} else {
|
|
245
|
+
diskEl.textContent = "—";
|
|
246
|
+
}
|
|
226
247
|
|
|
227
248
|
// ------------------- MEMORY -------------------
|
|
228
249
|
if (m.memory) {
|
|
229
250
|
const used = m.memory.ram_used || m.memory.used || "";
|
|
230
251
|
const percent = m.memory.ram_usage ?? m.memory.usage ?? m.memory.percent ?? "—";
|
|
231
|
-
|
|
252
|
+
const totalMem = m.memory.ram_total ?? m.memory.total ?? "—";
|
|
253
|
+
memEl.textContent = `Total: ${totalMem}\nUsed: ${used}\nPercent: ${percent}%`;
|
|
232
254
|
pushPoint(memChart, num(percent));
|
|
233
255
|
}
|
|
234
256
|
|
|
@@ -278,7 +300,7 @@
|
|
|
278
300
|
const m5 = load.m5 ?? load[1];
|
|
279
301
|
const m15 = load.m15 ?? load[2];
|
|
280
302
|
|
|
281
|
-
loadEl.textContent = `${m1} / ${m5} / ${m15}`;
|
|
303
|
+
loadEl.textContent = `${round2(m1)} / ${round2(m5)} / ${round2(m15)}`;
|
|
282
304
|
pushPoint(loadChart, num(m1) ?? 0);
|
|
283
305
|
} else {
|
|
284
306
|
loadEl.textContent = load;
|
|
@@ -309,16 +331,28 @@
|
|
|
309
331
|
}
|
|
310
332
|
|
|
311
333
|
// ------------------- DOCKER -------------------
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
334
|
+
const dockerList = m.docker_stats;
|
|
335
|
+
|
|
336
|
+
if (Array.isArray(dockerList) && dockerTableHead && dockerTableBody) {
|
|
337
|
+
dockerTableHead.innerHTML = "";
|
|
338
|
+
dockerTableBody.innerHTML = "";
|
|
339
|
+
|
|
340
|
+
if (dockerList.length > 0) {
|
|
341
|
+
// Table header
|
|
342
|
+
const columns = Object.keys(dockerList[0]);
|
|
343
|
+
dockerTableHead.innerHTML =
|
|
344
|
+
"<tr>" + columns.map(c => `<th>${c}</th>`).join("") + "</tr>";
|
|
345
|
+
|
|
346
|
+
// Table rows
|
|
347
|
+
dockerList.forEach(c => {
|
|
348
|
+
const row = "<tr>" +
|
|
349
|
+
columns.map(col => `<td>${c[col] ?? ""}</td>`).join("") +
|
|
350
|
+
"</tr>";
|
|
351
|
+
dockerTableBody.insertAdjacentHTML("beforeend", row);
|
|
352
|
+
});
|
|
321
353
|
}
|
|
354
|
+
} else {
|
|
355
|
+
console.warn("Cannot render Docker table:", dockerList, dockerTableHead, dockerTableBody);
|
|
322
356
|
}
|
|
323
357
|
|
|
324
358
|
// ------------------- DISKS -------------------
|
|
@@ -44,7 +44,7 @@ html,body { height:100%; margin:0; font-family: Inter, system-ui, -apple-system,
|
|
|
44
44
|
.core-mini .label { color:var(--muted); font-size:11px; }
|
|
45
45
|
.core-mini .value { font-weight:700; margin-top:6px; }
|
|
46
46
|
|
|
47
|
-
.tables-row { display:grid; grid-template-columns:
|
|
47
|
+
.tables-row { display:grid; grid-template-columns:1fr; grid-auto-rows:auto; gap:12px; }
|
|
48
48
|
.table { width:100%; border-collapse:collapse; font-size:13px; }
|
|
49
49
|
.table th, .table td { padding:8px; border-bottom:1px solid rgba(255,255,255,0.03); text-align:left; }
|
|
50
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; }
|
|
@@ -91,9 +91,10 @@
|
|
|
91
91
|
<div class="panel">
|
|
92
92
|
<div class="panel-header"><h3>Docker / Containers</h3></div>
|
|
93
93
|
<div class="panel-body">
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
<table class="table" id="docker-table">
|
|
95
|
+
<thead></thead>
|
|
96
|
+
<tbody></tbody>
|
|
97
|
+
</table>
|
|
97
98
|
</div>
|
|
98
99
|
</div>
|
|
99
100
|
|
pyobservability/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.0.
|
|
1
|
+
__version__ = "0.0.0a2"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyObservability
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.0a2
|
|
4
4
|
Summary: Lightweight OS-agnostic observability UI for PyNinja
|
|
5
5
|
Author-email: Vignesh Rao <svignesh1793@gmail.com>
|
|
6
6
|
License: MIT License
|
|
@@ -41,11 +41,13 @@ Classifier: Topic :: System :: Monitoring
|
|
|
41
41
|
Requires-Python: >=3.11
|
|
42
42
|
Description-Content-Type: text/markdown
|
|
43
43
|
License-File: LICENSE
|
|
44
|
-
Requires-Dist:
|
|
45
|
-
Requires-Dist:
|
|
46
|
-
Requires-Dist:
|
|
47
|
-
Requires-Dist:
|
|
48
|
-
Requires-Dist:
|
|
44
|
+
Requires-Dist: aiohttp==3.13.*
|
|
45
|
+
Requires-Dist: fastapi==0.122.*
|
|
46
|
+
Requires-Dist: Jinja2==3.1.*
|
|
47
|
+
Requires-Dist: pydantic==2.12.*
|
|
48
|
+
Requires-Dist: pydantic-settings==2.12.*
|
|
49
|
+
Requires-Dist: python-dotenv==1.2.*
|
|
50
|
+
Requires-Dist: uvicorn[standard]==0.38.*
|
|
49
51
|
Provides-Extra: dev
|
|
50
52
|
Requires-Dist: sphinx==5.1.1; extra == "dev"
|
|
51
53
|
Requires-Dist: pre-commit; extra == "dev"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pyobservability/__init__.py,sha256=rr4udGMbbNPl3yo7l8R3FUUVVahBtYVaW6vSWWgXlv0,2617
|
|
2
|
+
pyobservability/main.py,sha256=m3jNBQ7B495d1Pk_Fcdy3AQbNts2K8iFwDQDKV1pB0M,2527
|
|
3
|
+
pyobservability/monitor.py,sha256=s2sVp97sLjkkdtL6be82bX5ydu_gBdMSoWxDlmUtpgE,6613
|
|
4
|
+
pyobservability/version.py,sha256=za-UuO_D1PzxYCprpyh75AnwaFhntPuAZpHRqS1fIxc,24
|
|
5
|
+
pyobservability/config/settings.py,sha256=53dYdfO5SbmHQ4cLzPM2JQvrU2Lw70vBghlhiLy28ZI,3013
|
|
6
|
+
pyobservability/static/app.js,sha256=poc7eReoiRUbyI5JKnPwxSqmSNCuOge4aZKCITFy7eo,13494
|
|
7
|
+
pyobservability/static/styles.css,sha256=t6r1C0ueBanipgRRjdu18nmq6RbSGLK5bhpf0BdMOpQ,3245
|
|
8
|
+
pyobservability/templates/index.html,sha256=PsN3aq-7Q6RzGeshoNH5v37G3sHoI2saJq6mfuA6JYs,3977
|
|
9
|
+
pyobservability-0.0.0a2.dist-info/licenses/LICENSE,sha256=_sOIKJWdD2o1WwwDIwYB2qTP2nlSWqT5Tyg9jr1Xa4w,1070
|
|
10
|
+
pyobservability-0.0.0a2.dist-info/METADATA,sha256=y74dxaDkt1pTwgqFmkNj7bemJqOPqKUjfHBHPwWh0UA,2775
|
|
11
|
+
pyobservability-0.0.0a2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
pyobservability-0.0.0a2.dist-info/entry_points.txt,sha256=DSGIr_VA8Tb3FYa2iNUYpf55eAvuFCAoInNS4ngXaME,57
|
|
13
|
+
pyobservability-0.0.0a2.dist-info/top_level.txt,sha256=p20T0EmihDYW1uMintRXr7X9bg3XWYKyoSbBHOVC1xI,16
|
|
14
|
+
pyobservability-0.0.0a2.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|