@yuuki824/kanshi 0.1.0 → 0.1.2
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 +4 -4
- package/Dockerfile +24 -19
- package/README.md +139 -36
- package/docker-compose.yml +16 -7
- package/go.mod +3 -0
- package/internal/config/config.go +140 -0
- package/internal/dockerstats/dockerstats.go +501 -0
- package/internal/server/server.go +372 -0
- package/internal/storage/storage.go +586 -0
- package/internal/vitals/vitals.go +614 -0
- package/main.go +76 -0
- package/package.json +5 -3
- package/web/app.js +167 -8
- package/web/index.html +10 -0
- package/web/style.css +27 -0
- package/web/treemap.js +21 -63
- package/app/__init__.py +0 -0
- package/app/config.py +0 -72
- package/app/dockerstats.py +0 -207
- package/app/main.py +0 -174
- package/app/storage.py +0 -220
- package/app/vitals.py +0 -170
- package/requirements.txt +0 -4
package/web/app.js
CHANGED
|
@@ -167,13 +167,132 @@
|
|
|
167
167
|
|
|
168
168
|
function current() { return trail[trail.length - 1]; }
|
|
169
169
|
|
|
170
|
+
// Bars are always full width, so height is the only scarce dimension. Grow
|
|
171
|
+
// the section so even the smallest visible bar clears the label threshold,
|
|
172
|
+
// capped so one tiny outlier can't blow the section up indefinitely.
|
|
173
|
+
function treemapHeight(kids) {
|
|
174
|
+
const MIN_HEIGHT = 300, MAX_HEIGHT = 640, MIN_ROW_HEIGHT = 30;
|
|
175
|
+
if (!kids || !kids.length) return MIN_HEIGHT;
|
|
176
|
+
let total = 0, minSize = Infinity;
|
|
177
|
+
kids.forEach((d) => { total += d.size; if (d.size > 0 && d.size < minSize) minSize = d.size; });
|
|
178
|
+
if (!total || !isFinite(minSize)) return MIN_HEIGHT;
|
|
179
|
+
const needed = Math.ceil((MIN_ROW_HEIGHT * total) / minSize);
|
|
180
|
+
return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, needed));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Pinned custom paths: bookmarks into the already-scanned tree, so adding one
|
|
184
|
+
// costs no server round trip and can never reach outside what was walked.
|
|
185
|
+
const PIN_KEY = "kanshi-pins";
|
|
186
|
+
function loadPins() {
|
|
187
|
+
try { return JSON.parse(localStorage.getItem(PIN_KEY)) || []; } catch (e) { return []; }
|
|
188
|
+
}
|
|
189
|
+
function savePins() { localStorage.setItem(PIN_KEY, JSON.stringify(pins)); }
|
|
190
|
+
let pins = loadPins();
|
|
191
|
+
|
|
192
|
+
// Longest matching root label wins, so "/mnt/data/x" resolves against the
|
|
193
|
+
// "/mnt/data" root rather than the "/" root that also technically contains it.
|
|
194
|
+
function bestRootForPath(path) {
|
|
195
|
+
let best = null;
|
|
196
|
+
storage.roots.forEach((r, i) => {
|
|
197
|
+
const label = r.name;
|
|
198
|
+
const matches = label === "/" ? path.indexOf("/") === 0 : (path === label || path.indexOf(label + "/") === 0);
|
|
199
|
+
if (!matches) return;
|
|
200
|
+
if (!best || label.length > best.label.length) {
|
|
201
|
+
const rest = label === "/" ? path.slice(1) : path.slice(label.length + 1);
|
|
202
|
+
best = { rootIndex: i, label: label, segs: rest.split("/").filter(Boolean) };
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
return best;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resolvePin(pin) {
|
|
209
|
+
const idx = storage.roots.findIndex((r) => r.name === pin.root);
|
|
210
|
+
if (idx < 0) return null;
|
|
211
|
+
let node = storage.roots[idx];
|
|
212
|
+
const resTrail = [node];
|
|
213
|
+
for (const seg of pin.segs) {
|
|
214
|
+
const kids2 = node.children || [];
|
|
215
|
+
const hit = kids2.find((k) => k.name === seg);
|
|
216
|
+
if (!hit) break;
|
|
217
|
+
node = hit;
|
|
218
|
+
resTrail.push(node);
|
|
219
|
+
}
|
|
220
|
+
return { rootIndex: idx, trail: resTrail, complete: resTrail.length === pin.segs.length + 1 };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function goToPin(pin) {
|
|
224
|
+
const res = resolvePin(pin);
|
|
225
|
+
if (!res) return;
|
|
226
|
+
rootIndex = res.rootIndex;
|
|
227
|
+
trail = res.trail;
|
|
228
|
+
drawStorage();
|
|
229
|
+
if (!res.complete) {
|
|
230
|
+
$("#tm-focus").textContent = "Landed as deep as the scan reaches — the rest is below the scan depth or folded away.";
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function removePin(pin) {
|
|
235
|
+
pins = pins.filter((p) => !(p.root === pin.root && p.label === pin.label));
|
|
236
|
+
savePins();
|
|
237
|
+
renderRootBar();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function flashPinError(msg) { $("#pinform-err").textContent = msg; }
|
|
241
|
+
|
|
242
|
+
function submitPin(raw) {
|
|
243
|
+
const path = raw.trim();
|
|
244
|
+
if (path.indexOf("/") !== 0) { flashPinError("Use an absolute path, e.g. /mnt/data/media"); return; }
|
|
245
|
+
const match = bestRootForPath(path);
|
|
246
|
+
if (!match) { flashPinError("No storage root covers that path"); return; }
|
|
247
|
+
const pin = { root: storage.roots[match.rootIndex].name, segs: match.segs, label: path };
|
|
248
|
+
if (!pins.some((p) => p.root === pin.root && p.label === pin.label)) {
|
|
249
|
+
pins.push(pin);
|
|
250
|
+
savePins();
|
|
251
|
+
}
|
|
252
|
+
renderRootBar();
|
|
253
|
+
goToPin(pin);
|
|
254
|
+
hidePinForm();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function showPinForm() {
|
|
258
|
+
$("#pinform-err").textContent = "";
|
|
259
|
+
$("#pinform").hidden = false;
|
|
260
|
+
$("#pinform-input").value = "";
|
|
261
|
+
$("#pinform-input").focus();
|
|
262
|
+
}
|
|
263
|
+
function hidePinForm() { $("#pinform").hidden = true; }
|
|
264
|
+
|
|
265
|
+
$("#pinform").addEventListener("submit", (ev) => {
|
|
266
|
+
ev.preventDefault();
|
|
267
|
+
submitPin($("#pinform-input").value);
|
|
268
|
+
});
|
|
269
|
+
$("#pinform-cancel").addEventListener("click", hidePinForm);
|
|
270
|
+
|
|
271
|
+
function escapeHtml(s) {
|
|
272
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
273
|
+
}
|
|
274
|
+
|
|
170
275
|
function renderRootBar() {
|
|
171
|
-
|
|
276
|
+
const rootChips = storage.roots.map((r, i) =>
|
|
172
277
|
'<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
|
-
|
|
278
|
+
(i === rootIndex) + '">' + escapeHtml(r.name) + " · " + bytes(r.size) + "</button>").join("");
|
|
279
|
+
const pinChips = pins.map((p, i) => {
|
|
280
|
+
const label = escapeHtml(p.label);
|
|
281
|
+
return '<span class="chip pin" data-i="' + i + '">' +
|
|
282
|
+
'<button type="button" class="pin-go" title="' + label + '">' + label + "</button>" +
|
|
283
|
+
'<button type="button" class="pin-x" aria-label="Remove pinned path">×</button></span>';
|
|
284
|
+
}).join("");
|
|
285
|
+
$("#rootbar").innerHTML = rootChips + pinChips +
|
|
286
|
+
'<button class="chip pin-add" id="pin-add" type="button">+ Add path</button>';
|
|
287
|
+
$("#rootbar").querySelectorAll("button.chip[data-i]").forEach((b) => {
|
|
175
288
|
b.addEventListener("click", () => { rootIndex = +b.dataset.i; trail = [storage.roots[rootIndex]]; drawStorage(); });
|
|
176
289
|
});
|
|
290
|
+
$("#rootbar").querySelectorAll(".chip.pin").forEach((span) => {
|
|
291
|
+
const pin = pins[+span.dataset.i];
|
|
292
|
+
span.querySelector(".pin-go").addEventListener("click", () => goToPin(pin));
|
|
293
|
+
span.querySelector(".pin-x").addEventListener("click", () => removePin(pin));
|
|
294
|
+
});
|
|
295
|
+
$("#pin-add").addEventListener("click", showPinForm);
|
|
177
296
|
}
|
|
178
297
|
|
|
179
298
|
function renderCrumbs() {
|
|
@@ -199,7 +318,30 @@
|
|
|
199
318
|
$("#stor-meta").textContent = "";
|
|
200
319
|
}
|
|
201
320
|
|
|
321
|
+
// There is nothing to compare against on the very first walk ever — no
|
|
322
|
+
// previous byte totals — so the server omits `progress` rather than
|
|
323
|
+
// shipping a meaningless 0%. The bar only appears once there is a real
|
|
324
|
+
// percentage to show.
|
|
325
|
+
function renderScanProgress() {
|
|
326
|
+
const bar = $("#scan-progress");
|
|
327
|
+
const p = storage && storage.progress;
|
|
328
|
+
if (!storage || !storage.scanning || !p) { bar.hidden = true; return; }
|
|
329
|
+
bar.hidden = false;
|
|
330
|
+
$("#scan-progress-fill").style.width = Math.min(100, p.percent) + "%";
|
|
331
|
+
$("#scan-progress-label").textContent =
|
|
332
|
+
"Scanning " + p.root + "… " + p.percent.toFixed(0) + "% · " + bytes(p.bytes_done) + " of " + bytes(p.bytes_total);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function updateRescanButton() {
|
|
336
|
+
const btn = $("#rescan");
|
|
337
|
+
const scanning = !!(storage && storage.scanning);
|
|
338
|
+
btn.disabled = scanning;
|
|
339
|
+
btn.textContent = scanning ? "Scanning…" : "Rescan";
|
|
340
|
+
}
|
|
341
|
+
|
|
202
342
|
function drawStorage() {
|
|
343
|
+
renderScanProgress();
|
|
344
|
+
updateRescanButton();
|
|
203
345
|
if (!storage || !storage.roots || !storage.roots.length) {
|
|
204
346
|
emptyStorage(storage && storage.error
|
|
205
347
|
? "Scan failed: " + storage.error
|
|
@@ -212,7 +354,8 @@
|
|
|
212
354
|
|
|
213
355
|
const box = svg.parentElement.getBoundingClientRect();
|
|
214
356
|
const width = Math.max(200, Math.round(box.width));
|
|
215
|
-
const height =
|
|
357
|
+
const height = treemapHeight(kids);
|
|
358
|
+
svg.style.height = height + "px";
|
|
216
359
|
svg.setAttribute("height", height);
|
|
217
360
|
|
|
218
361
|
Treemap.render(svg, kids, {
|
|
@@ -254,10 +397,24 @@
|
|
|
254
397
|
ago(storage.scanned_at) + (storage.scanning ? " · rescanning…" : "") + warn;
|
|
255
398
|
}
|
|
256
399
|
|
|
400
|
+
// While a walk is running the server updates its progress counters live, so
|
|
401
|
+
// poll every second instead of the normal 60s cadence — cheap, since /api/storage
|
|
402
|
+
// just reads counters rather than repeating any filesystem work. The chain
|
|
403
|
+
// stops itself the moment a poll comes back with scanning: false.
|
|
404
|
+
let scanPollTimer = null;
|
|
405
|
+
function scheduleScanPoll() {
|
|
406
|
+
clearTimeout(scanPollTimer);
|
|
407
|
+
scanPollTimer = setTimeout(async () => {
|
|
408
|
+
await loadStorage();
|
|
409
|
+
if (storage && storage.scanning) scheduleScanPoll();
|
|
410
|
+
}, 1000);
|
|
411
|
+
}
|
|
412
|
+
|
|
257
413
|
async function loadStorage() {
|
|
258
414
|
try {
|
|
259
415
|
const res = await fetch("/api/storage");
|
|
260
416
|
storage = await res.json();
|
|
417
|
+
if (storage.scanning) scheduleScanPoll();
|
|
261
418
|
if (!storage.roots || !storage.roots.length) { drawStorage(); return; }
|
|
262
419
|
if (rootIndex >= storage.roots.length) rootIndex = 0;
|
|
263
420
|
// Re-anchor the current view onto the fresh tree so a background rescan
|
|
@@ -275,11 +432,13 @@
|
|
|
275
432
|
} catch (err) { /* keep the previous render */ }
|
|
276
433
|
}
|
|
277
434
|
|
|
435
|
+
// The rescan endpoint now starts the walk in the background and returns
|
|
436
|
+
// immediately (a full walk can run well over a minute), so the button just
|
|
437
|
+
// kicks it off and lets the 1s poll loop above carry the live percentage —
|
|
438
|
+
// it does not wait for the walk to finish.
|
|
278
439
|
$("#rescan").addEventListener("click", async () => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
try { await fetch("/api/storage/rescan", { method: "POST" }); await loadStorage(); }
|
|
282
|
-
finally { btn.disabled = false; btn.textContent = "Rescan"; }
|
|
440
|
+
try { await fetch("/api/storage/rescan", { method: "POST" }); } catch (err) { /* next poll retries */ }
|
|
441
|
+
await loadStorage();
|
|
283
442
|
});
|
|
284
443
|
|
|
285
444
|
let resizeTimer;
|
package/web/index.html
CHANGED
|
@@ -50,7 +50,17 @@
|
|
|
50
50
|
<h2 id="stor-h">Storage map</h2>
|
|
51
51
|
<button id="rescan" class="btn" type="button">Rescan</button>
|
|
52
52
|
</div>
|
|
53
|
+
<div class="scan-progress" id="scan-progress" hidden aria-live="polite">
|
|
54
|
+
<div class="meter-track"><div class="meter-fill" id="scan-progress-fill" style="width:0%"></div></div>
|
|
55
|
+
<p class="muted small" id="scan-progress-label"></p>
|
|
56
|
+
</div>
|
|
53
57
|
<div class="rootbar" id="rootbar" role="tablist" aria-label="Storage root"></div>
|
|
58
|
+
<form class="pinform" id="pinform" hidden>
|
|
59
|
+
<input id="pinform-input" type="text" placeholder="/mnt/data/media/movies" autocomplete="off" spellcheck="false">
|
|
60
|
+
<button class="btn" type="submit">Go</button>
|
|
61
|
+
<button class="btn btn-quiet" type="button" id="pinform-cancel">Cancel</button>
|
|
62
|
+
<span class="pinform-err" id="pinform-err" role="alert"></span>
|
|
63
|
+
</form>
|
|
54
64
|
<nav class="crumbs" id="crumbs" aria-label="Breadcrumb"></nav>
|
|
55
65
|
<div class="treemap-wrap">
|
|
56
66
|
<svg id="treemap" role="img" aria-labelledby="stor-h" preserveAspectRatio="none"></svg>
|
package/web/style.css
CHANGED
|
@@ -193,6 +193,9 @@ main { max-width: 760px; margin: 0 auto; padding: 12px 12px 28px; display: grid;
|
|
|
193
193
|
.btn[disabled] { opacity: .5; cursor: default; }
|
|
194
194
|
.btn-quiet { border-color: transparent; background: transparent; }
|
|
195
195
|
|
|
196
|
+
.scan-progress { margin-bottom: 10px; }
|
|
197
|
+
.scan-progress p { margin: 5px 0 0; }
|
|
198
|
+
|
|
196
199
|
.rootbar, .sortbar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; }
|
|
197
200
|
.chip {
|
|
198
201
|
font: inherit; font-size: 12px; padding: 5px 11px; min-height: 30px;
|
|
@@ -204,6 +207,30 @@ main { max-width: 760px; margin: 0 auto; padding: 12px 12px 28px; display: grid;
|
|
|
204
207
|
over-wide item, so each chip truncates at the container's width. */
|
|
205
208
|
.rootbar .chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
206
209
|
|
|
210
|
+
/* Pinned custom paths: a chip made of two buttons (jump, remove) rather than
|
|
211
|
+
one, so the base .chip padding has to move onto the inner buttons. */
|
|
212
|
+
.chip.pin { display: inline-flex; align-items: center; gap: 0; padding: 0; max-width: 100%; }
|
|
213
|
+
.chip.pin .pin-go {
|
|
214
|
+
font: inherit; font-size: 12px; padding: 5px 4px 5px 11px; border: 0;
|
|
215
|
+
background: transparent; color: var(--text-muted); cursor: pointer;
|
|
216
|
+
max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
217
|
+
}
|
|
218
|
+
.chip.pin .pin-x {
|
|
219
|
+
font: inherit; font-size: 13px; line-height: 1; padding: 5px 10px 5px 4px;
|
|
220
|
+
border: 0; background: transparent; color: var(--text-muted); cursor: pointer;
|
|
221
|
+
}
|
|
222
|
+
.chip.pin .pin-x:hover { color: var(--status-critical); }
|
|
223
|
+
.chip.pin-add { cursor: pointer; }
|
|
224
|
+
|
|
225
|
+
.pinform { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: -2px 0 10px; }
|
|
226
|
+
.pinform[hidden] { display: none; }
|
|
227
|
+
.pinform input {
|
|
228
|
+
font: inherit; font-size: 13px; flex: 1 1 220px; min-width: 0;
|
|
229
|
+
padding: 6px 10px; border-radius: 8px; border: 1px solid var(--border);
|
|
230
|
+
background: var(--surface-1); color: var(--text-primary);
|
|
231
|
+
}
|
|
232
|
+
.pinform-err { flex: 1 0 100%; font-size: 12px; color: var(--status-critical); }
|
|
233
|
+
|
|
207
234
|
/* ── Breadcrumb ──────────────────────────────────────────────────────────── */
|
|
208
235
|
.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; margin-bottom: 8px; font-size: 12px; }
|
|
209
236
|
.crumbs button {
|
package/web/treemap.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* of this app is to have no external dependencies.
|
|
1
|
+
/* Full-width stacked bars, ~90 lines of SVG. No charting library: the whole
|
|
2
|
+
* point of this app is to have no external dependencies.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Each item is a bar spanning the full width, stacked top to bottom, height
|
|
5
|
+
* proportional to its byte share. A squarified 2D treemap packs a dominant
|
|
6
|
+
* item into a near-square block and leaves the rest a sliver whose width is
|
|
7
|
+
* fixed by that item's share — no matter how tall the section grows, a 2%
|
|
8
|
+
* item stays too narrow for its name. Giving every bar the full width means
|
|
9
|
+
* only height is ever scarce, and the section can grow to make room for it. */
|
|
7
10
|
(function (global) {
|
|
8
11
|
"use strict";
|
|
9
12
|
|
|
@@ -12,63 +15,18 @@
|
|
|
12
15
|
const RADIUS = 3;
|
|
13
16
|
const GLYPH = { dir: "▸", file: "·", rest: "⋯" };
|
|
14
17
|
|
|
15
|
-
function
|
|
16
|
-
if (!row.length) return Infinity;
|
|
17
|
-
const s = rowSum * scale;
|
|
18
|
-
if (s <= 0) return Infinity;
|
|
19
|
-
const max = row[0].size * scale;
|
|
20
|
-
const min = row[row.length - 1].size * scale;
|
|
21
|
-
const sq = shortSide * shortSide;
|
|
22
|
-
return Math.max((sq * max) / (s * s), (s * s) / (sq * min));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function squarify(items, x, y, w, h) {
|
|
18
|
+
function layoutRows(items, w, h) {
|
|
26
19
|
const out = [];
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
let rowSum = 0;
|
|
38
|
-
let prevWorst = Infinity;
|
|
39
|
-
|
|
40
|
-
while (remaining.length) {
|
|
41
|
-
const candidate = remaining[0];
|
|
42
|
-
const nextSum = rowSum + candidate.size;
|
|
43
|
-
const ratio = worstRatio(row.concat([candidate]), nextSum, shortSide, scale);
|
|
44
|
-
// Adding this tile is only worth it while it makes the row *less* oblong.
|
|
45
|
-
if (row.length === 0 || ratio <= prevWorst) {
|
|
46
|
-
row.push(remaining.shift());
|
|
47
|
-
rowSum = nextSum;
|
|
48
|
-
prevWorst = ratio;
|
|
49
|
-
} else break;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const rowArea = rowSum * scale;
|
|
53
|
-
if (w >= h) {
|
|
54
|
-
const rw = rowArea / h;
|
|
55
|
-
let cy = y;
|
|
56
|
-
for (const item of row) {
|
|
57
|
-
const rh = (item.size * scale) / rw;
|
|
58
|
-
out.push({ item: item, x: x, y: cy, w: rw, h: rh });
|
|
59
|
-
cy += rh;
|
|
60
|
-
}
|
|
61
|
-
x += rw; w -= rw;
|
|
62
|
-
} else {
|
|
63
|
-
const rh = rowArea / w;
|
|
64
|
-
let cx = x;
|
|
65
|
-
for (const item of row) {
|
|
66
|
-
const rw = (item.size * scale) / rh;
|
|
67
|
-
out.push({ item: item, x: cx, y: y, w: rw, h: rh });
|
|
68
|
-
cx += rw;
|
|
69
|
-
}
|
|
70
|
-
y += rh; h -= rh;
|
|
71
|
-
}
|
|
20
|
+
const list = items.filter((d) => d.size > 0).slice().sort((a, b) => b.size - a.size);
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (const item of list) total += item.size;
|
|
23
|
+
if (total <= 0) return out;
|
|
24
|
+
|
|
25
|
+
let y = 0;
|
|
26
|
+
for (const item of list) {
|
|
27
|
+
const rh = (item.size / total) * h;
|
|
28
|
+
out.push({ item: item, x: 0, y: y, w: w, h: rh });
|
|
29
|
+
y += rh;
|
|
72
30
|
}
|
|
73
31
|
return out;
|
|
74
32
|
}
|
|
@@ -114,7 +72,7 @@
|
|
|
114
72
|
return;
|
|
115
73
|
}
|
|
116
74
|
|
|
117
|
-
const placed =
|
|
75
|
+
const placed = layoutRows(children, W, H);
|
|
118
76
|
const pending = []; // labels to measure once they are in the document
|
|
119
77
|
|
|
120
78
|
for (const cell of placed) {
|
|
@@ -184,5 +142,5 @@
|
|
|
184
142
|
for (const entry of pending) fitLabel(entry);
|
|
185
143
|
}
|
|
186
144
|
|
|
187
|
-
global.Treemap = { render: render,
|
|
145
|
+
global.Treemap = { render: render, layoutRows: layoutRows };
|
|
188
146
|
})(window);
|
package/app/__init__.py
DELETED
|
File without changes
|
package/app/config.py
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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()
|
package/app/dockerstats.py
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
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
|
-
}
|