@yuuki824/kanshi 0.1.0

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/storage.py ADDED
@@ -0,0 +1,220 @@
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 ADDED
@@ -0,0 +1,170 @@
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/bin/kanshi.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { existsSync } = require('node:fs');
4
+ const { spawnSync } = require('node:child_process');
5
+ const { join } = require('node:path');
6
+
7
+ const packageRoot = join(__dirname, '..');
8
+ const composeFile = join(packageRoot, 'docker-compose.yml');
9
+ const callerDirectory = process.cwd();
10
+ const args = process.argv.slice(2);
11
+
12
+ if (args.includes('--help') || args.includes('-h')) {
13
+ console.log(`Usage: npx @yuuki824/kanshi [docker-compose arguments]
14
+
15
+ Starts Kanshi with Docker Compose. A .env file in the current directory is used
16
+ when present. Common commands:
17
+ npx @yuuki824/kanshi
18
+ npx @yuuki824/kanshi logs -f
19
+ npx @yuuki824/kanshi down
20
+
21
+ Docker and the Docker Compose v2 plugin are required.`);
22
+ process.exit(0);
23
+ }
24
+
25
+ const dockerCheck = spawnSync('docker', ['compose', 'version'], { stdio: 'ignore' });
26
+ if (dockerCheck.error || dockerCheck.status !== 0) {
27
+ console.error('Kanshi requires Docker with the Docker Compose v2 plugin.');
28
+ console.error('Install Docker first, then run this command again.');
29
+ process.exit(1);
30
+ }
31
+
32
+ const composeArgs = [
33
+ 'compose',
34
+ '--project-directory', packageRoot,
35
+ '--project-name', 'kanshi',
36
+ '--file', composeFile,
37
+ ];
38
+
39
+ const envFile = join(callerDirectory, '.env');
40
+ if (callerDirectory !== packageRoot && existsSync(envFile)) {
41
+ composeArgs.push('--env-file', envFile);
42
+ }
43
+
44
+ if (args.length === 0) {
45
+ composeArgs.push('up', '--detach', '--build');
46
+ } else {
47
+ composeArgs.push(...args);
48
+ }
49
+
50
+ const result = spawnSync('docker', composeArgs, { stdio: 'inherit' });
51
+ if (result.error) {
52
+ console.error(`Could not start Docker: ${result.error.message}`);
53
+ process.exit(1);
54
+ }
55
+ process.exit(result.status ?? 1);
@@ -0,0 +1,59 @@
1
+ services:
2
+ kanshi:
3
+ build: .
4
+ container_name: kanshi
5
+ restart: unless-stopped
6
+
7
+ # Host networking for two reasons: /proc/net/dev is namespaced, so it is the
8
+ # only way to report real host network throughput, and it lets us bind
9
+ # straight to the Tailscale address below.
10
+ network_mode: host
11
+
12
+ environment:
13
+ # Bind to the Tailscale IP only — not the LAN, not the public interface.
14
+ # Reachable at http://homeserver.tail7ec1d9.ts.net:8100 from any device on
15
+ # the tailnet. Set to 0.0.0.0 to also expose it on the LAN.
16
+ KANSHI_HOST: "${KANSHI_HOST:-100.73.243.14}"
17
+ KANSHI_PORT: "${KANSHI_PORT:-8100}"
18
+
19
+ # Live poll cadence. Each tick is one /containers/json plus one /stats per
20
+ # running container, so keep this conservative.
21
+ KANSHI_POLL_INTERVAL: "${KANSHI_POLL_INTERVAL:-5}"
22
+ KANSHI_DOCKER_CONCURRENCY: "${KANSHI_DOCKER_CONCURRENCY:-8}"
23
+ # Polling stops entirely once no browser has been connected this long.
24
+ KANSHI_IDLE_TIMEOUT: "${KANSHI_IDLE_TIMEOUT:-30}"
25
+
26
+ # Storage walk: label=path pairs. Cached, never recomputed per refresh.
27
+ KANSHI_STORAGE_ROOTS: "${KANSHI_STORAGE_ROOTS:-/mnt/data=/mnt/data,/=/hostfs}"
28
+ KANSHI_STORAGE_INTERVAL: "${KANSHI_STORAGE_INTERVAL:-1800}"
29
+ # Container-side paths to skip. /hostfs/var/lib/docker is ~48 GB across
30
+ # ~100k overlay2 files and dominates the walk time — uncomment to trim it.
31
+ KANSHI_STORAGE_EXCLUDE: "${KANSHI_STORAGE_EXCLUDE:-}"
32
+ KANSHI_TREE_DEPTH: "${KANSHI_TREE_DEPTH:-4}"
33
+
34
+ volumes:
35
+ # Read-only on the socket is a mount flag, not an API restriction — it is
36
+ # the Tailscale-only bind above that keeps this out of reach.
37
+ - /var/run/docker.sock:/var/run/docker.sock:ro
38
+ - /mnt/data:/mnt/data:ro
39
+ - /:/hostfs:ro
40
+
41
+ read_only: true
42
+ tmpfs:
43
+ - /tmp:size=16m
44
+ cap_drop: [ALL]
45
+ # Read/traverse bypass only — it does NOT grant write bypass (that would be
46
+ # DAC_OVERRIDE). Without it the walk cannot enter /home/yuuki (0750) and
47
+ # silently under-reports the root filesystem by ~330 GB.
48
+ cap_add: [DAC_READ_SEARCH]
49
+ security_opt:
50
+ - no-new-privileges:true
51
+
52
+ # Stays out of the way of the other ~28 containers on this 4-core box.
53
+ # Steady state is ~1% of one core; the ceiling is headroom for the walk.
54
+ cpus: "1.0"
55
+ mem_limit: 256m
56
+
57
+ logging:
58
+ driver: json-file
59
+ options: { max-size: "10m", max-file: "3" }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@yuuki824/kanshi",
3
+ "version": "0.1.0",
4
+ "description": "Launch the Kanshi Docker host dashboard with npx.",
5
+ "license": "UNLICENSED",
6
+ "bin": {
7
+ "kanshi": "bin/kanshi.js"
8
+ },
9
+ "files": [
10
+ "app",
11
+ "web",
12
+ "bin",
13
+ "Dockerfile",
14
+ "docker-compose.yml",
15
+ "requirements.txt",
16
+ "README.md",
17
+ ".env.example"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "scripts": {
23
+ "pack:check": "npm pack --dry-run",
24
+ "test": "node --check bin/kanshi.js"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "keywords": [
30
+ "docker",
31
+ "dashboard",
32
+ "monitoring",
33
+ "tailscale"
34
+ ]
35
+ }
@@ -0,0 +1,4 @@
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ psutil==6.1.1
4
+ httpx==0.28.1