Show live progress while pruning networks, and ask first (0.63.0)
The network prune joins the image and volume prunes on the shared
PruneJob: it runs in the background, the Networks page shows a progress
bar and a toast with the outcome, and the button now asks before deleting
instead of pruning on click. Docker has no -a for networks and they hold
no data, so there is no option and no space figure.
Candidates are stricter than docker network prune, which goes by active
endpoints and so removes a stopped container's network, after which that
container fails to start ("network not found"). A network any container,
running or stopped, is attached to (by name or id) is kept, as are
bridge/host/none, ingress/docker_gwbridge and swarm-scoped networks.
Removal is per network, so one the daemon refuses does not stop the rest.
- POST /api/networks/prune takes background=; without it it still waits
and returns {NetworksDeleted}. GET /api/networks/prune-status is
admin-only and registered before /{network_id}, which would otherwise
take "prune-status" for an id.
- The long-timeout test for network prune in test_docker_timeouts.py is
superseded by test_network_prune.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57231e4a67
commit
ee98d0da6d
12 files changed
+293
-61
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.62.0",
|
||||
"version": "0.63.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import api from "./client";
|
||||
import type { PruneStatus } from "@/types";
|
||||
|
||||
export interface NetworkInfo {
|
||||
id: string;
|
||||
@@ -47,6 +48,10 @@ export const networksApi = {
|
||||
disconnect: (id: string, container: string, force = false) =>
|
||||
api.post(`${base}/${id}/disconnect`, { container, force }).then((r) => r.data),
|
||||
remove: (id: string) => api.delete(`${base}/${id}`).then((r) => r.data),
|
||||
prune: () =>
|
||||
api.post<{ NetworksDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
|
||||
// Runs in the background on the server; poll pruneStatus() for progress.
|
||||
startPrune: () =>
|
||||
api
|
||||
.post<PruneStatus>(`${base}/prune`, null, { params: { background: true } })
|
||||
.then((r) => r.data),
|
||||
pruneStatus: () => api.get<PruneStatus>(`${base}/prune-status`).then((r) => r.data),
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { formatBytes } from "@/lib/utils";
|
||||
import type { PruneStatus } from "@/types";
|
||||
|
||||
/**
|
||||
* A server-side prune that runs in the background (images, volumes).
|
||||
* A server-side prune that runs in the background (images, volumes, networks).
|
||||
*
|
||||
* Polls the status while a prune is active. It is fetched once on mount too,
|
||||
* so a prune started in another tab, or before a reload, still shows up. The
|
||||
|
||||
@@ -14,6 +14,8 @@ 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 { PruneProgress } from "@/components/ui/PruneProgress";
|
||||
import { usePruneJob } from "@/hooks/usePruneJob";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups";
|
||||
import { StackGroupHeader } from "@/components/ui/StackGroupHeader";
|
||||
@@ -38,6 +40,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [pruneOpen, setPruneOpen] = useState(false);
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
||||
const colSpan = isAdmin ? 6 : 5;
|
||||
|
||||
@@ -63,14 +66,13 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
[data, stackById]
|
||||
);
|
||||
|
||||
const prune = useMutation({
|
||||
mutationFn: () => networksApi.prune(),
|
||||
onSuccess: (r) => {
|
||||
const n = r.NetworksDeleted?.length ?? 0;
|
||||
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
|
||||
invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
const prune = usePruneJob({
|
||||
noun: "network",
|
||||
queryKey: "network-prune-status",
|
||||
fetchStatus: networksApi.pruneStatus,
|
||||
start: networksApi.startPrune,
|
||||
enabled: isAdmin,
|
||||
onFinished: invalidate,
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => networksApi.remove(id),
|
||||
@@ -86,7 +88,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
<section>
|
||||
{isAdmin && (
|
||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
||||
<Button variant="outline" onClick={() => setPruneOpen(true)} loading={!!prune.running}>
|
||||
<Eraser className="h-4 w-4" /> Prune unused
|
||||
</Button>
|
||||
<Button onClick={() => setCreating(true)}>
|
||||
@@ -95,6 +97,10 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prune.running && (
|
||||
<PruneProgress status={prune.running} title="Pruning unused networks…" />
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
@@ -150,6 +156,20 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
{pruneOpen && (
|
||||
<ConfirmDialog
|
||||
title="Prune networks"
|
||||
message="Remove every network no container is attached to, running or stopped. The built-in bridge, host and none networks are kept; a stack recreates its own on the next deploy."
|
||||
confirmLabel="Prune networks"
|
||||
danger
|
||||
busy={prune.start.isPending}
|
||||
onConfirm={() =>
|
||||
prune.start.mutate(false, { onSuccess: () => setPruneOpen(false) })
|
||||
}
|
||||
onCancel={() => setPruneOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toDelete && (
|
||||
<ConfirmDialog
|
||||
title={`Delete network “${toDelete.name}”?`}
|
||||
|
||||
@@ -150,7 +150,7 @@ export interface TokenPair {
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** Progress of a running image/volume prune, or the outcome of the last one. */
|
||||
/** Progress of a running image/volume/network prune, or the outcome of the last one. */
|
||||
export interface PruneStatus {
|
||||
active: boolean;
|
||||
/** Increments per prune, so a client can tell "its" run from an older one. */
|
||||
|
||||
Reference in new issue
Block a user