diff --git a/README.md b/README.md index a1f083a..0886320 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,28 @@ 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.54.0 — nothing to do + +**The Volumes page is grouped by stack.** Docker names a compose volume +`_`, so an alphabetical list already put a stack's volumes next +to each other — but you were left reading prefixes to work out whose was whose. +Each stack is now a heading, with its icon, its name, and how many volumes it +owns (plus how many are unused, and their total size once you hit *Compute +sizes*). The rows below it drop the prefix and show only the part that differs: +`pgdata`, not `immich_pgdata`. + +Stacks are ordered by the name you gave them, and **volumes that belong to no +stack come last** — they are the ones you scroll past rather than look for. + +One group is worth knowing about: volumes still labelled with a compose project +that is no longer a stack. They are not loose, so they do not land in the +unassigned group; they get their own heading marked **stack removed**. That is +where data left behind by a deleted stack collects, and it was previously +invisible in a flat list. + +Nothing moved on the server and no endpoint changed — the owning stack was +already on every volume. + ## Upgrading to 0.53.0 — nothing to do A dark-mode fix. 0.52.0 put every app logo on a white tile so that black line @@ -535,8 +557,13 @@ it is what your saved destination credentials are encrypted with. ### Phase 16 — Volumes page -- **New Volumes page** (sidebar). Lists Docker volumes with driver, owning stack, - in-use containers and mountpoint. +- **New Volumes page** (sidebar). Lists Docker volumes with driver, in-use + containers and mountpoint, **grouped by the stack that owns them** — each + group headed by the stack's icon and name with its volume count, unused count + and total size, and the rows under it stripped of the `_` prefix. + Stacks sort by display name; volumes belonging to no stack come last. Volumes + still labelled with a stack that has been deleted form their own group, marked + *stack removed* — that is where forgotten data collects. - Admin actions: delete a volume (with an in-use warning + force option) and **Prune unused**; an *Only unused* filter. - **Volume sizes** are loaded on demand via a *Compute sizes* button (runs diff --git a/backend/version.py b/backend/version.py index 3af2d41..04d67ca 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.53.0" +APP_VERSION = "0.54.0" diff --git a/frontend/package.json b/frontend/package.json index 2b5ee59..ef1ba66 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.53.0", + "version": "0.54.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/lib/volumeGroups.test.ts b/frontend/src/lib/volumeGroups.test.ts new file mode 100644 index 0000000..a7ab0b0 --- /dev/null +++ b/frontend/src/lib/volumeGroups.test.ts @@ -0,0 +1,140 @@ +/** + * 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 new file mode 100644 index 0000000..9394215 --- /dev/null +++ b/frontend/src/lib/volumeGroups.ts @@ -0,0 +1,68 @@ +/** + * 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/Volumes.tsx b/frontend/src/pages/Volumes.tsx index b97cf72..936d5de 100644 --- a/frontend/src/pages/Volumes.tsx +++ b/frontend/src/pages/Volumes.tsx @@ -1,13 +1,17 @@ -import { useState } from "react"; +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 } from "lucide-react"; +import { Database, Trash2, Eraser, HardDrive, Unlink } from "lucide-react"; import { toast } from "sonner"; -import { Badge, Button, Card, Spinner } from "@/components/ui"; +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 type { VolumeInfo } from "@/types"; export function Volumes() { @@ -55,7 +59,21 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { onError: (e) => toast.error(apiErrorMessage(e)), }); + // Volumes carry their stack in a compose label, so the grouping needs no new + // endpoint. The stacks list is only for the headers (icon, display name, and + // whether the stack still exists) and is usually already cached. + 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 rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true)); + const groups = useMemo(() => groupByStack(rows, stackById), [rows, stackById]); const colSpan = isAdmin ? 6 : 5; return ( @@ -101,54 +119,28 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { - {rows.map((v) => ( - - -
- - {v.name} - {v.stack && {v.stack}} -
- - {v.driver} - - {sizes.data - ? sizes.data[v.name] != null - ? formatBytes(sizes.data[v.name]!) - : "—" - : sizes.isFetching - ? "…" - : "—"} - - - {v.in_use ? ( - - {v.used_by.length} container{v.used_by.length > 1 ? "s" : ""} - - ) : ( - - )} - - - - {v.mountpoint} - - - {isAdmin && ( - - - - )} - + {groups.map((group) => ( + + + {group.volumes.map((v) => ( + { + setForce(false); + setToDelete(v); + }} + /> + ))} + ))} {rows.length === 0 && ( @@ -195,3 +187,121 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) { ); } + +function VolumeRow({ + volume, + label, + size, + sizesPending, + isAdmin, + onDelete, +}: { + volume: VolumeInfo; + /** The name with the stack's prefix removed, when it had one. */ + label: string; + size?: number | null; + sizesPending: boolean; + isAdmin: boolean; + onDelete: () => void; +}) { + return ( + + {/* Indented under its group heading. */} + +
+ + + {label} + +
+ + {volume.driver} + + {size != null ? formatBytes(size) : sizesPending ? "…" : "—"} + + + {volume.in_use ? ( + + {volume.used_by.length} container{volume.used_by.length > 1 ? "s" : ""} + + ) : ( + + )} + + + + {volume.mountpoint} + + + {isAdmin && ( + + + + )} + + ); +} + +function GroupHeader({ + group, + colSpan, + sizes, +}: { + group: VolumeGroup; + colSpan: number; + sizes?: Record; +}) { + 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)}`} + +
+ + + ); +}