@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/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
|
+
}
|
package/requirements.txt
ADDED