@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/web/app.js ADDED
@@ -0,0 +1,344 @@
1
+ /* kanshi — single-page live dashboard. No framework, no build step. */
2
+ (function () {
3
+ "use strict";
4
+
5
+ const $ = (sel) => document.querySelector(sel);
6
+ const KB = 1024;
7
+
8
+ /* ── formatting ─────────────────────────────────────────────────────── */
9
+ function bytes(n) {
10
+ if (n === null || n === undefined || isNaN(n)) return "—";
11
+ if (n < KB) return n + " B";
12
+ const units = ["KB", "MB", "GB", "TB", "PB"];
13
+ let v = n / KB, i = 0;
14
+ while (v >= KB && i < units.length - 1) { v /= KB; i++; }
15
+ return (v >= 100 ? v.toFixed(0) : v >= 10 ? v.toFixed(1) : v.toFixed(2)) + " " + units[i];
16
+ }
17
+ function rate(n) {
18
+ if (!n || n < 1) return "0";
19
+ return bytes(n) + "/s";
20
+ }
21
+ function shortRate(n) {
22
+ if (!n || n < KB) return "0";
23
+ const units = ["K", "M", "G"];
24
+ let v = n / KB, i = 0;
25
+ while (v >= KB && i < units.length - 1) { v /= KB; i++; }
26
+ return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + units[i];
27
+ }
28
+ function duration(secs) {
29
+ if (!secs && secs !== 0) return "—";
30
+ const d = Math.floor(secs / 86400), h = Math.floor((secs % 86400) / 3600), m = Math.floor((secs % 3600) / 60);
31
+ if (d) return d + "d " + h + "h";
32
+ if (h) return h + "h " + m + "m";
33
+ return m + "m";
34
+ }
35
+ function ago(ts) {
36
+ if (!ts) return "never";
37
+ return duration(Math.max(0, Date.now() / 1000 - ts)) + " ago";
38
+ }
39
+ function severity(pct, warn, crit) {
40
+ return pct >= (crit || 90) ? "crit" : pct >= (warn || 75) ? "warn" : "ok";
41
+ }
42
+ function severityWord(cls) {
43
+ return cls === "crit" ? "critical" : cls === "warn" ? "high" : "";
44
+ }
45
+
46
+ /* ── processor card ─────────────────────────────────────────────────── */
47
+ const coreEls = [];
48
+ function renderCpu(v) {
49
+ const cpu = v.cpu;
50
+ $("#cpu-hero").innerHTML = cpu.percent.toFixed(0) + '<span class="hero-unit">%</span>';
51
+ $("#cpu-count").textContent = cpu.count;
52
+ const hot = cpu.temp !== null && cpu.temp !== undefined;
53
+ $("#cpu-meta").textContent = cpu.count + " cores" + (hot ? " · " + cpu.temp + "°C" : "");
54
+
55
+ const aux = [
56
+ ["Load avg", cpu.load.map((n) => n.toFixed(2)).join(" ")],
57
+ ["Net", "↓" + shortRate(v.network.rx) + " ↑" + shortRate(v.network.tx)],
58
+ ["Disk", "↓" + shortRate(v.diskio.read) + " ↑" + shortRate(v.diskio.write)],
59
+ ];
60
+ $("#cpu-aux").innerHTML = aux.map((r) => "<dt>" + r[0] + "</dt><dd>" + r[1] + "</dd>").join("");
61
+
62
+ const host = $("#cores");
63
+ if (coreEls.length !== cpu.cores.length) {
64
+ host.innerHTML = "";
65
+ coreEls.length = 0;
66
+ cpu.cores.forEach(() => {
67
+ const slot = document.createElement("div");
68
+ const fill = document.createElement("span");
69
+ slot.appendChild(fill);
70
+ host.appendChild(slot);
71
+ coreEls.push(fill);
72
+ });
73
+ }
74
+ // One hue for every core: the bar height already encodes the value, so a
75
+ // per-core colour ramp would double-encode it and say nothing new.
76
+ cpu.cores.forEach((pct, i) => { coreEls[i].style.height = Math.max(2, pct) + "%"; });
77
+ host.setAttribute("aria-label", "Per-core utilisation: " + cpu.cores.map((c) => c.toFixed(0) + "%").join(", "));
78
+ }
79
+
80
+ /* ── meters ─────────────────────────────────────────────────────────── */
81
+ function meter(name, pct, detail, warn, crit) {
82
+ const cls = severity(pct, warn, crit);
83
+ const word = severityWord(cls);
84
+ return '<div class="meter ' + cls + '">' +
85
+ '<div class="meter-head"><span class="meter-name">' + name + "</span>" +
86
+ '<span class="meter-val">' + (word ? '<span class="flag">⚠</span>' + word + " · " : "") +
87
+ "<b>" + pct.toFixed(1) + "%</b> · " + detail + "</span></div>" +
88
+ '<div class="meter-track"><div class="meter-fill" style="width:' + Math.min(100, pct) + '%"></div></div>' +
89
+ "</div>";
90
+ }
91
+ function renderMeters(v) {
92
+ const parts = [
93
+ meter("RAM", v.memory.percent, bytes(v.memory.used) + " of " + bytes(v.memory.total), 80, 92),
94
+ ];
95
+ if (v.swap.total > 0) {
96
+ parts.push(meter("Swap", v.swap.percent, bytes(v.swap.used) + " of " + bytes(v.swap.total), 50, 80));
97
+ }
98
+ v.filesystems.forEach((fs) => {
99
+ parts.push(meter(fs.label, fs.percent, bytes(fs.free) + " free", 80, 92));
100
+ });
101
+ $("#meters").innerHTML = parts.join("");
102
+ }
103
+
104
+ /* ── containers ─────────────────────────────────────────────────────── */
105
+ let sortKey = "cpu";
106
+ let lastDocker = null;
107
+
108
+ function netTotal(c) { return c.net ? c.net.rx_rate + c.net.tx_rate : -1; }
109
+
110
+ function renderContainers(d) {
111
+ lastDocker = d;
112
+ if (!d) return;
113
+ if (d.error) {
114
+ $("#ctr-meta").textContent = "docker: " + d.error;
115
+ return;
116
+ }
117
+ const list = d.containers.slice();
118
+ const cmp = {
119
+ cpu: (a, b) => b.cpu - a.cpu,
120
+ mem: (a, b) => b.mem_used - a.mem_used,
121
+ net: (a, b) => netTotal(b) - netTotal(a),
122
+ name: (a, b) => a.name.localeCompare(b.name),
123
+ }[sortKey];
124
+ list.sort((a, b) => (a.state !== "running") - (b.state !== "running") || cmp(a, b) || a.name.localeCompare(b.name));
125
+
126
+ $("#ctr-meta").textContent = d.running + " running · " + d.total + " total";
127
+
128
+ const rows = list.map((c) => {
129
+ const running = c.state === "running";
130
+ const bad = c.health === "unhealthy" || (!running && c.state !== "exited");
131
+ const dot = bad ? "bad" : running ? "run" : "stop";
132
+ // Bar width is capped at one full core so a 300% spike stays readable.
133
+ const barPct = Math.min(100, c.cpu);
134
+ const mem = running ? bytes(c.mem_used) : "—";
135
+ const net = c.net ? "↓" + shortRate(c.net.rx_rate) + " ↑" + shortRate(c.net.tx_rate)
136
+ : (running ? "shared" : "—");
137
+ // Project first in the sub-line: it is the shared prefix, so it belongs
138
+ // where it can be skimmed past rather than eating the name column.
139
+ const sub = [c.project, running ? c.status : c.state + " · " + c.status]
140
+ .filter(Boolean).join(" · ");
141
+ return '<tr class="' + (running ? "" : "is-stopped") + '" title="' + (c.full_name || c.name) + '">' +
142
+ "<td><div class=\"name-cell\"><i class=\"dot " + dot + '"></i><span class="ctr-name">' + c.name + "</span></div>" +
143
+ '<div class="ctr-sub">' + sub + "</div>" +
144
+ '<div class="ctr-bar"><i style="width:' + barPct + '%"></i></div></td>' +
145
+ '<td class="num">' + (running ? c.cpu.toFixed(1) + "%" : "—") + "</td>" +
146
+ '<td class="num">' + mem + "</td>" +
147
+ '<td class="num">' + net + "</td>" +
148
+ "</tr>";
149
+ });
150
+ $("#ctr-tbl").querySelector("tbody").innerHTML = rows.join("");
151
+ }
152
+
153
+ document.querySelectorAll(".sortbar .chip").forEach((chip) => {
154
+ chip.addEventListener("click", () => {
155
+ document.querySelectorAll(".sortbar .chip").forEach((c) => c.classList.remove("is-on"));
156
+ chip.classList.add("is-on");
157
+ sortKey = chip.dataset.sort;
158
+ renderContainers(lastDocker);
159
+ });
160
+ });
161
+
162
+ /* ── storage ────────────────────────────────────────────────────────── */
163
+ const svg = $("#treemap");
164
+ let storage = null;
165
+ let rootIndex = 0;
166
+ let trail = []; // node stack from the selected root down to the view
167
+
168
+ function current() { return trail[trail.length - 1]; }
169
+
170
+ function renderRootBar() {
171
+ $("#rootbar").innerHTML = storage.roots.map((r, i) =>
172
+ '<button role="tab" class="chip' + (i === rootIndex ? " is-on" : "") + '" data-i="' + i + '" type="button" aria-selected="' +
173
+ (i === rootIndex) + '">' + r.name + " · " + bytes(r.size) + "</button>").join("");
174
+ $("#rootbar").querySelectorAll("button").forEach((b) => {
175
+ b.addEventListener("click", () => { rootIndex = +b.dataset.i; trail = [storage.roots[rootIndex]]; drawStorage(); });
176
+ });
177
+ }
178
+
179
+ function renderCrumbs() {
180
+ const html = trail.map((n, i) => {
181
+ const last = i === trail.length - 1;
182
+ return '<button type="button" data-i="' + i + '"' + (last ? " disabled" : "") + ">" + n.name + "</button>" +
183
+ (last ? "" : '<span class="sep">/</span>');
184
+ }).join("");
185
+ $("#crumbs").innerHTML = html;
186
+ $("#crumbs").querySelectorAll("button").forEach((b) => {
187
+ b.addEventListener("click", () => { trail = trail.slice(0, +b.dataset.i + 1); drawStorage(); });
188
+ });
189
+ }
190
+
191
+ function emptyStorage(message) {
192
+ // The first walk can take the better part of a minute on a big filesystem.
193
+ // Say so, rather than leaving a blank box that reads as broken.
194
+ $("#rootbar").innerHTML = "";
195
+ $("#crumbs").innerHTML = "";
196
+ $("#treemap").innerHTML = "";
197
+ $("#stor-tbl").querySelector("tbody").innerHTML = "";
198
+ $("#tm-focus").textContent = message;
199
+ $("#stor-meta").textContent = "";
200
+ }
201
+
202
+ function drawStorage() {
203
+ if (!storage || !storage.roots || !storage.roots.length) {
204
+ emptyStorage(storage && storage.error
205
+ ? "Scan failed: " + storage.error
206
+ : "Walking the filesystem for the first time — this can take a minute.");
207
+ return;
208
+ }
209
+ const node = current();
210
+ const kids = node.children || [];
211
+ renderCrumbs();
212
+
213
+ const box = svg.parentElement.getBoundingClientRect();
214
+ const width = Math.max(200, Math.round(box.width));
215
+ const height = Math.round(parseFloat(getComputedStyle(svg).height)) || 300;
216
+ svg.setAttribute("height", height);
217
+
218
+ Treemap.render(svg, kids, {
219
+ width: width, height: height, fmt: bytes,
220
+ onFocus: (d) => {
221
+ const share = node.size ? (d.size / node.size * 100).toFixed(1) : "0";
222
+ $("#tm-focus").textContent = d.name + " — " + bytes(d.size) + " (" + share + "% of " + node.name + ")";
223
+ },
224
+ onSelect: (d) => {
225
+ if (d.kind === "dir" && d.children && d.children.length) { trail.push(d); drawStorage(); }
226
+ },
227
+ });
228
+
229
+ // The table twin: every value in the map is reachable without colour or hover.
230
+ const rows = kids.slice().sort((a, b) => b.size - a.size).map((d) => {
231
+ const share = node.size ? (d.size / node.size * 100) : 0;
232
+ const drillable = d.kind === "dir" && d.children && d.children.length;
233
+ const glyph = d.kind === "dir" ? "▸" : d.kind === "file" ? "·" : "⋯";
234
+ return "<tr" + (drillable ? ' class="tap" data-name="' + encodeURIComponent(d.name) + '"' : "") + ">" +
235
+ '<td><div class="name-cell"><span class="g" aria-hidden="true">' + glyph + "</span><span>" + d.name + "</span></div></td>" +
236
+ '<td class="num">' + bytes(d.size) + "</td>" +
237
+ '<td class="num">' + share.toFixed(1) + "%</td></tr>";
238
+ });
239
+ const tbody = $("#stor-tbl").querySelector("tbody");
240
+ tbody.innerHTML = rows.join("") || '<tr><td colspan="3" class="muted">Empty</td></tr>';
241
+ tbody.querySelectorAll("tr.tap").forEach((tr) => {
242
+ tr.addEventListener("click", () => {
243
+ const name = decodeURIComponent(tr.dataset.name);
244
+ const hit = kids.find((k) => k.name === name);
245
+ if (hit) { trail.push(hit); drawStorage(); }
246
+ });
247
+ });
248
+
249
+ const root = storage.roots[rootIndex];
250
+ const warn = root.unreadable
251
+ ? " · ⚠ " + root.unreadable + " unreadable dir" + (root.unreadable === 1 ? "" : "s") + " not counted"
252
+ : "";
253
+ $("#stor-meta").textContent = "walked " + root.root + " in " + root.walk_seconds + "s · scanned " +
254
+ ago(storage.scanned_at) + (storage.scanning ? " · rescanning…" : "") + warn;
255
+ }
256
+
257
+ async function loadStorage() {
258
+ try {
259
+ const res = await fetch("/api/storage");
260
+ storage = await res.json();
261
+ if (!storage.roots || !storage.roots.length) { drawStorage(); return; }
262
+ if (rootIndex >= storage.roots.length) rootIndex = 0;
263
+ // Re-anchor the current view onto the fresh tree so a background rescan
264
+ // doesn't kick the user back to the root while they're drilling around.
265
+ const names = trail.slice(1).map((n) => n.name);
266
+ trail = [storage.roots[rootIndex]];
267
+ for (const name of names) {
268
+ const kids = current().children || [];
269
+ const hit = kids.find((k) => k.name === name);
270
+ if (!hit) break;
271
+ trail.push(hit);
272
+ }
273
+ renderRootBar();
274
+ drawStorage();
275
+ } catch (err) { /* keep the previous render */ }
276
+ }
277
+
278
+ $("#rescan").addEventListener("click", async () => {
279
+ const btn = $("#rescan");
280
+ btn.disabled = true; btn.textContent = "Scanning…";
281
+ try { await fetch("/api/storage/rescan", { method: "POST" }); await loadStorage(); }
282
+ finally { btn.disabled = false; btn.textContent = "Rescan"; }
283
+ });
284
+
285
+ let resizeTimer;
286
+ new ResizeObserver(() => {
287
+ clearTimeout(resizeTimer);
288
+ resizeTimer = setTimeout(drawStorage, 120);
289
+ }).observe(svg.parentElement);
290
+
291
+ /* ── live stream ────────────────────────────────────────────────────── */
292
+ let source = null, retry = 1000;
293
+ function setConn(state, text) {
294
+ const el = $("#conn");
295
+ el.className = "conn " + state;
296
+ el.querySelector("span").textContent = text;
297
+ document.body.classList.toggle("is-stale", state !== "live");
298
+ }
299
+
300
+ function connect() {
301
+ if (source) source.close();
302
+ source = new EventSource("/api/stream");
303
+ source.onopen = () => { retry = 1000; setConn("live", "live"); };
304
+ source.onmessage = (ev) => {
305
+ let payload;
306
+ try { payload = JSON.parse(ev.data); } catch (e) { return; }
307
+ setConn("live", "live");
308
+ if (payload.vitals) {
309
+ renderCpu(payload.vitals);
310
+ renderMeters(payload.vitals);
311
+ $("#uptime").textContent = "up " + duration(payload.vitals.uptime);
312
+ $("#foot-meta").textContent = "updated " + new Date().toLocaleTimeString();
313
+ }
314
+ if (payload.docker) renderContainers(payload.docker);
315
+ };
316
+ source.onerror = () => {
317
+ setConn("down", "reconnecting");
318
+ source.close();
319
+ setTimeout(connect, retry);
320
+ retry = Math.min(retry * 2, 15000); // back off instead of hammering
321
+ };
322
+ }
323
+
324
+ // A phone suspends the page on lock; reconnect and refresh the moment it returns.
325
+ document.addEventListener("visibilitychange", () => {
326
+ if (document.visibilityState === "visible") { connect(); loadStorage(); }
327
+ });
328
+
329
+ /* ── theme toggle ───────────────────────────────────────────────────── */
330
+ const saved = localStorage.getItem("kanshi-theme");
331
+ if (saved) document.documentElement.dataset.theme = saved;
332
+ $("#theme").addEventListener("click", () => {
333
+ const now = document.documentElement.dataset.theme;
334
+ const prefersDark = matchMedia("(prefers-color-scheme: dark)").matches;
335
+ const next = now === "dark" ? "light" : now === "light" ? "dark" : (prefersDark ? "light" : "dark");
336
+ document.documentElement.dataset.theme = next;
337
+ localStorage.setItem("kanshi-theme", next);
338
+ drawStorage();
339
+ });
340
+
341
+ connect();
342
+ loadStorage();
343
+ setInterval(loadStorage, 60000); // cheap: the server serves a cached tree
344
+ })();
package/web/index.html ADDED
@@ -0,0 +1,105 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="auto">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <meta name="theme-color" content="#f9f9f7" media="(prefers-color-scheme: light)">
8
+ <meta name="theme-color" content="#0d0d0d" media="(prefers-color-scheme: dark)">
9
+ <title>kanshi</title>
10
+ <link rel="stylesheet" href="/static/style.css">
11
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='4' fill='%232a78d6'/></svg>">
12
+ </head>
13
+ <body class="viz-root">
14
+
15
+ <header class="topbar">
16
+ <h1>kanshi</h1>
17
+ <div class="topbar-meta">
18
+ <span id="uptime" class="muted">—</span>
19
+ <span id="conn" class="conn" title="Live connection status"><i></i><span>connecting</span></span>
20
+ </div>
21
+ </header>
22
+
23
+ <main id="app">
24
+
25
+ <!-- ───────── CPU: the hero figure, exactly one per view ───────── -->
26
+ <section class="card" aria-labelledby="cpu-h">
27
+ <div class="card-head">
28
+ <h2 id="cpu-h">Processor</h2>
29
+ <span id="cpu-meta" class="muted small">—</span>
30
+ </div>
31
+ <div class="hero-row">
32
+ <div>
33
+ <div id="cpu-hero" class="hero">—<span class="hero-unit">%</span></div>
34
+ <div class="hero-label muted small">utilisation across <span id="cpu-count">—</span> cores</div>
35
+ </div>
36
+ <dl class="minitable" id="cpu-aux"></dl>
37
+ </div>
38
+ <div class="cores" id="cores" role="img" aria-label="Per-core utilisation"></div>
39
+ </section>
40
+
41
+ <!-- ───────── Memory + filesystem meters ───────── -->
42
+ <section class="card" aria-labelledby="mem-h">
43
+ <div class="card-head"><h2 id="mem-h">Memory &amp; volumes</h2></div>
44
+ <div id="meters" class="meters"></div>
45
+ </section>
46
+
47
+ <!-- ───────── Storage treemap ───────── -->
48
+ <section class="card" aria-labelledby="stor-h">
49
+ <div class="card-head">
50
+ <h2 id="stor-h">Storage map</h2>
51
+ <button id="rescan" class="btn" type="button">Rescan</button>
52
+ </div>
53
+ <div class="rootbar" id="rootbar" role="tablist" aria-label="Storage root"></div>
54
+ <nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>
55
+ <div class="treemap-wrap">
56
+ <svg id="treemap" role="img" aria-labelledby="stor-h" preserveAspectRatio="none"></svg>
57
+ </div>
58
+ <p id="tm-focus" class="focusline muted small" aria-live="polite">Tap a block to drill in.</p>
59
+ <ul class="legend">
60
+ <li><span class="g">▸</span>folder</li>
61
+ <li><span class="g">·</span>file</li>
62
+ <li><span class="g">⋯</span>aggregated</li>
63
+ </ul>
64
+ <table class="tbl" id="stor-tbl">
65
+ <caption class="visually-hidden">Contents of the selected directory by size</caption>
66
+ <thead><tr><th scope="col">Name</th><th scope="col" class="num">Size</th><th scope="col" class="num">Share</th></tr></thead>
67
+ <tbody></tbody>
68
+ </table>
69
+ <p id="stor-meta" class="muted small"></p>
70
+ </section>
71
+
72
+ <!-- ───────── Containers ───────── -->
73
+ <section class="card" aria-labelledby="ctr-h">
74
+ <div class="card-head">
75
+ <h2 id="ctr-h">Containers</h2>
76
+ <span id="ctr-meta" class="muted small">—</span>
77
+ </div>
78
+ <div class="sortbar" role="group" aria-label="Sort containers">
79
+ <button class="chip is-on" data-sort="cpu" type="button">CPU</button>
80
+ <button class="chip" data-sort="mem" type="button">Memory</button>
81
+ <button class="chip" data-sort="net" type="button">Network</button>
82
+ <button class="chip" data-sort="name" type="button">Name</button>
83
+ </div>
84
+ <table class="tbl ctr-tbl" id="ctr-tbl">
85
+ <caption class="visually-hidden">Per-container CPU, memory and network</caption>
86
+ <thead><tr>
87
+ <th scope="col">Container</th>
88
+ <th scope="col" class="num">CPU</th>
89
+ <th scope="col" class="num">Memory</th>
90
+ <th scope="col" class="num">Net ↓↑</th>
91
+ </tr></thead>
92
+ <tbody></tbody>
93
+ </table>
94
+ </section>
95
+
96
+ <footer class="foot muted small">
97
+ <span id="foot-meta">—</span>
98
+ <button id="theme" class="btn btn-quiet" type="button" aria-label="Toggle colour theme">Theme</button>
99
+ </footer>
100
+ </main>
101
+
102
+ <script src="/static/treemap.js"></script>
103
+ <script src="/static/app.js"></script>
104
+ </body>
105
+ </html>