diff --git a/README.md b/README.md index 0886320..574f16c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,31 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.55.0 — nothing to do + +**Images and Networks are grouped by stack too**, the same way Volumes were in +0.54.0: a heading per stack with its icon and name, the rows under it stripped +of the `_` prefix, and anything unclaimed at the bottom. Compose names +a stack's own network `_default`, so that column gets a lot quieter. + +Two group kinds are new, because those two pages have cases volumes do not: + +- **Shared by several stacks.** An image has no compose label — its owners are + worked out from the containers running it, so `postgres:16` can belong to four + stacks at once. Listing it under each would show the same image four times + with four sizes, and the page would add up to more disk than the host has. It + is listed once instead, and the *Used by* column names the stacks. +- **Docker built-ins.** The `bridge`, `host` and `none` networks belong to no + stack but are not leftovers either, so they sit in their own group below the + unassigned one rather than padding it. + +Group headings also carry a count: volumes show unused and total size, images +total size and how many have an update waiting, networks how many are idle. + +The grouping itself is now one shared function and one shared heading component +for all three pages, so a stack looks and sorts the same wherever it appears. +Nothing changed on the server — every one of these already knew its stack. + ## Upgrading to 0.54.0 — nothing to do **The Volumes page is grouped by stack.** Docker names a compose volume @@ -395,11 +420,12 @@ it is what your saved destination credentials are encrypted with. ### Phase 9 — Networks -- **Network management**: the Networks page lists Docker networks (driver, scope, - subnet, attached containers / in-use, owning stack), with **create** (bridge / - macvlan / ipvlan / overlay, optional subnet+gateway, internal/attachable), - **delete** (default networks protected; in-use guarded by Docker), and **prune - unused**. +- **Network management**: the Networks page lists Docker networks **grouped by + the stack that owns them** (driver, scope, subnet, attached containers / + in-use), with **create** (bridge / macvlan / ipvlan / overlay, optional + subnet+gateway, internal/attachable), **delete** (default networks protected; + in-use guarded by Docker), and **prune unused**. Docker's own `bridge` / `host` + / `none` sit in a *built-ins* group at the bottom. - **Stack delete**: local stacks can now be deleted from the UI (stack detail and the stack card), with a confirm dialog and an optional "keep files on disk". @@ -588,8 +614,11 @@ it is what your saved destination credentials are encrypted with. connect/disconnect containers — including a *Prune unused* button, which resolves the common "all predefined address pools have been fully subnetted" deploy error without SSH. -- **Images**: list image tags (with using-stacks) and run on-demand update - checks. +- **Images**: list image tags **grouped by the stack that uses them** and run + on-demand update checks. An image has no compose label, so its owners come + from the containers running it — which means an image can have several, and + those are listed once under *Shared by several stacks* rather than repeated + under each. ### Phase 12 — File browser diff --git a/backend/version.py b/backend/version.py index 04d67ca..4ff25f9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.54.0" +APP_VERSION = "0.55.0" diff --git a/frontend/package.json b/frontend/package.json index ef1ba66..a00cfe5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.54.0", + "version": "0.55.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ui/StackGroupHeader.tsx b/frontend/src/components/ui/StackGroupHeader.tsx new file mode 100644 index 0000000..eaaca41 --- /dev/null +++ b/frontend/src/components/ui/StackGroupHeader.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { Boxes, Layers, Unlink } from "lucide-react"; +import { StackIcon } from "@/components/ui/StackIcon"; +import type { StackGroup } from "@/lib/stackGroups"; + +/** + * The heading row above a stack's volumes, networks or images. + * + * One component for all three pages, so a stack looks the same wherever it + * appears — same icon, same name, same wording for the leftovers. + * + * `meta` is whatever the page can count that the others cannot: volumes know + * their size, images their update status. It sits at the end in the muted tone + * so the stack's name stays the thing you scan for. + */ +export function StackGroupHeader({ + group, + colSpan, + meta, +}: { + group: StackGroup; + colSpan: number; + meta?: ReactNode; +}) { + return ( + + +
+
+ + + ); +} + +function Label({ group }: { group: StackGroup }) { + if (group.kind === "shared") { + return ( + <> + + Shared by several stacks + + ); + } + if (group.kind === "builtin") { + return ( + <> + + Docker built-ins + + ); + } + if (group.kind === "none") { + return ( + <> + + Not part of a stack + + ); + } + if (group.stack) { + return ( + <> + + + {group.stack.name} + + + ); + } + // Labelled with a compose project that is no longer a stack: exactly where + // things left behind by a deleted stack collect. + return ( + <> + + {group.stackId} + + stack removed + + + ); +} diff --git a/frontend/src/lib/stackGroups.test.ts b/frontend/src/lib/stackGroups.test.ts new file mode 100644 index 0000000..aae5be1 --- /dev/null +++ b/frontend/src/lib/stackGroups.test.ts @@ -0,0 +1,183 @@ +/** + * Grouping resources by the stack that owns them. + * + * The ordering rules are the whole feature, and three pages depend on them + * agreeing. The cases worth pinning down: a stack that has been deleted still + * owns things and must not be mistaken for "unassigned"; an image several + * stacks share is listed once rather than under each of them; and whatever else + * happens, the unclaimed group stays at the bottom. + */ +import { describe, expect, it } from "vitest"; +import { groupByStack, stripStackPrefix } from "./stackGroups"; +import type { StackSummary } from "@/types"; + +interface Thing { + name: string; + owners: string[]; + builtIn?: boolean; +} + +const thing = (name: string, owners: string[] = [], builtIn = false): Thing => ({ + name, + owners, + builtIn, +}); + +const stack = (id: string, name: string): StackSummary => ({ + id, + name, + status: "running", + service_count: 1, + running_count: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +}); + +const KNOWN = new Map([ + ["immich", stack("immich", "Immich")], + ["arr-stack", stack("arr-stack", "Arr Stack")], +]); + +const group = (items: Thing[], stackById = KNOWN) => + groupByStack(items, { + stacksOf: (t) => t.owners, + sortKey: (t) => t.name, + stackById, + isBuiltIn: (t) => Boolean(t.builtIn), + }); + +describe("groupByStack", () => { + it("puts each stack's things in one group", () => { + const groups = group([ + thing("immich_model-cache", ["immich"]), + thing("arr-stack_config", ["arr-stack"]), + thing("immich_pgdata", ["immich"]), + ]); + expect(groups.map((g) => g.key)).toEqual(["arr-stack", "immich"]); + expect(groups[1].items.map((t) => t.name)).toEqual([ + "immich_model-cache", + "immich_pgdata", + ]); + }); + + it("orders stacks by the name people see, not by the id", () => { + const stacks = new Map([ + ["zz-project", stack("zz-project", "Alpha")], + ["aa-project", stack("aa-project", "Zulu")], + ]); + const groups = group( + [thing("a", ["aa-project"]), thing("z", ["zz-project"])], + stacks + ); + expect(groups.map((g) => g.stack?.name)).toEqual(["Alpha", "Zulu"]); + }); + + it("sorts things inside a group by name", () => { + const groups = group([ + thing("pgdata", ["immich"]), + thing("cache", ["immich"]), + thing("uploads", ["immich"]), + ]); + expect(groups[0].items.map((t) => t.name)).toEqual(["cache", "pgdata", "uploads"]); + }); + + // ------------------------------------------------------------------- // + // The trailing groups + // ------------------------------------------------------------------- // + + it("keeps unowned things in their own group, last", () => { + const groups = group([thing("portainer_data"), thing("pgdata", ["immich"])]); + expect(groups.at(-1)).toMatchObject({ kind: "none", stackId: null }); + expect(groups.at(-1)?.items.map((t) => t.name)).toEqual(["portainer_data"]); + }); + + it("keeps them last even when the only stack sorts after them", () => { + // The trailing groups are appended, never sorted in. + const groups = group( + [thing("loose"), thing("data", ["zulu"])], + new Map([["zulu", stack("zulu", "Zulu")]]) + ); + expect(groups.map((g) => g.kind)).toEqual(["stack", "none"]); + }); + + it("lists something several stacks share once, not under each", () => { + // Otherwise postgres:16 shows up three times with three sizes, and the + // page adds up to more disk than the host has. + const groups = group([ + thing("postgres:16", ["immich", "arr-stack"]), + thing("immich/server", ["immich"]), + ]); + expect(groups.map((g) => g.kind)).toEqual(["stack", "shared"]); + expect(groups[1].items.map((t) => t.name)).toEqual(["postgres:16"]); + }); + + it("orders the trailing groups shared → unowned → built-in", () => { + const groups = group([ + thing("bridge", [], true), + thing("loose"), + thing("shared", ["immich", "arr-stack"]), + thing("owned", ["immich"]), + ]); + expect(groups.map((g) => g.kind)).toEqual(["stack", "shared", "none", "builtin"]); + }); + + it("takes built-ins out before ownership is even considered", () => { + // Docker's own networks belong to nobody, but they are not leftovers and + // must not pad the "unassigned" group people scan for junk. + const groups = group([thing("bridge", [], true), thing("host", [], true)]); + expect(groups).toHaveLength(1); + expect(groups[0].kind).toBe("builtin"); + }); + + // ------------------------------------------------------------------- // + // Stacks that no longer exist + // ------------------------------------------------------------------- // + + it("still groups things whose stack was deleted", () => { + const groups = group([ + thing("old-stack_data", ["old-stack"]), + thing("immich_pgdata", ["immich"]), + ]); + const orphan = groups.find((g) => g.key === "old-stack"); + // It has an owner, so it is not unassigned — there is just no stack to show. + expect(orphan).toMatchObject({ kind: "stack", stackId: "old-stack", stack: undefined }); + }); + + // ------------------------------------------------------------------- // + // Odds and ends + // ------------------------------------------------------------------- // + + it("does not mutate the list it was given", () => { + const input = [thing("b", ["immich"]), thing("a", ["immich"])]; + group(input); + expect(input.map((t) => t.name)).toEqual(["b", "a"]); + }); + + it("returns nothing for nothing", () => { + expect(group([])).toEqual([]); + }); + + it("omits a trailing group that has no members", () => { + const groups = group([thing("pgdata", ["immich"])]); + expect(groups.map((g) => g.kind)).toEqual(["stack"]); + }); +}); + +describe("stripStackPrefix", () => { + it("drops the compose project prefix", () => { + expect(stripStackPrefix("immich_pgdata", "immich")).toBe("pgdata"); + // Compose names a stack's own network "_default". + expect(stripStackPrefix("immich_default", "immich")).toBe("default"); + }); + + it("leaves a name that does not carry the prefix alone", () => { + // An external volume adopted by a stack keeps the name it was created with; + // trimming a prefix that is not there would misname it. + expect(stripStackPrefix("shared-media", "immich")).toBe("shared-media"); + expect(stripStackPrefix("immichpgdata", "immich")).toBe("immichpgdata"); + }); + + it("leaves everything alone when there is no stack", () => { + expect(stripStackPrefix("postgres:16", null)).toBe("postgres:16"); + }); +}); diff --git a/frontend/src/lib/stackGroups.ts b/frontend/src/lib/stackGroups.ts new file mode 100644 index 0000000..dff5adb --- /dev/null +++ b/frontend/src/lib/stackGroups.ts @@ -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). + * + * 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; +} diff --git a/frontend/src/lib/volumeGroups.test.ts b/frontend/src/lib/volumeGroups.test.ts deleted file mode 100644 index a7ab0b0..0000000 --- a/frontend/src/lib/volumeGroups.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Grouping volumes by their stack. - * - * The ordering rules are the whole feature: stacks by the name people see, and - * the unclaimed volumes at the bottom no matter what they are called. The - * awkward case is a volume still labelled with a stack that has since been - * deleted — it has an owner that no longer exists, and it must neither vanish - * nor be mistaken for a loose volume, because that group is where forgotten - * data sits. - */ -import { describe, expect, it } from "vitest"; -import { groupByStack, shortName } from "./volumeGroups"; -import type { StackSummary, VolumeInfo } from "@/types"; - -const volume = (name: string, stack?: string | null): VolumeInfo => ({ - name, - driver: "local", - mountpoint: `/var/lib/docker/volumes/${name}/_data`, - labels: {}, - stack: stack ?? null, - used_by: [], - in_use: false, -}); - -const stack = (id: string, name: string): StackSummary => ({ - id, - name, - status: "running", - service_count: 1, - running_count: 1, - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", -}); - -const KNOWN = new Map([ - ["immich", stack("immich", "Immich")], - ["arr-stack", stack("arr-stack", "Arr Stack")], -]); - -describe("groupByStack", () => { - it("puts each stack's volumes in one group", () => { - const groups = groupByStack( - [ - volume("immich_model-cache", "immich"), - volume("arr-stack_config", "arr-stack"), - volume("immich_pgdata", "immich"), - ], - KNOWN - ); - expect(groups.map((g) => g.key)).toEqual(["arr-stack", "immich"]); - expect(groups[1].volumes.map((v) => v.name)).toEqual([ - "immich_model-cache", - "immich_pgdata", - ]); - }); - - it("orders stacks by the name people see, not by the id", () => { - const stacks = new Map([ - ["zz-project", stack("zz-project", "Alpha")], - ["aa-project", stack("aa-project", "Zulu")], - ]); - const groups = groupByStack( - [volume("aa-project_x", "aa-project"), volume("zz-project_y", "zz-project")], - stacks - ); - expect(groups.map((g) => g.stack?.name)).toEqual(["Alpha", "Zulu"]); - }); - - it("keeps unassigned volumes in their own group, last", () => { - const groups = groupByStack( - [volume("portainer_data"), volume("immich_pgdata", "immich")], - KNOWN - ); - expect(groups.at(-1)).toMatchObject({ - stackId: null, - volumes: [expect.objectContaining({ name: "portainer_data" })], - }); - }); - - it("keeps them last even when the only stack sorts after them", () => { - // The loose group is appended, never sorted in — "zulu" must not push it up. - const groups = groupByStack( - [volume("loose"), volume("zulu_data", "zulu")], - new Map([["zulu", stack("zulu", "Zulu")]]) - ); - expect(groups.map((g) => g.stackId)).toEqual(["zulu", null]); - }); - - it("still groups volumes whose stack was deleted", () => { - const groups = groupByStack( - [volume("old-stack_data", "old-stack"), volume("immich_pgdata", "immich")], - KNOWN - ); - const orphan = groups.find((g) => g.key === "old-stack"); - // It has an owner, so it is not "unassigned" — but there is no stack to show. - expect(orphan?.stackId).toBe("old-stack"); - expect(orphan?.stack).toBeUndefined(); - expect(groups.at(-1)?.stackId).toBe("old-stack"); - }); - - it("sorts volumes inside a group by name", () => { - const groups = groupByStack( - [ - volume("immich_pgdata", "immich"), - volume("immich_cache", "immich"), - volume("immich_uploads", "immich"), - ], - KNOWN - ); - expect(groups[0].volumes.map((v) => v.name)).toEqual([ - "immich_cache", - "immich_pgdata", - "immich_uploads", - ]); - }); - - it("does not mutate the list it was given", () => { - const input = [volume("b", "immich"), volume("a", "immich")]; - groupByStack(input, KNOWN); - expect(input.map((v) => v.name)).toEqual(["b", "a"]); - }); - - it("returns nothing for nothing", () => { - expect(groupByStack([], KNOWN)).toEqual([]); - }); -}); - -describe("shortName", () => { - it("drops the compose project prefix", () => { - expect(shortName("immich_pgdata", "immich")).toBe("pgdata"); - }); - - it("leaves a name that does not carry the prefix alone", () => { - // An external volume adopted by a stack keeps whatever name it was created - // with; trimming a prefix that is not there would be a lie. - expect(shortName("shared-media", "immich")).toBe("shared-media"); - // A near-miss must not be trimmed either. - expect(shortName("immichpgdata", "immich")).toBe("immichpgdata"); - }); -}); diff --git a/frontend/src/lib/volumeGroups.ts b/frontend/src/lib/volumeGroups.ts deleted file mode 100644 index 9394215..0000000 --- a/frontend/src/lib/volumeGroups.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Volumes grouped by the stack that owns them. - * - * Docker names a compose volume `_`, so a plain alphabetical - * list already sorts a stack's volumes next to each other — but you are left - * reading prefixes to tell whose is whose, and a long list of - * `arr-stack_config`, `arr-stack_downloads`, `immich_model-cache` is exactly as - * hard to scan as it sounds. Grouping says the owner once per group instead. - */ -import type { StackSummary, VolumeInfo } from "@/types"; - -export interface VolumeGroup { - key: string; - /** Compose project the volumes belong to, or null for the loose ones. */ - stackId: string | null; - /** The stack itself, when it still exists. */ - stack?: StackSummary; - volumes: VolumeInfo[]; -} - -/** - * Group volumes by their compose project. - * - * Order: stacks by display name, then the volumes nobody claims. Those go last - * because they are the ones you scroll past rather than look for — and the - * group is where leftovers from deleted stacks collect. - */ -export function groupByStack( - volumes: VolumeInfo[], - stackById: Map -): VolumeGroup[] { - const byStack = new Map(); - for (const volume of volumes) { - const key = volume.stack || ""; - const bucket = byStack.get(key); - if (bucket) bucket.push(volume); - else byStack.set(key, [volume]); - } - - const byName = (a: VolumeInfo, b: VolumeInfo) => a.name.localeCompare(b.name); - const groups: VolumeGroup[] = []; - for (const [key, list] of byStack) { - if (!key) continue; - groups.push({ - key, - stackId: key, - stack: stackById.get(key), - volumes: [...list].sort(byName), - }); - } - // A stack that was deleted keeps its label on the volumes, so it still forms - // a group — sorted by the id, which is all that is left of it. - groups.sort((a, b) => - (a.stack?.name ?? a.key).localeCompare(b.stack?.name ?? b.key) - ); - - const loose = byStack.get(""); - if (loose) { - groups.push({ key: "__none__", stackId: null, volumes: [...loose].sort(byName) }); - } - return groups; -} - -/** Drop the `_` Docker prepends, so the row shows what differs. */ -export function shortName(name: string, stackId: string): string { - const prefix = `${stackId}_`; - return name.startsWith(prefix) ? name.slice(prefix.length) : name; -} diff --git a/frontend/src/pages/Images.tsx b/frontend/src/pages/Images.tsx index 2b95e26..20506bd 100644 --- a/frontend/src/pages/Images.tsx +++ b/frontend/src/pages/Images.tsx @@ -1,10 +1,13 @@ -import { useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle, Eraser } from "lucide-react"; import { toast } from "sonner"; import { Button, Card, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { imagesApi, type ImageRow } from "@/api/images"; +import { stacksApi } from "@/api/stacks"; +import { groupByStack, type StackGroup } from "@/lib/stackGroups"; +import { StackGroupHeader } from "@/components/ui/StackGroupHeader"; import { apiErrorMessage } from "@/api/client"; import { formatBytes, relativeTime } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; @@ -40,6 +43,26 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) { queryKey: ["images"], queryFn: () => imagesApi.list(), }); + // Images carry no compose label; the backend works their owners out from the + // containers running them, which is why an image can have several. + const stacks = useQuery({ + queryKey: ["stacks"], + queryFn: stacksApi.list, + staleTime: 30000, + }); + const stackById = useMemo( + () => new Map((stacks.data ?? []).map((s) => [s.id, s])), + [stacks.data] + ); + const groups = useMemo( + () => + groupByStack(data ?? [], { + stacksOf: (row) => row.stacks, + sortKey: (row) => row.tag, + stackById, + }), + [data, stackById] + ); const check = async () => { setChecking(true); @@ -99,18 +122,32 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) { - {data?.map((row) => ( - - {row.tag} - - {row.stacks.length ? row.stacks.join(", ") : "—"} - - {formatBytes(row.size)} - - {row.created ? relativeTime(row.created) : "—"} - - - + {groups.map((group) => ( + + + {group.items.map((row) => ( + + {row.tag} + + {row.stacks.length ? row.stacks.join(", ") : "—"} + + {formatBytes(row.size)} + + {row.created ? relativeTime(row.created) : "—"} + + + + + + ))} + ))} {data?.length === 0 && ( @@ -154,3 +191,13 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) { ); } + +/** What an image group can say for itself: how many, how big, how stale. */ +function groupMeta(group: StackGroup): string { + const parts = [`${group.items.length} image${group.items.length > 1 ? "s" : ""}`]; + const total = group.items.reduce((sum, row) => sum + row.size, 0); + if (total > 0) parts.push(formatBytes(total)); + const stale = group.items.filter((row) => row.update?.update_available).length; + if (stale > 0) parts.push(`${stale} with an update`); + return parts.join(" · "); +} diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index 1882f91..389f3a3 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Network as NetworkIcon, @@ -14,6 +14,9 @@ import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { networksApi, type NetworkInfo } from "@/api/networks"; +import { stacksApi } from "@/api/stacks"; +import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups"; +import { StackGroupHeader } from "@/components/ui/StackGroupHeader"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; @@ -38,6 +41,28 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); const colSpan = isAdmin ? 6 : 5; + const stacks = useQuery({ + queryKey: ["stacks"], + queryFn: stacksApi.list, + staleTime: 30000, + }); + const stackById = useMemo( + () => new Map((stacks.data ?? []).map((s) => [s.id, s])), + [stacks.data] + ); + const groups = useMemo( + () => + groupByStack(data ?? [], { + stacksOf: (n) => (n.stack ? [n.stack] : []), + sortKey: (n) => n.name, + stackById, + // bridge / host / none belong to nobody but are not leftovers either, + // so they get their own group at the very bottom. + isBuiltIn: (n) => n.is_default, + }), + [data, stackById] + ); + const prune = useMutation({ mutationFn: () => networksApi.prune(), onSuccess: (r) => { @@ -86,59 +111,25 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { - {data?.map((n) => ( - - setExpanded((e) => (e === n.id ? null : n.id))} - className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" - > - -
- {expanded === n.id ? ( - - ) : ( - - )} - - {n.name} - {n.is_default && default} - {n.stack && {n.stack}} - {n.internal && internal} -
- - {n.driver} - {n.scope} - {n.subnet ?? "—"} - - {n.in_use ? ( - - {n.containers.length} container{n.containers.length > 1 ? "s" : ""} - - ) : ( - - )} - - {isAdmin && ( - - {!n.is_default && ( - - )} - - )} - - {expanded === n.id && ( - - - - - - )} + {groups.map((group) => ( + + + {group.items.map((n) => ( + setExpanded((e) => (e === n.id ? null : n.id))} + onDelete={() => setToDelete(n)} + /> + ))} ))} {data?.length === 0 && ( @@ -178,6 +169,92 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) { ); } +/** One network: its row, plus the detail row when it is expanded. */ +function NetworkRows({ + network: n, + label, + colSpan, + isAdmin, + expanded, + onToggle, + onDelete, +}: { + network: NetworkInfo; + /** The name with the stack's prefix removed, when it had one. */ + label: string; + colSpan: number; + isAdmin: boolean; + expanded: boolean; + onToggle: () => void; + onDelete: () => void; +}) { + return ( + <> + + {/* Indented under its group heading. */} + +
+ {expanded ? ( + + ) : ( + + )} + + {/* The stack is the heading now, so the row shows the part of the + name that differs — compose calls a stack's own network + "_default". */} + + {label} + + {n.internal && internal} +
+ + {n.driver} + {n.scope} + {n.subnet ?? "—"} + + {n.in_use ? ( + + {n.containers.length} container{n.containers.length > 1 ? "s" : ""} + + ) : ( + + )} + + {isAdmin && ( + + {!n.is_default && ( + + )} + + )} + + {expanded && ( + + + + + + )} + + ); +} + function NetworkDetail({ network, isAdmin, @@ -375,3 +452,11 @@ function CreateNetworkDialog({ ); } + +/** What a network group can say for itself. */ +function groupMeta(group: StackGroup): string { + const parts = [`${group.items.length} network${group.items.length > 1 ? "s" : ""}`]; + const idle = group.items.filter((n) => !n.in_use).length; + if (idle > 0) parts.push(`${idle} unused`); + return parts.join(" · "); +} diff --git a/frontend/src/pages/Volumes.tsx b/frontend/src/pages/Volumes.tsx index 936d5de..8c8ab41 100644 --- a/frontend/src/pages/Volumes.tsx +++ b/frontend/src/pages/Volumes.tsx @@ -1,17 +1,16 @@ import { Fragment, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Trash2, Eraser, HardDrive, Unlink } from "lucide-react"; +import { Database, Trash2, Eraser, HardDrive } from "lucide-react"; import { toast } from "sonner"; import { Button, Card, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; -import { StackIcon } from "@/components/ui/StackIcon"; import { volumesApi } from "@/api/volumes"; import { stacksApi } from "@/api/stacks"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; import { formatBytes } from "@/lib/utils"; -import { groupByStack, shortName, type VolumeGroup } from "@/lib/volumeGroups"; +import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups"; +import { StackGroupHeader } from "@/components/ui/StackGroupHeader"; import type { VolumeInfo } from "@/types"; export function Volumes() { @@ -73,7 +72,15 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { ); const rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true)); - const groups = useMemo(() => groupByStack(rows, stackById), [rows, stackById]); + const groups = useMemo( + () => + groupByStack(rows, { + stacksOf: (v) => (v.stack ? [v.stack] : []), + sortKey: (v) => v.name, + stackById, + }), + [rows, stackById] + ); const colSpan = isAdmin ? 6 : 5; return ( @@ -121,16 +128,16 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { {groups.map((group) => ( - - {group.volumes.map((v) => ( + {group.items.map((v) => ( ; -}) { - const total = sizes - ? group.volumes.reduce((sum, v) => sum + (sizes[v.name] ?? 0), 0) - : null; - const unused = group.volumes.filter((v) => !v.in_use).length; - - return ( - - -
- {group.stack ? ( - <> - - - {group.stack.name} - - - ) : group.stackId ? ( - <> - {/* Labelled with a compose project that is no longer a stack: - exactly where data from a deleted stack is left behind. */} - - {group.stackId} - - stack removed - - - ) : ( - <> - - Not part of a stack - - )} - - {group.volumes.length} volume{group.volumes.length > 1 ? "s" : ""} - {unused > 0 && ` · ${unused} unused`} - {total != null && total > 0 && ` · ${formatBytes(total)}`} - -
- - - ); +/** The counts a volume group can show: how many, how many idle, how big. */ +function groupMeta( + group: StackGroup, + sizes?: Record +): string { + const parts = [`${group.items.length} volume${group.items.length > 1 ? "s" : ""}`]; + const unused = group.items.filter((v) => !v.in_use).length; + if (unused > 0) parts.push(`${unused} unused`); + if (sizes) { + const total = group.items.reduce((sum, v) => sum + (sizes[v.name] ?? 0), 0); + if (total > 0) parts.push(formatBytes(total)); + } + return parts.join(" · "); }