/** * 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). * * 2–4 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 { /** 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 { /** 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; /** Docker's own, which belong to no stack and are not leftovers either. */ isBuiltIn?: (item: T) => boolean; } export function groupByStack(items: T[], options: GroupOptions): StackGroup[] { const { stacksOf, sortKey, stackById, isBuiltIn } = options; const perStack = new Map(); 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[] = []; 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 `_` 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; }