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
@@ -831,7 +831,11 @@ GET /api/images/prune-status (progress of the running / last prune
|
|||||||
- **Network management**: list, inspect, create, delete, prune, and
|
- **Network management**: list, inspect, create, delete, prune, and
|
||||||
connect/disconnect containers — including a *Prune unused* button, which
|
connect/disconnect containers — including a *Prune unused* button, which
|
||||||
resolves the common "all predefined address pools have been fully subnetted"
|
resolves the common "all predefined address pools have been fully subnetted"
|
||||||
deploy error without SSH.
|
deploy error without SSH. The prune asks first, runs in the background with
|
||||||
|
the same **live progress** as the image and volume prunes, and keeps any
|
||||||
|
network a *stopped* container is attached to (plain `docker network prune`
|
||||||
|
removes those, and the container then fails to start)
|
||||||
|
(`POST /api/networks/prune?background=true`, `GET /api/networks/prune-status`).
|
||||||
- **Images**: list image tags **grouped by the stack that uses them** and run
|
- **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
|
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
|
from the containers running it — which means an image can have several, and
|
||||||
@@ -1064,7 +1068,8 @@ POST /api/backups/schedules/{id}/run
|
|||||||
|
|
||||||
```
|
```
|
||||||
GET /api/networks | /{id} POST /api/networks
|
GET /api/networks | /{id} POST /api/networks
|
||||||
DELETE /api/networks/{id} POST /api/networks/prune
|
DELETE /api/networks/{id} POST /api/networks/prune?background=
|
||||||
|
GET /api/networks/prune-status
|
||||||
DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI)
|
DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
"""Docker network management endpoints."""
|
"""Docker network management endpoints."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from auth import get_current_user, require_admin
|
from auth import get_current_user, require_admin
|
||||||
from database import get_session
|
from database import engine, get_session
|
||||||
from docker_client import DockerError
|
from docker_client import DockerError
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import audit_service, network_service
|
from services import audit_service, network_service
|
||||||
|
from services.prune_job import PruneBusy
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/networks", tags=["networks"])
|
router = APIRouter(prefix="/api/networks", tags=["networks"])
|
||||||
|
|
||||||
@@ -47,6 +48,13 @@ def list_networks(_user: User = Depends(get_current_user)) -> list[dict]:
|
|||||||
return network_service.list_networks()
|
return network_service.list_networks()
|
||||||
|
|
||||||
|
|
||||||
|
# Before /{network_id}, which would otherwise take "prune-status" for an id.
|
||||||
|
@router.get("/prune-status")
|
||||||
|
def prune_status(_admin: User = Depends(require_admin)) -> dict:
|
||||||
|
"""Progress of a running (or the last) prune. Admin-only like the prune."""
|
||||||
|
return network_service.prune_status()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{network_id}")
|
@router.get("/{network_id}")
|
||||||
def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict:
|
def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -142,12 +150,32 @@ def delete_network(
|
|||||||
@router.post("/prune")
|
@router.post("/prune")
|
||||||
def prune_networks(
|
def prune_networks(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
response: Response,
|
||||||
|
background: bool = Query(False),
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
result = network_service.prune_networks()
|
"""Remove networks no container (running or stopped) is attached to.
|
||||||
audit_service.record(
|
|
||||||
session, user=user.username, action="network.prune", target="networks",
|
Waits for the result by default. ``background=true`` returns 202 with the
|
||||||
detail=str(result.get("NetworksDeleted") or []), ip=_ip(request),
|
initial status at once and leaves the caller to poll /prune-status, which
|
||||||
)
|
is what the UI does.
|
||||||
|
"""
|
||||||
|
username, ip = user.username, _ip(request)
|
||||||
|
|
||||||
|
def audit(result: dict) -> None:
|
||||||
|
# Its own session: in the background case the request's is long closed.
|
||||||
|
with Session(engine) as own:
|
||||||
|
audit_service.record(
|
||||||
|
own, user=username, action="network.prune", target="networks",
|
||||||
|
detail=str(result["NetworksDeleted"]), ip=ip,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if background:
|
||||||
|
response.status_code = 202
|
||||||
|
return network_service.start_prune(on_done=audit)
|
||||||
|
result = network_service.prune_networks()
|
||||||
|
except PruneBusy as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||||
|
audit(result)
|
||||||
return result
|
return result
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
"""Docker network management."""
|
"""Docker network management."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from docker.errors import APIError
|
from docker.errors import APIError
|
||||||
from docker.types import IPAMConfig, IPAMPool
|
from docker.types import IPAMConfig, IPAMPool
|
||||||
|
|
||||||
from docker_client import DockerError, get_client, get_long_client, safe_call
|
from docker_client import DockerError, get_client, get_long_client, safe_call
|
||||||
|
from services.prune_job import PruneJob, PrunePlan
|
||||||
|
|
||||||
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||||
DEFAULT_NETWORKS = {"bridge", "host", "none"}
|
DEFAULT_NETWORKS = {"bridge", "host", "none"}
|
||||||
|
#: Networks the daemon manages for swarm; never a prune's to remove.
|
||||||
|
_SWARM_NETWORKS = {"ingress", "docker_gwbridge"}
|
||||||
|
|
||||||
|
|
||||||
def _summary(net) -> dict:
|
def _summary(net) -> dict:
|
||||||
@@ -132,6 +135,65 @@ def delete_network(network_id: str) -> None:
|
|||||||
safe_call(net.remove)
|
safe_call(net.remove)
|
||||||
|
|
||||||
|
|
||||||
def prune_networks() -> dict:
|
_prune = PruneJob("network")
|
||||||
|
|
||||||
|
|
||||||
|
def prune_status() -> dict:
|
||||||
|
return _prune.status()
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(client) -> list[dict]:
|
||||||
|
"""The networks no container, running or stopped, is attached to.
|
||||||
|
|
||||||
|
Stricter than ``docker network prune``, which goes by active endpoints and
|
||||||
|
so removes a stopped container's network, after which that container no
|
||||||
|
longer starts ("network not found").
|
||||||
|
"""
|
||||||
|
in_use: set[str] = set()
|
||||||
|
for c in safe_call(client.api.containers, all=True):
|
||||||
|
networks = (c.get("NetworkSettings") or {}).get("Networks") or {}
|
||||||
|
for name, settings in networks.items():
|
||||||
|
in_use.add(name)
|
||||||
|
if (settings or {}).get("NetworkID"):
|
||||||
|
in_use.add(settings["NetworkID"])
|
||||||
|
return [
|
||||||
|
n
|
||||||
|
for n in safe_call(client.api.networks)
|
||||||
|
if n["Name"] not in DEFAULT_NETWORKS | _SWARM_NETWORKS
|
||||||
|
and n.get("Scope", "local") == "local"
|
||||||
|
and n["Name"] not in in_use
|
||||||
|
and n["Id"] not in in_use
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _plan() -> PrunePlan:
|
||||||
client = get_long_client()
|
client = get_long_client()
|
||||||
return safe_call(client.networks.prune)
|
|
||||||
|
def remove(network: dict) -> tuple[list, bool]:
|
||||||
|
# The daemon refuses if a container was attached since the listing.
|
||||||
|
safe_call(client.api.remove_network, network["Id"])
|
||||||
|
return [network["Name"]], True
|
||||||
|
|
||||||
|
def finish(names: list) -> tuple[dict, Optional[int]]:
|
||||||
|
# Networks hold no data: nothing to measure.
|
||||||
|
return {"NetworksDeleted": names}, None
|
||||||
|
|
||||||
|
return PrunePlan(
|
||||||
|
all=False,
|
||||||
|
items=_candidates(client),
|
||||||
|
label=lambda network: network["Name"],
|
||||||
|
remove=remove,
|
||||||
|
finish=finish,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_networks() -> dict:
|
||||||
|
"""Remove unused networks and wait for the result. Progress is visible
|
||||||
|
through prune_status() while it runs."""
|
||||||
|
return _prune.run(_plan)
|
||||||
|
|
||||||
|
|
||||||
|
def start_prune(on_done: Optional[Callable[[dict], None]] = None) -> dict:
|
||||||
|
"""Start a prune in the background and return its initial status; the UI
|
||||||
|
polls prune_status(). ``on_done`` gets what prune_networks() would return."""
|
||||||
|
return _prune.start(_plan, on_done)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Prunes with visible progress, shared by images and volumes.
|
"""Prunes with visible progress, shared by images, volumes and networks.
|
||||||
|
|
||||||
``docker image prune`` / ``docker volume prune`` are single blocking calls that
|
``docker image prune`` / ``docker volume prune`` are single blocking calls that
|
||||||
report nothing until they are done, which on a host with a lot to delete means
|
report nothing until they are done, which on a host with a lot to delete means
|
||||||
|
|||||||
@@ -50,35 +50,6 @@ def test_volume_prune_timeout_is_a_502_with_detail(as_admin, monkeypatch):
|
|||||||
assert r.json()["error"] == "docker_timeout"
|
assert r.json()["error"] == "docker_timeout"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
# The image, volume and network prunes go item by item and have their own
|
||||||
"module, fn, args",
|
# tests (test_image_prune.py, test_volume_prune.py, test_network_prune.py),
|
||||||
[
|
# including that they use the long-timeout client.
|
||||||
("network_service", "prune_networks", ()),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_prunes_use_the_long_timeout_client(monkeypatch, module, fn, args):
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
svc = importlib.import_module(f"services.{module}")
|
|
||||||
used = []
|
|
||||||
|
|
||||||
class Any_:
|
|
||||||
def prune(self, filters=None):
|
|
||||||
used.append("long")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
class Client:
|
|
||||||
volumes = images = networks = Any_()
|
|
||||||
|
|
||||||
def short():
|
|
||||||
raise AssertionError("prune must not use the 60s client")
|
|
||||||
|
|
||||||
monkeypatch.setattr(svc, "get_client", short)
|
|
||||||
monkeypatch.setattr(svc, "get_long_client", lambda: Client())
|
|
||||||
getattr(svc, fn)(*args)
|
|
||||||
assert used == ["long"]
|
|
||||||
|
|
||||||
|
|
||||||
# The image and volume prunes go item by item and have their own tests in
|
|
||||||
# test_image_prune.py / test_volume_prune.py, including that they use the
|
|
||||||
# long-timeout client.
|
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Network prune with visible progress.
|
||||||
|
|
||||||
|
Same mechanism as the image and volume prunes (services/prune_job.py). Docker
|
||||||
|
has no -a for networks, and nothing to measure: networks hold no data.
|
||||||
|
|
||||||
|
Pinned here: the built-in and swarm networks are never touched, and neither is
|
||||||
|
a network a *stopped* container is attached to. That is stricter than
|
||||||
|
``docker network prune``, which goes by active endpoints and so leaves a
|
||||||
|
stopped container unable to start ("network not found").
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docker.errors import APIError
|
||||||
|
|
||||||
|
|
||||||
|
class FakeApi:
|
||||||
|
def __init__(self):
|
||||||
|
self._networks = [
|
||||||
|
{"Id": "n-bridge", "Name": "bridge", "Scope": "local"},
|
||||||
|
{"Id": "n-host", "Name": "host", "Scope": "local"},
|
||||||
|
{"Id": "n-none", "Name": "none", "Scope": "local"},
|
||||||
|
{"Id": "n-ingress", "Name": "ingress", "Scope": "swarm"},
|
||||||
|
{"Id": "n-gw", "Name": "docker_gwbridge", "Scope": "local"},
|
||||||
|
{"Id": "n-overlay", "Name": "shared_overlay", "Scope": "swarm"},
|
||||||
|
{"Id": "n-run", "Name": "media_default", "Scope": "local"},
|
||||||
|
{"Id": "n-stopped", "Name": "wiki_default", "Scope": "local"},
|
||||||
|
{"Id": "n-old1", "Name": "oldstack_default", "Scope": "local"},
|
||||||
|
{"Id": "n-old2", "Name": "scratch", "Scope": "local"},
|
||||||
|
]
|
||||||
|
self._containers = [
|
||||||
|
# running
|
||||||
|
{"State": "running", "NetworkSettings": {"Networks": {
|
||||||
|
"media_default": {"NetworkID": "n-run"}}}},
|
||||||
|
# stopped: still attached, must keep its network
|
||||||
|
{"State": "exited", "NetworkSettings": {"Networks": {
|
||||||
|
"wiki_default": {"NetworkID": "n-stopped"}}}},
|
||||||
|
]
|
||||||
|
self.removed: list[str] = []
|
||||||
|
self.refuse: set[str] = set()
|
||||||
|
|
||||||
|
def containers(self, all=False):
|
||||||
|
assert all is True
|
||||||
|
return self._containers
|
||||||
|
|
||||||
|
def networks(self):
|
||||||
|
return list(self._networks)
|
||||||
|
|
||||||
|
def remove_network(self, net_id):
|
||||||
|
if net_id in self.refuse:
|
||||||
|
raise APIError(f"error while removing network: {net_id} has active endpoints")
|
||||||
|
self.removed.append(net_id)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, api):
|
||||||
|
self.api = api
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def svc(monkeypatch):
|
||||||
|
from services import network_service
|
||||||
|
|
||||||
|
api = FakeApi()
|
||||||
|
|
||||||
|
def short():
|
||||||
|
raise AssertionError("prune must not use the 60s client")
|
||||||
|
|
||||||
|
monkeypatch.setattr(network_service, "get_client", short)
|
||||||
|
monkeypatch.setattr(network_service, "get_long_client", lambda: FakeClient(api))
|
||||||
|
yield network_service, api
|
||||||
|
if network_service._prune._lock.locked():
|
||||||
|
network_service._prune._lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_networks_no_container_is_attached_to(svc):
|
||||||
|
network_service, api = svc
|
||||||
|
|
||||||
|
result = network_service.prune_networks()
|
||||||
|
|
||||||
|
assert set(api.removed) == {"n-old1", "n-old2"}
|
||||||
|
assert set(result["NetworksDeleted"]) == {"oldstack_default", "scratch"}
|
||||||
|
status = network_service.prune_status()
|
||||||
|
assert (status["total"], status["done"], status["deleted"]) == (2, 2, 2)
|
||||||
|
assert status["reclaimed"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_network_does_not_stop_the_rest(svc):
|
||||||
|
network_service, api = svc
|
||||||
|
api.refuse.add("n-old1")
|
||||||
|
|
||||||
|
result = network_service.prune_networks()
|
||||||
|
|
||||||
|
assert result["NetworksDeleted"] == ["scratch"]
|
||||||
|
status = network_service.prune_status()
|
||||||
|
assert status["failed"] == 1 and status["errors"][0].startswith("oldstack_default: ")
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_background_then_poll(svc, as_admin):
|
||||||
|
r = as_admin.post("/api/networks/prune", params={"background": True})
|
||||||
|
assert r.status_code == 202 and r.json()["total"] == 2
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
s = as_admin.get("/api/networks/prune-status").json()
|
||||||
|
if not s["active"]:
|
||||||
|
break
|
||||||
|
threading.Event().wait(0.02)
|
||||||
|
assert s["deleted"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_waits_by_default_and_is_audited(svc, as_admin):
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from database import engine
|
||||||
|
from models.audit import AuditLog
|
||||||
|
|
||||||
|
r = as_admin.post("/api/networks/prune")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["NetworksDeleted"] == ["oldstack_default", "scratch"]
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
row = session.exec(
|
||||||
|
select(AuditLog).where(AuditLog.action == "network.prune").order_by(AuditLog.id.desc())
|
||||||
|
).first()
|
||||||
|
assert row.detail == "['oldstack_default', 'scratch']"
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_rejects_a_second_prune(svc, as_admin):
|
||||||
|
network_service, _ = svc
|
||||||
|
network_service._prune._lock.acquire()
|
||||||
|
try:
|
||||||
|
assert as_admin.post("/api/networks/prune").status_code == 409
|
||||||
|
finally:
|
||||||
|
network_service._prune._lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_is_admin_only(svc, as_user):
|
||||||
|
assert as_user.post("/api/networks/prune").status_code == 403
|
||||||
|
assert as_user.get("/api/networks/prune-status").status_code == 403
|
||||||
+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.62.0"
|
APP_VERSION = "0.63.0"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.62.0",
|
"version": "0.63.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
|
import type { PruneStatus } from "@/types";
|
||||||
|
|
||||||
export interface NetworkInfo {
|
export interface NetworkInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +48,10 @@ export const networksApi = {
|
|||||||
disconnect: (id: string, container: string, force = false) =>
|
disconnect: (id: string, container: string, force = false) =>
|
||||||
api.post(`${base}/${id}/disconnect`, { container, force }).then((r) => r.data),
|
api.post(`${base}/${id}/disconnect`, { container, force }).then((r) => r.data),
|
||||||
remove: (id: string) => api.delete(`${base}/${id}`).then((r) => r.data),
|
remove: (id: string) => api.delete(`${base}/${id}`).then((r) => r.data),
|
||||||
prune: () =>
|
// Runs in the background on the server; poll pruneStatus() for progress.
|
||||||
api.post<{ NetworksDeleted: string[] | null }>(`${base}/prune`).then((r) => r.data),
|
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";
|
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,
|
* 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
|
* 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 { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { networksApi, type NetworkInfo } from "@/api/networks";
|
import { networksApi, type NetworkInfo } from "@/api/networks";
|
||||||
|
import { PruneProgress } from "@/components/ui/PruneProgress";
|
||||||
|
import { usePruneJob } from "@/hooks/usePruneJob";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups";
|
import { groupByStack, stripStackPrefix, type StackGroup } from "@/lib/stackGroups";
|
||||||
import { StackGroupHeader } from "@/components/ui/StackGroupHeader";
|
import { StackGroupHeader } from "@/components/ui/StackGroupHeader";
|
||||||
@@ -38,6 +40,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
|
const [toDelete, setToDelete] = useState<NetworkInfo | null>(null);
|
||||||
const [expanded, setExpanded] = useState<string | null>(null);
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
const [pruneOpen, setPruneOpen] = useState(false);
|
||||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
||||||
const colSpan = isAdmin ? 6 : 5;
|
const colSpan = isAdmin ? 6 : 5;
|
||||||
|
|
||||||
@@ -63,14 +66,13 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
[data, stackById]
|
[data, stackById]
|
||||||
);
|
);
|
||||||
|
|
||||||
const prune = useMutation({
|
const prune = usePruneJob({
|
||||||
mutationFn: () => networksApi.prune(),
|
noun: "network",
|
||||||
onSuccess: (r) => {
|
queryKey: "network-prune-status",
|
||||||
const n = r.NetworksDeleted?.length ?? 0;
|
fetchStatus: networksApi.pruneStatus,
|
||||||
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
|
start: networksApi.startPrune,
|
||||||
invalidate();
|
enabled: isAdmin,
|
||||||
},
|
onFinished: invalidate,
|
||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
|
||||||
});
|
});
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => networksApi.remove(id),
|
mutationFn: (id: string) => networksApi.remove(id),
|
||||||
@@ -86,7 +88,7 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<section>
|
<section>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
|
<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
|
<Eraser className="h-4 w-4" /> Prune unused
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCreating(true)}>
|
<Button onClick={() => setCreating(true)}>
|
||||||
@@ -95,6 +97,10 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{prune.running && (
|
||||||
|
<PruneProgress status={prune.running} title="Pruning unused networks…" />
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
@@ -150,6 +156,20 @@ function NetworksSection({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
onCancel={() => setCreating(false)}
|
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 && (
|
{toDelete && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
title={`Delete network “${toDelete.name}”?`}
|
title={`Delete network “${toDelete.name}”?`}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export interface TokenPair {
|
|||||||
token_type: string;
|
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 {
|
export interface PruneStatus {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
/** Increments per prune, so a client can tell "its" run from an older one. */
|
/** Increments per prune, so a client can tell "its" run from an older one. */
|
||||||
|
|||||||
Reference in new issue
Block a user