@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/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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
273
+ }
274
+
170
275
  function renderRootBar() {
171
- $("#rootbar").innerHTML = storage.roots.map((r, i) =>
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
- $("#rootbar").querySelectorAll("button").forEach((b) => {
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 = Math.round(parseFloat(getComputedStyle(svg).height)) || 300;
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
- 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"; }
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
- /* Squarified treemap, ~120 lines of SVG. No charting library: the whole point
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
- * Bruls/Huizing/van Wijk squarified layout — greedily fills rows along the
5
- * short side, keeping each tile as close to square as it can, so tiles stay
6
- * tappable on a phone instead of degenerating into slivers. */
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 worstRatio(row, rowSum, shortSide, scale) {
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
- let remaining = items.filter((d) => d.size > 0).slice().sort((a, b) => b.size - a.size);
28
-
29
- while (remaining.length && w > 0.5 && h > 0.5) {
30
- let totalRem = 0;
31
- for (const item of remaining) totalRem += item.size;
32
- if (totalRem <= 0) break;
33
-
34
- const scale = (w * h) / totalRem;
35
- const shortSide = Math.min(w, h);
36
- const row = [];
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 = squarify(children, 0, 0, W, H);
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, squarify: squarify };
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()
@@ -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
- }