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:
1 parent
d7c4f06e67
commit
a2adb59526
11 files changed
+650
-346
No files matched your search
+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(" · ");
|
||||
}
|
||||
Reference in new issue
Block a user