start: group Docker containers by compose project (collapsible stacks with running count, health dot, CPU/mem); collector captures compose labels
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-25 17:38:23 +00:00
parent 3f30bb883f
commit 48ec29beac
3 changed files with 145 additions and 12 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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<Record<string, boolean>>({});
$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<string, number> = { off: 0, ok: 1, warn: 2, bad: 3 };
const containerGroups = $derived.by(() => {
const byProject: Record<string, Container[]> = {};
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<string, number> = {
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 @@
</div>
<p class="sub">
load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
containers
containers in {containerGroups.length} stacks
</p>
<div class="stacks">
{#each containerGroups as g (g.key)}
<details
class="group stack"
open={!closedStacks[g.key]}
ontoggle={(e) => toggleStack(g.key, e.currentTarget.open)}
>
<summary
title={g.key
? `compose project ${g.key}`
: 'containers without a compose project'}
>
<i class="dot {g.dot}"></i>
<span class="sname">{g.name}</span>
<small class={{ bad: g.up < g.items.length && g.dot !== 'off' }}
>{g.up}/{g.items.length}</small
>
<span class="num">{g.cpu ? `${g.cpu.toFixed(2)}%` : ''}</span>
<span class="num">{g.mem ? bytes(g.mem) : ''}</span>
</summary>
<ul class="containers">
{#each containers as c (c.name)}
<li title="{c.image} · {c.state}{c.health ? ` (${c.health})` : ''}">
{#each g.items as c (c.name)}
<li title="{c.name} · {c.image} · {c.state}{c.health ? ` (${c.health})` : ''}">
<i class="dot {containerDot(c)}"></i>
<span class="name">{shortName(c.name)}</span>
<span class="name">{label(c)}</span>
{#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num">{c.cpu ?? ''}</span>
<span class="num">{c.mem ?? ''}</span>
</li>
{/each}
</ul>
</details>
{/each}
</div>
{:else}
<p class="empty">The host collector hasn't written a snapshot yet.</p>
{/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;