Group Images and Networks by stack, like Volumes (0.55.0)
CI / check (push) Successful in 12m23s
CI / build-and-push (push) Successful in 2m4s

The same argument as 0.54.0: these pages already sorted a stack's resources next
to each other, and still made you read prefixes to work out where one stack
ended and the next began. Networks are the clearest win — compose names a
stack's own network <project>_default, so the column was almost entirely prefix.

Doing it a second and third time made the shape obvious, so the grouping is now
one function and one heading component shared by all three pages rather than
three copies drifting apart. A stack looks and sorts the same wherever it turns
up. volumeGroups.ts became stackGroups.ts on the way, and the Volumes page moved
onto it with no behaviour change.

Images forced the model to grow, and this is the part worth reading. An image
carries no compose label — the backend derives its owners from the containers
running it, so ownership is a *list*, and postgres:16 may belong to four stacks
at once. Listing it under each owner would show the same image four times with
four sizes, and a page that adds up to more disk than the host has. So anything
with more than one owner is listed once in a "Shared by several stacks" group,
and the Used by column names them. Each resource appears exactly once, which
keeps the per-group totals honest.

Networks needed a second new kind. bridge, host and none belong to no stack, but
they are not leftovers either, and dropping them into the unassigned group pads
the exact list people scan for junk. They get their own group below it. The
built-in check runs before ownership is even considered, so they can never be
counted as unclaimed.

Ordering is unchanged and now stated once: stacks by display name, then shared,
then unclaimed, then built-ins. The trailing three are appended, never sorted
in, so "unassigned at the bottom" holds whatever anything is called — there is a
test for the case where the only real stack sorts after them.

Group headings gained the counts each page can actually produce: volumes show
unused and total size, images total size and how many have an update waiting,
networks how many are idle.

Both row bodies moved into their own components. Nesting them a level deeper
inside the group left the indentation adrift, and the networks row had a second
<tr> for its expanded detail riding along inside an already doubled map.

Fifteen tests on the shared grouping, including the shared and built-in groups
and a stack that has been deleted since. No server change: every one of these
already knew its stack.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-09-18 00:11:26 +02:00
co-authored by Claude Opus 5
parent d7c4f06e67
commit a2adb59526
11 changed files with 650 additions and 346 deletions
+117
View File
@@ -0,0 +1,117 @@
/**
* Grouping Docker resources by the stack that owns them.
*
* Volumes, networks and images all sort into the same shape on their pages:
* one heading per stack, then the leftovers. The rule is shared here because
* the three pages have to agree — a resource that is "unassigned" on one page
* and "shared" on another would just be confusing.
*
* Ownership comes from Docker itself. Volumes and networks carry the
* `com.docker.compose.project` label; images have no such label, so their
* owners are derived from the containers running them — which is why ownership
* is modelled as a *list* rather than a single stack. That is the interesting
* case: `postgres:16` may be pulled by four different stacks at once.
*
* Group order, top to bottom:
*
* 1. one group per stack, by the display name the user gave it (a group whose
* stack no longer exists sorts in among them under its bare id — it still
* has an owner, it just has no stack left to show),
* 2. resources several stacks share,
* 3. resources no stack claims,
* 4. Docker's own built-ins, where a page has any (the `bridge`/`host`/`none`
* networks).
*
* 24 are appended in that order and never sorted in, so "unassigned" stays at
* the bottom no matter what anything is called.
*/
import type { StackSummary } from "@/types";
export type GroupKind = "stack" | "shared" | "none" | "builtin";
export interface StackGroup<T> {
/** Stable React key. */
key: string;
kind: GroupKind;
/** The compose project, for a `stack` group. Null for the others. */
stackId: string | null;
/** The stack itself — absent when it has been deleted since. */
stack?: StackSummary;
items: T[];
}
export interface GroupOptions<T> {
/** The stacks that own this item. Empty means nobody does. */
stacksOf: (item: T) => string[];
/** Sorted by this within a group. */
sortKey: (item: T) => string;
stackById: Map<string, StackSummary>;
/** Docker's own, which belong to no stack and are not leftovers either. */
isBuiltIn?: (item: T) => boolean;
}
export function groupByStack<T>(items: T[], options: GroupOptions<T>): StackGroup<T>[] {
const { stacksOf, sortKey, stackById, isBuiltIn } = options;
const perStack = new Map<string, T[]>();
const shared: T[] = [];
const none: T[] = [];
const builtin: T[] = [];
for (const item of items) {
if (isBuiltIn?.(item)) {
builtin.push(item);
continue;
}
const owners = stacksOf(item);
if (owners.length === 0) {
none.push(item);
} else if (owners.length > 1) {
// Listing it under each owner would mean the same image appearing three
// times with three sizes, and a page that adds up to more disk than the
// host has. It is named once, and the header says who shares it.
shared.push(item);
} else {
const bucket = perStack.get(owners[0]);
if (bucket) bucket.push(item);
else perStack.set(owners[0], [item]);
}
}
const byKey = (a: T, b: T) => sortKey(a).localeCompare(sortKey(b));
const groups: StackGroup<T>[] = [];
for (const [stackId, list] of perStack) {
groups.push({
key: stackId,
kind: "stack",
stackId,
stack: stackById.get(stackId),
items: [...list].sort(byKey),
});
}
groups.sort((a, b) => (a.stack?.name ?? a.key).localeCompare(b.stack?.name ?? b.key));
const trailing: [GroupKind, string, T[]][] = [
["shared", "__shared__", shared],
["none", "__none__", none],
["builtin", "__builtin__", builtin],
];
for (const [kind, key, list] of trailing) {
if (list.length === 0) continue;
groups.push({ key, kind, stackId: null, items: [...list].sort(byKey) });
}
return groups;
}
/**
* Drop the `<project>_` that compose prepends, so a row shows what differs.
*
* Only when the prefix is really there: an external volume adopted by a stack
* keeps whatever name it was created with, and trimming a prefix off that would
* be a lie about what the resource is called.
*/
export function stripStackPrefix(name: string, stackId: string | null): string {
if (!stackId) return name;
const prefix = `${stackId}_`;
return name.startsWith(prefix) ? name.slice(prefix.length) : name;
}