diff --git a/deploy/home-collector/home-collector.py b/deploy/home-collector/home-collector.py index 451828c..3e69bfb 100644 --- a/deploy/home-collector/home-collector.py +++ b/deploy/home-collector/home-collector.py @@ -4,7 +4,7 @@ Runs every minute as a systemd user timer on the server (home-collector.timer) and upserts two rows into home.snapshots with the limited `home_collector` role: host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts, - CPU/mem), systemd timer results (backups, disk-guard, scrapers) and service checks + CPU/mem, compose project/service), systemd timer results (backups, disk-guard, scrapers) and service checks ntfy the most recent messages from the owner's ntfy topics The app container has no Docker socket and never gets one; this script uses the host's @@ -154,6 +154,7 @@ def containers(): for c in info: st = c.get('State', {}) name = c.get('Name', '').lstrip('/') + labels = c.get('Config', {}).get('Labels') or {} out.append({ 'name': name, 'image': c.get('Config', {}).get('Image', '').split('@')[0][:80], @@ -164,6 +165,9 @@ def containers(): 'startedAt': st.get('StartedAt'), 'cpu': stats.get(name, {}).get('cpu'), 'mem': stats.get(name, {}).get('mem'), + # Compose project/service, so /start can group containers by stack. + 'project': labels.get('com.docker.compose.project'), + 'service': labels.get('com.docker.compose.service'), }) out.sort(key=lambda c: c['name']) return out diff --git a/src/lib/home.ts b/src/lib/home.ts index cde8300..b6cd798 100644 --- a/src/lib/home.ts +++ b/src/lib/home.ts @@ -32,6 +32,9 @@ export type Container = { startedAt: string | null; cpu: string | null; mem: string | null; + /** com.docker.compose.project / .service labels (absent on standalone containers). */ + project?: string | null; + service?: string | null; }; export type TimerState = { label: string; diff --git a/src/routes/start/+page.svelte b/src/routes/start/+page.svelte index dd9181b..974864b 100644 --- a/src/routes/start/+page.svelte +++ b/src/routes/start/+page.svelte @@ -44,6 +44,66 @@ (host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-')) ); const containersUp = $derived(containers.filter((c) => c.state === 'running').length); + // Containers grouped by Docker Compose project (com.docker.compose.project label); + // standalone containers go last under "other". Groups are collapsible, remembered per browser. + const STACKS_KEY = 'start:containers:closed'; + let closedStacks = $state>({}); + $effect(() => { + try { + closedStacks = JSON.parse(localStorage.getItem(STACKS_KEY) ?? '{}'); + } catch { + closedStacks = {}; + } + }); + function toggleStack(key: string, open: boolean) { + if (!closedStacks[key] === open) return; + closedStacks = { ...closedStacks, [key]: !open }; + try { + localStorage.setItem(STACKS_KEY, JSON.stringify(closedStacks)); + } catch { + // private mode: just don't remember + } + } + const DOT_RANK: Record = { off: 0, ok: 1, warn: 2, bad: 3 }; + const containerGroups = $derived.by(() => { + const byProject: Record = {}; + for (const c of containers) (byProject[c.project || ''] ??= []).push(c); + return Object.entries(byProject) + .map(([key, items]) => { + items.sort((a, b) => label(a).localeCompare(label(b))); + const dots = items.map(containerDot); + const running = dots.filter((d) => d !== 'off'); + return { + key, + name: key || 'other', + items, + up: items.filter((c) => c.state === 'running').length, + // Worst state of the running containers; grey only when everything is stopped. + dot: running.length + ? running.reduce((a, b) => (DOT_RANK[b] > DOT_RANK[a] ? b : a), 'ok') + : 'off', + cpu: items.reduce((n, c) => n + (parseFloat(c.cpu ?? '') || 0), 0), + mem: items.reduce((n, c) => n + memBytes(c.mem), 0) + }; + }) + .sort((a, b) => Number(!a.key) - Number(!b.key) || a.name.localeCompare(b.name)); + }); + const UNITS: Record = { + B: 1, + KIB: 1024, + MIB: 1024 ** 2, + GIB: 1024 ** 3, + TIB: 1024 ** 4, + KB: 1e3, + MB: 1e6, + GB: 1e9 + }; + function memBytes(m: string | null) { + const x = /^([\d.]+)\s*([A-Za-z]+)$/.exec(m?.trim() ?? ''); + return x ? parseFloat(x[1]) * (UNITS[x[2].toUpperCase()] ?? 0) : 0; + } + /** Row label: the compose service name inside a stack, else the trimmed container name. */ + const label = (c: Container) => (c.project && c.service ? c.service : shortName(c.name)); const servicesDown = $derived((host?.services ?? []).filter((s) => s.ok === false)); const timers = $derived( (host?.timers ?? []).map((t) => ({ ...t, status: timerStatus(t, Math.floor(now / 1000)) })) @@ -464,19 +524,42 @@

load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length} - containers + containers in {containerGroups.length} stacks

-
    - {#each containers as c (c.name)} -
  • - - {shortName(c.name)} - {#if c.restarts}↻{c.restarts}{/if} - {c.cpu ?? ''} - {c.mem ?? ''} -
  • +
    + {#each containerGroups as g (g.key)} +
    toggleStack(g.key, e.currentTarget.open)} + > + + + {g.name} + {g.up}/{g.items.length} + {g.cpu ? `${g.cpu.toFixed(2)}%` : ''} + {g.mem ? bytes(g.mem) : ''} + +
      + {#each g.items as c (c.name)} +
    • + + {label(c)} + {#if c.restarts}↻{c.restarts}{/if} + {c.cpu ?? ''} + {c.mem ?? ''} +
    • + {/each} +
    +
    {/each} -
+ {:else}

The host collector hasn't written a snapshot yet.

{/if} @@ -919,6 +1002,49 @@ display: grid; gap: 0; } + .stacks { + display: grid; + gap: 2px; + } + .group.stack > summary { + align-items: center; + gap: 5px; + margin: 0; + padding: 3px 6px; + border-radius: 6px; + border: 1px solid transparent; + transition: + background 120ms ease, + border-color 120ms ease; + } + .group.stack > summary:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--line); + } + .group.stack > summary .dot { + margin-right: 0; + } + .group.stack .sname { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .group.stack > summary small.bad { + color: var(--bad); + opacity: 1; + } + .group.stack > summary .num { + font-size: 11px; + text-transform: none; + letter-spacing: 0; + } + .group.stack .containers { + margin: 0 0 4px 18px; + padding-left: 8px; + border-left: 1px solid var(--line); + } .containers li { display: flex; align-items: center;