@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/.env.example ADDED
@@ -0,0 +1,19 @@
1
+ # Copy to .env to override. Every value here is the built-in default.
2
+
3
+ # Bind address. The Tailscale IP of this host keeps the dashboard off the LAN
4
+ # and off the public interface; 0.0.0.0 exposes it everywhere.
5
+ KANSHI_HOST=100.73.243.14
6
+ KANSHI_PORT=8100
7
+
8
+ # Live metrics
9
+ KANSHI_POLL_INTERVAL=5
10
+ KANSHI_DOCKER_CONCURRENCY=8
11
+ KANSHI_IDLE_TIMEOUT=30
12
+
13
+ # Storage treemap
14
+ KANSHI_STORAGE_ROOTS=/mnt/data=/mnt/data,/=/hostfs
15
+ KANSHI_STORAGE_INTERVAL=1800
16
+ # Paths to skip entirely, comma separated (container-side paths).
17
+ # The biggest win is /hostfs/var/lib/docker — ~100k overlay2 files.
18
+ KANSHI_STORAGE_EXCLUDE=
19
+ KANSHI_TREE_DEPTH=4
package/Dockerfile ADDED
@@ -0,0 +1,27 @@
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONUNBUFFERED=1 \
4
+ PYTHONDONTWRITEBYTECODE=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ PIP_DISABLE_PIP_VERSION_CHECK=1
7
+
8
+ WORKDIR /opt/kanshi
9
+
10
+ COPY requirements.txt ./
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY app ./app
14
+ COPY web ./web
15
+
16
+ EXPOSE 8100
17
+
18
+ # Probe the address uvicorn actually bound to — with network_mode: host that
19
+ # may be the Tailscale IP rather than loopback.
20
+ HEALTHCHECK --interval=60s --timeout=5s --start-period=15s --retries=3 \
21
+ CMD python -c "import os,urllib.request,sys; \
22
+ h=os.environ.get('KANSHI_HOST','127.0.0.1'); h='127.0.0.1' if h=='0.0.0.0' else h; \
23
+ sys.exit(0 if urllib.request.urlopen('http://%s:%s/healthz' % (h, os.environ.get('KANSHI_PORT','8100')), timeout=4).status==200 else 1)"
24
+
25
+ CMD ["sh", "-c", "exec python -m uvicorn app.main:app \
26
+ --host ${KANSHI_HOST:-0.0.0.0} --port ${KANSHI_PORT:-8100} \
27
+ --no-access-log --timeout-keep-alive 65"]
package/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # kanshi
2
+
3
+ A one-page, mobile-first glance at this homeserver: live CPU and RAM, a
4
+ Filelight-style storage treemap, and `docker stats` for every container — no
5
+ historical storage, no alerting, no external services.
6
+
7
+ Reachable from any device on the tailnet:
8
+
9
+ http://homeserver.tail7ec1d9.ts.net:8100
10
+
11
+ ## Install with npx
12
+
13
+ `npx` downloads and runs an npm package; it does not upload a project. This
14
+ repository includes a small npm launcher which starts the included Docker
15
+ Compose app, so Docker (with the Compose v2 plugin) is still required.
16
+
17
+ From a directory containing your `.env` file:
18
+
19
+ ```sh
20
+ npx @yuuki824/kanshi
21
+ ```
22
+
23
+ The command is equivalent to `docker compose up -d --build`. It reads a `.env`
24
+ file from the directory where you run it, so first copy the template and set a
25
+ safe `KANSHI_HOST` for the host being monitored:
26
+
27
+ ```sh
28
+ cp .env.example .env
29
+ # edit .env, then:
30
+ npx @yuuki824/kanshi
31
+ ```
32
+
33
+ Pass Docker Compose commands after the package name, for example:
34
+
35
+ ```sh
36
+ npx @yuuki824/kanshi logs -f
37
+ npx @yuuki824/kanshi down
38
+ ```
39
+
40
+ To publish the launcher, use:
41
+
42
+ ```sh
43
+ npm login
44
+ npm run test
45
+ npm run pack:check
46
+ npm publish
47
+ ```
48
+
49
+ The scoped package is configured to publish publicly. Use a unique package
50
+ name you control; `npm publish --dry-run` is a final check that uploads nothing.
51
+
52
+ ## Run it
53
+
54
+ ```sh
55
+ docker compose up -d --build
56
+ ```
57
+
58
+ Copy `.env.example` to `.env` to override anything. Every setting has a
59
+ conservative default; the file documents each one.
60
+
61
+ ## What it shows
62
+
63
+ | Card | Source | Refresh |
64
+ |---|---|---|
65
+ | Processor — hero %, per-core bars, load, temp, host net/disk throughput | `psutil` over `/proc` | every `KANSHI_POLL_INTERVAL` (5s) |
66
+ | Memory & volumes — RAM, swap, one meter per storage root | `psutil` + `statvfs` | same tick |
67
+ | Storage map — squarified treemap, tap to drill, table twin below | cached `os.scandir` walk | every `KANSHI_STORAGE_INTERVAL` (30m), or the Rescan button |
68
+ | Containers — CPU%, memory, network rates, health | Docker Engine API | same tick |
69
+
70
+ The browser holds **one SSE connection** (`/api/stream`) and the server pushes
71
+ each tick, so nothing polls and nothing needs a manual reload. There is also a
72
+ plain REST surface: `/api/vitals`, `/api/containers`, `/api/storage`,
73
+ `POST /api/storage/rescan`, `/healthz`.
74
+
75
+ ## Design notes
76
+
77
+ **It stops working when you stop looking.** After `KANSHI_IDLE_TIMEOUT` with no
78
+ browser attached, the poller stops entirely and the Docker socket goes
79
+ untouched until someone loads the page. Idle cost is ~0.2% of one core.
80
+
81
+ **One-shot container stats.** `GET /containers/{id}/stats?stream=false` makes the
82
+ daemon block for a full collection cycle so it can populate `precpu_stats` —
83
+ measured at **8.3s per tick** across 31 containers. Kanshi uses `one-shot=true`
84
+ (**0.07s**) and computes CPU% against the previous tick itself. Same arithmetic,
85
+ and a 5s window is steadier to read than the daemon's 1s one.
86
+
87
+ **The disk walk is depth-bounded.** The tree is pruned to `KANSHI_TREE_DEPTH`
88
+ before it is served, so the walk only materialises a node for directories within
89
+ that depth — ~2.3k instead of ~96k on this host. Deeper directories are still
90
+ fully traversed and counted; their bytes roll up into the nearest kept ancestor.
91
+ Sizes come from `st_blocks` (so they match `du`, not apparent size) and
92
+ hardlinked files are counted once.
93
+
94
+ **The walk runs at `nice 10`** in a worker thread. A full pass over `/` takes
95
+ ~47s, and the live cards keep their exact 5s cadence throughout.
96
+
97
+ **Unreadable directories are reported, not hidden.** If the walk cannot enter a
98
+ directory it is counted and the storage card says so — a silently truncated tree
99
+ that under-reports by 300 GB is worse than an obviously incomplete one.
100
+
101
+ ## Access
102
+
103
+ The port is bound to the **Tailscale interface only** (`KANSHI_HOST`), so the
104
+ dashboard is not exposed on the LAN or the public interface and needs no auth
105
+ layer of its own. Set `KANSHI_HOST=0.0.0.0` to expose it on the LAN — but note
106
+ that anything that can reach this app can reach the Docker socket through it, so
107
+ add an auth proxy first if you do.
108
+
109
+ If Tailscale is down when Docker starts, the bind fails and `restart:
110
+ unless-stopped` retries until `tailscale0` is back.
111
+
112
+ ## Permissions
113
+
114
+ Runs as root with `cap_drop: ALL` plus **`DAC_READ_SEARCH`** — read and traverse
115
+ bypass, but *not* write bypass (that would be `DAC_OVERRIDE`). Without it the
116
+ walk cannot enter `/home/yuuki` (mode 0750) and silently under-reports the root
117
+ filesystem by ~330 GB. The rootfs is read-only and every host mount is `:ro`.
118
+
119
+ ## About kanshi's own memory number
120
+
121
+ The dashboard will show kanshi using far more memory than you'd expect for a
122
+ 10 MB Python process. That figure is mostly **reclaimable kernel dentry cache**
123
+ charged to its cgroup — an unavoidable side effect of `stat`-ing ~150k files
124
+ during a walk. Actual anonymous memory is ~27 MB; `mem_limit` acts as a ceiling
125
+ on the cache and the kernel reclaims it under pressure. The number matches what
126
+ `docker stats` reports for any container, which is the point.
127
+
128
+ ## Tuning
129
+
130
+ Slowest thing here is the walk of `/`, dominated by ~100k overlay2 files under
131
+ `/var/lib/docker`. To skip it:
132
+
133
+ ```sh
134
+ KANSHI_STORAGE_EXCLUDE=/hostfs/var/lib/docker
135
+ ```
136
+
137
+ Its ~48 GB then disappears from the `/` total, so only do this if you don't want
138
+ it counted.
File without changes
package/app/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Runtime configuration, all via environment variables.
2
+
3
+ Defaults are deliberately conservative: this box has 4 cores and ~28 other
4
+ containers, so Kanshi should be invisible in `docker stats`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ def _int(name: str, default: int) -> int:
13
+ try:
14
+ return int(os.environ.get(name, "") or default)
15
+ except ValueError:
16
+ return default
17
+
18
+
19
+ def _float(name: str, default: float) -> float:
20
+ try:
21
+ return float(os.environ.get(name, "") or default)
22
+ except ValueError:
23
+ return default
24
+
25
+
26
+ def _list(name: str, default: str) -> list[str]:
27
+ raw = os.environ.get(name, "") or default
28
+ return [p.strip() for p in raw.split(",") if p.strip()]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Config:
33
+ # How often the live poller samples vitals + container stats, in seconds.
34
+ poll_interval: float = field(default_factory=lambda: _float("KANSHI_POLL_INTERVAL", 5.0))
35
+
36
+ # Stop polling entirely once no browser has been connected for this long.
37
+ # Nobody is looking, so there is no reason to keep waking the Docker daemon.
38
+ idle_timeout: float = field(default_factory=lambda: _float("KANSHI_IDLE_TIMEOUT", 30.0))
39
+
40
+ # Max concurrent /stats requests against the Docker socket per tick.
41
+ docker_concurrency: int = field(default_factory=lambda: _int("KANSHI_DOCKER_CONCURRENCY", 8))
42
+ docker_socket: str = field(default_factory=lambda: os.environ.get("KANSHI_DOCKER_SOCKET", "/var/run/docker.sock"))
43
+
44
+ # Storage walk. Roots are "label=path" or just "path".
45
+ storage_roots: list[str] = field(default_factory=lambda: _list("KANSHI_STORAGE_ROOTS", "/mnt/data=/mnt/data,/=/hostfs"))
46
+ storage_interval: float = field(default_factory=lambda: _float("KANSHI_STORAGE_INTERVAL", 1800.0))
47
+ # Absolute container-side paths to skip entirely. Their bytes vanish from
48
+ # the totals, so only exclude things you truly don't want counted.
49
+ storage_exclude: list[str] = field(default_factory=lambda: _list("KANSHI_STORAGE_EXCLUDE", ""))
50
+ storage_min_rescan: float = field(default_factory=lambda: _float("KANSHI_STORAGE_MIN_RESCAN", 30.0))
51
+
52
+ # Tree pruning, to keep the JSON the phone downloads small.
53
+ tree_depth: int = field(default_factory=lambda: _int("KANSHI_TREE_DEPTH", 4))
54
+ # Children smaller than this fraction of their parent are folded into an
55
+ # aggregate node rather than shipped individually.
56
+ tree_min_fraction: float = field(default_factory=lambda: _float("KANSHI_TREE_MIN_FRACTION", 0.005))
57
+ tree_max_children: int = field(default_factory=lambda: _int("KANSHI_TREE_MAX_CHILDREN", 24))
58
+
59
+ host: str = field(default_factory=lambda: os.environ.get("KANSHI_HOST", "0.0.0.0"))
60
+ port: int = field(default_factory=lambda: _int("KANSHI_PORT", 8100))
61
+
62
+ def roots(self) -> list[tuple[str, str]]:
63
+ out: list[tuple[str, str]] = []
64
+ for entry in self.storage_roots:
65
+ label, _, path = entry.partition("=")
66
+ if not path:
67
+ label, path = os.path.basename(label.rstrip("/")) or label, label
68
+ out.append((label, path))
69
+ return out
70
+
71
+
72
+ config = Config()
@@ -0,0 +1,207 @@
1
+ """Docker Engine API client over the unix socket.
2
+
3
+ Uses `GET /containers/{id}/stats?stream=false&one-shot=true`. The one-shot form
4
+ returns immediately; without it the daemon blocks each request for a full
5
+ collection cycle to produce `precpu_stats`, which measured 8.3s per tick across
6
+ 31 containers versus 0.07s here.
7
+
8
+ The tradeoff is that one-shot zeroes `precpu_stats`, so CPU% is computed against
9
+ the previous tick's counters instead — the same thing the daemon would have
10
+ done, just over the poll interval rather than a 1s window. That is also a
11
+ steadier number to read at a glance.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+
18
+ import httpx
19
+
20
+ from .config import config
21
+
22
+ API_VERSION = "v1.43"
23
+
24
+ _prev_net: dict[str, tuple[float, int, int]] = {}
25
+ _prev_cpu: dict[str, tuple[int, int]] = {} # cid -> (total_usage, system_usage)
26
+ _client: httpx.AsyncClient | None = None
27
+
28
+
29
+ def client() -> httpx.AsyncClient:
30
+ global _client
31
+ if _client is None:
32
+ _client = httpx.AsyncClient(
33
+ transport=httpx.AsyncHTTPTransport(uds=config.docker_socket, retries=1),
34
+ base_url=f"http://docker/{API_VERSION}",
35
+ timeout=httpx.Timeout(20.0, connect=5.0),
36
+ )
37
+ return _client
38
+
39
+
40
+ async def close() -> None:
41
+ global _client
42
+ if _client is not None:
43
+ await _client.aclose()
44
+ _client = None
45
+
46
+
47
+ def _cpu_percent(cid: str, stats: dict) -> float:
48
+ """CPU% against the previous tick. 100% = one full core, as `docker stats`."""
49
+ cpu = stats.get("cpu_stats") or {}
50
+ usage = (cpu.get("cpu_usage") or {}).get("total_usage")
51
+ system = cpu.get("system_cpu_usage")
52
+ if usage is None or system is None:
53
+ return 0.0
54
+
55
+ prev = _prev_cpu.get(cid)
56
+ _prev_cpu[cid] = (usage, system)
57
+ if prev is None:
58
+ return 0.0 # first sighting; the next tick has a real delta
59
+
60
+ cpu_delta = usage - prev[0]
61
+ sys_delta = system - prev[1]
62
+ # A restarted container resets its counters — report 0 rather than a
63
+ # nonsensical negative or a huge spike.
64
+ if sys_delta <= 0 or cpu_delta < 0:
65
+ return 0.0
66
+ # online_cpus is absent on older daemons; fall back to the per-cpu array.
67
+ ncpu = cpu.get("online_cpus") or len((cpu.get("cpu_usage") or {}).get("percpu_usage") or []) or 1
68
+ return round(min(cpu_delta / sys_delta * ncpu * 100.0, ncpu * 100.0), 2)
69
+
70
+
71
+ def _memory(stats: dict) -> tuple[int, int]:
72
+ mem = stats.get("memory_stats") or {}
73
+ usage = mem.get("usage")
74
+ if usage is None:
75
+ return (0, 0)
76
+ detail = mem.get("stats") or {}
77
+ # Match `docker stats`: subtract page cache so the number reflects the
78
+ # working set. cgroup v2 exposes inactive_file, v1 exposes cache.
79
+ if "inactive_file" in detail:
80
+ usage -= min(detail["inactive_file"], usage)
81
+ elif "cache" in detail:
82
+ usage -= min(detail["cache"], usage)
83
+ return (usage, mem.get("limit") or 0)
84
+
85
+
86
+ def _network(cid: str, stats: dict, now: float) -> dict | None:
87
+ networks = stats.get("networks")
88
+ if not networks:
89
+ # Containers on `network_mode: service:...` (e.g. behind gluetun) report
90
+ # no interfaces of their own — their traffic shows up on the provider.
91
+ _prev_net.pop(cid, None)
92
+ return None
93
+ rx = sum(n.get("rx_bytes", 0) for n in networks.values())
94
+ tx = sum(n.get("tx_bytes", 0) for n in networks.values())
95
+ prev = _prev_net.get(cid)
96
+ _prev_net[cid] = (now, rx, tx)
97
+ rate_rx = rate_tx = 0.0
98
+ if prev and now > prev[0]:
99
+ dt = now - prev[0]
100
+ # A restarted container resets its counters; clamp instead of going negative.
101
+ rate_rx = max(0, rx - prev[1]) / dt
102
+ rate_tx = max(0, tx - prev[2]) / dt
103
+ return {"rx": rx, "tx": tx, "rx_rate": rate_rx, "tx_rate": rate_tx}
104
+
105
+
106
+ def _block_io(stats: dict) -> dict | None:
107
+ entries = (stats.get("blkio_stats") or {}).get("io_service_bytes_recursive")
108
+ if not entries:
109
+ return None # commonly empty under cgroup v2
110
+ read = sum(e.get("value", 0) for e in entries if e.get("op", "").lower() == "read")
111
+ write = sum(e.get("value", 0) for e in entries if e.get("op", "").lower() == "write")
112
+ return {"read": read, "write": write}
113
+
114
+
115
+ def _identify(meta: dict) -> tuple[str, str, str]:
116
+ """(full name, display name, project).
117
+
118
+ Runtipi names containers `<project>-<service>-1`, so at phone width three
119
+ Immich containers all truncate to the same "immich_migra…". The compose
120
+ labels carry the service name on its own, which is what actually
121
+ distinguishes them.
122
+ """
123
+ full = (meta.get("Names") or ["/?"])[0].lstrip("/")
124
+ labels = meta.get("Labels") or {}
125
+ service = labels.get("com.docker.compose.service") or ""
126
+ project = labels.get("com.docker.compose.project") or ""
127
+ return full, (service or full), project
128
+
129
+
130
+ async def _one(cid: str, meta: dict, sem: asyncio.Semaphore) -> dict | None:
131
+ async with sem:
132
+ try:
133
+ r = await client().get(f"/containers/{cid}/stats", params={"stream": "false", "one-shot": "true"})
134
+ r.raise_for_status()
135
+ stats = r.json()
136
+ except Exception:
137
+ return None
138
+ now = time.monotonic()
139
+ used, limit = _memory(stats)
140
+ full, name, project = _identify(meta)
141
+ state = meta.get("State", "")
142
+ health = ((meta.get("Status") or "").split("(")[-1].rstrip(")") if "(" in (meta.get("Status") or "") else None)
143
+ return {
144
+ "id": cid[:12],
145
+ "name": name,
146
+ "full_name": full,
147
+ "project": project,
148
+ "image": meta.get("Image", ""),
149
+ "state": state,
150
+ "status": meta.get("Status", ""),
151
+ "health": health if health in ("healthy", "unhealthy", "health: starting", "starting") else None,
152
+ "created": meta.get("Created", 0),
153
+ "cpu": _cpu_percent(cid, stats),
154
+ "mem_used": used,
155
+ "mem_limit": limit,
156
+ "mem_percent": round(used / limit * 100, 2) if limit else 0.0,
157
+ "pids": (stats.get("pids_stats") or {}).get("current", 0),
158
+ "net": _network(cid, stats, now),
159
+ "blkio": _block_io(stats),
160
+ }
161
+
162
+
163
+ async def sample() -> dict:
164
+ """One full pass: list containers, then fetch stats for the running ones."""
165
+ try:
166
+ r = await client().get("/containers/json", params={"all": "true"})
167
+ r.raise_for_status()
168
+ listing = r.json()
169
+ except Exception as exc:
170
+ return {"error": f"{type(exc).__name__}: {exc}", "containers": []}
171
+
172
+ running = [c for c in listing if c.get("State") == "running"]
173
+ sem = asyncio.Semaphore(max(1, config.docker_concurrency))
174
+ results = await asyncio.gather(*(_one(c["Id"], c, sem) for c in running))
175
+ containers = [c for c in results if c]
176
+
177
+ # Keep stopped containers visible but without stats, so a crashed service
178
+ # is obvious at a glance rather than silently missing from the list.
179
+ for c in listing:
180
+ if c.get("State") != "running":
181
+ full, name, project = _identify(c)
182
+ containers.append({
183
+ "id": c["Id"][:12],
184
+ "name": name,
185
+ "full_name": full,
186
+ "project": project,
187
+ "image": c.get("Image", ""),
188
+ "state": c.get("State", ""),
189
+ "status": c.get("Status", ""),
190
+ "health": None,
191
+ "created": c.get("Created", 0),
192
+ "cpu": 0.0, "mem_used": 0, "mem_limit": 0, "mem_percent": 0.0,
193
+ "pids": 0, "net": None, "blkio": None,
194
+ })
195
+
196
+ live = {c["Id"] for c in running}
197
+ for stale in [k for k in _prev_net if k not in live]:
198
+ _prev_net.pop(stale, None)
199
+ for stale in [k for k in _prev_cpu if k not in live]:
200
+ _prev_cpu.pop(stale, None)
201
+
202
+ containers.sort(key=lambda c: (c["state"] != "running", -c["cpu"], c["name"]))
203
+ return {
204
+ "containers": containers,
205
+ "running": len(running),
206
+ "total": len(listing),
207
+ }
package/app/main.py ADDED
@@ -0,0 +1,174 @@
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")