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 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: two rows into home.snapshots with the limited `home_collector` role:
host CPU, RAM, swap, disk, load, uptime, Docker containers (state, health, restarts, 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 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 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: for c in info:
st = c.get('State', {}) st = c.get('State', {})
name = c.get('Name', '').lstrip('/') name = c.get('Name', '').lstrip('/')
labels = c.get('Config', {}).get('Labels') or {}
out.append({ out.append({
'name': name, 'name': name,
'image': c.get('Config', {}).get('Image', '').split('@')[0][:80], 'image': c.get('Config', {}).get('Image', '').split('@')[0][:80],
@@ -164,6 +165,9 @@ def containers():
'startedAt': st.get('StartedAt'), 'startedAt': st.get('StartedAt'),
'cpu': stats.get(name, {}).get('cpu'), 'cpu': stats.get(name, {}).get('cpu'),
'mem': stats.get(name, {}).get('mem'), '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']) out.sort(key=lambda c: c['name'])
return out return out

View File

@@ -32,6 +32,9 @@ export type Container = {
startedAt: string | null; startedAt: string | null;
cpu: string | null; cpu: string | null;
mem: string | null; mem: string | null;
/** com.docker.compose.project / .service labels (absent on standalone containers). */
project?: string | null;
service?: string | null;
}; };
export type TimerState = { export type TimerState = {
label: string; label: string;

View File

@@ -44,6 +44,66 @@
(host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-')) (host?.containers ?? []).filter((c) => !c.name.startsWith('GITEA-ACTIONS-'))
); );
const containersUp = $derived(containers.filter((c) => c.state === 'running').length); 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 servicesDown = $derived((host?.services ?? []).filter((s) => s.ok === false));
const timers = $derived( const timers = $derived(
(host?.timers ?? []).map((t) => ({ ...t, status: timerStatus(t, Math.floor(now / 1000)) })) (host?.timers ?? []).map((t) => ({ ...t, status: timerStatus(t, Math.floor(now / 1000)) }))
@@ -464,19 +524,42 @@
</div> </div>
<p class="sub"> <p class="sub">
load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length} load {host.load.join(' / ')} · up {duration(host.uptimeS)} · {containersUp}/{containers.length}
containers containers in {containerGroups.length} stacks
</p> </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"> <ul class="containers">
{#each containers as c (c.name)} {#each g.items as c (c.name)}
<li title="{c.image} · {c.state}{c.health ? ` (${c.health})` : ''}"> <li title="{c.name} · {c.image} · {c.state}{c.health ? ` (${c.health})` : ''}">
<i class="dot {containerDot(c)}"></i> <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} {#if c.restarts}<span class="tag warn">↻{c.restarts}</span>{/if}
<span class="num">{c.cpu ?? ''}</span> <span class="num">{c.cpu ?? ''}</span>
<span class="num">{c.mem ?? ''}</span> <span class="num">{c.mem ?? ''}</span>
</li> </li>
{/each} {/each}
</ul> </ul>
</details>
{/each}
</div>
{:else} {:else}
<p class="empty">The host collector hasn't written a snapshot yet.</p> <p class="empty">The host collector hasn't written a snapshot yet.</p>
{/if} {/if}
@@ -919,6 +1002,49 @@
display: grid; display: grid;
gap: 0; 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 { .containers li {
display: flex; display: flex;
align-items: center; align-items: center;