@yuuki824/kanshi 0.3.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.
Files changed (45) hide show
  1. package/.env.example +34 -0
  2. package/Dockerfile +53 -0
  3. package/LICENSE +661 -0
  4. package/README.md +219 -0
  5. package/bin/kanshi.js +63 -0
  6. package/docker-compose.yml +84 -0
  7. package/go.mod +3 -0
  8. package/internal/access/access.go +275 -0
  9. package/internal/access/listeners.go +229 -0
  10. package/internal/config/config.go +256 -0
  11. package/internal/config/defaults_other.go +5 -0
  12. package/internal/config/defaults_windows.go +4 -0
  13. package/internal/config/envfile.go +147 -0
  14. package/internal/dockerstats/dial.go +32 -0
  15. package/internal/dockerstats/dial_other.go +13 -0
  16. package/internal/dockerstats/dial_windows.go +62 -0
  17. package/internal/dockerstats/dockerstats.go +626 -0
  18. package/internal/roots/roots.go +175 -0
  19. package/internal/roots/roots_linux.go +132 -0
  20. package/internal/roots/roots_windows.go +74 -0
  21. package/internal/server/access.go +155 -0
  22. package/internal/server/server.go +523 -0
  23. package/internal/server/static.go +165 -0
  24. package/internal/storage/cache.go +283 -0
  25. package/internal/storage/dir_linux.go +177 -0
  26. package/internal/storage/dir_windows.go +244 -0
  27. package/internal/storage/statat_fast.go +21 -0
  28. package/internal/storage/statat_other.go +11 -0
  29. package/internal/storage/storage.go +598 -0
  30. package/internal/storage/sysnum_amd64.go +7 -0
  31. package/internal/storage/sysnum_generic.go +7 -0
  32. package/internal/storage/walk.go +342 -0
  33. package/internal/vitals/vitals.go +246 -0
  34. package/internal/vitals/vitals_linux.go +548 -0
  35. package/internal/vitals/vitals_windows.go +286 -0
  36. package/main.go +129 -0
  37. package/main_other.go +11 -0
  38. package/main_windows.go +29 -0
  39. package/package.json +49 -0
  40. package/web/app.js +835 -0
  41. package/web/apple-touch-icon.png +0 -0
  42. package/web/icon.svg +6 -0
  43. package/web/index.html +151 -0
  44. package/web/style.css +356 -0
  45. package/web/treemap.js +217 -0
package/web/app.js ADDED
@@ -0,0 +1,835 @@
1
+ /* kanshi — single-page live dashboard. No framework, no build step. */
2
+ (function () {
3
+ "use strict";
4
+
5
+ const $ = (sel) => document.querySelector(sel);
6
+
7
+ // Most of a tick's markup is identical to the last one: a stopped container,
8
+ // a meter that did not move. Skipping those writes spares the browser a
9
+ // reparse and relayout every few seconds on a page that may sit open all day.
10
+ function setHTML(el, html) {
11
+ if (el.__html === html) return;
12
+ el.__html = html;
13
+ el.innerHTML = html;
14
+ }
15
+ const KB = 1024;
16
+ function escapeHtml(s) {
17
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
18
+ }
19
+
20
+ /* ── formatting ─────────────────────────────────────────────────────── */
21
+ function bytes(n) {
22
+ if (n === null || n === undefined || isNaN(n)) return "—";
23
+ if (n < KB) return n + " B";
24
+ const units = ["KB", "MB", "GB", "TB", "PB"];
25
+ let v = n / KB, i = 0;
26
+ while (v >= KB && i < units.length - 1) { v /= KB; i++; }
27
+ return (v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2)) + " " + units[i];
28
+ }
29
+ function rate(n) {
30
+ if (!n || n < 1) return "0";
31
+ return bytes(n) + "/s";
32
+ }
33
+ function shortRate(n) {
34
+ if (!n || n < KB) return "0";
35
+ const units = ["K", "M", "G"];
36
+ let v = n / KB, i = 0;
37
+ while (v >= KB && i < units.length - 1) { v /= KB; i++; }
38
+ return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + units[i];
39
+ }
40
+ function duration(secs) {
41
+ if (!secs && secs !== 0) return "—";
42
+ const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
43
+ if (d) return d + "d " + h + "h";
44
+ if (h) return h + "h " + m + "m";
45
+ return m + "m";
46
+ }
47
+ function ago(ts) {
48
+ if (!ts) return "never";
49
+ return duration(Math.max(0, Date.now() / 1000 - ts)) + " ago";
50
+ }
51
+ function severity(pct, warn, crit) {
52
+ return pct >= (crit || 90) ? "crit" : pct >= (warn || 75) ? "warn" : "ok";
53
+ }
54
+ function severityWord(cls) {
55
+ return cls === "crit" ? "critical" : cls === "warn" ? "high" : "";
56
+ }
57
+
58
+ /* ── processor card ─────────────────────────────────────────────────── */
59
+ const coreEls = [];
60
+ function renderCpu(v) {
61
+ const cpu = v.cpu;
62
+ setHTML($("#cpu-hero"), cpu.percent.toFixed(0) + '<span class="hero-unit">%</span>');
63
+ $("#cpu-count").textContent = cpu.count;
64
+ const hot = cpu.temp !== null && cpu.temp !== undefined;
65
+ $("#cpu-meta").textContent = cpu.count + " cores" + (hot ? " · " + cpu.temp + "°C" : "");
66
+
67
+ // Windows keeps no load average, so the row is left out rather than
68
+ // showing three zeros that look like a measurement.
69
+ const aux = [
70
+ cpu.load ? ["Load avg", cpu.load.map((n) => n.toFixed(2)).join(" ")] : null,
71
+ ["Net", "↓" + shortRate(v.network.rx) + " ↑" + shortRate(v.network.tx)],
72
+ ["Disk", "↓" + shortRate(v.diskio.read) + " ↑" + shortRate(v.diskio.write)],
73
+ ].filter(Boolean);
74
+ setHTML($("#cpu-aux"), aux.map((r) => "<dt>" + r[0] + "</dt><dd>" + r[1] + "</dd>").join(""));
75
+
76
+ const host = $("#cores");
77
+ if (coreEls.length !== cpu.cores.length) {
78
+ host.innerHTML = "";
79
+ coreEls.length = 0;
80
+ cpu.cores.forEach(() => {
81
+ const slot = document.createElement("div");
82
+ const fill = document.createElement("span");
83
+ slot.appendChild(fill);
84
+ host.appendChild(slot);
85
+ coreEls.push(fill);
86
+ });
87
+ }
88
+ // One hue for every core: the bar height already encodes the value, so a
89
+ // per-core colour ramp would double-encode it and say nothing new.
90
+ cpu.cores.forEach((pct, i) => { coreEls[i].style.height = Math.max(2, pct) + "%"; });
91
+ host.setAttribute("aria-label", "Per-core utilisation: " + cpu.cores.map((c) => c.toFixed(0) + "%").join(", "));
92
+ }
93
+
94
+ /* ── meters ─────────────────────────────────────────────────────────── */
95
+ function meter(name, pct, detail, warn, crit) {
96
+ const cls = severity(pct, warn, crit);
97
+ const word = severityWord(cls);
98
+ return '<div class="meter ' + cls + '">' +
99
+ '<div class="meter-head"><span class="meter-name">' + name + "</span>" +
100
+ '<span class="meter-val">' + (word ? '<span class="flag">⚠</span>' + word + " · " : "") +
101
+ "<b>" + pct.toFixed(1) + "%</b> · " + detail + "</span></div>" +
102
+ '<div class="meter-track"><div class="meter-fill" style="width:' + Math.min(100, pct) + '%"></div></div>' +
103
+ "</div>";
104
+ }
105
+ function renderMeters(v) {
106
+ const parts = [
107
+ meter("RAM", v.memory.percent, bytes(v.memory.used) + " of " + bytes(v.memory.total), 80, 92),
108
+ ];
109
+ if (v.swap.total > 0) {
110
+ parts.push(meter("Swap", v.swap.percent, bytes(v.swap.used) + " of " + bytes(v.swap.total), 50, 80));
111
+ }
112
+ v.filesystems.forEach((fs) => {
113
+ parts.push(meter(escapeHtml(fs.label), fs.percent, bytes(fs.free) + " free", 80, 92));
114
+ });
115
+ setHTML($("#meters"), parts.join(""));
116
+ }
117
+
118
+ /* ── containers ─────────────────────────────────────────────────────── */
119
+ let sortKey = "cpu";
120
+ let lastDocker = null;
121
+
122
+ function netTotal(c) { return c.net ? c.net.rx_rate + c.net.tx_rate : -1; }
123
+
124
+ /* ── port mappings ──────────────────────────────────────────────────── */
125
+ // A container publishes on the same host that served this page — a Tailscale
126
+ // IP, a LAN name, whatever is in the address bar — so the page's own
127
+ // hostname is what a port link should point at. Hard-coding one would break
128
+ // the moment the dashboard is reached by any other route.
129
+ function hostAddr() {
130
+ const h = location.hostname;
131
+ return h.indexOf(":") >= 0 ? "[" + h + "]" : h; // a bare IPv6 literal needs brackets
132
+ }
133
+ // Docker hands the bind address through verbatim; keep an href to characters
134
+ // an address can actually contain rather than trusting the daemon's string.
135
+ function bindAddr(ip) {
136
+ if (!ip || !/^[0-9a-fA-F.:]+$/.test(ip)) return "";
137
+ return ip.indexOf(":") >= 0 ? "[" + ip + "]" : ip;
138
+ }
139
+
140
+ // Past a handful the line stops being glanceable, and the row is one line
141
+ // tall either way — so the overflow is counted rather than wrapped.
142
+ const PORT_LIMIT = 5;
143
+
144
+ function proto(p) { return String(p.type || "tcp").replace(/[^a-z]/gi, "").toLowerCase(); }
145
+ // host→internal reads as one token, so the arrow is set tight and muted and
146
+ // the two numbers carry the weight.
147
+ function mapping(pub, priv, suffix) {
148
+ return pub + '<i class="arr">→</i>' + priv + suffix;
149
+ }
150
+
151
+ function portLine(c) {
152
+ if (!c.ports || !c.ports.length) return "";
153
+ const published = c.ports.filter((p) => Number(p.public) > 0);
154
+ const internal = c.ports.filter((p) => !Number(p.public));
155
+ const parts = [];
156
+
157
+ published.slice(0, PORT_LIMIT).forEach((p) => {
158
+ const pub = Number(p.public), priv = Number(p.private), pr = proto(p);
159
+ const suffix = pr === "tcp" ? "" : '<i class="pr">' + pr + "</i>";
160
+ const label = mapping(pub, priv, suffix);
161
+ // Only a plain TCP publish is something a browser can open; UDP gets the
162
+ // same reading without the dead link.
163
+ if (pr !== "tcp") {
164
+ parts.push('<span title="Host port ' + pub + ' → internal port ' + priv + ', ' + pr +
165
+ ' — not reachable from a browser">' + label + "</span>");
166
+ return;
167
+ }
168
+ // A specific bind address is the authoritative target; a wildcard bind
169
+ // means "wherever you reached this page from".
170
+ const url = "http://" + (bindAddr(p.ip) || hostAddr()) + ":" + pub;
171
+ const hint = "Open " + url + " — host port " + pub + " → internal port " + priv;
172
+ parts.push('<a href="' + url + '" target="_blank" rel="noopener noreferrer" title="' + hint +
173
+ '" aria-label="' + hint + '">' + label + "</a>");
174
+ });
175
+ if (published.length > PORT_LIMIT) {
176
+ parts.push(quiet(published.length - PORT_LIMIT + " more", published.slice(PORT_LIMIT)
177
+ .map((p) => p.public + " → " + p.private).join(", ")));
178
+ }
179
+
180
+ if (!published.length) {
181
+ // Nothing published, so the internal ports are all this row has to say.
182
+ internal.slice(0, PORT_LIMIT).forEach((p) => {
183
+ const pr = proto(p), priv = Number(p.private);
184
+ parts.push(quiet(priv + (pr === "tcp" ? "" : '<i class="pr">' + pr + "</i>"),
185
+ "Internal port " + priv + " — exposed inside Docker only, not published to the host"));
186
+ });
187
+ if (internal.length > PORT_LIMIT) parts.push(quiet("+" + (internal.length - PORT_LIMIT), ""));
188
+ } else if (internal.length) {
189
+ // The published ports are what you came here for; the rest fold away so
190
+ // a container like gluetun does not bury them under nine entries.
191
+ parts.push(quiet(internal.length + " internal", "Exposed inside Docker only: " +
192
+ internal.map((p) => p.private + "/" + proto(p)).join(", ")));
193
+ }
194
+
195
+ return '<div class="ctr-ports">' + parts.join('<i class="sep">·</i>') + "</div>";
196
+ }
197
+
198
+ // Not a link and not meant to compete with one.
199
+ function quiet(label, hint) {
200
+ return '<span class="pq"' + (hint ? ' title="' + hint + '"' : "") + ">" + label + "</span>";
201
+ }
202
+
203
+ // One row per container, kept across ticks and updated in place. A tick
204
+ // changes a few numbers per row; rewriting the whole table every five
205
+ // seconds would reparse and relayout thirty rows for that.
206
+ const ctrRows = new Map(); // id → { tr, head, bar, cpu, mem, net }
207
+
208
+ function ctrRow() {
209
+ const tr = document.createElement("tr");
210
+ tr.innerHTML = '<td><div class="ctr-head"></div><div class="ctr-bar"><i></i></div></td>' +
211
+ '<td class="num"></td><td class="num"></td><td class="num"></td>';
212
+ return { tr: tr, head: tr.querySelector(".ctr-head"), bar: tr.querySelector(".ctr-bar i"),
213
+ cpu: tr.cells[1], mem: tr.cells[2], net: tr.cells[3] };
214
+ }
215
+ function setText(el, text) { if (el.textContent !== text) el.textContent = text; }
216
+
217
+ function fillRow(row, c) {
218
+ const running = c.state === "running";
219
+ const bad = c.health === "unhealthy" || (!running && c.state !== "exited");
220
+ const dot = bad ? "bad" : running ? "run" : "stop";
221
+ // Project first in the sub-line: it is the shared prefix, so it belongs
222
+ // where it can be skimmed past rather than eating the name column.
223
+ const sub = [c.project, running ? c.status : c.state + " · " + c.status].filter(Boolean).join(" · ");
224
+ setHTML(row.head, '<div class="name-cell"><i class="dot ' + dot + '"></i><span class="ctr-name">' +
225
+ escapeHtml(c.name) + '</span></div><div class="ctr-sub">' + escapeHtml(sub) + "</div>" + portLine(c));
226
+ // Bar width is capped at one full core so a 300% spike stays readable.
227
+ const width = Math.min(100, c.cpu) + "%";
228
+ if (row.bar.style.width !== width) row.bar.style.width = width;
229
+ setText(row.cpu, running ? c.cpu.toFixed(1) + "%" : "—");
230
+ setText(row.mem, running ? bytes(c.mem_used) : "—");
231
+ setText(row.net, c.net ? "↓" + shortRate(c.net.rx_rate) + " ↑" + shortRate(c.net.tx_rate) : (running ? "shared" : "—"));
232
+ const cls = running ? "" : "is-stopped";
233
+ if (row.tr.className !== cls) row.tr.className = cls;
234
+ const title = c.full_name || c.name;
235
+ if (row.tr.title !== title) row.tr.title = title;
236
+ }
237
+
238
+ function renderContainers(d) {
239
+ lastDocker = d;
240
+ if (!d) return;
241
+ // No daemon at all (a laptop without Docker) is not an error, just
242
+ // nothing to show.
243
+ $("#ctr-empty").hidden = !d.unavailable;
244
+ $(".sortbar").hidden = !!d.unavailable;
245
+ $("#ctr-tbl").hidden = !!d.unavailable;
246
+ if (d.unavailable) {
247
+ $("#ctr-meta").textContent = "not detected";
248
+ return;
249
+ }
250
+ if (d.error) {
251
+ $("#ctr-meta").textContent = "docker: " + d.error;
252
+ return;
253
+ }
254
+ const list = d.containers.slice();
255
+ const cmp = {
256
+ cpu: (a, b) => b.cpu - a.cpu,
257
+ mem: (a, b) => b.mem_used - a.mem_used,
258
+ net: (a, b) => netTotal(b) - netTotal(a),
259
+ name: (a, b) => a.name.localeCompare(b.name),
260
+ }[sortKey];
261
+ list.sort((a, b) => (a.state !== "running") - (b.state !== "running") || cmp(a, b) || a.name.localeCompare(b.name));
262
+
263
+ $("#ctr-meta").textContent = d.running + " running · " + d.total + " total";
264
+
265
+ const tbody = $("#ctr-tbl").querySelector("tbody");
266
+ const live = new Set();
267
+ list.forEach((c, i) => {
268
+ let row = ctrRows.get(c.id);
269
+ if (!row) { row = ctrRow(); ctrRows.set(c.id, row); }
270
+ live.add(c.id);
271
+ fillRow(row, c);
272
+ // Only rows that actually changed place are moved.
273
+ if (tbody.children[i] !== row.tr) tbody.insertBefore(row.tr, tbody.children[i] || null);
274
+ });
275
+ ctrRows.forEach((row, id) => {
276
+ if (!live.has(id)) { row.tr.remove(); ctrRows.delete(id); }
277
+ });
278
+ }
279
+
280
+ document.querySelectorAll(".sortbar .chip").forEach((chip) => {
281
+ chip.addEventListener("click", () => {
282
+ document.querySelectorAll(".sortbar .chip").forEach((c) => c.classList.remove("is-on"));
283
+ chip.classList.add("is-on");
284
+ sortKey = chip.dataset.sort;
285
+ renderContainers(lastDocker);
286
+ });
287
+ });
288
+
289
+ /* ── storage ────────────────────────────────────────────────────────── */
290
+ // The page only ever has one directory. /api/storage is a one-line summary
291
+ // per root, and each directory is read from /api/storage/dir as it is
292
+ // opened. Subfolders the server has not sized yet come back pending; it
293
+ // walks them in the background, and the live stream says when one is done.
294
+ const svg = $("#treemap");
295
+ let storage = null; // walk queue status plus one summary per root
296
+ let rootIndex = 0;
297
+ let segs = []; // path below the root, one directory name per level
298
+ let listing = null; // the directory on screen
299
+ let items = []; // its contents as rendered, largest first
300
+ let navToken = 0; // drops a slow response that a newer click overtook
301
+
302
+ // The table starts short and grows on request. The treemap always covers the
303
+ // whole directory, but slivers too thin to see or tap share one tile.
304
+ const PAGE = 15, MORE = 25;
305
+ const TILE_MIN_SHARE = 0.005, TILE_MAX = 40;
306
+ let shown = PAGE;
307
+ let showFiles = localStorage.getItem("kanshi-files") === "1";
308
+
309
+ function plural(n, word) { return n + " " + word + (n === 1 ? "" : "s"); }
310
+ function currentName() {
311
+ return segs.length ? segs[segs.length - 1] : storage.roots[rootIndex].name;
312
+ }
313
+
314
+ // Everything in the current directory. Files are either named one by one or
315
+ // collapsed into a single entry, so the parts always add up. Pending folders
316
+ // have no size yet and sort last.
317
+ function viewItems() {
318
+ const l = listing;
319
+ const out = l.dirs.map((d) => ({ name: d.name, size: d.size, kind: "dir", pending: !!d.pending }));
320
+ if (showFiles) {
321
+ let named = 0;
322
+ l.files.forEach((f) => { out.push({ name: f.name, size: f.size, kind: "file" }); named += f.size; });
323
+ const rest = l.file_bytes - named, restCount = l.file_count - l.files.length;
324
+ if (rest > 0 && restCount > 0) out.push({ name: plural(restCount, "smaller file"), size: rest, kind: "rest" });
325
+ } else if (l.file_bytes > 0) {
326
+ out.push({ name: plural(l.file_count, "file"), size: l.file_bytes, kind: "rest", files: true });
327
+ }
328
+ return out.sort((a, b) => (a.pending - b.pending) || (b.size - a.size));
329
+ }
330
+
331
+ // Sized items are sorted, so everything below the cut is a suffix of them.
332
+ function tileItems(total) {
333
+ const sized = items.filter((d) => !d.pending);
334
+ const floor = total * TILE_MIN_SHARE;
335
+ let keep = 0;
336
+ while (keep < sized.length && keep < TILE_MAX && sized[keep].size >= floor) keep++;
337
+ if (sized.length - keep <= 1) return sized;
338
+ let folded = 0;
339
+ for (let i = keep; i < sized.length; i++) folded += sized[i].size;
340
+ return sized.slice(0, keep).concat([{
341
+ name: plural(sized.length - keep, "smaller item"), size: folded, kind: "rest", smaller: true,
342
+ }]);
343
+ }
344
+
345
+ // What tapping an entry does, shared by the treemap and the table: open a
346
+ // folder, or expand whichever aggregate was tapped.
347
+ function activate(d) {
348
+ if (d.kind === "dir") navigate(rootIndex, segs.concat([d.name]));
349
+ else if (d.files) setShowFiles(true);
350
+ else if (d.smaller) { shown = items.length; renderTable(); $("#stor-tbl").scrollIntoView({ block: "nearest" }); }
351
+ }
352
+
353
+ function dirQuery(root, path) {
354
+ return "root=" + root + "&path=" + encodeURIComponent(path.join("/"));
355
+ }
356
+
357
+ async function navigate(root, path, keepShown) {
358
+ const token = ++navToken;
359
+ let l;
360
+ try {
361
+ const res = await fetch("/api/storage/dir?" + dirQuery(root, path));
362
+ if (!res.ok) throw new Error("HTTP " + res.status);
363
+ l = await res.json();
364
+ } catch (err) {
365
+ if (token === navToken) $("#tm-focus").textContent = "Could not load that folder — try again.";
366
+ return;
367
+ }
368
+ if (token !== navToken) return;
369
+ if (root !== rootIndex) renderRootBar(root);
370
+ rootIndex = root;
371
+ segs = l.path ? l.path.split("/") : [];
372
+ listing = l;
373
+ if (!keepShown) shown = PAGE;
374
+ drawView();
375
+ $("#tm-focus").textContent = l.partial
376
+ ? "That folder is gone or can't be opened — showing the closest one that is still there."
377
+ : l.pending
378
+ ? "Sizing " + plural(l.pending, "folder") + " — the map fills in as each one is done."
379
+ : "Tap a block to drill in.";
380
+ // The server has just queued those folders. Say so now rather than at the
381
+ // next live frame, which brings the details.
382
+ if (l.pending && storage && !storage.scanning) {
383
+ storage.scanning = true;
384
+ renderScanStatus();
385
+ }
386
+ }
387
+
388
+ // Pinned custom paths: bookmarks under a root. The server resolves them the
389
+ // way a walk would, so one can never reach outside the roots.
390
+ const PIN_KEY = "kanshi-pins";
391
+ function loadPins() {
392
+ try { return JSON.parse(localStorage.getItem(PIN_KEY)) || []; } catch (e) { return []; }
393
+ }
394
+ function savePins() { localStorage.setItem(PIN_KEY, JSON.stringify(pins)); }
395
+ let pins = loadPins();
396
+
397
+ // Paths are compared the way the server's filesystem does: Windows ones in
398
+ // either slash direction and any case, since that is how Explorer treats them.
399
+ function sep() { return (storage && storage.sep) || "/"; }
400
+ function windowsPaths() { return sep() === "\\"; }
401
+ // A backslash is an ordinary character in a Linux file name, so it is only
402
+ // treated as a separator on Windows.
403
+ function slashes(p) { return windowsPaths() ? p.replace(/\\/g, "/") : p; }
404
+ function normPath(p) {
405
+ const q = slashes(p).replace(/\/+$/, "");
406
+ return windowsPaths() ? q.toLowerCase() : q;
407
+ }
408
+ function absolutePath(p) {
409
+ return windowsPaths() ? /^[a-z]:[\\/]/i.test(p) || p.indexOf("\\\\") === 0 : p.indexOf("/") === 0;
410
+ }
411
+
412
+ // Longest matching root label wins, so "/mnt/data/x" resolves against the
413
+ // "/mnt/data" root rather than the "/" root that also technically contains it.
414
+ function bestRootForPath(path) {
415
+ const target = normPath(path);
416
+ let best = null;
417
+ storage.roots.forEach((r, i) => {
418
+ const label = normPath(r.name); // "/" becomes "", C:\ becomes "c:"
419
+ if (target !== label && target.indexOf(label + "/") !== 0) return;
420
+ if (!best || label.length > best.label.length) {
421
+ // Segments keep the case they were typed in; the server matches them
422
+ // case-insensitively where the filesystem does.
423
+ const rest = slashes(path).replace(/\/+$/, "").slice(label.length + 1);
424
+ best = { rootIndex: i, label: label, segs: rest.split("/").filter(Boolean) };
425
+ }
426
+ });
427
+ return best;
428
+ }
429
+
430
+ function goToPin(pin) {
431
+ const idx = storage.roots.findIndex((r) => r.name === pin.root);
432
+ if (idx >= 0) navigate(idx, pin.segs);
433
+ }
434
+
435
+ function removePin(pin) {
436
+ pins = pins.filter((p) => !(p.root === pin.root && p.label === pin.label));
437
+ savePins();
438
+ renderRootBar(rootIndex);
439
+ }
440
+
441
+ function flashPinError(msg) { $("#pinform-err").textContent = msg; }
442
+
443
+ function submitPin(raw) {
444
+ const path = raw.trim();
445
+ if (!absolutePath(path)) { flashPinError("Use a full path, e.g. " + $("#pinform-input").placeholder); return; }
446
+ const match = bestRootForPath(path);
447
+ if (!match) { flashPinError("No storage root covers that path"); return; }
448
+ const pin = { root: storage.roots[match.rootIndex].name, segs: match.segs, label: path };
449
+ if (!pins.some((p) => p.root === pin.root && p.label === pin.label)) {
450
+ pins.push(pin);
451
+ savePins();
452
+ }
453
+ renderRootBar(rootIndex);
454
+ goToPin(pin);
455
+ hidePinForm();
456
+ }
457
+
458
+ function showPinForm() {
459
+ $("#pinform-err").textContent = "";
460
+ $("#pinform").hidden = false;
461
+ $("#pinform-input").value = "";
462
+ $("#pinform-input").focus();
463
+ }
464
+ function hidePinForm() { $("#pinform").hidden = true; }
465
+
466
+ $("#pinform").addEventListener("submit", (ev) => {
467
+ ev.preventDefault();
468
+ submitPin($("#pinform-input").value);
469
+ });
470
+ $("#pinform-cancel").addEventListener("click", hidePinForm);
471
+
472
+ function renderRootBar(active) {
473
+ const rootChips = storage.roots.map((r, i) =>
474
+ '<button role="tab" class="chip' + (i === active ? " is-on" : "") + '" data-root="' + i + '" type="button" aria-selected="' +
475
+ (i === active) + '">' + escapeHtml(r.name) + (r.size == null ? "" : " · " + bytes(r.size)) + "</button>").join("");
476
+ const pinChips = pins.map((p, i) => {
477
+ const label = escapeHtml(p.label);
478
+ return '<span class="chip pin" data-pin="' + i + '">' +
479
+ '<button type="button" class="pin-go" title="' + label + '">' + label + "</button>" +
480
+ '<button type="button" class="pin-x" aria-label="Remove pinned path">×</button></span>';
481
+ }).join("");
482
+ $("#rootbar").innerHTML = rootChips + pinChips +
483
+ '<button class="chip pin-add" data-add="1" type="button">+ Add path</button>';
484
+ }
485
+
486
+ $("#rootbar").addEventListener("click", (ev) => {
487
+ const b = ev.target.closest("button");
488
+ if (!b) return;
489
+ if (b.dataset.root) { navigate(+b.dataset.root, []); return; }
490
+ if (b.dataset.add) { showPinForm(); return; }
491
+ const pin = pins[+b.parentElement.dataset.pin];
492
+ if (!pin) return;
493
+ if (b.classList.contains("pin-x")) removePin(pin); else goToPin(pin);
494
+ });
495
+
496
+ // The crumbs read as the path itself: C:\Users\you on Windows. A root
497
+ // label's own trailing separator is dropped so it is not shown twice, except
498
+ // for "/", which would otherwise vanish.
499
+ function renderCrumbs() {
500
+ let root = storage.roots[rootIndex].name;
501
+ if (segs.length && root.length > 1 && root.slice(-1) === sep()) root = root.slice(0, -1);
502
+ const names = [root].concat(segs);
503
+ $("#crumbs").innerHTML = names.map((n, i) => {
504
+ const last = i === names.length - 1;
505
+ return '<button type="button" data-depth="' + i + '"' + (last ? " disabled" : "") + ">" + escapeHtml(n) + "</button>" +
506
+ (last ? "" : '<span class="sep">' + escapeHtml(sep()) + "</span>");
507
+ }).join("");
508
+ }
509
+
510
+ $("#crumbs").addEventListener("click", (ev) => {
511
+ const b = ev.target.closest("button");
512
+ if (b && !b.disabled) navigate(rootIndex, segs.slice(0, +b.dataset.depth));
513
+ });
514
+
515
+ function emptyStorage(message) {
516
+ listing = null;
517
+ items = [];
518
+ $("#rootbar").innerHTML = "";
519
+ $("#crumbs").innerHTML = "";
520
+ svg.textContent = "";
521
+ $("#stor-tbl").querySelector("tbody").innerHTML = "";
522
+ $("#stor-more").hidden = true;
523
+ $("#tm-focus").textContent = message;
524
+ $("#stor-meta").textContent = "";
525
+ }
526
+
527
+ // A folder walked for the first time has no previous size to measure
528
+ // against, so the server omits `progress` rather than shipping a
529
+ // meaningless 0%; the bar then only names what is being walked.
530
+ function renderScanStatus() {
531
+ const bar = $("#scan-progress");
532
+ const st = storage || {};
533
+ bar.hidden = !st.scanning;
534
+ if (st.scanning) {
535
+ const p = st.progress;
536
+ $("#scan-progress-fill").style.width = (p ? Math.min(100, p.percent) : 0) + "%";
537
+ $("#scan-progress-label").textContent = "Sizing " + (st.current || "folders") + "…" +
538
+ (p ? " " + p.percent.toFixed(0) + "% · " + bytes(p.bytes_done) + " of " + bytes(p.bytes_total) : "") +
539
+ (st.queued ? " · " + st.queued + " more queued" : "");
540
+ }
541
+ renderMeta();
542
+ }
543
+
544
+ function renderMeta() {
545
+ if (!listing) return;
546
+ const parts = [];
547
+ if (listing.pending) parts.push("sizing " + plural(listing.pending, "folder") + "…");
548
+ else if (listing.sized_at) parts.push("sized " + ago(listing.sized_at));
549
+ if (listing.unreadable) parts.push("⚠ " + plural(listing.unreadable, "unreadable dir") + " not counted");
550
+ if (storage && storage.error) parts.push("⚠ " + storage.error);
551
+ $("#stor-meta").textContent = parts.join(" · ");
552
+ }
553
+
554
+ function drawTreemap() {
555
+ if (!listing) return;
556
+ const box = svg.parentElement.getBoundingClientRect();
557
+ const width = Math.max(200, Math.round(box.width));
558
+ const height = Math.round(parseFloat(getComputedStyle(svg).height)) || 300;
559
+ svg.setAttribute("height", height);
560
+ const total = listing.size;
561
+ Treemap.render(svg, tileItems(total), {
562
+ width: width, height: height, fmt: bytes,
563
+ onFocus: (d) => {
564
+ const share = total ? (d.size / total * 100).toFixed(1) : "0";
565
+ $("#tm-focus").textContent = d.name + " — " + bytes(d.size) + " (" + share + "% of " + currentName() + ")";
566
+ },
567
+ onSelect: activate,
568
+ });
569
+ }
570
+
571
+ // The table twin: every value in the map is reachable without colour or
572
+ // hover, and past the first page, without the treemap folding it away.
573
+ function renderTable() {
574
+ const total = listing.size;
575
+ const count = Math.min(shown, items.length);
576
+ let html = "";
577
+ for (let i = 0; i < count; i++) {
578
+ const d = items[i];
579
+ const share = total ? (d.size / total * 100) : 0;
580
+ const tappable = d.kind === "dir" || d.files;
581
+ const glyph = d.kind === "dir" ? "▸" : d.kind === "file" ? "·" : "⋯";
582
+ html += "<tr" + (tappable ? ' class="tap" data-i="' + i + '"' : "") + ">" +
583
+ '<td><div class="name-cell"><span class="g" aria-hidden="true">' + glyph + "</span><span>" +
584
+ escapeHtml(d.name) + "</span></div></td>" +
585
+ (d.pending
586
+ ? '<td class="num muted">sizing…</td><td class="num muted">—</td></tr>'
587
+ : '<td class="num">' + bytes(d.size) + "</td>" +
588
+ '<td class="num">' + share.toFixed(1) + "%</td></tr>");
589
+ }
590
+ $("#stor-tbl").querySelector("tbody").innerHTML = html || '<tr><td colspan="3" class="muted">Empty</td></tr>';
591
+
592
+ const left = items.length - count;
593
+ $("#stor-more").hidden = items.length <= PAGE;
594
+ $("#stor-more-btn").hidden = left <= 0;
595
+ $("#stor-more-btn").textContent = "Show " + Math.min(MORE, left) + " more";
596
+ $("#stor-all-btn").hidden = left <= MORE;
597
+ $("#stor-all-btn").textContent = "Show all " + items.length;
598
+ $("#stor-less-btn").hidden = left > 0;
599
+ $("#stor-count").textContent = count + " of " + items.length;
600
+ }
601
+
602
+ $("#stor-tbl").querySelector("tbody").addEventListener("click", (ev) => {
603
+ const tr = ev.target.closest("tr.tap");
604
+ if (tr) activate(items[+tr.dataset.i]);
605
+ });
606
+ $("#stor-more-btn").addEventListener("click", () => { shown += MORE; renderTable(); });
607
+ $("#stor-all-btn").addEventListener("click", () => { shown = items.length; renderTable(); });
608
+ $("#stor-less-btn").addEventListener("click", () => {
609
+ shown = PAGE;
610
+ renderTable();
611
+ $("#stor-tbl").scrollIntoView({ block: "nearest" });
612
+ });
613
+
614
+ function renderFilesToggle() {
615
+ const chip = $("#show-files");
616
+ chip.classList.toggle("is-on", showFiles);
617
+ chip.setAttribute("aria-pressed", String(showFiles));
618
+ }
619
+ function setShowFiles(on) {
620
+ showFiles = on;
621
+ localStorage.setItem("kanshi-files", on ? "1" : "0");
622
+ renderFilesToggle();
623
+ if (listing) drawView();
624
+ }
625
+ $("#show-files").addEventListener("click", () => setShowFiles(!showFiles));
626
+ renderFilesToggle();
627
+
628
+ function drawView() {
629
+ items = viewItems();
630
+ renderCrumbs();
631
+ drawTreemap();
632
+ renderTable();
633
+ renderMeta();
634
+ }
635
+
636
+ async function loadStorage() {
637
+ let snap;
638
+ try { snap = await (await fetch("/api/storage")).json(); } catch (err) { return; }
639
+ storage = snap;
640
+ $("#pinform-input").placeholder = windowsPaths() ? "C:\\Users\\you\\Downloads" : "/mnt/data/media/movies";
641
+ renderScanStatus();
642
+ if (!snap.roots || !snap.roots.length) {
643
+ emptyStorage("No storage roots found.");
644
+ return;
645
+ }
646
+ if (rootIndex >= snap.roots.length) { rootIndex = 0; segs = []; }
647
+ renderRootBar(rootIndex);
648
+ // Re-read the same folder, so a walk finishing in the background fills in
649
+ // its sizes without kicking the user back to the root.
650
+ await navigate(rootIndex, segs, true);
651
+ }
652
+
653
+ // Every live frame carries the walk queue's status, so the progress bar
654
+ // moves without any polling, and the folder on screen is only re-read once
655
+ // a walk completes.
656
+ function onScanStatus(st) {
657
+ if (!storage) return; // the first load is still in flight and will carry this
658
+ const finished = st.updated_at !== storage.updated_at || (st.error || null) !== (storage.error || null);
659
+ ["scanning", "current", "queued", "progress", "updated_at", "error"].forEach((k) => { storage[k] = st[k]; });
660
+ renderScanStatus();
661
+ if (finished) loadStorage();
662
+ }
663
+
664
+ // Re-walks the subfolders of the folder on screen. The walks run in the
665
+ // background; the live stream carries them from there.
666
+ $("#rescan").addEventListener("click", async () => {
667
+ try {
668
+ const res = await fetch("/api/storage/rescan?" + dirQuery(rootIndex, segs),
669
+ { method: "POST", headers: { "X-Kanshi": "1" } });
670
+ if (res.ok) onScanStatus(await res.json());
671
+ } catch (err) { /* the next frame will say where things stand */ }
672
+ });
673
+
674
+ let resizeTimer;
675
+ new ResizeObserver(() => {
676
+ clearTimeout(resizeTimer);
677
+ resizeTimer = setTimeout(drawTreemap, 120);
678
+ }).observe(svg.parentElement);
679
+
680
+ /* ── live stream ────────────────────────────────────────────────────── */
681
+ let source = null, retry = 1000, retryTimer = null;
682
+ function setConn(state, text) {
683
+ const el = $("#conn");
684
+ el.className = "conn " + state;
685
+ el.querySelector("span").textContent = text;
686
+ document.body.classList.toggle("is-stale", state !== "live");
687
+ }
688
+
689
+ function disconnect() {
690
+ clearTimeout(retryTimer);
691
+ if (source) { source.close(); source = null; }
692
+ }
693
+
694
+ function connect() {
695
+ disconnect();
696
+ if (document.hidden) return;
697
+ source = new EventSource("/api/stream");
698
+ source.onopen = () => { retry = 1000; setConn("live", "live"); };
699
+ source.onmessage = (ev) => {
700
+ let payload;
701
+ try { payload = JSON.parse(ev.data); } catch (e) { return; }
702
+ setConn("live", "live");
703
+ if (payload.vitals) {
704
+ renderCpu(payload.vitals);
705
+ renderMeters(payload.vitals);
706
+ $("#uptime").textContent = "up " + duration(payload.vitals.uptime);
707
+ $("#foot-meta").textContent = (appVersion ? "kanshi " + appVersion + " · " : "") +
708
+ "updated " + new Date().toLocaleTimeString();
709
+ }
710
+ if (payload.docker) renderContainers(payload.docker);
711
+ if (payload.storage) onScanStatus(payload.storage);
712
+ };
713
+ source.onerror = () => {
714
+ setConn("down", "reconnecting");
715
+ disconnect();
716
+ retryTimer = setTimeout(connect, retry);
717
+ retry = Math.min(retry * 2, 15000); // back off instead of hammering
718
+ };
719
+ }
720
+
721
+ // A background tab drops its stream, so once nobody is looking the server's
722
+ // poller goes idle and stops touching /proc and the Docker socket entirely.
723
+ // A phone suspends the page on lock anyway; either way, reconnect and
724
+ // refresh the moment it is visible again.
725
+ document.addEventListener("visibilitychange", () => {
726
+ if (document.hidden) { disconnect(); setConn("down", "paused"); return; }
727
+ connect();
728
+ loadStorage();
729
+ });
730
+
731
+ /* ── network access ─────────────────────────────────────────────────── */
732
+ // Who else can open the dashboard. The server decides whether this page may
733
+ // change it (only from this computer, and only when no flag or environment
734
+ // variable pins it); the dialog just shows what it was told.
735
+ let appVersion = "";
736
+ let access = null;
737
+ const KIND = { local: "This computer", lan: "Local network", tailscale: "Tailscale", custom: "Address", other: "Other" };
738
+ const dlg = $("#access-dlg");
739
+ const box = (name) => $("#access-opts").querySelector('input[name="' + name + '"]');
740
+
741
+ function renderAccessLine() {
742
+ if (!access || !access.links) return;
743
+ const others = access.links.filter((l) => l.kind !== "local");
744
+ $("#access-line").textContent = others.length
745
+ ? "Also reachable at " + others.map((l) => l.url.replace(/^http:\/\//, "") + " (" + (KIND[l.kind] || l.kind) + ")").join(" · ")
746
+ : "Only reachable from this computer.";
747
+ }
748
+
749
+ function renderAccessDialog() {
750
+ const tokens = (access.mode || "local").split(",");
751
+ const all = tokens.indexOf("all") >= 0;
752
+ box("all").checked = all;
753
+ box("lan").checked = all || tokens.indexOf("lan") >= 0;
754
+ box("tailscale").checked = all || tokens.indexOf("tailscale") >= 0;
755
+ syncAccessBoxes();
756
+ // Explicit addresses have no checkbox; they are kept as they are.
757
+ const extra = tokens.filter((t) => ["local", "lan", "tailscale", "all"].indexOf(t) < 0);
758
+ $("#access-extra").hidden = !extra.length;
759
+ $("#access-extra").textContent = extra.length ? "Also listening on " + extra.join(", ") + " (set in the config)." : "";
760
+ $("#access-opts").disabled = !access.editable;
761
+ $("#access-save").hidden = !access.editable;
762
+ $("#access-reason").textContent = access.editable ? "" : access.reason || "";
763
+ $("#access-links").innerHTML = access.links.map((l) =>
764
+ '<li><span class="k">' + escapeHtml(KIND[l.kind] || l.kind) + '</span><a href="' + escapeHtml(l.url) +
765
+ '" target="_blank" rel="noopener">' + escapeHtml(l.url) + "</a></li>").join("");
766
+ }
767
+
768
+ // "Every network" already includes the other two, so they are shown ticked
769
+ // and locked while it is on.
770
+ function syncAccessBoxes() {
771
+ const all = box("all").checked;
772
+ ["lan", "tailscale"].forEach((n) => {
773
+ box(n).disabled = all;
774
+ if (all) box(n).checked = true;
775
+ });
776
+ }
777
+ box("all").addEventListener("change", () => {
778
+ if (!box("all").checked) { box("lan").checked = false; box("tailscale").checked = false; }
779
+ syncAccessBoxes();
780
+ });
781
+
782
+ async function loadAccess() {
783
+ try {
784
+ access = await (await fetch("/api/access")).json();
785
+ } catch (err) { return; }
786
+ renderAccessLine();
787
+ if (dlg.open) renderAccessDialog();
788
+ }
789
+
790
+ $("#access-open").addEventListener("click", async () => {
791
+ $("#access-err").textContent = "";
792
+ await loadAccess();
793
+ if (!access) return;
794
+ renderAccessDialog();
795
+ dlg.showModal();
796
+ });
797
+
798
+ $("#access-save").addEventListener("click", async () => {
799
+ const extra = (access.mode || "").split(",").filter((t) => ["local", "lan", "tailscale", "all", ""].indexOf(t) < 0);
800
+ const picked = box("all").checked ? ["all"] : ["lan", "tailscale"].filter((n) => box(n).checked);
801
+ const mode = picked.concat(box("all").checked ? [] : extra).join(",") || "local";
802
+ $("#access-err").textContent = "";
803
+ try {
804
+ const res = await fetch("/api/access", {
805
+ method: "POST",
806
+ headers: { "Content-Type": "application/json", "X-Kanshi": "1" },
807
+ body: JSON.stringify({ mode: mode }),
808
+ });
809
+ if (!res.ok) throw new Error((await res.text()).trim() || "HTTP " + res.status);
810
+ access = await res.json();
811
+ renderAccessLine();
812
+ renderAccessDialog();
813
+ if (access.reason) $("#access-err").textContent = access.reason;
814
+ } catch (err) {
815
+ $("#access-err").textContent = "Could not change it: " + err.message;
816
+ }
817
+ });
818
+
819
+ fetch("/api/config").then((r) => r.json()).then((c) => { appVersion = c.version || ""; }).catch(() => {});
820
+ loadAccess();
821
+
822
+ /* ── theme toggle ───────────────────────────────────────────────────── */
823
+ const saved = localStorage.getItem("kanshi-theme");
824
+ if (saved) document.documentElement.dataset.theme = saved;
825
+ $("#theme").addEventListener("click", () => {
826
+ const now = document.documentElement.dataset.theme;
827
+ const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
828
+ const next = now === "dark" ? "light" : now === "light" ? "dark" : (prefersDark ? "light" : "dark");
829
+ document.documentElement.dataset.theme = next;
830
+ localStorage.setItem("kanshi-theme", next);
831
+ });
832
+
833
+ connect();
834
+ loadStorage();
835
+ })();