Group Images and Networks by stack, like Volumes (0.55.0)
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:
@@ -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 `<project>_` prefix, and anything unclaimed at the bottom. Compose names
|
||||
a stack's own network `<project>_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
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the StackPilot release version."""
|
||||
|
||||
APP_VERSION = "0.54.0"
|
||||
APP_VERSION = "0.55.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.54.0",
|
||||
"version": "0.55.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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<T>({
|
||||
group,
|
||||
colSpan,
|
||||
meta,
|
||||
}: {
|
||||
group: StackGroup<T>;
|
||||
colSpan: number;
|
||||
meta?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<tr className="bg-slate-50/80 dark:bg-slate-800/40">
|
||||
<td colSpan={colSpan} className="px-4 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label group={group} />
|
||||
{meta && <span className="text-xs text-slate-400">{meta}</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function Label<T>({ group }: { group: StackGroup<T> }) {
|
||||
if (group.kind === "shared") {
|
||||
return (
|
||||
<>
|
||||
<Layers className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="font-semibold text-slate-500">Shared by several stacks</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (group.kind === "builtin") {
|
||||
return (
|
||||
<>
|
||||
<Boxes className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="font-semibold text-slate-500">Docker built-ins</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (group.kind === "none") {
|
||||
return (
|
||||
<>
|
||||
<Unlink className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="font-semibold text-slate-500">Not part of a stack</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (group.stack) {
|
||||
return (
|
||||
<>
|
||||
<StackIcon stack={group.stack} status={group.stack.status} size="sm" />
|
||||
<Link to={`/stacks/${group.stack.id}`} className="font-semibold hover:underline">
|
||||
{group.stack.name}
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// Labelled with a compose project that is no longer a stack: exactly where
|
||||
// things left behind by a deleted stack collect.
|
||||
return (
|
||||
<>
|
||||
<Unlink className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="font-semibold">{group.stackId}</span>
|
||||
<span className="rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400">
|
||||
stack removed
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 "<project>_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");
|
||||
});
|
||||
});
|
||||
@@ -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<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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Volumes grouped by the stack that owns them.
|
||||
*
|
||||
* Docker names a compose volume `<project>_<name>`, 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<string, StackSummary>
|
||||
): VolumeGroup[] {
|
||||
const byStack = new Map<string, VolumeInfo[]>();
|
||||
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 `<project>_` 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;
|
||||
}
|
||||
@@ -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 }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((row) => (
|
||||
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.created ? relativeTime(row.created) : "—"}
|
||||
</td>
|
||||
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||
</tr>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<StackGroupHeader
|
||||
group={group}
|
||||
colSpan={5}
|
||||
meta={groupMeta(group)}
|
||||
/>
|
||||
{group.items.map((row) => (
|
||||
<tr
|
||||
key={row.tag}
|
||||
className="border-b border-slate-100 dark:border-slate-700/50"
|
||||
>
|
||||
<td className="py-3 pl-10 pr-3 font-mono text-xs">{row.tag}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||
<td className="p-3 text-xs text-slate-500">
|
||||
{row.created ? relativeTime(row.created) : "—"}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<UpdateBadge row={row} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{data?.length === 0 && (
|
||||
<tr>
|
||||
@@ -154,3 +191,13 @@ function ImagesSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** What an image group can say for itself: how many, how big, how stale. */
|
||||
function groupMeta(group: StackGroup<ImageRow>): 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(" · ");
|
||||
}
|
||||
|
||||
+139
-54
@@ -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 }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{data?.map((n) => (
|
||||
<Fragment key={n.id}>
|
||||
<tr
|
||||
onClick={() => setExpanded((e) => (e === n.id ? null : n.id))}
|
||||
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded === n.id ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<NetworkIcon className="h-4 w-4 text-slate-400" />
|
||||
<span className="font-medium">{n.name}</span>
|
||||
{n.is_default && <Badge>default</Badge>}
|
||||
{n.stack && <Badge>{n.stack}</Badge>}
|
||||
{n.internal && <span className="text-xs text-slate-400">internal</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500">{n.driver}</td>
|
||||
<td className="px-4 py-2 text-slate-500">{n.scope}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-slate-500">{n.subnet ?? "—"}</td>
|
||||
<td className="px-4 py-2">
|
||||
{n.in_use ? (
|
||||
<span title={n.containers.join(", ")} className="text-slate-600 dark:text-slate-300">
|
||||
{n.containers.length} container{n.containers.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-4 py-2 text-right">
|
||||
{!n.is_default && (
|
||||
<button
|
||||
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
|
||||
onClick={(e) => { e.stopPropagation(); setToDelete(n); }}
|
||||
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{expanded === n.id && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
|
||||
<NetworkDetail network={n} isAdmin={isAdmin} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<StackGroupHeader
|
||||
group={group}
|
||||
colSpan={colSpan}
|
||||
meta={groupMeta(group)}
|
||||
/>
|
||||
{group.items.map((n) => (
|
||||
<NetworkRows
|
||||
key={n.id}
|
||||
network={n}
|
||||
label={stripStackPrefix(n.name, group.stackId)}
|
||||
colSpan={colSpan}
|
||||
isAdmin={isAdmin}
|
||||
expanded={expanded === n.id}
|
||||
onToggle={() => setExpanded((e) => (e === n.id ? null : n.id))}
|
||||
onDelete={() => setToDelete(n)}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
{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 (
|
||||
<>
|
||||
<tr
|
||||
onClick={onToggle}
|
||||
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
||||
>
|
||||
{/* Indented under its group heading. */}
|
||||
<td className="py-2 pl-10 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
<NetworkIcon className="h-4 w-4 text-slate-400" />
|
||||
{/* The stack is the heading now, so the row shows the part of the
|
||||
name that differs — compose calls a stack's own network
|
||||
"<project>_default". */}
|
||||
<span className="font-medium" title={n.name}>
|
||||
{label}
|
||||
</span>
|
||||
{n.internal && <span className="text-xs text-slate-400">internal</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-slate-500">{n.driver}</td>
|
||||
<td className="px-4 py-2 text-slate-500">{n.scope}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-slate-500">{n.subnet ?? "—"}</td>
|
||||
<td className="px-4 py-2">
|
||||
{n.in_use ? (
|
||||
<span
|
||||
title={n.containers.join(", ")}
|
||||
className="text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
{n.containers.length} container{n.containers.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-4 py-2 text-right">
|
||||
{!n.is_default && (
|
||||
<button
|
||||
title={n.in_use ? "In use — disconnect containers first" : "Delete"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
|
||||
<NetworkDetail network={n} isAdmin={isAdmin} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkDetail({
|
||||
network,
|
||||
isAdmin,
|
||||
@@ -375,3 +452,11 @@ function CreateNetworkDialog({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a network group can say for itself. */
|
||||
function groupMeta(group: StackGroup<NetworkInfo>): 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(" · ");
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group.key}>
|
||||
<GroupHeader
|
||||
<StackGroupHeader
|
||||
group={group}
|
||||
colSpan={colSpan}
|
||||
sizes={sizes.data}
|
||||
meta={groupMeta(group, sizes.data)}
|
||||
/>
|
||||
{group.volumes.map((v) => (
|
||||
{group.items.map((v) => (
|
||||
<VolumeRow
|
||||
key={v.name}
|
||||
volume={v}
|
||||
label={group.stackId ? shortName(v.name, group.stackId) : v.name}
|
||||
label={stripStackPrefix(v.name, group.stackId)}
|
||||
size={sizes.data?.[v.name]}
|
||||
sizesPending={!sizes.data && sizes.isFetching}
|
||||
isAdmin={isAdmin}
|
||||
@@ -251,57 +258,17 @@ function VolumeRow({
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeader({
|
||||
group,
|
||||
colSpan,
|
||||
sizes,
|
||||
}: {
|
||||
group: VolumeGroup;
|
||||
colSpan: number;
|
||||
sizes?: Record<string, number | null>;
|
||||
}) {
|
||||
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 (
|
||||
<tr className="bg-slate-50/80 dark:bg-slate-800/40">
|
||||
<td colSpan={colSpan} className="px-4 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{group.stack ? (
|
||||
<>
|
||||
<StackIcon stack={group.stack} status={group.stack.status} size="sm" />
|
||||
<Link
|
||||
to={`/stacks/${group.stack.id}`}
|
||||
className="font-semibold hover:underline"
|
||||
>
|
||||
{group.stack.name}
|
||||
</Link>
|
||||
</>
|
||||
) : group.stackId ? (
|
||||
<>
|
||||
{/* Labelled with a compose project that is no longer a stack:
|
||||
exactly where data from a deleted stack is left behind. */}
|
||||
<Unlink className="h-4 w-4 shrink-0 text-amber-500" />
|
||||
<span className="font-semibold">{group.stackId}</span>
|
||||
<span className="rounded-pill border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[11px] font-semibold text-amber-600 dark:text-amber-400">
|
||||
stack removed
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlink className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="font-semibold text-slate-500">Not part of a stack</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">
|
||||
{group.volumes.length} volume{group.volumes.length > 1 ? "s" : ""}
|
||||
{unused > 0 && ` · ${unused} unused`}
|
||||
{total != null && total > 0 && ` · ${formatBytes(total)}`}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
/** The counts a volume group can show: how many, how many idle, how big. */
|
||||
function groupMeta(
|
||||
group: StackGroup<VolumeInfo>,
|
||||
sizes?: Record<string, number | null>
|
||||
): 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(" · ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user