@yuuki824/kanshi 0.1.0 → 0.1.2
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.
- package/.env.example +4 -4
- package/Dockerfile +24 -19
- package/README.md +139 -36
- package/docker-compose.yml +16 -7
- package/go.mod +3 -0
- package/internal/config/config.go +140 -0
- package/internal/dockerstats/dockerstats.go +501 -0
- package/internal/server/server.go +372 -0
- package/internal/storage/storage.go +586 -0
- package/internal/vitals/vitals.go +614 -0
- package/main.go +76 -0
- package/package.json +5 -3
- package/web/app.js +167 -8
- package/web/index.html +10 -0
- package/web/style.css +27 -0
- package/web/treemap.js +21 -63
- package/app/__init__.py +0 -0
- package/app/config.py +0 -72
- package/app/dockerstats.py +0 -207
- package/app/main.py +0 -174
- package/app/storage.py +0 -220
- package/app/vitals.py +0 -170
- package/requirements.txt +0 -4
package/app/main.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
"""Kanshi — at-a-glance vitals for a single homeserver."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import asyncio
|
|
5
|
-
import contextlib
|
|
6
|
-
import json
|
|
7
|
-
import time
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
|
|
10
|
-
from fastapi import FastAPI
|
|
11
|
-
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
12
|
-
from fastapi.staticfiles import StaticFiles
|
|
13
|
-
|
|
14
|
-
from . import dockerstats, storage, vitals
|
|
15
|
-
from .config import config
|
|
16
|
-
|
|
17
|
-
WEB = Path(__file__).resolve().parent.parent / "web"
|
|
18
|
-
|
|
19
|
-
_subscribers: set[asyncio.Queue] = set()
|
|
20
|
-
_latest: dict = {"vitals": None, "docker": None}
|
|
21
|
-
_wake = asyncio.Event()
|
|
22
|
-
_last_seen = 0.0
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
async def _sample_once() -> dict:
|
|
26
|
-
host, containers = await asyncio.gather(
|
|
27
|
-
asyncio.to_thread(vitals.sample),
|
|
28
|
-
dockerstats.sample(),
|
|
29
|
-
)
|
|
30
|
-
_latest["vitals"], _latest["docker"] = host, containers
|
|
31
|
-
return {"vitals": host, "docker": containers}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
async def _seed() -> None:
|
|
35
|
-
"""Fill the delta baselines so the first frame shows real numbers.
|
|
36
|
-
|
|
37
|
-
Both CPU readings are differences against a previous sample, so a cold
|
|
38
|
-
poller would otherwise publish a screen of zeros. One throwaway pass plus a
|
|
39
|
-
short gap costs ~0.1s and makes the first frame the user sees correct.
|
|
40
|
-
"""
|
|
41
|
-
vitals.prime()
|
|
42
|
-
try:
|
|
43
|
-
await dockerstats.sample()
|
|
44
|
-
except Exception:
|
|
45
|
-
pass
|
|
46
|
-
await asyncio.sleep(1.0)
|
|
47
|
-
vitals.prime()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
async def _poller() -> None:
|
|
51
|
-
await _seed()
|
|
52
|
-
while True:
|
|
53
|
-
idle = not _subscribers and (time.monotonic() - _last_seen) > config.idle_timeout
|
|
54
|
-
if idle:
|
|
55
|
-
# Nobody is watching: stop touching the Docker socket entirely and
|
|
56
|
-
# wait to be woken by a new subscriber or a REST request.
|
|
57
|
-
_wake.clear()
|
|
58
|
-
with contextlib.suppress(asyncio.TimeoutError):
|
|
59
|
-
await asyncio.wait_for(_wake.wait(), timeout=60.0)
|
|
60
|
-
await _seed()
|
|
61
|
-
continue
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
payload = await _sample_once()
|
|
65
|
-
except Exception as exc:
|
|
66
|
-
payload = {"error": f"{type(exc).__name__}: {exc}"}
|
|
67
|
-
for queue in list(_subscribers):
|
|
68
|
-
if queue.full():
|
|
69
|
-
with contextlib.suppress(asyncio.QueueEmpty):
|
|
70
|
-
queue.get_nowait() # drop the stale frame, never block the poller
|
|
71
|
-
with contextlib.suppress(asyncio.QueueFull):
|
|
72
|
-
queue.put_nowait(payload)
|
|
73
|
-
await asyncio.sleep(config.poll_interval)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _touch() -> None:
|
|
77
|
-
global _last_seen
|
|
78
|
-
_last_seen = time.monotonic()
|
|
79
|
-
_wake.set()
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@contextlib.asynccontextmanager
|
|
83
|
-
async def lifespan(app: FastAPI):
|
|
84
|
-
tasks = [asyncio.create_task(_poller()), asyncio.create_task(storage.loop())]
|
|
85
|
-
try:
|
|
86
|
-
yield
|
|
87
|
-
finally:
|
|
88
|
-
for task in tasks:
|
|
89
|
-
task.cancel()
|
|
90
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
|
91
|
-
await dockerstats.close()
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
app = FastAPI(title="Kanshi", lifespan=lifespan, docs_url=None, redoc_url=None)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@app.get("/api/vitals")
|
|
98
|
-
async def api_vitals():
|
|
99
|
-
_touch()
|
|
100
|
-
if _latest["vitals"] is None:
|
|
101
|
-
await _sample_once()
|
|
102
|
-
return _latest["vitals"]
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@app.get("/api/containers")
|
|
106
|
-
async def api_containers():
|
|
107
|
-
_touch()
|
|
108
|
-
if _latest["docker"] is None:
|
|
109
|
-
await _sample_once()
|
|
110
|
-
return _latest["docker"]
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
@app.get("/api/storage")
|
|
114
|
-
async def api_storage():
|
|
115
|
-
snap = storage.snapshot()
|
|
116
|
-
if snap["scanned_at"] is None and not snap["scanning"]:
|
|
117
|
-
await storage.scan(force=True)
|
|
118
|
-
snap = storage.snapshot()
|
|
119
|
-
return snap
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
@app.post("/api/storage/rescan")
|
|
123
|
-
async def api_rescan():
|
|
124
|
-
return await storage.scan()
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
@app.get("/api/config")
|
|
128
|
-
async def api_config():
|
|
129
|
-
return {
|
|
130
|
-
"poll_interval": config.poll_interval,
|
|
131
|
-
"storage_interval": config.storage_interval,
|
|
132
|
-
"tree_depth": config.tree_depth,
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
@app.get("/healthz")
|
|
137
|
-
async def healthz():
|
|
138
|
-
return {"ok": True}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
@app.get("/api/stream")
|
|
142
|
-
async def api_stream():
|
|
143
|
-
queue: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
144
|
-
_subscribers.add(queue)
|
|
145
|
-
_touch()
|
|
146
|
-
|
|
147
|
-
async def events():
|
|
148
|
-
try:
|
|
149
|
-
if _latest["vitals"] is not None:
|
|
150
|
-
yield f"data: {json.dumps(_latest)}\n\n"
|
|
151
|
-
while True:
|
|
152
|
-
try:
|
|
153
|
-
payload = await asyncio.wait_for(queue.get(), timeout=25.0)
|
|
154
|
-
except asyncio.TimeoutError:
|
|
155
|
-
yield ": keepalive\n\n" # keeps mobile proxies from closing the stream
|
|
156
|
-
continue
|
|
157
|
-
_touch()
|
|
158
|
-
yield f"data: {json.dumps(payload)}\n\n"
|
|
159
|
-
finally:
|
|
160
|
-
_subscribers.discard(queue)
|
|
161
|
-
|
|
162
|
-
return StreamingResponse(
|
|
163
|
-
events(),
|
|
164
|
-
media_type="text/event-stream",
|
|
165
|
-
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
@app.get("/")
|
|
170
|
-
async def index():
|
|
171
|
-
return FileResponse(WEB / "index.html", headers={"Cache-Control": "no-cache"})
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
app.mount("/static", StaticFiles(directory=WEB), name="static")
|
package/app/storage.py
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
"""Filelight-style directory size tree.
|
|
2
|
-
|
|
3
|
-
The walk is a single os.scandir pass per root, run in a worker thread on a slow
|
|
4
|
-
timer and cached — never recomputed per refresh. Sizes come from st_blocks so
|
|
5
|
-
they match `du` (actual blocks on disk) rather than apparent size, and hardlinked
|
|
6
|
-
files are counted once.
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import asyncio
|
|
11
|
-
import heapq
|
|
12
|
-
import os
|
|
13
|
-
import time
|
|
14
|
-
|
|
15
|
-
from .config import config
|
|
16
|
-
|
|
17
|
-
# Never ship a tile smaller than this, regardless of the fraction threshold.
|
|
18
|
-
MIN_ABSOLUTE = 4 * 1024 * 1024
|
|
19
|
-
# Largest individual files kept per directory; the rest are aggregated.
|
|
20
|
-
TOP_FILES = 12
|
|
21
|
-
|
|
22
|
-
_state: dict = {"roots": [], "scanned_at": None, "duration": None, "scanning": False, "error": None}
|
|
23
|
-
_lock = asyncio.Lock()
|
|
24
|
-
_last_scan = 0.0
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _walk(root_path: str, max_depth: int) -> tuple[dict, str, int]:
|
|
28
|
-
"""Iterative DFS. Returns (nodes-by-path, root-path, unreadable-dir-count).
|
|
29
|
-
|
|
30
|
-
Only directories within `max_depth` of the root get a node of their own.
|
|
31
|
-
Anything deeper is still fully traversed and counted, but its bytes roll up
|
|
32
|
-
into the nearest retained ancestor — the tree is pruned to this depth before
|
|
33
|
-
it is served anyway, so materialising a node per directory just burns memory.
|
|
34
|
-
(On this host that is ~2.3k retained nodes instead of ~96k.)
|
|
35
|
-
"""
|
|
36
|
-
root_dev = os.stat(root_path).st_dev
|
|
37
|
-
excluded = set(config.storage_exclude)
|
|
38
|
-
unreadable = 0
|
|
39
|
-
# Packed into one int rather than a (dev, ino) tuple: overlay2 hardlinks
|
|
40
|
-
# everything, so this set reaches six figures and tuples cost ~3x the ints.
|
|
41
|
-
seen_inodes: set[int] = set()
|
|
42
|
-
nodes: dict[str, dict] = {}
|
|
43
|
-
order: list[str] = []
|
|
44
|
-
# (path, depth, nearest retained ancestor path)
|
|
45
|
-
stack: list[tuple[str, int, str | None]] = [(root_path, 0, None)]
|
|
46
|
-
|
|
47
|
-
while stack:
|
|
48
|
-
cur, depth, anchor = stack.pop()
|
|
49
|
-
if cur in excluded:
|
|
50
|
-
continue
|
|
51
|
-
|
|
52
|
-
if depth <= max_depth:
|
|
53
|
-
node = {
|
|
54
|
-
"name": os.path.basename(cur.rstrip("/")) or cur,
|
|
55
|
-
"self": 0, # bytes of files directly inside this dir
|
|
56
|
-
"deep": 0, # bytes below the retained depth
|
|
57
|
-
"children": [], # child directory paths that have their own node
|
|
58
|
-
"files": [], # min-heap of (size, name), capped at TOP_FILES
|
|
59
|
-
}
|
|
60
|
-
nodes[cur] = node
|
|
61
|
-
order.append(cur)
|
|
62
|
-
anchor = cur
|
|
63
|
-
else:
|
|
64
|
-
node = nodes[anchor]
|
|
65
|
-
|
|
66
|
-
try:
|
|
67
|
-
it = os.scandir(cur)
|
|
68
|
-
except OSError:
|
|
69
|
-
# A directory we cannot read would otherwise silently vanish from
|
|
70
|
-
# the totals — count it so the UI can say the tree is incomplete
|
|
71
|
-
# rather than quietly under-reporting.
|
|
72
|
-
unreadable += 1
|
|
73
|
-
continue
|
|
74
|
-
with it:
|
|
75
|
-
while True:
|
|
76
|
-
try:
|
|
77
|
-
entry = next(it)
|
|
78
|
-
except StopIteration:
|
|
79
|
-
break
|
|
80
|
-
except OSError:
|
|
81
|
-
break
|
|
82
|
-
try:
|
|
83
|
-
if entry.is_symlink():
|
|
84
|
-
continue # never follow; the link itself is negligible
|
|
85
|
-
st = entry.stat(follow_symlinks=False)
|
|
86
|
-
if entry.is_dir(follow_symlinks=False):
|
|
87
|
-
# Don't cross into other filesystems — each root is
|
|
88
|
-
# walked separately, so we'd otherwise double-count.
|
|
89
|
-
if st.st_dev != root_dev:
|
|
90
|
-
continue
|
|
91
|
-
stack.append((entry.path, depth + 1, anchor))
|
|
92
|
-
if depth + 1 <= max_depth:
|
|
93
|
-
node["children"].append(entry.path)
|
|
94
|
-
elif entry.is_file(follow_symlinks=False):
|
|
95
|
-
if st.st_nlink > 1:
|
|
96
|
-
key = (st.st_dev << 48) | st.st_ino
|
|
97
|
-
if key in seen_inodes:
|
|
98
|
-
continue
|
|
99
|
-
seen_inodes.add(key)
|
|
100
|
-
size = st.st_blocks * 512
|
|
101
|
-
if depth <= max_depth:
|
|
102
|
-
node["self"] += size
|
|
103
|
-
if len(node["files"]) < TOP_FILES:
|
|
104
|
-
heapq.heappush(node["files"], (size, entry.name))
|
|
105
|
-
elif size > node["files"][0][0]:
|
|
106
|
-
heapq.heapreplace(node["files"], (size, entry.name))
|
|
107
|
-
else:
|
|
108
|
-
node["deep"] += size
|
|
109
|
-
except OSError:
|
|
110
|
-
continue
|
|
111
|
-
|
|
112
|
-
# Children always follow their parent in a DFS pre-order, so walking the
|
|
113
|
-
# order backwards guarantees every child is totalled before its parent.
|
|
114
|
-
for path in reversed(order):
|
|
115
|
-
node = nodes[path]
|
|
116
|
-
node["size"] = node["self"] + node["deep"] + sum(nodes[c]["size"] for c in node["children"])
|
|
117
|
-
return nodes, root_path, unreadable
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
def _to_tree(nodes: dict, path: str, depth: int) -> dict:
|
|
121
|
-
node = nodes[path]
|
|
122
|
-
total = node["size"]
|
|
123
|
-
out = {"name": node["name"], "path": path, "size": total, "kind": "dir"}
|
|
124
|
-
|
|
125
|
-
if depth <= 0 or total <= 0:
|
|
126
|
-
return out
|
|
127
|
-
|
|
128
|
-
entries: list[dict] = []
|
|
129
|
-
for child in node["children"]:
|
|
130
|
-
entries.append({"_path": child, "size": nodes[child]["size"], "kind": "dir"})
|
|
131
|
-
kept_files = sorted(node["files"], reverse=True)
|
|
132
|
-
file_bytes_kept = 0
|
|
133
|
-
for size, name in kept_files:
|
|
134
|
-
entries.append({"name": name, "size": size, "kind": "file"})
|
|
135
|
-
file_bytes_kept += size
|
|
136
|
-
remainder = node["self"] + node["deep"] - file_bytes_kept
|
|
137
|
-
if remainder > 0:
|
|
138
|
-
entries.append({"name": "other files", "size": remainder, "kind": "rest"})
|
|
139
|
-
|
|
140
|
-
entries.sort(key=lambda e: -e["size"])
|
|
141
|
-
threshold = max(total * config.tree_min_fraction, MIN_ABSOLUTE)
|
|
142
|
-
|
|
143
|
-
children, folded, folded_count = [], 0, 0
|
|
144
|
-
for entry in entries:
|
|
145
|
-
if entry["size"] < threshold or len(children) >= config.tree_max_children:
|
|
146
|
-
folded += entry["size"]
|
|
147
|
-
folded_count += 1
|
|
148
|
-
continue
|
|
149
|
-
if entry["kind"] == "dir":
|
|
150
|
-
children.append(_to_tree(nodes, entry["_path"], depth - 1))
|
|
151
|
-
else:
|
|
152
|
-
children.append({"name": entry["name"], "size": entry["size"], "kind": entry["kind"]})
|
|
153
|
-
if folded > 0:
|
|
154
|
-
# Keep the aggregate so child sizes still sum to the parent — otherwise
|
|
155
|
-
# the treemap silently under-reports and the areas lie.
|
|
156
|
-
children.append({"name": f"{folded_count} smaller items", "size": folded, "kind": "rest"})
|
|
157
|
-
|
|
158
|
-
if children:
|
|
159
|
-
out["children"] = children
|
|
160
|
-
return out
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def _scan_sync() -> list[dict]:
|
|
164
|
-
# The walk is a long burst of syscalls competing with the live poller for
|
|
165
|
-
# this container's CPU quota. Deprioritise it so a rescan never makes the
|
|
166
|
-
# at-a-glance numbers stutter — the walk finishing a few seconds later is
|
|
167
|
-
# invisible, a frozen dashboard is not.
|
|
168
|
-
try:
|
|
169
|
-
os.nice(10)
|
|
170
|
-
except OSError:
|
|
171
|
-
pass
|
|
172
|
-
|
|
173
|
-
trees = []
|
|
174
|
-
for label, path in config.roots():
|
|
175
|
-
if not os.path.isdir(path):
|
|
176
|
-
continue
|
|
177
|
-
started = time.monotonic()
|
|
178
|
-
nodes, root, unreadable = _walk(path, config.tree_depth)
|
|
179
|
-
tree = _to_tree(nodes, root, config.tree_depth)
|
|
180
|
-
tree["name"] = label
|
|
181
|
-
tree["root"] = path
|
|
182
|
-
tree["unreadable"] = unreadable
|
|
183
|
-
tree["walk_seconds"] = round(time.monotonic() - started, 2)
|
|
184
|
-
trees.append(tree)
|
|
185
|
-
return trees
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
async def scan(force: bool = False) -> dict:
|
|
189
|
-
"""Rescan the roots. Rate-limited unless `force` is set by the timer."""
|
|
190
|
-
global _last_scan
|
|
191
|
-
if _lock.locked():
|
|
192
|
-
return _state
|
|
193
|
-
since = time.monotonic() - _last_scan
|
|
194
|
-
if not force and _last_scan and since < config.storage_min_rescan:
|
|
195
|
-
return _state
|
|
196
|
-
|
|
197
|
-
async with _lock:
|
|
198
|
-
_state["scanning"] = True
|
|
199
|
-
started = time.time()
|
|
200
|
-
try:
|
|
201
|
-
trees = await asyncio.to_thread(_scan_sync)
|
|
202
|
-
_state.update(roots=trees, scanned_at=started, error=None,
|
|
203
|
-
duration=round(time.time() - started, 2))
|
|
204
|
-
except Exception as exc:
|
|
205
|
-
_state["error"] = f"{type(exc).__name__}: {exc}"
|
|
206
|
-
finally:
|
|
207
|
-
_state["scanning"] = False
|
|
208
|
-
_last_scan = time.monotonic()
|
|
209
|
-
return _state
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def snapshot() -> dict:
|
|
213
|
-
return _state
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
async def loop() -> None:
|
|
217
|
-
"""Background refresher — slow by default (15 min)."""
|
|
218
|
-
while True:
|
|
219
|
-
await scan(force=True)
|
|
220
|
-
await asyncio.sleep(max(60.0, config.storage_interval))
|
package/app/vitals.py
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
"""Host-wide CPU / memory / disk / network vitals via psutil.
|
|
2
|
-
|
|
3
|
-
Kanshi runs with `network_mode: host` and without lxcfs, so /proc/stat,
|
|
4
|
-
/proc/meminfo, /proc/diskstats and /proc/net/dev all report real host values
|
|
5
|
-
and psutil needs no special configuration.
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import os
|
|
10
|
-
import time
|
|
11
|
-
|
|
12
|
-
import psutil
|
|
13
|
-
|
|
14
|
-
from .config import config
|
|
15
|
-
|
|
16
|
-
_last_net: tuple[float, int, int] | None = None
|
|
17
|
-
_last_disk: tuple[float, int, int] | None = None
|
|
18
|
-
_last_rates: dict[str, tuple[float, float]] = {"net": (0.0, 0.0), "disk": (0.0, 0.0)}
|
|
19
|
-
_last_cpu_call = 0.0
|
|
20
|
-
|
|
21
|
-
# Below this gap the counter deltas are too small to divide by: a 20ms window
|
|
22
|
-
# turns a routine 2MB read into a fake 100MB/s spike. Hold the previous rate.
|
|
23
|
-
MIN_DT = 0.5
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def prime() -> None:
|
|
27
|
-
"""Seed psutil's internal counters so the first sample isn't all zeros."""
|
|
28
|
-
global _last_cpu_call
|
|
29
|
-
psutil.cpu_percent(percpu=True, interval=None)
|
|
30
|
-
_last_cpu_call = time.monotonic()
|
|
31
|
-
_rate_net()
|
|
32
|
-
_rate_disk()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _cpu_cores() -> list[float]:
|
|
36
|
-
"""Per-core utilisation since the previous call.
|
|
37
|
-
|
|
38
|
-
psutil's interval=None form is a delta against the last call, so if that
|
|
39
|
-
call was moments ago every core reads 0%. When the gap is too short to be
|
|
40
|
-
meaningful, measure a real (short, blocking) window instead — sample() runs
|
|
41
|
-
in a worker thread, so this never stalls the event loop.
|
|
42
|
-
"""
|
|
43
|
-
global _last_cpu_call
|
|
44
|
-
gap = time.monotonic() - _last_cpu_call
|
|
45
|
-
cores = psutil.cpu_percent(percpu=True, interval=None if gap >= MIN_DT else 0.25)
|
|
46
|
-
_last_cpu_call = time.monotonic()
|
|
47
|
-
return cores
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _rate_net() -> tuple[float, float]:
|
|
51
|
-
global _last_net
|
|
52
|
-
now = time.monotonic()
|
|
53
|
-
try:
|
|
54
|
-
c = psutil.net_io_counters()
|
|
55
|
-
except Exception:
|
|
56
|
-
return (0.0, 0.0)
|
|
57
|
-
prev = _last_net
|
|
58
|
-
if prev is not None and now - prev[0] < MIN_DT:
|
|
59
|
-
return _last_rates["net"]
|
|
60
|
-
_last_net = (now, c.bytes_recv, c.bytes_sent)
|
|
61
|
-
if prev is None:
|
|
62
|
-
return (0.0, 0.0)
|
|
63
|
-
dt = now - prev[0]
|
|
64
|
-
# Counters are 64-bit but can reset if an interface disappears; clamp.
|
|
65
|
-
rates = (max(0, c.bytes_recv - prev[1]) / dt, max(0, c.bytes_sent - prev[2]) / dt)
|
|
66
|
-
_last_rates["net"] = rates
|
|
67
|
-
return rates
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _rate_disk() -> tuple[float, float]:
|
|
71
|
-
global _last_disk
|
|
72
|
-
now = time.monotonic()
|
|
73
|
-
try:
|
|
74
|
-
c = psutil.disk_io_counters()
|
|
75
|
-
except Exception:
|
|
76
|
-
return (0.0, 0.0)
|
|
77
|
-
if c is None:
|
|
78
|
-
return (0.0, 0.0)
|
|
79
|
-
prev = _last_disk
|
|
80
|
-
if prev is not None and now - prev[0] < MIN_DT:
|
|
81
|
-
return _last_rates["disk"]
|
|
82
|
-
_last_disk = (now, c.read_bytes, c.write_bytes)
|
|
83
|
-
if prev is None:
|
|
84
|
-
return (0.0, 0.0)
|
|
85
|
-
dt = now - prev[0]
|
|
86
|
-
rates = (max(0, c.read_bytes - prev[1]) / dt, max(0, c.write_bytes - prev[2]) / dt)
|
|
87
|
-
_last_rates["disk"] = rates
|
|
88
|
-
return rates
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _temperature() -> float | None:
|
|
92
|
-
"""Best-effort package temperature; needs /sys mounted (Docker does by default)."""
|
|
93
|
-
try:
|
|
94
|
-
temps = psutil.sensors_temperatures()
|
|
95
|
-
except Exception:
|
|
96
|
-
return None
|
|
97
|
-
for key in ("coretemp", "k10temp", "cpu_thermal", "acpitz", "zenpower"):
|
|
98
|
-
for entry in temps.get(key, []):
|
|
99
|
-
if entry.current:
|
|
100
|
-
return round(entry.current, 1)
|
|
101
|
-
for entries in temps.values():
|
|
102
|
-
for entry in entries:
|
|
103
|
-
if entry.current:
|
|
104
|
-
return round(entry.current, 1)
|
|
105
|
-
return None
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def filesystems() -> list[dict]:
|
|
109
|
-
"""Usage for each configured storage root, read straight off statvfs."""
|
|
110
|
-
out = []
|
|
111
|
-
for label, path in config.roots():
|
|
112
|
-
try:
|
|
113
|
-
st = os.statvfs(path)
|
|
114
|
-
except OSError:
|
|
115
|
-
continue
|
|
116
|
-
total = st.f_blocks * st.f_frsize
|
|
117
|
-
# f_bavail excludes root-reserved blocks, so used+free won't equal total.
|
|
118
|
-
# Report "used" the way df does, against the non-reserved capacity.
|
|
119
|
-
free = st.f_bavail * st.f_frsize
|
|
120
|
-
used = total - st.f_bfree * st.f_frsize
|
|
121
|
-
if total <= 0:
|
|
122
|
-
continue
|
|
123
|
-
out.append({
|
|
124
|
-
"label": label,
|
|
125
|
-
"path": path,
|
|
126
|
-
"total": total,
|
|
127
|
-
"used": used,
|
|
128
|
-
"free": free,
|
|
129
|
-
"percent": round(used / (used + free) * 100, 1) if used + free else 0.0,
|
|
130
|
-
})
|
|
131
|
-
return out
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def sample() -> dict:
|
|
135
|
-
per_core = _cpu_cores()
|
|
136
|
-
mem = psutil.virtual_memory()
|
|
137
|
-
swap = psutil.swap_memory()
|
|
138
|
-
rx, tx = _rate_net()
|
|
139
|
-
read, write = _rate_disk()
|
|
140
|
-
try:
|
|
141
|
-
load1, load5, load15 = os.getloadavg()
|
|
142
|
-
except OSError:
|
|
143
|
-
load1 = load5 = load15 = 0.0
|
|
144
|
-
|
|
145
|
-
return {
|
|
146
|
-
"ts": time.time(),
|
|
147
|
-
"cpu": {
|
|
148
|
-
"percent": round(sum(per_core) / len(per_core), 1) if per_core else 0.0,
|
|
149
|
-
"cores": [round(c, 1) for c in per_core],
|
|
150
|
-
"load": [round(load1, 2), round(load5, 2), round(load15, 2)],
|
|
151
|
-
"count": len(per_core),
|
|
152
|
-
"temp": _temperature(),
|
|
153
|
-
},
|
|
154
|
-
"memory": {
|
|
155
|
-
"total": mem.total,
|
|
156
|
-
"used": mem.total - mem.available,
|
|
157
|
-
"available": mem.available,
|
|
158
|
-
"cached": getattr(mem, "cached", 0) + getattr(mem, "buffers", 0),
|
|
159
|
-
"percent": round((mem.total - mem.available) / mem.total * 100, 1) if mem.total else 0.0,
|
|
160
|
-
},
|
|
161
|
-
"swap": {
|
|
162
|
-
"total": swap.total,
|
|
163
|
-
"used": swap.used,
|
|
164
|
-
"percent": round(swap.percent, 1),
|
|
165
|
-
},
|
|
166
|
-
"network": {"rx": rx, "tx": tx},
|
|
167
|
-
"diskio": {"read": read, "write": write},
|
|
168
|
-
"filesystems": filesystems(),
|
|
169
|
-
"uptime": time.time() - psutil.boot_time(),
|
|
170
|
-
}
|
package/requirements.txt
DELETED