Group the Volumes page by the stack that owns each volume (0.54.0)
A flat volume list is sorted by name, and because Docker names a compose volume <project>_<name> that already puts a stack's volumes next to each other. What it does not do is say so: you read prefixes down the column to work out where one stack's volumes end and the next begins, and `arr-stack_config`, `arr-stack_downloads`, `immich_model-cache` is exactly as hard to scan as it looks. So the stack becomes a heading instead of a prefix repeated on every row. Each group carries the stack's icon and name, its volume count, how many are unused, and — once sizes have been computed — what the stack costs on disk, which is the number you actually want when you are deciding what to clear out. The rows below drop the prefix and show the part that differs: `pgdata`, not `immich_pgdata`. The full name stays in the row's title attribute, since that is what you need when typing a docker command. Ordering: stacks by the name the user gave them rather than by the slug (an id of "zz-project" for a stack called "Alpha" should sort under A), and volumes belonging to no stack appended last, never sorted in — they are the ones you scroll past rather than look for. The case that turned out to be worth building for is the third one. A volume keeps its compose label after the stack is gone, so it is neither owned nor loose. Putting it in the unassigned group would hide it among portainer_data and friends; instead it keeps its own heading, marked "stack removed". A flat list made that invisible, and it is precisely where forgotten data sits. No server change: the owning stack has been on every volume all along, from the com.docker.compose.project label. The grouping is a pure function in lib/ rather than logic inside the page, so the ordering rules are tested directly — ten cases, including the deleted-stack group and that the unassigned group stays last when the only real stack sorts after it. The row markup moved into its own component on the way past. Nesting it one level deeper inside the group left its indentation two stops adrift, and a 70- line <tr> inline in a double map was already the least readable thing in the file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
|
||||||
> complete.
|
> complete.
|
||||||
|
|
||||||
|
## Upgrading to 0.54.0 — nothing to do
|
||||||
|
|
||||||
|
**The Volumes page is grouped by stack.** Docker names a compose volume
|
||||||
|
`<project>_<name>`, 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
|
## 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
|
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
|
### Phase 16 — Volumes page
|
||||||
|
|
||||||
- **New Volumes page** (sidebar). Lists Docker volumes with driver, owning stack,
|
- **New Volumes page** (sidebar). Lists Docker volumes with driver, in-use
|
||||||
in-use containers and mountpoint.
|
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 `<project>_` 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
|
- Admin actions: delete a volume (with an in-use warning + force option) and
|
||||||
**Prune unused**; an *Only unused* filter.
|
**Prune unused**; an *Only unused* filter.
|
||||||
- **Volume sizes** are loaded on demand via a *Compute sizes* button (runs
|
- **Volume sizes** are loaded on demand via a *Compute sizes* button (runs
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.53.0"
|
APP_VERSION = "0.54.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.53.0",
|
"version": "0.54.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
+161
-51
@@ -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 { 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 { 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 { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
|
import { StackIcon } from "@/components/ui/StackIcon";
|
||||||
import { volumesApi } from "@/api/volumes";
|
import { volumesApi } from "@/api/volumes";
|
||||||
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { formatBytes } from "@/lib/utils";
|
import { formatBytes } from "@/lib/utils";
|
||||||
|
import { groupByStack, shortName, type VolumeGroup } from "@/lib/volumeGroups";
|
||||||
import type { VolumeInfo } from "@/types";
|
import type { VolumeInfo } from "@/types";
|
||||||
|
|
||||||
export function Volumes() {
|
export function Volumes() {
|
||||||
@@ -55,7 +59,21 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
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 rows = (data ?? []).filter((v) => (onlyUnused ? !v.in_use : true));
|
||||||
|
const groups = useMemo(() => groupByStack(rows, stackById), [rows, stackById]);
|
||||||
const colSpan = isAdmin ? 6 : 5;
|
const colSpan = isAdmin ? 6 : 5;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -101,54 +119,28 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
{rows.map((v) => (
|
{groups.map((group) => (
|
||||||
<tr key={v.name} className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
<Fragment key={group.key}>
|
||||||
<td className="px-4 py-2">
|
<GroupHeader
|
||||||
<div className="flex items-center gap-2">
|
group={group}
|
||||||
<Database className="h-4 w-4 shrink-0 text-slate-400" />
|
colSpan={colSpan}
|
||||||
<span className="break-all font-medium">{v.name}</span>
|
sizes={sizes.data}
|
||||||
{v.stack && <Badge>{v.stack}</Badge>}
|
/>
|
||||||
</div>
|
{group.volumes.map((v) => (
|
||||||
</td>
|
<VolumeRow
|
||||||
<td className="px-4 py-2 text-slate-500">{v.driver}</td>
|
key={v.name}
|
||||||
<td className="px-4 py-2 tabular-nums text-slate-500">
|
volume={v}
|
||||||
{sizes.data
|
label={group.stackId ? shortName(v.name, group.stackId) : v.name}
|
||||||
? sizes.data[v.name] != null
|
size={sizes.data?.[v.name]}
|
||||||
? formatBytes(sizes.data[v.name]!)
|
sizesPending={!sizes.data && sizes.isFetching}
|
||||||
: "—"
|
isAdmin={isAdmin}
|
||||||
: sizes.isFetching
|
onDelete={() => {
|
||||||
? "…"
|
setForce(false);
|
||||||
: "—"}
|
setToDelete(v);
|
||||||
</td>
|
}}
|
||||||
<td className="px-4 py-2">
|
/>
|
||||||
{v.in_use ? (
|
))}
|
||||||
<span title={v.used_by.join(", ")} className="text-slate-600 dark:text-slate-300">
|
</Fragment>
|
||||||
{v.used_by.length} container{v.used_by.length > 1 ? "s" : ""}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-400">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 font-mono text-[11px] text-slate-400">
|
|
||||||
<span className="block max-w-[22rem] truncate" title={v.mountpoint}>
|
|
||||||
{v.mountpoint}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
{isAdmin && (
|
|
||||||
<td className="px-4 py-2 text-right">
|
|
||||||
<button
|
|
||||||
title={v.in_use ? "In use — delete needs force" : "Delete"}
|
|
||||||
onClick={() => {
|
|
||||||
setForce(false);
|
|
||||||
setToDelete(v);
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
{rows.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
@@ -195,3 +187,121 @@ function VolumesSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr className="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">
|
||||||
|
<Database className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
<span className="break-all font-medium" title={volume.name}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-500">{volume.driver}</td>
|
||||||
|
<td className="px-4 py-2 tabular-nums text-slate-500">
|
||||||
|
{size != null ? formatBytes(size) : sizesPending ? "…" : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{volume.in_use ? (
|
||||||
|
<span
|
||||||
|
title={volume.used_by.join(", ")}
|
||||||
|
className="text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{volume.used_by.length} container{volume.used_by.length > 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 font-mono text-[11px] text-slate-400">
|
||||||
|
<span className="block max-w-[22rem] truncate" title={volume.mountpoint}>
|
||||||
|
{volume.mountpoint}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
title={volume.in_use ? "In use — delete needs force" : "Delete"}
|
||||||
|
onClick={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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user