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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4bc0fb8901
commit
012614f5fb
@@ -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
|
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup
|
||||||
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
||||||
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
|
> + 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)
|
## 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`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/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
|
### Phase 12 — File browser
|
||||||
|
|
||||||
- **Files page (sidebar)**: a full host filesystem browser with breadcrumb
|
- **Files page (sidebar)**: a full host filesystem browser with breadcrumb
|
||||||
|
|||||||
+120
-2
@@ -36,11 +36,28 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from docker_client import DockerError, get_client, safe_call
|
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")
|
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
|
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
|
# Helpers
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -288,6 +320,92 @@ async def restore_stack(
|
|||||||
os.unlink(tmp.name)
|
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}")
|
@app.websocket("/agent/ws/logs/{stack_id}")
|
||||||
async def ws_logs(websocket: WebSocket, stack_id: str, token: str | None = Query(default=None)):
|
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)."""
|
"""Stream `docker compose logs -f` to the central app (token via query param)."""
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
|
|||||||
from models.backup_destination import BackupDestination
|
from models.backup_destination import BackupDestination
|
||||||
from models.stack import StackCreate, StackUpdate
|
from models.stack import StackCreate, StackUpdate
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
from routers.networks import ContainerRef, NetworkCreate
|
||||||
from services import (
|
from services import (
|
||||||
agent_service,
|
agent_service,
|
||||||
audit_service,
|
audit_service,
|
||||||
@@ -473,3 +474,171 @@ async def agent_restore_from(
|
|||||||
ip=_ip(request),
|
ip=_ip(request),
|
||||||
)
|
)
|
||||||
return result
|
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
|
||||||
|
|||||||
@@ -4,54 +4,15 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from auth import get_current_user, require_admin
|
from auth import get_current_user, require_admin
|
||||||
from docker_client import DockerError, get_client, safe_call
|
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import update_service
|
from services import image_service, update_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/images", tags=["images"])
|
router = APIRouter(prefix="/api/images", tags=["images"])
|
||||||
|
|
||||||
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
def list_images(_user: User = Depends(get_current_user)) -> list[dict]:
|
||||||
try:
|
return image_service.list_images()
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/updates")
|
@router.get("/updates")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.13.2",
|
"version": "0.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -18,10 +18,14 @@ export interface ImageRow {
|
|||||||
update: UpdateStatus | null;
|
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 = {
|
export const imagesApi = {
|
||||||
list: () => api.get<ImageRow[]>("/api/images").then((r) => r.data),
|
list: (agentId?: number) => api.get<ImageRow[]>(base(agentId)).then((r) => r.data),
|
||||||
updates: () =>
|
updates: (agentId?: number) =>
|
||||||
api.get<Record<string, UpdateStatus>>("/api/images/updates").then((r) => r.data),
|
api.get<Record<string, UpdateStatus>>(`${base(agentId)}/updates`).then((r) => r.data),
|
||||||
check: () =>
|
check: (agentId?: number) =>
|
||||||
api.post<Record<string, UpdateStatus>>("/api/images/check").then((r) => r.data),
|
api.post<Record<string, UpdateStatus>>(`${base(agentId)}/check`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,18 +34,25 @@ export interface NetworkContainer {
|
|||||||
connected: boolean;
|
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 = {
|
export const networksApi = {
|
||||||
list: () => api.get<NetworkInfo[]>("/api/networks").then((r) => r.data),
|
list: (agentId?: number) =>
|
||||||
inspect: (id: string) => api.get<NetworkInfo>(`/api/networks/${id}`).then((r) => r.data),
|
api.get<NetworkInfo[]>(base(agentId)).then((r) => r.data),
|
||||||
containers: (id: string) =>
|
inspect: (id: string, agentId?: number) =>
|
||||||
api.get<NetworkContainer[]>(`/api/networks/${id}/containers`).then((r) => r.data),
|
api.get<NetworkInfo>(`${base(agentId)}/${id}`).then((r) => r.data),
|
||||||
create: (body: NetworkCreate) =>
|
containers: (id: string, agentId?: number) =>
|
||||||
api.post<NetworkInfo>("/api/networks", body).then((r) => r.data),
|
api.get<NetworkContainer[]>(`${base(agentId)}/${id}/containers`).then((r) => r.data),
|
||||||
connect: (id: string, container: string, aliases?: string[]) =>
|
create: (body: NetworkCreate, agentId?: number) =>
|
||||||
api.post(`/api/networks/${id}/connect`, { container, aliases }).then((r) => r.data),
|
api.post<NetworkInfo>(base(agentId), body).then((r) => r.data),
|
||||||
disconnect: (id: string, container: string, force = false) =>
|
connect: (id: string, container: string, aliases?: string[], agentId?: number) =>
|
||||||
api.post(`/api/networks/${id}/disconnect`, { container, force }).then((r) => r.data),
|
api.post(`${base(agentId)}/${id}/connect`, { container, aliases }).then((r) => r.data),
|
||||||
remove: (id: string) => api.delete(`/api/networks/${id}`).then((r) => r.data),
|
disconnect: (id: string, container: string, force = false, agentId?: number) =>
|
||||||
prune: () =>
|
api.post(`${base(agentId)}/${id}/disconnect`, { container, force }).then((r) => r.data),
|
||||||
api.post<{ NetworksDeleted: string[] | null }>("/api/networks/prune").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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
{agent ? <Server className="h-4 w-4" /> : <HardDrive className="h-4 w-4" />}
|
||||||
|
{agent ? agent.name : "This host"}
|
||||||
|
{agent && <HostDot status={agent.status} />}
|
||||||
|
{agent?.hostname && (
|
||||||
|
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export function Dashboard() {
|
|||||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||||
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
||||||
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
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;
|
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
import { RefreshCw, ArrowUpCircle, CheckCircle2, HelpCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button, Card, Spinner } from "@/components/ui";
|
import { Button, Card, Spinner } from "@/components/ui";
|
||||||
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
import { imagesApi, type ImageRow } from "@/api/images";
|
import { imagesApi, type ImageRow } from "@/api/images";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { formatBytes, relativeTime } from "@/lib/utils";
|
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useState } from "react";
|
import type { Agent } from "@/types";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
function UpdateBadge({ row }: { row: ImageRow }) {
|
function UpdateBadge({ row }: { row: ImageRow }) {
|
||||||
const u = row.update;
|
const u = row.update;
|
||||||
@@ -27,16 +30,48 @@ function UpdateBadge({ row }: { row: ImageRow }) {
|
|||||||
|
|
||||||
export function Images() {
|
export function Images() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<ImagesSection isAdmin={isAdmin} showHostLabel={hasAgents} />
|
||||||
|
{agents.data?.map((agent) => (
|
||||||
|
<ImagesSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 qc = useQueryClient();
|
||||||
const [checking, setChecking] = useState(false);
|
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 () => {
|
const check = async () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
const t = toast.loading("Checking for updates…");
|
const t = toast.loading("Checking for updates…");
|
||||||
try {
|
try {
|
||||||
await imagesApi.check();
|
await imagesApi.check(agentId);
|
||||||
await qc.invalidateQueries({ queryKey: ["images"] });
|
await qc.invalidateQueries({ queryKey: ["images", agentId ?? "local"] });
|
||||||
toast.success("Update check complete", { id: t });
|
toast.success("Update check complete", { id: t });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(apiErrorMessage(e), { id: t });
|
toast.error(apiErrorMessage(e), { id: t });
|
||||||
@@ -45,47 +80,63 @@ export function Images() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<section>
|
||||||
<div className="flex items-center justify-between">
|
{(showHostLabel || (isAdmin && online)) && (
|
||||||
<p className="text-sm text-slate-500">{data?.length ?? 0} image tags</p>
|
<HostHeader agent={agent}>
|
||||||
{isAdmin && (
|
{isAdmin && online && (
|
||||||
<Button onClick={check} loading={checking}>
|
<Button onClick={check} loading={checking}>
|
||||||
<RefreshCw className="h-4 w-4" /> Check updates
|
<RefreshCw className="h-4 w-4" /> Check updates
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</HostHeader>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="overflow-x-auto p-0">
|
{!online ? (
|
||||||
<table className="w-full text-sm">
|
<Card>
|
||||||
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
<p className="text-sm text-slate-500">
|
||||||
<tr>
|
Host is {agent?.status}. Check it under Settings → Remote hosts.
|
||||||
<th className="p-3">Image</th>
|
</p>
|
||||||
<th className="p-3">Used by</th>
|
</Card>
|
||||||
<th className="p-3">Size</th>
|
) : isLoading ? (
|
||||||
<th className="p-3">Created</th>
|
<Spinner />
|
||||||
<th className="p-3">Status</th>
|
) : (
|
||||||
</tr>
|
<Card className="overflow-x-auto p-0">
|
||||||
</thead>
|
<table className="w-full text-sm">
|
||||||
<tbody>
|
<thead className="border-b border-slate-200 text-left text-xs text-slate-500 dark:border-slate-700">
|
||||||
{data?.map((row) => (
|
<tr>
|
||||||
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
<th className="p-3">Image</th>
|
||||||
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
<th className="p-3">Used by</th>
|
||||||
<td className="p-3 text-xs text-slate-500">
|
<th className="p-3">Size</th>
|
||||||
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
<th className="p-3">Created</th>
|
||||||
</td>
|
<th className="p-3">Status</th>
|
||||||
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
|
||||||
<td className="p-3 text-xs text-slate-500">
|
|
||||||
{row.created ? relativeTime(row.created) : "—"}
|
|
||||||
</td>
|
|
||||||
<td className="p-3"><UpdateBadge row={row} /></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{data?.map((row) => (
|
||||||
</Card>
|
<tr key={row.tag} className="border-b border-slate-100 dark:border-slate-700/50">
|
||||||
</div>
|
<td className="p-3 font-mono text-xs">{row.tag}</td>
|
||||||
|
<td className="p-3 text-xs text-slate-500">
|
||||||
|
{row.stacks.length ? row.stacks.join(", ") : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-xs">{formatBytes(row.size)}</td>
|
||||||
|
<td className="p-3 text-xs text-slate-500">
|
||||||
|
{row.created ? relativeTime(row.created) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="p-3"><UpdateBadge row={row} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-6 text-center text-sm text-slate-500">
|
||||||
|
No images.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-103
@@ -13,29 +13,61 @@ import {
|
|||||||
import { toast } from "sonner";
|
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 { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
import { networksApi, type NetworkInfo } from "@/api/networks";
|
import { networksApi, type NetworkInfo } from "@/api/networks";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import type { Agent } from "@/types";
|
||||||
|
|
||||||
const selectClass =
|
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";
|
"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() {
|
export function Networks() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<NetworksSection isAdmin={isAdmin} showHostLabel={hasAgents} />
|
||||||
|
{agents.data?.map((agent) => (
|
||||||
|
<NetworksSection key={agent.id} agent={agent} isAdmin={isAdmin} showHostLabel />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 qc = useQueryClient();
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["networks"],
|
queryKey: ["networks", agentId ?? "local"],
|
||||||
queryFn: networksApi.list,
|
queryFn: () => networksApi.list(agentId),
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
|
enabled: online,
|
||||||
});
|
});
|
||||||
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 invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
|
const invalidate = () => qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
|
||||||
const colSpan = isAdmin ? 6 : 5;
|
const colSpan = isAdmin ? 6 : 5;
|
||||||
|
|
||||||
const prune = useMutation({
|
const prune = useMutation({
|
||||||
mutationFn: networksApi.prune,
|
mutationFn: () => networksApi.prune(agentId),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
const n = r.NetworksDeleted?.length ?? 0;
|
const n = r.NetworksDeleted?.length ?? 0;
|
||||||
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
|
toast.success(n ? `Pruned ${n} network(s)` : "No unused networks");
|
||||||
@@ -44,7 +76,7 @@ export function Networks() {
|
|||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
});
|
});
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => networksApi.remove(id),
|
mutationFn: (id: string) => networksApi.remove(id, agentId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Network deleted");
|
toast.success("Network deleted");
|
||||||
setToDelete(null);
|
setToDelete(null);
|
||||||
@@ -53,102 +85,120 @@ export function Networks() {
|
|||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
const header = (
|
||||||
|
<HostHeader agent={agent}>
|
||||||
return (
|
{isAdmin && online && (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
|
||||||
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
<Button variant="outline" onClick={() => prune.mutate()} loading={prune.isPending}>
|
||||||
<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)}>
|
||||||
<Plus className="h-4 w-4" /> Create network
|
<Plus className="h-4 w-4" /> Create network
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</HostHeader>
|
||||||
|
);
|
||||||
|
|
||||||
<Card className="overflow-x-auto p-0">
|
return (
|
||||||
<table className="w-full text-left text-sm">
|
<section>
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
{(showHostLabel || (isAdmin && online)) && header}
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-2">Name</th>
|
{!online ? (
|
||||||
<th className="px-4 py-2">Driver</th>
|
<Card>
|
||||||
<th className="px-4 py-2">Scope</th>
|
<p className="text-sm text-slate-500">
|
||||||
<th className="px-4 py-2">Subnet</th>
|
Host is {agent?.status}. Check it under Settings → Remote hosts.
|
||||||
<th className="px-4 py-2">In use</th>
|
</p>
|
||||||
{isAdmin && <th className="px-4 py-2"></th>}
|
</Card>
|
||||||
</tr>
|
) : isLoading ? (
|
||||||
</thead>
|
<Spinner />
|
||||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
) : (
|
||||||
{data?.map((n) => (
|
<Card className="overflow-x-auto p-0">
|
||||||
<Fragment key={n.id}>
|
<table className="w-full text-left text-sm">
|
||||||
<tr
|
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
||||||
onClick={() => setExpanded((e) => (e === n.id ? null : n.id))}
|
<tr>
|
||||||
className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50"
|
<th className="px-4 py-2">Name</th>
|
||||||
>
|
<th className="px-4 py-2">Driver</th>
|
||||||
<td className="px-4 py-2">
|
<th className="px-4 py-2">Scope</th>
|
||||||
<div className="flex items-center gap-2">
|
<th className="px-4 py-2">Subnet</th>
|
||||||
{expanded === n.id ? (
|
<th className="px-4 py-2">In use</th>
|
||||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
{isAdmin && <th className="px-4 py-2"></th>}
|
||||||
) : (
|
|
||||||
<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>
|
</tr>
|
||||||
{expanded === n.id && (
|
</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} agentId={agentId} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{data?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={colSpan} className="bg-slate-50 px-4 py-3 dark:bg-slate-800/40">
|
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
|
||||||
<NetworkDetail network={n} isAdmin={isAdmin} />
|
No networks.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
{data?.length === 0 && (
|
</Card>
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
|
|
||||||
No networks.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<CreateNetworkDialog onDone={() => { setCreating(false); invalidate(); }} onCancel={() => setCreating(false)} />
|
<CreateNetworkDialog
|
||||||
|
agentId={agentId}
|
||||||
|
onDone={() => { setCreating(false); invalidate(); }}
|
||||||
|
onCancel={() => setCreating(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{toDelete && (
|
{toDelete && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -165,30 +215,38 @@ export function Networks() {
|
|||||||
onCancel={() => setToDelete(null)}
|
onCancel={() => setToDelete(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NetworkDetail({ network, isAdmin }: { network: NetworkInfo; isAdmin: boolean }) {
|
function NetworkDetail({
|
||||||
|
network,
|
||||||
|
isAdmin,
|
||||||
|
agentId,
|
||||||
|
}: {
|
||||||
|
network: NetworkInfo;
|
||||||
|
isAdmin: boolean;
|
||||||
|
agentId?: number;
|
||||||
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [pick, setPick] = useState("");
|
const [pick, setPick] = useState("");
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["network-containers", network.id],
|
queryKey: ["network-containers", agentId ?? "local", network.id],
|
||||||
queryFn: () => networksApi.containers(network.id),
|
queryFn: () => networksApi.containers(network.id, agentId),
|
||||||
refetchInterval: 10000,
|
refetchInterval: 10000,
|
||||||
});
|
});
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
|
qc.invalidateQueries({ queryKey: ["network-containers", agentId ?? "local", network.id] });
|
||||||
qc.invalidateQueries({ queryKey: ["networks"] });
|
qc.invalidateQueries({ queryKey: ["networks", agentId ?? "local"] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const connect = useMutation({
|
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(); },
|
onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); },
|
||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
});
|
});
|
||||||
const disconnect = useMutation({
|
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(); },
|
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
|
||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
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({
|
const [form, setForm] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
driver: "bridge",
|
driver: "bridge",
|
||||||
@@ -290,14 +356,17 @@ function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCance
|
|||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
networksApi.create({
|
networksApi.create(
|
||||||
name: form.name,
|
{
|
||||||
driver: form.driver,
|
name: form.name,
|
||||||
subnet: form.subnet.trim() || null,
|
driver: form.driver,
|
||||||
gateway: form.gateway.trim() || null,
|
subnet: form.subnet.trim() || null,
|
||||||
internal: form.internal,
|
gateway: form.gateway.trim() || null,
|
||||||
attachable: form.attachable,
|
internal: form.internal,
|
||||||
}),
|
attachable: form.attachable,
|
||||||
|
},
|
||||||
|
agentId
|
||||||
|
),
|
||||||
onSuccess: () => { toast.success("Network created"); onDone(); },
|
onSuccess: () => { toast.success("Network created"); onDone(); },
|
||||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user