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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 07:49:20 +00:00
co-authored by Claude Opus 4.8
parent ec7e3e706f
commit 1931500c24
18 changed files with 492 additions and 67 deletions
+24 -1
View File
@@ -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 > **Status:** Phase 1 (Core) + Phase 2 (Volumes & GPU) + Phase 3 (Quality of
> 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) complete. > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
> network attach) complete.
## What works today (Phase 1) ## 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 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`). 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 ## Deploying an agent on another host
```bash ```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) 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 ## Security notes
- The Docker socket is only ever touched by the backend process; it is never - The Docker socket is only ever touched by the backend process; it is never
+43 -2
View File
@@ -16,7 +16,21 @@ from dataclasses import asdict
import tempfile 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 fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
@@ -26,7 +40,7 @@ from services import backup_service, compose_service
logger = logging.getLogger("stackpilot.agent") 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) 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") @app.get("/agent/health")
def health() -> dict: def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
+1 -1
View File
@@ -54,7 +54,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel() 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
+1
View File
@@ -55,3 +55,4 @@ class TemplateSaveRequest(SQLModel):
class TemplateInstantiateRequest(SQLModel): class TemplateInstantiateRequest(SQLModel):
name: str # new stack name name: str # new stack name
values: dict[str, str] = {} values: dict[str, str] = {}
agent_id: int | None = None # None = local host; otherwise deploy to a remote agent
+57
View File
@@ -36,6 +36,12 @@ class NetworkCreate(BaseModel):
attachable: bool = True attachable: bool = True
class ContainerRef(BaseModel):
container: str
aliases: list[str] | None = None
force: bool = False
@router.get("") @router.get("")
def list_networks(_user: User = Depends(get_current_user)) -> list[dict]: def list_networks(_user: User = Depends(get_current_user)) -> list[dict]:
return network_service.list_networks() return network_service.list_networks()
@@ -43,7 +49,58 @@ def list_networks(_user: User = Depends(get_current_user)) -> list[dict]:
@router.get("/{network_id}") @router.get("/{network_id}")
def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict: def inspect_network(network_id: str, _user: User = Depends(get_current_user)) -> dict:
try:
return network_service.inspect_network(network_id) 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) @router.post("", status_code=201)
+27 -4
View File
@@ -8,10 +8,12 @@ from sqlmodel import Session
from auth import get_current_user, require_admin from auth import get_current_user, require_admin
from database import get_session from database import get_session
from models.agent import Agent
from models.stack import Stack from models.stack import Stack
from models.template import TemplateInstantiateRequest, TemplateSaveRequest from models.template import TemplateInstantiateRequest, TemplateSaveRequest
from models.user import User 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"]) router = APIRouter(prefix="/api/templates", tags=["templates"])
@@ -72,7 +74,7 @@ def delete_template(
@router.post("/{template_id}/instantiate", status_code=201) @router.post("/{template_id}/instantiate", status_code=201)
def instantiate( async def instantiate(
template_id: str, template_id: str,
body: TemplateInstantiateRequest, body: TemplateInstantiateRequest,
request: Request, request: Request,
@@ -83,11 +85,32 @@ def instantiate(
if not tpl: if not tpl:
raise HTTPException(status_code=404, detail="Template not found") 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) stack_id = compose_service.slugify(body.name)
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): 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") 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) compose_service.write_compose(stack_id, rendered)
stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) stack = Stack(id=stack_id, name=body.name, description=tpl.get("description"))
session.add(stack) session.add(stack)
@@ -96,4 +119,4 @@ def instantiate(
session, user=user.username, action="template.instantiate", session, user=user.username, action="template.instantiate",
target=stack_id, detail=template_id, ip=_ip(request), 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}
+47
View File
@@ -4,10 +4,16 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import contextlib
import websockets
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from jose import JWTError from jose import JWTError
from sqlmodel import Session
from auth import decode_token from auth import decode_token
from database import engine
from models.agent import Agent
from services import compose_service from services import compose_service
router = APIRouter(tags=["ws"]) router = APIRouter(tags=["ws"])
@@ -82,6 +88,47 @@ async def ws_service_logs(
pass 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") @router.websocket("/ws/events")
async def ws_events( async def ws_events(
websocket: WebSocket, websocket: WebSocket,
+37
View File
@@ -86,6 +86,43 @@ def create_network(spec: dict) -> dict:
return _summary(net) 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: def delete_network(network_id: str) -> None:
client = get_client() client = get_client()
net = safe_call(client.networks.get, network_id) net = safe_call(client.networks.get, network_id)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.10.0", "version": "0.11.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+4
View File
@@ -17,6 +17,10 @@ export const agentsApi = {
stacks: (id: number) => stacks: (id: number) =>
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data), api.get<RemoteStackSummary[]>(`/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) => stack: (id: number, stackId: string) =>
api.get<RemoteStackDetail>(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data), api.get<RemoteStackDetail>(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data),
logs: (id: number, stackId: string, tail = 200) => logs: (id: number, stackId: string, tail = 200) =>
+15
View File
@@ -26,10 +26,25 @@ export interface NetworkCreate {
attachable: boolean; attachable: boolean;
} }
export interface NetworkContainer {
id: string;
name: string;
state: string;
stack: string | null;
connected: boolean;
}
export const networksApi = { export const networksApi = {
list: () => api.get<NetworkInfo[]>("/api/networks").then((r) => r.data), list: () => api.get<NetworkInfo[]>("/api/networks").then((r) => r.data),
inspect: (id: string) => api.get<NetworkInfo>(`/api/networks/${id}`).then((r) => r.data),
containers: (id: string) =>
api.get<NetworkContainer[]>(`/api/networks/${id}/containers`).then((r) => r.data),
create: (body: NetworkCreate) => create: (body: NetworkCreate) =>
api.post<NetworkInfo>("/api/networks", body).then((r) => r.data), api.post<NetworkInfo>("/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), remove: (id: string) => api.delete(`/api/networks/${id}`).then((r) => r.data),
prune: () => prune: () =>
api.post<{ NetworksDeleted: string[] | null }>("/api/networks/prune").then((r) => r.data), api.post<{ NetworksDeleted: string[] | null }>("/api/networks/prune").then((r) => r.data),
+10 -5
View File
@@ -24,12 +24,17 @@ export const templatesApi = {
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data), list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
get: (id: string) => get: (id: string) =>
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data), api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
instantiate: (id: string, name: string, values: Record<string, string>) => instantiate: (
id: string,
name: string,
values: Record<string, string>,
agentId?: number | null
) =>
api api
.post<{ id: string; name: string }>(`/api/templates/${id}/instantiate`, { .post<{ id: string; name: string; agent_id: number | null }>(
name, `/api/templates/${id}/instantiate`,
values, { name, values, agent_id: agentId ?? null }
}) )
.then((r) => r.data), .then((r) => r.data),
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) => save: (body: { name: string; description?: string; tags: string[]; yaml: string }) =>
api.post("/api/templates", body).then((r) => r.data), api.post("/api/templates", body).then((r) => r.data),
+7 -3
View File
@@ -21,7 +21,7 @@ function colorFor(service: string | null): string {
return serviceColors[h % serviceColors.length]; 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 [lines, setLines] = useState<{ service: string | null; line: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
@@ -31,7 +31,11 @@ export function LogViewer({ stackId }: { stackId: string }) {
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
const proto = window.location.protocol === "https:" ? "wss" : "ws"; 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); const ws = new WebSocket(url);
ws.onopen = () => setConnected(true); ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false); ws.onclose = () => setConnected(false);
@@ -49,7 +53,7 @@ export function LogViewer({ stackId }: { stackId: string }) {
} }
}; };
return () => ws.close(); return () => ws.close();
}, [stackId, token]); }, [stackId, agentId, token]);
useEffect(() => { useEffect(() => {
if (autoScroll && containerRef.current) { if (autoScroll && containerRef.current) {
+141 -5
View File
@@ -1,6 +1,15 @@
import { useState } from "react"; import { Fragment, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { 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";
@@ -21,7 +30,9 @@ export function Networks() {
}); });
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 invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] }); const invalidate = () => qc.invalidateQueries({ queryKey: ["networks"] });
const colSpan = isAdmin ? 6 : 5;
const prune = useMutation({ const prune = useMutation({
mutationFn: networksApi.prune, mutationFn: networksApi.prune,
@@ -71,9 +82,18 @@ export function Networks() {
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700"> <tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{data?.map((n) => ( {data?.map((n) => (
<tr key={n.id}> <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"> <td className="px-4 py-2">
<div className="flex items-center gap-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" /> <NetworkIcon className="h-4 w-4 text-slate-400" />
<span className="font-medium">{n.name}</span> <span className="font-medium">{n.name}</span>
{n.is_default && <Badge>default</Badge>} {n.is_default && <Badge>default</Badge>}
@@ -98,7 +118,7 @@ export function Networks() {
{!n.is_default && ( {!n.is_default && (
<button <button
title={n.in_use ? "In use — disconnect containers first" : "Delete"} title={n.in_use ? "In use — disconnect containers first" : "Delete"}
onClick={() => setToDelete(n)} onClick={(e) => { e.stopPropagation(); setToDelete(n); }}
className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700" className="rounded-lg p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700"
> >
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
@@ -107,10 +127,18 @@ export function Networks() {
</td> </td>
)} )}
</tr> </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} />
</td>
</tr>
)}
</Fragment>
))} ))}
{data?.length === 0 && ( {data?.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-sm text-slate-500"> <td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-slate-500">
No networks. No networks.
</td> </td>
</tr> </tr>
@@ -141,6 +169,114 @@ export function Networks() {
); );
} }
function NetworkDetail({ network, isAdmin }: { network: NetworkInfo; isAdmin: boolean }) {
const qc = useQueryClient();
const [pick, setPick] = useState("");
const { data, isLoading } = useQuery({
queryKey: ["network-containers", network.id],
queryFn: () => networksApi.containers(network.id),
refetchInterval: 10000,
});
const refresh = () => {
qc.invalidateQueries({ queryKey: ["network-containers", network.id] });
qc.invalidateQueries({ queryKey: ["networks"] });
};
const connect = useMutation({
mutationFn: (container: string) => networksApi.connect(network.id, container),
onSuccess: () => { toast.success("Container connected"); setPick(""); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const disconnect = useMutation({
mutationFn: (container: string) => networksApi.disconnect(network.id, container),
onSuccess: () => { toast.success("Container disconnected"); refresh(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
const connected = (data ?? []).filter((c) => c.connected);
const available = (data ?? []).filter((c) => !c.connected);
return (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">
<Meta label="Driver" value={network.driver} />
<Meta label="Scope" value={network.scope} />
<Meta label="Subnet" value={network.subnet ?? "—"} mono />
<Meta label="Gateway" value={network.gateway ?? "—"} mono />
<Meta label="Attachable" value={network.attachable ? "yes" : "no"} />
<Meta label="Internal" value={network.internal ? "yes" : "no"} />
<Meta label="ID" value={network.id} mono />
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
Connected containers
</p>
{isLoading ? (
<Spinner />
) : connected.length === 0 ? (
<p className="text-slate-400">No containers connected.</p>
) : (
<ul className="space-y-1">
{connected.map((c) => (
<li key={c.id} className="flex items-center gap-2">
<span className="font-medium">{c.name}</span>
{c.stack && <Badge>{c.stack}</Badge>}
<span className="text-xs text-slate-400">{c.state}</span>
{isAdmin && !network.is_default && (
<button
title="Disconnect"
onClick={() => disconnect.mutate(c.name)}
disabled={disconnect.isPending}
className="ml-1 rounded p-1 hover:bg-slate-200 dark:hover:bg-slate-700"
>
<Unplug className="h-3.5 w-3.5 text-red-500" />
</button>
)}
</li>
))}
</ul>
)}
</div>
{isAdmin && (
<div className="flex items-center gap-2">
<select
className={selectClass + " max-w-xs"}
value={pick}
onChange={(e) => setPick(e.target.value)}
>
<option value="">Connect a container</option>
{available.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
{c.stack ? ` (${c.stack})` : ""}
</option>
))}
</select>
<Button
variant="outline"
disabled={!pick || connect.isPending}
loading={connect.isPending}
onClick={() => connect.mutate(pick)}
>
<Link2 className="h-4 w-4" /> Connect
</Button>
</div>
)}
</div>
);
}
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<span className="text-xs text-slate-400">{label}</span>
<p className={mono ? "font-mono text-xs" : ""}>{value}</p>
</div>
);
}
function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) { function CreateNetworkDialog({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", name: "",
-17
View File
@@ -1,17 +0,0 @@
import { Construction } from "lucide-react";
import { Card } from "@/components/ui";
export function Placeholder({ title, phase }: { title: string; phase: string }) {
return (
<Card className="flex flex-col items-center gap-3 py-16 text-center">
<Construction className="h-10 w-10 text-slate-400" />
<h2 className="text-lg font-semibold">{title}</h2>
<p className="max-w-md text-sm text-slate-500">
This section is part of {phase}. The backend foundation is ready the UI
lands in an upcoming build phase.
</p>
</Card>
);
}
export const Networks = () => <Placeholder title="Networks" phase="a future phase" />;
+6 -25
View File
@@ -14,6 +14,7 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot"; import { HostDot } from "@/components/hosts/HostDot";
import { LogViewer } from "@/components/stacks/LogViewer";
import { BackupButton } from "@/components/stacks/BackupRestore"; import { BackupButton } from "@/components/stacks/BackupRestore";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
@@ -116,7 +117,11 @@ export function RemoteStackDetail() {
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview containers={data.containers} />} {tab === "Overview" && <Overview containers={data.containers} />}
{tab === "Logs" && <RemoteLogs agentId={aid} stackId={id} />} {tab === "Logs" && (
<Card className="h-full overflow-hidden">
<LogViewer stackId={id} agentId={aid} />
</Card>
)}
{tab === "Environment" && ( {tab === "Environment" && (
<RemoteEditor <RemoteEditor
agentId={aid} agentId={aid}
@@ -169,30 +174,6 @@ function Overview({ containers }: { containers: any[] }) {
); );
} }
function RemoteLogs({ agentId, stackId }: { agentId: number; stackId: string }) {
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["agent-logs", agentId, stackId],
queryFn: () => agentsApi.logs(agentId, stackId, 400),
refetchInterval: 5000,
});
return (
<Card className="flex h-full flex-col overflow-hidden">
<div className="mb-2 flex justify-end">
<Button variant="ghost" onClick={() => refetch()} loading={isFetching}>
Refresh
</Button>
</div>
{isLoading ? (
<Spinner />
) : (
<pre className="flex-1 overflow-auto whitespace-pre-wrap break-all bg-slate-950 p-3 font-mono text-xs text-slate-100">
{data?.logs || "No logs."}
</pre>
)}
</Card>
);
}
function RemoteEditor({ function RemoteEditor({
agentId, agentId,
stackId, stackId,
+45
View File
@@ -8,6 +8,7 @@ import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { EnvEditor } from "@/components/env/EnvEditor"; import { EnvEditor } from "@/components/env/EnvEditor";
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog"; import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents";
import { portsApi, type PortConflict } from "@/api/ports"; import { portsApi, type PortConflict } from "@/api/ports";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme"; import { useThemeStore } from "@/store/theme";
@@ -38,6 +39,7 @@ export function StackEditor() {
const [runCmd, setRunCmd] = useState(""); const [runCmd, setRunCmd] = useState("");
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null); const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
const [checking, setChecking] = useState(false); const [checking, setChecking] = useState(false);
const [host, setHost] = useState("local");
const existing = useQuery({ const existing = useQuery({
queryKey: ["stack", id], queryKey: ["stack", id],
@@ -45,6 +47,14 @@ export function StackEditor() {
enabled: !isNew, 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(() => { useEffect(() => {
if (existing.data) { if (existing.data) {
setName(existing.data.name); setName(existing.data.name);
@@ -61,6 +71,21 @@ export function StackEditor() {
} }
setSaving(true); setSaving(true);
try { 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; let stackId = id;
if (isNew) { if (isNew) {
const created = await stacksApi.create({ name, description, yaml, env }); const created = await stacksApi.create({ name, description, yaml, env });
@@ -85,6 +110,11 @@ export function StackEditor() {
}; };
const onDeploy = async () => { const onDeploy = async () => {
// The local port-conflict check doesn't apply to remote hosts.
if (remote) {
save(true);
return;
}
setChecking(true); setChecking(true);
try { try {
const found = await portsApi.conflicts(yaml, id); const found = await portsApi.conflicts(yaml, id);
@@ -128,6 +158,21 @@ export function StackEditor() {
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
{isNew && onlineAgents.length > 0 && (
<select
value={host}
onChange={(e) => setHost(e.target.value)}
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
title="Target host"
>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
)}
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}> <Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
<Wand2 className="h-4 w-4" /> Convert docker run <Wand2 className="h-4 w-4" /> Convert docker run
</Button> </Button>
+25 -2
View File
@@ -4,10 +4,14 @@ import { useQuery } from "@tanstack/react-query";
import { LayoutTemplate, Cpu, Package } from "lucide-react"; import { LayoutTemplate, Cpu, Package } from "lucide-react";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates"; import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
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 { toast } from "sonner"; 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() { export function Templates() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [selected, setSelected] = useState<TemplateDetail | null>(null); const [selected, setSelected] = useState<TemplateDetail | null>(null);
@@ -78,8 +82,12 @@ function UseTemplateDialog({
const [values, setValues] = useState<Record<string, string>>( const [values, setValues] = useState<Record<string, string>>(
Object.fromEntries(template.variables.map((v) => [v.name, v.default])) Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
); );
const [host, setHost] = useState("local");
const [busy, setBusy] = useState(false); 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 () => { const create = async () => {
if (!name.trim()) { if (!name.trim()) {
toast.error("Stack name required"); toast.error("Stack name required");
@@ -87,9 +95,11 @@ function UseTemplateDialog({
} }
setBusy(true); setBusy(true);
try { 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`); 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) { } catch (e) {
toast.error(apiErrorMessage(e)); toast.error(apiErrorMessage(e));
} finally { } finally {
@@ -106,6 +116,19 @@ function UseTemplateDialog({
<span className="text-xs font-medium text-slate-500">Stack name</span> <span className="text-xs font-medium text-slate-500">Stack name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</label> </label>
{onlineAgents.length > 0 && (
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Deploy to host</span>
<select className={selectClass} value={host} onChange={(e) => setHost(e.target.value)}>
<option value="local">This host</option>
{onlineAgents.map((a) => (
<option key={a.id} value={String(a.id)}>
{a.name}
</option>
))}
</select>
</label>
)}
{template.variables.map((v) => ( {template.variables.map((v) => (
<label key={v.name} className="block space-y-1"> <label key={v.name} className="block space-y-1">
<span className="text-xs font-medium text-slate-500"> <span className="text-xs font-medium text-slate-500">