@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/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
@@ -1,4 +0,0 @@
1
- fastapi==0.115.6
2
- uvicorn[standard]==0.34.0
3
- psutil==6.1.1
4
- httpx==0.28.1