From 012614f5fb4fa38929a3cc3b6f8294dda58de4ef Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 11:52:53 +0000 Subject: [PATCH] Phase 13: multi-host networks & images (0.14.0) Networks and Images are now per-host, rendered as a section for the local host plus one per registered agent (like the Stacks page). - agent_app.py: new /agent/networks (list/inspect/containers/connect/disconnect/ create/delete/prune) and /agent/images (list/updates/check), reusing network_service and a new image_service; DockerError mapped to HTTP status (forbidden -> 400 so the proxy doesn't treat it as a token failure). - routers/agents.py: proxy routes at /api/agents/{id}/networks/* and /api/agents/{id}/images/*, audit-logging mutations. - services/image_service.py: extracted the image-listing logic so the central router and the agent share it. - Frontend: networksApi/imagesApi take an optional agentId; Networks/Images pages render NetworksSection/ImagesSection per host with a shared HostHeader. Remote "Prune unused" networks resolves the address-pool-exhaustion deploy error from the UI. Co-Authored-By: Claude Opus 4.8 --- README.md | 17 +- backend/agent_app.py | 122 +++++++- backend/main.py | 2 +- backend/routers/agents.py | 169 ++++++++++++ backend/routers/images.py | 43 +-- backend/services/image_service.py | 47 ++++ frontend/package.json | 2 +- frontend/src/api/images.ts | 14 +- frontend/src/api/networks.ts | 33 ++- frontend/src/components/hosts/HostHeader.tsx | 30 ++ frontend/src/pages/Dashboard.tsx | 2 +- frontend/src/pages/Images.tsx | 139 +++++++--- frontend/src/pages/Networks.tsx | 275 ++++++++++++------- 13 files changed, 683 insertions(+), 212 deletions(-) create mode 100644 backend/services/image_service.py create mode 100644 frontend/src/components/hosts/HostHeader.tsx diff --git a/README.md b/README.md index 5a7efc3..343db8f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & -> network attach) + Phase 12 (File browser) complete. +> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks & +> images) complete. ## What works today (Phase 1) @@ -145,6 +146,20 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. container or connect any container on the host (`POST /api/networks/{id}/connect` / `/disconnect`). +### Phase 13 — Multi-host networks & images + +- **Networks and Images are now per-host.** Both pages render a section for the + local host plus one for every registered agent (online dot included), exactly + like the Stacks page. Each agent section talks to that host's Docker daemon. +- **Remote network management**: list, inspect, create, delete, prune, and + connect/disconnect containers on an agent host — including a *Prune unused* + button, which resolves the common "all predefined address pools have been + fully subnetted" deploy error without SSH. +- **Remote images**: list image tags (with using-stacks) and run on-demand update + checks per host. +- New agent endpoints `/agent/networks/*` and `/agent/images/*`, proxied through + the central app at `/api/agents/{id}/networks/*` and `/api/agents/{id}/images/*`. + ### Phase 12 — File browser - **Files page (sidebar)**: a full host filesystem browser with breadcrumb diff --git a/backend/agent_app.py b/backend/agent_app.py index 7070f79..2b14c5b 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -36,11 +36,28 @@ from pydantic import BaseModel from config import settings from docker_client import DockerError, get_client, safe_call -from services import backup_service, compose_service +from services import ( + backup_service, + compose_service, + image_service, + network_service, + update_service, +) logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.13.2" +# Map network_service's DockerError codes to HTTP status. forbidden is mapped to +# 400 (not 403) so the central proxy doesn't misread it as a token failure. +_DOCKER_STATUS = {"invalid_request": 400, "forbidden": 400, "not_found": 404} + + +def _map_docker(exc: DockerError): + code = _DOCKER_STATUS.get(exc.error) + if code: + raise HTTPException(status_code=code, detail=exc.detail or exc.error) + raise exc # falls through to the global 502 DockerError handler + +AGENT_VERSION = "0.14.0" # --------------------------------------------------------------------------- # @@ -67,6 +84,21 @@ class StackBody(BaseModel): env: str | None = None +class NetworkCreateBody(BaseModel): + name: str + driver: str = "bridge" + subnet: str | None = None + gateway: str | None = None + internal: bool = False + attachable: bool = True + + +class ContainerRefBody(BaseModel): + container: str + aliases: list[str] | None = None + force: bool = False + + # --------------------------------------------------------------------------- # # Helpers # --------------------------------------------------------------------------- # @@ -288,6 +320,92 @@ async def restore_stack( os.unlink(tmp.name) +# --------------------------------------------------------------------------- # +# Networks +# --------------------------------------------------------------------------- # + + +@app.get("/agent/networks", dependencies=[Depends(verify_token)]) +def list_networks() -> list[dict]: + return network_service.list_networks() + + +@app.get("/agent/networks/{network_id}", dependencies=[Depends(verify_token)]) +def inspect_network(network_id: str) -> dict: + try: + return network_service.inspect_network(network_id) + except DockerError as exc: + _map_docker(exc) + + +@app.get("/agent/networks/{network_id}/containers", dependencies=[Depends(verify_token)]) +def network_containers(network_id: str) -> list[dict]: + try: + return network_service.connectable_containers(network_id) + except DockerError as exc: + _map_docker(exc) + + +@app.post("/agent/networks/{network_id}/connect", dependencies=[Depends(verify_token)]) +def connect_container(network_id: str, body: ContainerRefBody) -> dict: + try: + network_service.connect_container(network_id, body.container, body.aliases) + except DockerError as exc: + _map_docker(exc) + return {"ok": True} + + +@app.post("/agent/networks/{network_id}/disconnect", dependencies=[Depends(verify_token)]) +def disconnect_container(network_id: str, body: ContainerRefBody) -> dict: + try: + network_service.disconnect_container(network_id, body.container, body.force) + except DockerError as exc: + _map_docker(exc) + return {"ok": True} + + +@app.post("/agent/networks", dependencies=[Depends(verify_token)], status_code=201) +def create_network(body: NetworkCreateBody) -> dict: + try: + return network_service.create_network(body.model_dump()) + except DockerError as exc: + _map_docker(exc) + + +@app.delete("/agent/networks/{network_id}", dependencies=[Depends(verify_token)]) +def delete_network(network_id: str) -> dict: + try: + network_service.delete_network(network_id) + except DockerError as exc: + _map_docker(exc) + return {"ok": True} + + +@app.post("/agent/networks/prune", dependencies=[Depends(verify_token)]) +def prune_networks() -> dict: + return network_service.prune_networks() + + +# --------------------------------------------------------------------------- # +# Images +# --------------------------------------------------------------------------- # + + +@app.get("/agent/images", dependencies=[Depends(verify_token)]) +def list_images() -> list[dict]: + return image_service.list_images() + + +@app.get("/agent/images/updates", dependencies=[Depends(verify_token)]) +def image_updates() -> dict: + return update_service.get_cache() + + +@app.post("/agent/images/check", dependencies=[Depends(verify_token)]) +async def image_check() -> dict: + return await update_service.check_all() + + @app.websocket("/agent/ws/logs/{stack_id}") async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)): """Stream `docker compose logs -f` to the central app (token via query param).""" diff --git a/backend/main.py b/backend/main.py index 05b7d71..4c484e8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.13.2", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.14.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/routers/agents.py b/backend/routers/agents.py index a8da495..2079756 100644 --- a/backend/routers/agents.py +++ b/backend/routers/agents.py @@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate from models.backup_destination import BackupDestination from models.stack import StackCreate, StackUpdate from models.user import User +from routers.networks import ContainerRef, NetworkCreate from services import ( agent_service, audit_service, @@ -473,3 +474,171 @@ async def agent_restore_from( ip=_ip(request), ) return result + + +# --------------------------------------------------------------------------- # +# Networks (proxied) +# --------------------------------------------------------------------------- # + + +@router.get("/{agent_id}/networks") +async def agent_networks( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/networks") or [] + + +@router.get("/{agent_id}/networks/{network_id}") +async def agent_network_inspect( + agent_id: int, + network_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}") + + +@router.get("/{agent_id}/networks/{network_id}/containers") +async def agent_network_containers( + agent_id: int, + network_id: str, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", f"/agent/networks/{network_id}/containers") or [] + + +@router.post("/{agent_id}/networks/prune") +async def agent_network_prune( + agent_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/networks/prune") + audit_service.record( + session, user=user.username, action="agent.network.prune", target=agent.name, + detail=str(result.get("NetworksDeleted") or []), ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/networks", status_code=201) +async def agent_network_create( + agent_id: int, + body: NetworkCreate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/networks", json=body.model_dump()) + audit_service.record( + session, user=user.username, action="agent.network.create", + target=f"{agent.name}/{body.name}", ip=_ip(request), + ) + return result + + +@router.delete("/{agent_id}/networks/{network_id}") +async def agent_network_delete( + agent_id: int, + network_id: str, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "DELETE", f"/agent/networks/{network_id}") + audit_service.record( + session, user=user.username, action="agent.network.delete", + target=f"{agent.name}/{network_id}", ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/networks/{network_id}/connect") +async def agent_network_connect( + agent_id: int, + network_id: str, + body: ContainerRef, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "POST", f"/agent/networks/{network_id}/connect", json=body.model_dump() + ) + audit_service.record( + session, user=user.username, action="agent.network.connect", + target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request), + ) + return result + + +@router.post("/{agent_id}/networks/{network_id}/disconnect") +async def agent_network_disconnect( + agent_id: int, + network_id: str, + body: ContainerRef, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy( + session, agent, "POST", f"/agent/networks/{network_id}/disconnect", json=body.model_dump() + ) + audit_service.record( + session, user=user.username, action="agent.network.disconnect", + target=f"{agent.name}/{network_id}", detail=body.container, ip=_ip(request), + ) + return result + + +# --------------------------------------------------------------------------- # +# Images (proxied) +# --------------------------------------------------------------------------- # + + +@router.get("/{agent_id}/images") +async def agent_images( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> list[dict]: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/images") or [] + + +@router.get("/{agent_id}/images/updates") +async def agent_image_updates( + agent_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + agent = _get_or_404(session, agent_id) + return await _proxy(session, agent, "GET", "/agent/images/updates") + + +@router.post("/{agent_id}/images/check") +async def agent_image_check( + agent_id: int, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + agent = _get_or_404(session, agent_id) + result = await _proxy(session, agent, "POST", "/agent/images/check") + audit_service.record( + session, user=user.username, action="agent.image.check", target=agent.name, + ip=_ip(request), + ) + return result diff --git a/backend/routers/images.py b/backend/routers/images.py index 9d5e347..5624976 100644 --- a/backend/routers/images.py +++ b/backend/routers/images.py @@ -4,54 +4,15 @@ from __future__ import annotations from fastapi import APIRouter, Depends from auth import get_current_user, require_admin -from docker_client import DockerError, get_client, safe_call from models.user import User -from services import update_service +from services import image_service, update_service router = APIRouter(prefix="/api/images", tags=["images"]) -COMPOSE_PROJECT_LABEL = "com.docker.compose.project" - @router.get("") def list_images(_user: User = Depends(get_current_user)) -> list[dict]: - try: - client = get_client() - images = safe_call(client.images.list) - containers = safe_call(client.containers.list, all=True) - except DockerError: - return [] - - # Map image ref -> stacks using it. - usage: dict[str, set[str]] = {} - for c in containers: - ref = c.attrs.get("Config", {}).get("Image") - stack = c.labels.get(COMPOSE_PROJECT_LABEL) - if ref: - usage.setdefault(ref, set()) - if stack: - usage[ref].add(stack) - - cache = update_service.get_cache() - result = [] - for img in images: - tags = img.tags or [] - if not tags: - continue - for tag in tags: - upd = cache.get(tag) - result.append( - { - "id": img.short_id, - "tag": tag, - "size": img.attrs.get("Size", 0), - "created": img.attrs.get("Created"), - "stacks": sorted(usage.get(tag, set())), - "update": upd, - } - ) - result.sort(key=lambda r: r["tag"]) - return result + return image_service.list_images() @router.get("/updates") diff --git a/backend/services/image_service.py b/backend/services/image_service.py new file mode 100644 index 0000000..fe94d32 --- /dev/null +++ b/backend/services/image_service.py @@ -0,0 +1,47 @@ +"""Image listing — shared by the central images router and the agent.""" +from __future__ import annotations + +from docker_client import DockerError, get_client, safe_call +from services import update_service + +COMPOSE_PROJECT_LABEL = "com.docker.compose.project" + + +def list_images() -> list[dict]: + """Return one row per image tag, annotated with using-stacks + update status.""" + try: + client = get_client() + images = safe_call(client.images.list) + containers = safe_call(client.containers.list, all=True) + except DockerError: + return [] + + # Map image ref -> stacks using it. + usage: dict[str, set[str]] = {} + for c in containers: + ref = c.attrs.get("Config", {}).get("Image") + stack = c.labels.get(COMPOSE_PROJECT_LABEL) + if ref: + usage.setdefault(ref, set()) + if stack: + usage[ref].add(stack) + + cache = update_service.get_cache() + result = [] + for img in images: + tags = img.tags or [] + if not tags: + continue + for tag in tags: + result.append( + { + "id": img.short_id, + "tag": tag, + "size": img.attrs.get("Size", 0), + "created": img.attrs.get("Created"), + "stacks": sorted(usage.get(tag, set())), + "update": cache.get(tag), + } + ) + result.sort(key=lambda r: r["tag"]) + return result diff --git a/frontend/package.json b/frontend/package.json index c0bf660..707e6ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.13.2", + "version": "0.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/images.ts b/frontend/src/api/images.ts index 058f460..2bae1e6 100644 --- a/frontend/src/api/images.ts +++ b/frontend/src/api/images.ts @@ -18,10 +18,14 @@ export interface ImageRow { update: UpdateStatus | null; } +// Base path for the local host or, when agentId is given, a remote agent. +const base = (agentId?: number) => + agentId != null ? `/api/agents/${agentId}/images` : "/api/images"; + export const imagesApi = { - list: () => api.get("/api/images").then((r) => r.data), - updates: () => - api.get>("/api/images/updates").then((r) => r.data), - check: () => - api.post>("/api/images/check").then((r) => r.data), + list: (agentId?: number) => api.get(base(agentId)).then((r) => r.data), + updates: (agentId?: number) => + api.get>(`${base(agentId)}/updates`).then((r) => r.data), + check: (agentId?: number) => + api.post>(`${base(agentId)}/check`).then((r) => r.data), }; diff --git a/frontend/src/api/networks.ts b/frontend/src/api/networks.ts index 5b3eef5..90c5fd2 100644 --- a/frontend/src/api/networks.ts +++ b/frontend/src/api/networks.ts @@ -34,18 +34,25 @@ export interface NetworkContainer { connected: boolean; } +// Base path for the local host or, when agentId is given, a remote agent. +const base = (agentId?: number) => + agentId != null ? `/api/agents/${agentId}/networks` : "/api/networks"; + export const networksApi = { - list: () => api.get("/api/networks").then((r) => r.data), - inspect: (id: string) => api.get(`/api/networks/${id}`).then((r) => r.data), - containers: (id: string) => - api.get(`/api/networks/${id}/containers`).then((r) => r.data), - create: (body: NetworkCreate) => - api.post("/api/networks", body).then((r) => r.data), - connect: (id: string, container: string, aliases?: string[]) => - api.post(`/api/networks/${id}/connect`, { container, aliases }).then((r) => r.data), - disconnect: (id: string, container: string, force = false) => - api.post(`/api/networks/${id}/disconnect`, { container, force }).then((r) => r.data), - remove: (id: string) => api.delete(`/api/networks/${id}`).then((r) => r.data), - prune: () => - api.post<{ NetworksDeleted: string[] | null }>("/api/networks/prune").then((r) => r.data), + list: (agentId?: number) => + api.get(base(agentId)).then((r) => r.data), + inspect: (id: string, agentId?: number) => + api.get(`${base(agentId)}/${id}`).then((r) => r.data), + containers: (id: string, agentId?: number) => + api.get(`${base(agentId)}/${id}/containers`).then((r) => r.data), + create: (body: NetworkCreate, agentId?: number) => + api.post(base(agentId), body).then((r) => r.data), + connect: (id: string, container: string, aliases?: string[], agentId?: number) => + api.post(`${base(agentId)}/${id}/connect`, { container, aliases }).then((r) => r.data), + disconnect: (id: string, container: string, force = false, agentId?: number) => + api.post(`${base(agentId)}/${id}/disconnect`, { container, force }).then((r) => r.data), + remove: (id: string, agentId?: number) => + api.delete(`${base(agentId)}/${id}`).then((r) => r.data), + prune: (agentId?: number) => + api.post<{ NetworksDeleted: string[] | null }>(`${base(agentId)}/prune`).then((r) => r.data), }; diff --git a/frontend/src/components/hosts/HostHeader.tsx b/frontend/src/components/hosts/HostHeader.tsx new file mode 100644 index 0000000..2cf81fe --- /dev/null +++ b/frontend/src/components/hosts/HostHeader.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { HardDrive, Server } from "lucide-react"; +import { HostDot } from "@/components/hosts/HostDot"; +import type { Agent } from "@/types"; + +/** + * Section header for a host (local or a remote agent). `children` is rendered + * on the right for per-host action buttons. + */ +export function HostHeader({ + agent, + children, +}: { + agent?: Agent; + children?: ReactNode; +}) { + return ( +
+

+ {agent ? : } + {agent ? agent.name : "This host"} + {agent && } + {agent?.hostname && ( + {agent.hostname} + )} +

+
{children}
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index dbcea0a..b8f527a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -17,7 +17,7 @@ export function Dashboard() { const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 }); const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); - const updates = useQuery({ queryKey: ["image-updates"], queryFn: imagesApi.updates, refetchInterval: 60000 }); + const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 }); const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length; diff --git a/frontend/src/pages/Images.tsx b/frontend/src/pages/Images.tsx index 7e0f90e..a0b825e 100644 --- a/frontend/src/pages/Images.tsx +++ b/frontend/src/pages/Images.tsx @@ -1,12 +1,15 @@ +import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react"; +import { toast } from "sonner"; import { Button, Card, Spinner } from "@/components/ui"; +import { HostHeader } from "@/components/hosts/HostHeader"; import { imagesApi, type ImageRow } from "@/api/images"; +import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { formatBytes, relativeTime } from "@/lib/utils"; import { useAuthStore } from "@/store/auth"; -import { useState } from "react"; -import { toast } from "sonner"; +import type { Agent } from "@/types"; function UpdateBadge({ row }: { row: ImageRow }) { const u = row.update; @@ -27,16 +30,48 @@ function UpdateBadge({ row }: { row: ImageRow }) { export function Images() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const agents = useQuery({ + queryKey: ["agents"], + queryFn: () => agentsApi.list(), + refetchInterval: 15000, + }); + const hasAgents = (agents.data?.length ?? 0) > 0; + + return ( +
+ + {agents.data?.map((agent) => ( + + ))} +
+ ); +} + +function ImagesSection({ + agent, + isAdmin, + showHostLabel, +}: { + agent?: Agent; + isAdmin: boolean; + showHostLabel: boolean; +}) { + const agentId = agent?.id; + const online = !agent || agent.status === "online"; const qc = useQueryClient(); const [checking, setChecking] = useState(false); - const { data, isLoading } = useQuery({ queryKey: ["images"], queryFn: imagesApi.list }); + const { data, isLoading } = useQuery({ + queryKey: ["images", agentId ?? "local"], + queryFn: () => imagesApi.list(agentId), + enabled: online, + }); const check = async () => { setChecking(true); const t = toast.loading("Checking for updates…"); try { - await imagesApi.check(); - await qc.invalidateQueries({ queryKey: ["images"] }); + await imagesApi.check(agentId); + await qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] }); toast.success("Update check complete", { id: t }); } catch (e) { toast.error(apiErrorMessage(e), { id: t }); @@ -45,47 +80,63 @@ export function Images() { } }; - if (isLoading) return ; - return ( -
-
-

{data?.length ?? 0} image tags

- {isAdmin && ( - - )} -
+
+ {(showHostLabel || (isAdmin && online)) && ( + + {isAdmin && online && ( + + )} + + )} - - - - - - - - - - - - - {data?.map((row) => ( - - - - - - + {!online ? ( + +

+ Host is {agent?.status}. Check it under Settings → Remote hosts. +

+
+ ) : isLoading ? ( + + ) : ( + +
ImageUsed bySizeCreatedStatus
{row.tag} - {row.stacks.length ? row.stacks.join(", ") : "—"} - {formatBytes(row.size)} - {row.created ? relativeTime(row.created) : "—"} -
+ + + + + + + - ))} - -
ImageUsed bySizeCreatedStatus
-
-
+ + + {data?.map((row) => ( + + {row.tag} + + {row.stacks.length ? row.stacks.join(", ") : "—"} + + {formatBytes(row.size)} + + {row.created ? relativeTime(row.created) : "—"} + + + + ))} + {data?.length === 0 && ( + + + No images. + + + )} + + + + )} + ); } diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index da1f057..f3ff570 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -13,29 +13,61 @@ import { import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { HostHeader } from "@/components/hosts/HostHeader"; import { networksApi, type NetworkInfo } from "@/api/networks"; +import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; +import type { Agent } from "@/types"; const selectClass = "w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"; export function Networks() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const agents = useQuery({ + queryKey: ["agents"], + queryFn: () => agentsApi.list(), + refetchInterval: 15000, + }); + const hasAgents = (agents.data?.length ?? 0) > 0; + + return ( +
+ + {agents.data?.map((agent) => ( + + ))} +
+ ); +} + +function NetworksSection({ + agent, + isAdmin, + showHostLabel, +}: { + agent?: Agent; + isAdmin: boolean; + showHostLabel: boolean; +}) { + const agentId = agent?.id; + const online = !agent || agent.status === "online"; const qc = useQueryClient(); const { data, isLoading } = useQuery({ - queryKey: ["networks"], - queryFn: networksApi.list, + queryKey: ["networks", agentId ?? "local"], + queryFn: () => networksApi.list(agentId), refetchInterval: 10000, + enabled: online, }); const [creating, setCreating] = useState(false); const [toDelete, setToDelete] = useState(null); const [expanded, setExpanded] = useState(null); - const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); + const invalidate = () => qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] }); const colSpan = isAdmin ? 6 : 5; const prune = useMutation({ - mutationFn: networksApi.prune, + mutationFn: () => networksApi.prune(agentId), onSuccess: (r) => { const n = r.NetworksDeleted?.length ?? 0; toast.success(n ? `Pruned ${n} network(s)` : "No unused networks"); @@ -44,7 +76,7 @@ export function Networks() { onError: (e) => toast.error(apiErrorMessage(e)), }); const remove = useMutation({ - mutationFn: (id: string) => networksApi.remove(id), + mutationFn: (id: string) => networksApi.remove(id, agentId), onSuccess: () => { toast.success("Network deleted"); setToDelete(null); @@ -53,102 +85,120 @@ export function Networks() { onError: (e) => toast.error(apiErrorMessage(e)), }); - if (isLoading) return ; - - return ( -
- {isAdmin && ( -
+ const header = ( + + {isAdmin && online && ( + <> -
+ )} + + ); - - - - - - - - - - {isAdmin && } - - - - {data?.map((n) => ( - - setExpanded((e) => (e === n.id ? null : n.id))} - className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" - > - - - - - - {isAdmin && ( - - )} + return ( +
+ {(showHostLabel || (isAdmin && online)) && header} + + {!online ? ( + +

+ Host is {agent?.status}. Check it under Settings → Remote hosts. +

+
+ ) : isLoading ? ( + + ) : ( + +
NameDriverScopeSubnetIn use
-
- {expanded === n.id ? ( - - ) : ( - - )} - - {n.name} - {n.is_default && default} - {n.stack && {n.stack}} - {n.internal && internal} -
-
{n.driver}{n.scope}{n.subnet ?? "—"} - {n.in_use ? ( - - {n.containers.length} container{n.containers.length > 1 ? "s" : ""} - - ) : ( - - )} - - {!n.is_default && ( - - )} -
+ + + + + + + + {isAdmin && } - {expanded === n.id && ( + + + {data?.map((n) => ( + + setExpanded((e) => (e === n.id ? null : n.id))} + className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" + > + + + + + + {isAdmin && ( + + )} + + {expanded === n.id && ( + + + + )} + + ))} + {data?.length === 0 && ( - )} - - ))} - {data?.length === 0 && ( - - - - )} - -
NameDriverScopeSubnetIn use
+
+ {expanded === n.id ? ( + + ) : ( + + )} + + {n.name} + {n.is_default && default} + {n.stack && {n.stack}} + {n.internal && internal} +
+
{n.driver}{n.scope}{n.subnet ?? "—"} + {n.in_use ? ( + + {n.containers.length} container{n.containers.length > 1 ? "s" : ""} + + ) : ( + + )} + + {!n.is_default && ( + + )} +
+ +
- + + No networks.
- No networks. -
-
+ + + + )} {creating && ( - { setCreating(false); invalidate(); }} onCancel={() => setCreating(false)} /> + { setCreating(false); invalidate(); }} + onCancel={() => setCreating(false)} + /> )} {toDelete && ( setToDelete(null)} /> )} -
+ ); } -function NetworkDetail({ network, isAdmin }: { network: NetworkInfo; isAdmin: boolean }) { +function NetworkDetail({ + network, + isAdmin, + agentId, +}: { + network: NetworkInfo; + isAdmin: boolean; + agentId?: number; +}) { const qc = useQueryClient(); const [pick, setPick] = useState(""); const { data, isLoading } = useQuery({ - queryKey: ["network-containers", network.id], - queryFn: () => networksApi.containers(network.id), + queryKey: ["network-containers", agentId ?? "local", network.id], + queryFn: () => networksApi.containers(network.id, agentId), refetchInterval: 10000, }); const refresh = () => { - qc.invalidateQueries({ queryKey: ["network-containers", network.id] }); - qc.invalidateQueries({ queryKey: ["networks"] }); + qc.invalidateQueries({ queryKey: ["network-containers", agentId ?? "local", network.id] }); + qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] }); }; const connect = useMutation({ - mutationFn: (container: string) => networksApi.connect(network.id, container), + mutationFn: (container: string) => networksApi.connect(network.id, container, undefined, agentId), onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); }, onError: (e) => toast.error(apiErrorMessage(e)), }); const disconnect = useMutation({ - mutationFn: (container: string) => networksApi.disconnect(network.id, container), + mutationFn: (container: string) => networksApi.disconnect(network.id, container, false, agentId), onSuccess: () => { toast.success("Container disconnected"); refresh(); }, onError: (e) => toast.error(apiErrorMessage(e)), }); @@ -277,7 +335,15 @@ function Meta({ label, value, mono }: { label: string; value: string; mono?: boo ); } -function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { +function CreateNetworkDialog({ + agentId, + onDone, + onCancel, +}: { + agentId?: number; + onDone: () => void; + onCancel: () => void; +}) { const [form, setForm] = useState({ name: "", driver: "bridge", @@ -290,14 +356,17 @@ function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCance const create = useMutation({ mutationFn: () => - networksApi.create({ - name: form.name, - driver: form.driver, - subnet: form.subnet.trim() || null, - gateway: form.gateway.trim() || null, - internal: form.internal, - attachable: form.attachable, - }), + networksApi.create( + { + name: form.name, + driver: form.driver, + subnet: form.subnet.trim() || null, + gateway: form.gateway.trim() || null, + internal: form.internal, + attachable: form.attachable, + }, + agentId + ), onSuccess: () => { toast.success("Network created"); onDone(); }, onError: (e) => toast.error(apiErrorMessage(e)), });