@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 +19 -0
- package/Dockerfile +27 -0
- package/README.md +138 -0
- package/app/__init__.py +0 -0
- package/app/config.py +72 -0
- package/app/dockerstats.py +207 -0
- package/app/main.py +174 -0
- package/app/storage.py +220 -0
- package/app/vitals.py +170 -0
- package/bin/kanshi.js +55 -0
- package/docker-compose.yml +59 -0
- package/package.json +35 -0
- package/requirements.txt +4 -0
- package/web/app.js +344 -0
- package/web/index.html +105 -0
- package/web/style.css +272 -0
- package/web/treemap.js +188 -0
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.
|
package/app/__init__.py
ADDED
|
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")
|