From 1931500c24260fd5216fafbba21c8e9e8f96e975 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 07:49:20 +0000 Subject: [PATCH] Phase 11: remote UX & network attach (0.11.0) - Live remote-stack logs over a WebSocket proxied through the central app to the agent (/ws/agent-logs/{agent}/{stack}); agent gains a WS log endpoint. - Deploy to a remote host from the UI: host selector in the New Stack editor and template dialog; templates instantiate onto an agent via the proxy. - Network attach/detach: expandable inspect view per network with connect/disconnect + container picker; GET /{id}/containers, POST connect/disconnect. - Remove dead pages/Placeholder.tsx. Co-Authored-By: Claude Opus 4.8 --- README.md | 25 +++- backend/agent_app.py | 45 +++++- backend/main.py | 2 +- backend/models/template.py | 1 + backend/routers/networks.py | 59 +++++++- backend/routers/templates.py | 31 +++- backend/routers/ws.py | 47 ++++++ backend/services/network_service.py | 37 +++++ frontend/package.json | 2 +- frontend/src/api/agents.ts | 4 + frontend/src/api/networks.ts | 15 ++ frontend/src/api/templates.ts | 15 +- frontend/src/components/stacks/LogViewer.tsx | 10 +- frontend/src/pages/Networks.tsx | 146 ++++++++++++++++++- frontend/src/pages/Placeholder.tsx | 17 --- frontend/src/pages/RemoteStackDetail.tsx | 31 +--- frontend/src/pages/StackEditor.tsx | 45 ++++++ frontend/src/pages/Templates.tsx | 27 +++- 18 files changed, 492 insertions(+), 67 deletions(-) delete mode 100644 frontend/src/pages/Placeholder.tsx diff --git a/README.md b/README.md index 76bff98..a3273bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of > 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) complete. +> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & +> network attach) complete. ## What works today (Phase 1) @@ -131,6 +132,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. resolve inside images, so the numeric GID is what actually grants access. The GPU selector shows the detected GIDs; removal cleans them (and `LIBVA_DRIVER_NAME`). +### Phase 11 — Remote UX & network attach + +- **Live remote logs**: remote-stack logs now stream over a WebSocket proxied + through the central app to the agent (`/ws/agent-logs/{agent}/{stack}`), instead + of polling — same live viewer as local stacks. +- **Deploy to a remote host from the UI**: the New Stack editor and the template + dialog gained a *host* selector. Pick an online agent and the stack is created + (and optionally started) on that host; you land on its remote detail page. +- **Network attach/detach**: each network row on the Networks page expands to an + inspect view listing connected containers, with admin controls to disconnect a + container or connect any container on the host (`POST /api/networks/{id}/connect` + / `/disconnect`). + ## Deploying an agent on another host ```bash @@ -292,6 +306,15 @@ DELETE /api/networks/{id} POST /api/networks/prune DELETE /api/stacks/{id}?delete_files= (stack delete, now surfaced in the UI) ``` +### Phase 11 endpoints + +``` +WS /ws/agent-logs/{agent_id}/{stack_id} (live remote logs, proxied to the agent) +GET /api/networks/{id}/containers POST /api/networks/{id}/connect | /disconnect +POST /api/agents/{id}/stacks (create a stack on a remote host — now in the UI) +POST /api/templates/{id}/instantiate {agent_id} (instantiate a template onto a remote host) +``` + ## Security notes - The Docker socket is only ever touched by the backend process; it is never diff --git a/backend/agent_app.py b/backend/agent_app.py index 29535b5..93a9822 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -16,7 +16,21 @@ from dataclasses import asdict import tempfile -from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile +import json + +from fastapi import ( + Depends, + FastAPI, + File, + Form, + Header, + HTTPException, + Query, + Request, + UploadFile, + WebSocket, + WebSocketDisconnect, +) from fastapi.responses import FileResponse, JSONResponse from pydantic import BaseModel @@ -26,7 +40,7 @@ from services import backup_service, compose_service logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.10.0" +AGENT_VERSION = "0.11.0" # --------------------------------------------------------------------------- # @@ -274,6 +288,33 @@ async def restore_stack( os.unlink(tmp.name) +@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).""" + await websocket.accept() + expected = settings.AGENT_TOKEN + if not expected or token != expected: + await websocket.close(code=4401) + return + if not os.path.isdir(compose_service.stack_dir(stack_id)): + await websocket.send_text(json.dumps({"type": "error", "detail": "stack not found"})) + await websocket.close() + return + args = ["logs", "--no-color", "--tail", "200", "--timestamps", "-f"] + try: + async for line in compose_service.stream_compose(stack_id, args): + await websocket.send_text( + json.dumps({"type": "log", "stack_id": stack_id, "service": None, "line": line}) + ) + except WebSocketDisconnect: + pass + except Exception as exc: # noqa: BLE001 + try: + await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) + except Exception: # noqa: BLE001 + pass + + @app.get("/agent/health") def health() -> dict: return {"status": "ok"} diff --git a/backend/main.py b/backend/main.py index 9442901..38b748a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -54,7 +54,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.10.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.11.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/models/template.py b/backend/models/template.py index 0efdcd0..e626c07 100644 --- a/backend/models/template.py +++ b/backend/models/template.py @@ -55,3 +55,4 @@ class TemplateSaveRequest(SQLModel): class TemplateInstantiateRequest(SQLModel): name: str # new stack name values: dict[str, str] = {} + agent_id: int | None = None # None = local host; otherwise deploy to a remote agent diff --git a/backend/routers/networks.py b/backend/routers/networks.py index 3fd77c0..ca573a0 100644 --- a/backend/routers/networks.py +++ b/backend/routers/networks.py @@ -36,6 +36,12 @@ class NetworkCreate(BaseModel): attachable: bool = True +class ContainerRef(BaseModel): + container: str + aliases: list[str] | None = None + force: bool = False + + @router.get("") def list_networks(_user: User = Depends(get_current_user)) -> list[dict]: return network_service.list_networks() @@ -43,7 +49,58 @@ def list_networks(_user: User = Depends(get_current_user)) -> list[dict]: @router.get("/{network_id}") def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict: - return network_service.inspect_network(network_id) + try: + return network_service.inspect_network(network_id) + except DockerError as exc: + _map(exc) + + +@router.get("/{network_id}/containers") +def network_containers( + network_id: str, _user: User = Depends(get_current_user) +) -> list[dict]: + try: + return network_service.connectable_containers(network_id) + except DockerError as exc: + _map(exc) + + +@router.post("/{network_id}/connect") +def connect_container( + network_id: str, + body: ContainerRef, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + try: + network_service.connect_container(network_id, body.container, body.aliases) + except DockerError as exc: + _map(exc) + audit_service.record( + session, user=user.username, action="network.connect", target=network_id, + detail=body.container, ip=_ip(request), + ) + return {"ok": True} + + +@router.post("/{network_id}/disconnect") +def disconnect_container( + network_id: str, + body: ContainerRef, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + try: + network_service.disconnect_container(network_id, body.container, body.force) + except DockerError as exc: + _map(exc) + audit_service.record( + session, user=user.username, action="network.disconnect", target=network_id, + detail=body.container, ip=_ip(request), + ) + return {"ok": True} @router.post("", status_code=201) diff --git a/backend/routers/templates.py b/backend/routers/templates.py index b20330d..f2d65b3 100644 --- a/backend/routers/templates.py +++ b/backend/routers/templates.py @@ -8,10 +8,12 @@ from sqlmodel import Session from auth import get_current_user, require_admin from database import get_session +from models.agent import Agent from models.stack import Stack from models.template import TemplateInstantiateRequest, TemplateSaveRequest from models.user import User -from services import audit_service, compose_service, template_service +from services import agent_service, audit_service, compose_service, template_service +from services.agent_service import AgentError router = APIRouter(prefix="/api/templates", tags=["templates"]) @@ -72,7 +74,7 @@ def delete_template( @router.post("/{template_id}/instantiate", status_code=201) -def instantiate( +async def instantiate( template_id: str, body: TemplateInstantiateRequest, request: Request, @@ -83,11 +85,32 @@ def instantiate( if not tpl: raise HTTPException(status_code=404, detail="Template not found") + rendered = template_service.render(tpl["yaml"], body.values) + + if body.agent_id is not None: + agent = session.get(Agent, body.agent_id) + if not agent: + raise HTTPException(status_code=404, detail=f"Agent {body.agent_id} not found") + try: + result = await agent_service.call( + session, agent, "POST", "/agent/stacks", + json={"name": body.name, "yaml": rendered, "env": None}, + ) + except AgentError as exc: + raise HTTPException( + status_code=exc.status if exc.status >= 400 else 502, + detail={"error": exc.error, "detail": exc.detail}, + ) + audit_service.record( + session, user=user.username, action="template.instantiate", + target=f"{agent.name}/{result.get('id')}", detail=template_id, ip=_ip(request), + ) + return {"id": result.get("id"), "name": body.name, "agent_id": agent.id} + stack_id = compose_service.slugify(body.name) if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") - rendered = template_service.render(tpl["yaml"], body.values) compose_service.write_compose(stack_id, rendered) stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) session.add(stack) @@ -96,4 +119,4 @@ def instantiate( session, user=user.username, action="template.instantiate", target=stack_id, detail=template_id, ip=_ip(request), ) - return {"id": stack_id, "name": body.name} + return {"id": stack_id, "name": body.name, "agent_id": None} diff --git a/backend/routers/ws.py b/backend/routers/ws.py index 3cc6aaf..d661b61 100644 --- a/backend/routers/ws.py +++ b/backend/routers/ws.py @@ -4,10 +4,16 @@ from __future__ import annotations import asyncio import json +import contextlib + +import websockets from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from jose import JWTError +from sqlmodel import Session from auth import decode_token +from database import engine +from models.agent import Agent from services import compose_service router = APIRouter(tags=["ws"]) @@ -82,6 +88,47 @@ async def ws_service_logs( pass +@router.websocket("/ws/agent-logs/{agent_id}/{stack_id}") +async def ws_agent_logs( + websocket: WebSocket, + agent_id: int, + stack_id: str, + token: str | None = Query(default=None), +): + """Proxy live compose logs from a remote agent through to the browser.""" + await websocket.accept() + if not await _authorize(websocket, token): + return + + with Session(engine) as session: + agent = session.get(Agent, agent_id) + if not agent: + await websocket.send_text(json.dumps({"type": "error", "detail": "agent not found"})) + await websocket.close() + return + + base = agent.url.rstrip("/") + ws_url = ("wss://" + base[8:] if base.startswith("https://") + else "ws://" + base[7:] if base.startswith("http://") + else "ws://" + base) + ws_url += f"/agent/ws/logs/{stack_id}?token={agent.token}" + + try: + async with websockets.connect(ws_url, open_timeout=10, ping_interval=20) as upstream: + async for message in upstream: + await websocket.send_text( + message if isinstance(message, str) else message.decode("utf-8", "replace") + ) + except WebSocketDisconnect: + pass + except Exception as exc: # noqa: BLE001 + with contextlib.suppress(Exception): + await websocket.send_text(json.dumps({"type": "error", "detail": str(exc)})) + finally: + with contextlib.suppress(Exception): + await websocket.close() + + @router.websocket("/ws/events") async def ws_events( websocket: WebSocket, diff --git a/backend/services/network_service.py b/backend/services/network_service.py index b535acc..0fb8ab8 100644 --- a/backend/services/network_service.py +++ b/backend/services/network_service.py @@ -86,6 +86,43 @@ def create_network(spec: dict) -> dict: return _summary(net) +def connectable_containers(network_id: str) -> list[dict]: + """All containers on the host, flagged whether already on this network.""" + client = get_client() + net = safe_call(client.networks.get, network_id) + net.reload() + connected = set((net.attrs.get("Containers") or {}).keys()) + out = [] + for c in safe_call(client.containers.list, all=True): + labels = c.labels or {} + out.append( + { + "id": c.id[:12], + "name": c.name, + "state": c.status, + "stack": labels.get(COMPOSE_PROJECT_LABEL), + "connected": c.id in connected, + } + ) + return sorted(out, key=lambda x: x["name"]) + + +def connect_container(network_id: str, container: str, aliases: Optional[list[str]] = None) -> None: + if not (container or "").strip(): + raise DockerError("invalid_request", "Container is required") + client = get_client() + net = safe_call(client.networks.get, network_id) + safe_call(net.connect, container, aliases=aliases or None) + + +def disconnect_container(network_id: str, container: str, force: bool = False) -> None: + if not (container or "").strip(): + raise DockerError("invalid_request", "Container is required") + client = get_client() + net = safe_call(client.networks.get, network_id) + safe_call(net.disconnect, container, force=force) + + def delete_network(network_id: str) -> None: client = get_client() net = safe_call(client.networks.get, network_id) diff --git a/frontend/package.json b/frontend/package.json index 567ba91..149f4c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.10.0", + "version": "0.11.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts index 622f21d..edde39b 100644 --- a/frontend/src/api/agents.ts +++ b/frontend/src/api/agents.ts @@ -17,6 +17,10 @@ export const agentsApi = { stacks: (id: number) => api.get(`/api/agents/${id}/stacks`).then((r) => r.data), + createStack: (id: number, body: { name: string; yaml: string; env?: string }) => + api + .post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body) + .then((r) => r.data), stack: (id: number, stackId: string) => api.get(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data), logs: (id: number, stackId: string, tail = 200) => diff --git a/frontend/src/api/networks.ts b/frontend/src/api/networks.ts index 51b1d37..5b3eef5 100644 --- a/frontend/src/api/networks.ts +++ b/frontend/src/api/networks.ts @@ -26,10 +26,25 @@ export interface NetworkCreate { attachable: boolean; } +export interface NetworkContainer { + id: string; + name: string; + state: string; + stack: string | null; + connected: boolean; +} + 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), diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts index 409bd9a..00b9275 100644 --- a/frontend/src/api/templates.ts +++ b/frontend/src/api/templates.ts @@ -24,12 +24,17 @@ export const templatesApi = { list: () => api.get("/api/templates").then((r) => r.data), get: (id: string) => api.get(`/api/templates/${id}`).then((r) => r.data), - instantiate: (id: string, name: string, values: Record) => + instantiate: ( + id: string, + name: string, + values: Record, + agentId?: number | null + ) => api - .post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, { - name, - values, - }) + .post<{ id: string; name: string; agent_id: number | null }>( + `/api/templates/${id}/instantiate`, + { name, values, agent_id: agentId ?? null } + ) .then((r) => r.data), save: (body: { name: string; description?: string; tags: string[]; yaml: string }) => api.post("/api/templates", body).then((r) => r.data), diff --git a/frontend/src/components/stacks/LogViewer.tsx b/frontend/src/components/stacks/LogViewer.tsx index 966056a..8f225da 100644 --- a/frontend/src/components/stacks/LogViewer.tsx +++ b/frontend/src/components/stacks/LogViewer.tsx @@ -21,7 +21,7 @@ function colorFor(service: string | null): string { return serviceColors[h % serviceColors.length]; } -export function LogViewer({ stackId }: { stackId: string }) { +export function LogViewer({ stackId, agentId }: { stackId: string; agentId?: number }) { const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]); const [autoScroll, setAutoScroll] = useState(true); const [connected, setConnected] = useState(false); @@ -31,7 +31,11 @@ export function LogViewer({ stackId }: { stackId: string }) { useEffect(() => { if (!token) return; const proto = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`; + const path = + agentId != null + ? `/ws/agent-logs/${agentId}/${stackId}` + : `/ws/logs/${stackId}`; + const url = `${proto}://${window.location.host}${path}?token=${token}`; const ws = new WebSocket(url); ws.onopen = () => setConnected(true); ws.onclose = () => setConnected(false); @@ -49,7 +53,7 @@ export function LogViewer({ stackId }: { stackId: string }) { } }; return () => ws.close(); - }, [stackId, token]); + }, [stackId, agentId, token]); useEffect(() => { if (autoScroll && containerRef.current) { diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index a85dfb9..da1f057 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -1,6 +1,15 @@ -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Network as NetworkIcon, Plus, Trash2, Eraser } from "lucide-react"; +import { + Network as NetworkIcon, + Plus, + Trash2, + Eraser, + ChevronRight, + ChevronDown, + Link2, + Unplug, +} from "lucide-react"; import { toast } from "sonner"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; @@ -21,7 +30,9 @@ export function Networks() { }); const [creating, setCreating] = useState(false); const [toDelete, setToDelete] = useState(null); + const [expanded, setExpanded] = useState(null); const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); + const colSpan = isAdmin ? 6 : 5; const prune = useMutation({ mutationFn: networksApi.prune, @@ -71,9 +82,18 @@ export function Networks() { {data?.map((n) => ( - + + setExpanded((e) => (e === n.id ? null : n.id))} + className="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50" + >
+ {expanded === n.id ? ( + + ) : ( + + )} {n.name} {n.is_default && default} @@ -98,7 +118,7 @@ export function Networks() { {!n.is_default && ( + )} + + ))} + + )} +
+ + {isAdmin && ( +
+ + +
+ )} + + ); +} + +function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} +

{value}

+
+ ); +} + function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { const [form, setForm] = useState({ name: "", diff --git a/frontend/src/pages/Placeholder.tsx b/frontend/src/pages/Placeholder.tsx deleted file mode 100644 index 6e4dddd..0000000 --- a/frontend/src/pages/Placeholder.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Construction } from "lucide-react"; -import { Card } from "@/components/ui"; - -export function Placeholder({ title, phase }: { title: string; phase: string }) { - return ( - - -

{title}

-

- This section is part of {phase}. The backend foundation is ready — the UI - lands in an upcoming build phase. -

-
- ); -} - -export const Networks = () => ; diff --git a/frontend/src/pages/RemoteStackDetail.tsx b/frontend/src/pages/RemoteStackDetail.tsx index 8d33c57..8436205 100644 --- a/frontend/src/pages/RemoteStackDetail.tsx +++ b/frontend/src/pages/RemoteStackDetail.tsx @@ -14,6 +14,7 @@ import { import { toast } from "sonner"; import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; import { HostDot } from "@/components/hosts/HostDot"; +import { LogViewer } from "@/components/stacks/LogViewer"; import { BackupButton } from "@/components/stacks/BackupRestore"; import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; @@ -116,7 +117,11 @@ export function RemoteStackDetail() {
{tab === "Overview" && } - {tab === "Logs" && } + {tab === "Logs" && ( + + + + )} {tab === "Environment" && ( agentsApi.logs(agentId, stackId, 400), - refetchInterval: 5000, - }); - return ( - -
- -
- {isLoading ? ( - - ) : ( -
-          {data?.logs || "No logs."}
-        
- )} -
- ); -} - function RemoteEditor({ agentId, stackId, diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index 8206390..8cbe753 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -8,6 +8,7 @@ import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel"; import { EnvEditor } from "@/components/env/EnvEditor"; import { PortConflictDialog } from "@/components/stacks/PortConflictDialog"; import { stacksApi } from "@/api/stacks"; +import { agentsApi } from "@/api/agents"; import { portsApi, type PortConflict } from "@/api/ports"; import { apiErrorMessage } from "@/api/client"; import { useThemeStore } from "@/store/theme"; @@ -38,6 +39,7 @@ export function StackEditor() { const [runCmd, setRunCmd] = useState(""); const [conflicts, setConflicts] = useState(null); const [checking, setChecking] = useState(false); + const [host, setHost] = useState("local"); const existing = useQuery({ queryKey: ["stack", id], @@ -45,6 +47,14 @@ export function StackEditor() { enabled: !isNew, }); + const agents = useQuery({ + queryKey: ["agents"], + queryFn: () => agentsApi.list(), + enabled: isNew, + }); + const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online"); + const remote = isNew && host !== "local"; + useEffect(() => { if (existing.data) { setName(existing.data.name); @@ -61,6 +71,21 @@ export function StackEditor() { } setSaving(true); try { + // Remote host: create the stack on the agent, then optionally start it. + if (remote) { + const aid = Number(host); + const created = await agentsApi.createStack(aid, { name, yaml, env }); + qc.invalidateQueries({ queryKey: ["agent-stacks", aid] }); + toast.success("Saved"); + if (deploy) { + const t = toast.loading("Deploying…"); + await agentsApi.action(aid, created.id, "start"); + toast.success("Deployed ✓", { id: t }); + } + navigate(`/hosts/${aid}/stacks/${created.id}`); + return; + } + let stackId = id; if (isNew) { const created = await stacksApi.create({ name, description, yaml, env }); @@ -85,6 +110,11 @@ export function StackEditor() { }; const onDeploy = async () => { + // The local port-conflict check doesn't apply to remote hosts. + if (remote) { + save(true); + return; + } setChecking(true); try { const found = await portsApi.conflicts(yaml, id); @@ -128,6 +158,21 @@ export function StackEditor() { value={description} onChange={(e) => setDescription(e.target.value)} /> + {isNew && onlineAgents.length > 0 && ( + + )} diff --git a/frontend/src/pages/Templates.tsx b/frontend/src/pages/Templates.tsx index a9eecde..9c33b8e 100644 --- a/frontend/src/pages/Templates.tsx +++ b/frontend/src/pages/Templates.tsx @@ -4,10 +4,14 @@ import { useQuery } from "@tanstack/react-query"; import { LayoutTemplate, Cpu, Package } from "lucide-react"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates"; +import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; import { toast } from "sonner"; +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 Templates() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const [selected, setSelected] = useState(null); @@ -78,8 +82,12 @@ function UseTemplateDialog({ const [values, setValues] = useState>( Object.fromEntries(template.variables.map((v) => [v.name, v.default])) ); + const [host, setHost] = useState("local"); const [busy, setBusy] = useState(false); + const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list() }); + const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online"); + const create = async () => { if (!name.trim()) { toast.error("Stack name required"); @@ -87,9 +95,11 @@ function UseTemplateDialog({ } setBusy(true); try { - const res = await templatesApi.instantiate(template.id, name, values); + const agentId = host === "local" ? null : Number(host); + const res = await templatesApi.instantiate(template.id, name, values, agentId); toast.success(`Stack '${res.name}' created`); - navigate(`/stacks/${res.id}/edit`); + if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`); + else navigate(`/stacks/${res.id}/edit`); } catch (e) { toast.error(apiErrorMessage(e)); } finally { @@ -106,6 +116,19 @@ function UseTemplateDialog({ Stack name setName(e.target.value)} /> + {onlineAgents.length > 0 && ( + + )} {template.variables.map((v) => (