Phase 5: multi-host agents (0.5.0)

- stackpilot-agent: slim token-guarded FastAPI (reuses compose_service) exposing
  stack CRUD/lifecycle/logs + system info; same image, different CMD. agent/
  Dockerfile + compose + .env.example.
- Central proxy: Agent model, agent_service (httpx ping/proxy + live status:
  online/offline/unauthorized + hostname/last_seen), routers/agents.py
  (CRUD + ping + proxied stacks/lifecycle/logs/system).
- Frontend: Settings → Remote hosts (add/check/remove, connectivity dot); Stacks
  grouped by host; remote stack detail with lifecycle, live logs, compose/.env edit.

Verified end-to-end: agent+main on a shared network — register (good/bad token),
list/create/start/logs/delete remote stacks, offline detection (502).

Remote backup destinations (SFTP/S3) deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 21:23:17 +00:00
co-authored by Claude Opus 4.8
parent 8d19b09abd
commit 59037f4287
21 changed files with 1380 additions and 39 deletions
+41 -5
View File
@@ -4,8 +4,8 @@ A self-hosted Docker Compose manager for power users and homelab enthusiasts —
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) complete. Multi-host agents are planned for a
> later phase.
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup
> destinations (SFTP/S3) are planned for a later phase.
## What works today (Phase 1)
@@ -70,9 +70,30 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
- **Audit log page**: searchable, paginated view of all recorded actions.
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
> **Not yet:** multi-host agents (a second deployable agent app + remote proxying)
> and remote backup destinations (SFTP/S3) are intentionally deferred to a future
> phase — backups currently download to / upload from the browser.
### Phase 5 — Multi-host
- **Remote agents**: deploy `stackpilot-agent` (same image, different CMD) on any
host — it needs only the Docker socket and a shared `AGENT_TOKEN`, and exposes a
slim, token-guarded stack/system API (no UI, no DB).
- **Central management**: add hosts under **Settings → Remote hosts** (name, agent
URL, token) with a live connectivity dot. The Stacks page groups stacks by host
("This host" + one section per agent); remote stacks have their own detail view
with full lifecycle (start/stop/restart/pull/update/down), live logs, and
compose/.env editing — all proxied to the agent.
> **Not yet:** remote backup destinations (SFTP/S3) — backups currently download
> to / upload from the browser.
## Deploying an agent on another host
```bash
cd agent
cp .env.example .env # set a strong AGENT_TOKEN
docker compose up -d # exposes the agent on :5010
```
Then in the central UI: **Settings → Remote hosts → Add host** with
`http://<that-host>:5010` and the same `AGENT_TOKEN`.
## Architecture
@@ -174,6 +195,21 @@ GET /api/auth/users POST /api/auth/users
PATCH /api/auth/users/{id} DELETE /api/auth/users/{id}
```
### Phase 5 endpoints
```
GET /api/agents POST /api/agents
PUT /api/agents/{id} DELETE /api/agents/{id}
POST /api/agents/{id}/ping GET /api/agents/{id}/system
GET /api/agents/{id}/stacks | /{sid} GET /api/agents/{id}/stacks/{sid}/logs
POST /api/agents/{id}/stacks PUT /api/agents/{id}/stacks/{sid}
DELETE /api/agents/{id}/stacks/{sid} POST /api/agents/{id}/stacks/{sid}/{action}
agent (on the remote host, Bearer AGENT_TOKEN):
GET /agent/ping | /system | /stacks | /stacks/{id} | /stacks/{id}/logs
POST /agent/stacks | /stacks/{id}/{action} PUT/DELETE /agent/stacks/{id}
```
## Security notes
- The Docker socket is only ever touched by the backend process; it is never
+7
View File
@@ -0,0 +1,7 @@
# Shared secret the central StackPilot must present to manage this host.
# Generate with: openssl rand -base64 32
# Enter the SAME value when adding this host under Settings → Remote hosts.
AGENT_TOKEN=change-me-to-a-long-random-shared-secret
# Host directory where this host's stack folders live.
STACKS_HOST_DIR=./data/stacks
+14
View File
@@ -0,0 +1,14 @@
# The agent reuses the backend image (same compose/Docker code + deps) and
# just runs a different ASGI app. Build the backend image first.
ARG BACKEND_IMAGE=10.10.5.10:3020/menzelj/stackpilot-backend:latest
FROM ${BACKEND_IMAGE}
ENV STACKS_DIR=/opt/stacks \
PORT=5010
EXPOSE 5010
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -fsS http://localhost:5010/agent/health || exit 1
CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010"]
+23
View File
@@ -0,0 +1,23 @@
# StackPilot agent — deploy this on each remote host you want to manage.
# It needs only the Docker socket and a shared AGENT_TOKEN (must match the
# token you enter when adding this host in the central StackPilot UI).
services:
agent:
image: 10.10.5.10:3020/menzelj/stackpilot-agent:latest
build:
context: .
args:
BACKEND_IMAGE: 10.10.5.10:3020/menzelj/stackpilot-backend:latest
restart: unless-stopped
environment:
- AGENT_TOKEN=${AGENT_TOKEN:?set AGENT_TOKEN in .env}
- STACKS_DIR=/opt/stacks
- HOST_PROC_PATH=/host_proc
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${STACKS_HOST_DIR:-./data/stacks}:/opt/stacks
- /proc:/host_proc:ro
# Read-only host devices for status/detection parity with the main host.
- /dev:/dev:ro
ports:
- "5010:5010"
+231
View File
@@ -0,0 +1,231 @@
"""StackPilot agent — a slim, token-guarded Docker Compose API for one host.
The agent runs on each remote host (same image as the backend, different CMD).
It has no users, no database and no UI: it exposes just enough of the stack /
system surface for a central StackPilot to manage this host's compose stacks,
authenticated by a single shared bearer token (``AGENT_TOKEN``).
All compose/Docker logic is reused from the backend's ``compose_service`` and
``docker_client`` so behaviour matches the local host exactly.
"""
from __future__ import annotations
import logging
import os
from dataclasses import asdict
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from config import settings
from docker_client import DockerError, get_client, safe_call
from services import compose_service
logger = logging.getLogger("stackpilot.agent")
AGENT_VERSION = "0.5.0"
# --------------------------------------------------------------------------- #
# Auth
# --------------------------------------------------------------------------- #
def verify_token(authorization: str = Header(default="")) -> None:
expected = settings.AGENT_TOKEN
if not expected:
raise HTTPException(status_code=503, detail="Agent token not configured")
if authorization != f"Bearer {expected}":
raise HTTPException(status_code=401, detail="Invalid agent token")
# --------------------------------------------------------------------------- #
# Schemas
# --------------------------------------------------------------------------- #
class StackBody(BaseModel):
name: str | None = None
yaml: str | None = None
env: str | None = None
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _summary(stack_id: str) -> dict:
try:
containers = compose_service.containers_for_stack(stack_id)
status = compose_service.compute_status(stack_id)
except DockerError:
containers = []
status = "unknown"
return {
"id": stack_id,
"name": stack_id,
"description": None,
"status": status,
"service_count": len(containers),
"running_count": sum(1 for c in containers if c.state == "running"),
"created_at": None,
"updated_at": None,
}
def _hostname() -> str:
return os.uname().nodename
def _system_info() -> dict:
docker_version = ""
host_os = ""
running = total = 0
try:
client = get_client()
docker_version = safe_call(client.version).get("Version", "")
info = safe_call(client.info)
host_os = info.get("OperatingSystem", "")
running = info.get("ContainersRunning", 0)
total = info.get("Containers", 0)
except DockerError as exc:
docker_version = f"unavailable ({exc.error})"
return {
"hostname": _hostname(),
"docker_version": docker_version,
"host_os": host_os,
"containers_running": running,
"containers_total": total,
}
# --------------------------------------------------------------------------- #
# App
# --------------------------------------------------------------------------- #
app = FastAPI(title="StackPilot Agent", version=AGENT_VERSION)
@app.exception_handler(DockerError)
async def _docker_error(_request: Request, exc: DockerError):
return JSONResponse(status_code=502, content={"error": exc.error, "detail": exc.detail})
@app.get("/agent/ping", dependencies=[Depends(verify_token)])
def ping() -> dict:
return {"ok": True, "hostname": _hostname(), "version": AGENT_VERSION}
@app.get("/agent/system", dependencies=[Depends(verify_token)])
def system() -> dict:
return _system_info()
@app.get("/agent/stacks", dependencies=[Depends(verify_token)])
def list_stacks() -> list[dict]:
return [_summary(sid) for sid in compose_service.discover_stacks()]
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
def get_stack(stack_id: str) -> dict:
directory = compose_service.stack_dir(stack_id)
if not os.path.isdir(directory):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
try:
containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)]
status = compose_service.compute_status(stack_id)
except DockerError:
containers = []
status = "unknown"
return {
"id": stack_id,
"name": stack_id,
"description": None,
"status": status,
"yaml": compose_service.read_compose(stack_id),
"env": compose_service.read_env(stack_id),
"containers": containers,
"created_at": None,
"updated_at": None,
}
@app.post("/agent/stacks", dependencies=[Depends(verify_token)], status_code=201)
def create_stack(body: StackBody) -> dict:
if not body.name:
raise HTTPException(status_code=400, detail="name is required")
stack_id = compose_service.slugify(body.name)
if os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists")
compose_service.write_compose(stack_id, body.yaml or "services:\n")
if body.env:
compose_service.write_env(stack_id, body.env)
return _summary(stack_id)
@app.put("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
def update_stack(stack_id: str, body: StackBody) -> dict:
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
if body.yaml is not None:
compose_service.write_compose(stack_id, body.yaml)
if body.env is not None:
compose_service.write_env(stack_id, body.env)
return _summary(stack_id)
@app.delete("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
async def delete_stack(stack_id: str, delete_files: bool = Query(True)) -> dict:
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
try:
await compose_service.down(stack_id)
except Exception: # noqa: BLE001 - best-effort teardown
pass
if delete_files:
compose_service.delete_stack_files(stack_id)
return {"ok": True}
_ACTIONS = {
"start": compose_service.up,
"stop": compose_service.stop,
"restart": compose_service.restart,
"pull": compose_service.pull,
"update": compose_service.update,
"down": compose_service.down,
}
@app.post("/agent/stacks/{stack_id}/{action}", dependencies=[Depends(verify_token)])
async def lifecycle(stack_id: str, action: str) -> dict:
fn = _ACTIONS.get(action)
if not fn:
raise HTTPException(status_code=400, detail=f"Unknown action '{action}'")
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
result = await fn(stack_id)
if result.get("returncode") not in (0, None):
raise HTTPException(
status_code=500,
detail={
"error": f"compose {action} failed",
"detail": result.get("stderr", "").strip()[-2000:],
},
)
return result
@app.get("/agent/stacks/{stack_id}/logs", dependencies=[Depends(verify_token)])
async def stack_logs(stack_id: str, tail: int = Query(200, le=2000)) -> dict:
if not os.path.isdir(compose_service.stack_dir(stack_id)):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
result = await compose_service.logs(stack_id, tail=tail)
return {"logs": result.get("stdout", "") + result.get("stderr", "")}
@app.get("/agent/health")
def health() -> dict:
return {"status": "ok"}
+4
View File
@@ -35,6 +35,10 @@ class Settings(BaseSettings):
# Throwaway image used to read/write named-volume contents during backup.
BACKUP_HELPER_IMAGE: str = "alpine:latest"
# Multi-host agent: shared bearer token the agent requires on every request.
# Only used when running the agent app (agent_app:app).
AGENT_TOKEN: str = ""
# Host browser sandbox roots
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
"/", "/mnt", "/media", "/srv", "/opt",
+3 -1
View File
@@ -14,6 +14,7 @@ from config import settings
from database import engine, init_db
from docker_client import DockerError
from routers import (
agents,
audit,
auth,
backups,
@@ -48,7 +49,7 @@ async def lifespan(app: FastAPI):
update_task.cancel()
app = FastAPI(title="StackPilot", version="0.4.0", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.5.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -78,6 +79,7 @@ app.include_router(templates.router)
app.include_router(audit.router)
app.include_router(settings_router.router)
app.include_router(backups.router)
app.include_router(agents.router)
app.include_router(ws.router)
+2 -1
View File
@@ -1,8 +1,9 @@
"""SQLModel table models. Importing this package registers all tables."""
from models.agent import Agent
from models.audit import AuditLog
from models.setting import Setting, Webhook
from models.stack import Stack
from models.template import Template
from models.user import User
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook"]
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"]
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
def _now() -> datetime:
return datetime.now(timezone.utc)
class Agent(SQLModel, table=True):
"""A remote host running stackpilot-agent."""
id: Optional[int] = Field(default=None, primary_key=True)
name: str
url: str # e.g. http://10.0.0.5:5010
token: str # shared AGENT_TOKEN of that host
status: str = "unknown" # online | offline | unauthorized | unknown
hostname: Optional[str] = None # reported by the agent on ping
last_seen: Optional[datetime] = None
created_at: datetime = Field(default_factory=_now)
# --- API schemas ---
class AgentCreate(SQLModel):
name: str
url: str
token: str
class AgentUpdate(SQLModel):
name: Optional[str] = None
url: Optional[str] = None
token: Optional[str] = None
class AgentRead(SQLModel):
id: int
name: str
url: str
status: str
hostname: Optional[str]
last_seen: Optional[datetime]
created_at: datetime
token_set: bool
+286
View File
@@ -0,0 +1,286 @@
"""Remote host (agent) management + proxied stack/system operations."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlmodel import Session, select
from auth import get_current_user, require_admin
from database import get_session
from models.agent import Agent, AgentCreate, AgentRead, AgentUpdate
from models.stack import StackCreate, StackUpdate
from models.user import User
from services import agent_service, audit_service
from services.agent_service import AgentError
router = APIRouter(prefix="/api/agents", tags=["agents"])
_ACTIONS = {"start", "stop", "restart", "pull", "update", "down"}
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _to_read(a: Agent) -> AgentRead:
return AgentRead(
id=a.id,
name=a.name,
url=a.url,
status=a.status,
hostname=a.hostname,
last_seen=a.last_seen,
created_at=a.created_at,
token_set=bool(a.token),
)
def _get_or_404(session: Session, agent_id: int) -> Agent:
agent = session.get(Agent, agent_id)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found")
return agent
def _raise(exc: AgentError):
raise HTTPException(
status_code=exc.status if exc.status >= 400 else 502,
detail={"error": exc.error, "detail": exc.detail},
)
async def _proxy(session: Session, agent: Agent, method: str, path: str, **kw):
try:
return await agent_service.call(session, agent, method, path, **kw)
except AgentError as exc:
_raise(exc)
# --------------------------------------------------------------------------- #
# CRUD
# --------------------------------------------------------------------------- #
@router.get("", response_model=list[AgentRead])
async def list_agents(
refresh: bool = Query(True),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[AgentRead]:
agents = session.exec(select(Agent).order_by(Agent.id)).all()
if refresh:
for agent in agents:
await agent_service.ping(session, agent)
return [_to_read(a) for a in agents]
@router.post("", response_model=AgentRead, status_code=201)
async def create_agent(
body: AgentCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> AgentRead:
agent = Agent(name=body.name, url=body.url.rstrip("/"), token=body.token)
session.add(agent)
session.commit()
session.refresh(agent)
# Validate connectivity immediately (best-effort; agent is saved regardless).
await agent_service.ping(session, agent)
audit_service.record(
session, user=user.username, action="agent.create", target=agent.name,
detail=agent.url, ip=_ip(request),
)
return _to_read(agent)
@router.put("/{agent_id}", response_model=AgentRead)
async def update_agent(
agent_id: int,
body: AgentUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> AgentRead:
agent = _get_or_404(session, agent_id)
if body.name is not None:
agent.name = body.name
if body.url is not None:
agent.url = body.url.rstrip("/")
if body.token:
agent.token = body.token
agent.status = "unknown"
session.add(agent)
session.commit()
session.refresh(agent)
await agent_service.ping(session, agent)
audit_service.record(
session, user=user.username, action="agent.update", target=agent.name,
ip=_ip(request),
)
return _to_read(agent)
@router.delete("/{agent_id}")
def delete_agent(
agent_id: int,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
agent = _get_or_404(session, agent_id)
name = agent.name
session.delete(agent)
session.commit()
audit_service.record(
session, user=user.username, action="agent.delete", target=name, ip=_ip(request),
)
return {"ok": True}
@router.post("/{agent_id}/ping")
async def ping_agent(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
return await agent_service.ping(session, agent)
# --------------------------------------------------------------------------- #
# Proxied stack + system operations
# --------------------------------------------------------------------------- #
@router.get("/{agent_id}/system")
async def agent_system(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
):
agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/system")
@router.get("/{agent_id}/stacks")
async def agent_stacks(
agent_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> list[dict]:
agent = _get_or_404(session, agent_id)
stacks = await _proxy(session, agent, "GET", "/agent/stacks") or []
for s in stacks:
s["agent_id"] = agent.id
s["agent_name"] = agent.name
return stacks
@router.get("/{agent_id}/stacks/{stack_id}")
async def agent_stack_detail(
agent_id: int,
stack_id: str,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
agent = _get_or_404(session, agent_id)
data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}")
data["agent_id"] = agent.id
data["agent_name"] = agent.name
return data
@router.get("/{agent_id}/stacks/{stack_id}/logs")
async def agent_stack_logs(
agent_id: int,
stack_id: str,
tail: int = Query(200, le=2000),
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/stacks/{stack_id}/logs", params={"tail": tail}
)
@router.post("/{agent_id}/stacks", status_code=201)
async def agent_create_stack(
agent_id: int,
body: StackCreate,
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/stacks",
json={"name": body.name, "yaml": body.yaml, "env": body.env},
)
audit_service.record(
session, user=user.username, action="agent.stack.create",
target=f"{agent.name}/{result.get('id')}", ip=_ip(request),
)
return result
@router.put("/{agent_id}/stacks/{stack_id}")
async def agent_update_stack(
agent_id: int,
stack_id: str,
body: StackUpdate,
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, "PUT", f"/agent/stacks/{stack_id}",
json={"yaml": body.yaml, "env": body.env},
)
audit_service.record(
session, user=user.username, action="agent.stack.update",
target=f"{agent.name}/{stack_id}", ip=_ip(request),
)
return result
@router.delete("/{agent_id}/stacks/{stack_id}")
async def agent_delete_stack(
agent_id: int,
stack_id: str,
request: Request,
delete_files: bool = Query(True),
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/stacks/{stack_id}",
params={"delete_files": delete_files},
)
audit_service.record(
session, user=user.username, action="agent.stack.delete",
target=f"{agent.name}/{stack_id}", ip=_ip(request),
)
return result
@router.post("/{agent_id}/stacks/{stack_id}/{action}")
async def agent_lifecycle(
agent_id: int,
stack_id: str,
action: str,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict:
if action not in _ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action '{action}'")
agent = _get_or_404(session, agent_id)
result = await _proxy(session, agent, "POST", f"/agent/stacks/{stack_id}/{action}")
audit_service.record(
session, user=user.username, action=f"agent.stack.{action}",
target=f"{agent.name}/{stack_id}", ip=_ip(request),
)
return result
+115
View File
@@ -0,0 +1,115 @@
"""Talk to remote stackpilot-agent hosts over HTTP.
The central app stores an ``Agent`` row per remote host and proxies stack /
system calls to it using the agent's shared token. Connectivity state
(``status``, ``hostname``, ``last_seen``) is refreshed on every successful or
failed call so the UI can show a live dot per host.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any, Optional
import httpx
from sqlmodel import Session
from models.agent import Agent
logger = logging.getLogger("stackpilot.agent_proxy")
_TIMEOUT = 30.0
class AgentError(Exception):
def __init__(self, status: int, error: str, detail: str = ""):
self.status = status
self.error = error
self.detail = detail
super().__init__(f"{error}: {detail}" if detail else error)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _mark(session: Session, agent: Agent, status: str, hostname: Optional[str] = None) -> None:
agent.status = status
if status == "online":
agent.last_seen = _now()
if hostname:
agent.hostname = hostname
session.add(agent)
session.commit()
session.refresh(agent)
async def _request(
agent: Agent,
method: str,
path: str,
*,
params: Optional[dict] = None,
json: Any = None,
) -> httpx.Response:
url = agent.url.rstrip("/") + path
headers = {"Authorization": f"Bearer {agent.token}"}
async with httpx.AsyncClient(follow_redirects=True) as client:
return await client.request(
method, url, headers=headers, params=params, json=json, timeout=_TIMEOUT
)
async def call(
session: Session,
agent: Agent,
method: str,
path: str,
*,
params: Optional[dict] = None,
json: Any = None,
) -> Any:
"""Proxy a request to the agent, updating its status, returning parsed JSON."""
try:
resp = await _request(agent, method, path, params=params, json=json)
except httpx.HTTPError as exc:
_mark(session, agent, "offline")
raise AgentError(502, "agent_unreachable", str(exc)) from exc
if resp.status_code in (401, 403):
_mark(session, agent, "unauthorized")
raise AgentError(resp.status_code, "agent_unauthorized", "Invalid agent token")
_mark(session, agent, "online")
if resp.status_code >= 400:
detail = ""
try:
body = resp.json()
detail = body.get("detail") if isinstance(body, dict) else str(body)
if isinstance(detail, dict):
detail = detail.get("detail") or detail.get("error") or str(detail)
except ValueError:
detail = resp.text[:500]
raise AgentError(resp.status_code, "agent_error", str(detail))
if resp.content:
try:
return resp.json()
except ValueError:
return resp.text
return None
async def ping(session: Session, agent: Agent) -> dict:
"""Health-check an agent and refresh its status + hostname. Never raises."""
try:
data = await call(session, agent, "GET", "/agent/ping")
if isinstance(data, dict) and data.get("hostname"):
agent.hostname = data["hostname"]
session.add(agent)
session.commit()
session.refresh(agent)
return {"status": agent.status, "hostname": agent.hostname, "data": data}
except AgentError:
return {"status": agent.status, "hostname": agent.hostname, "data": None}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.4.0",
"version": "0.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -6,6 +6,7 @@ import { Dashboard } from "@/pages/Dashboard";
import { Stacks } from "@/pages/Stacks";
import { StackDetail } from "@/pages/StackDetail";
import { StackEditor } from "@/pages/StackEditor";
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
import { Images } from "@/pages/Images";
import { Templates } from "@/pages/Templates";
import { Settings } from "@/pages/Settings";
@@ -43,6 +44,7 @@ export default function App() {
<Route path="/stacks/new" element={<StackEditor />} />
<Route path="/stacks/:id" element={<StackDetail />} />
<Route path="/stacks/:id/edit" element={<StackEditor />} />
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
<Route path="/networks" element={<Networks />} />
<Route path="/images" element={<Images />} />
<Route path="/templates" element={<Templates />} />
+30
View File
@@ -0,0 +1,30 @@
import api from "./client";
import type { Agent, StackDetail, StackSummary } from "@/types";
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
export const agentsApi = {
list: (refresh = true) =>
api.get<Agent[]>(`/api/agents?refresh=${refresh}`).then((r) => r.data),
create: (body: { name: string; url: string; token: string }) =>
api.post<Agent>("/api/agents", body).then((r) => r.data),
update: (id: number, body: { name?: string; url?: string; token?: string }) =>
api.put<Agent>(`/api/agents/${id}`, body).then((r) => r.data),
remove: (id: number) => api.delete(`/api/agents/${id}`).then((r) => r.data),
ping: (id: number) =>
api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data),
stacks: (id: number) =>
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
stack: (id: number, stackId: string) =>
api.get<RemoteStackDetail>(`/api/agents/${id}/stacks/${stackId}`).then((r) => r.data),
logs: (id: number, stackId: string, tail = 200) =>
api
.get<{ logs: string }>(`/api/agents/${id}/stacks/${stackId}/logs?tail=${tail}`)
.then((r) => r.data),
update_stack: (id: number, stackId: string, body: { yaml?: string; env?: string }) =>
api.put(`/api/agents/${id}/stacks/${stackId}`, body).then((r) => r.data),
action: (id: number, stackId: string, action: string) =>
api.post(`/api/agents/${id}/stacks/${stackId}/${action}`).then((r) => r.data),
};
+17
View File
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";
const color: Record<string, string> = {
online: "bg-green-500",
offline: "bg-red-500",
unauthorized: "bg-amber-500",
unknown: "bg-slate-400",
};
export function HostDot({ status }: { status: string }) {
return (
<span
className={cn("inline-block h-2.5 w-2.5 rounded-full", color[status] ?? color.unknown)}
title={status}
/>
);
}
@@ -0,0 +1,78 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Server } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { HostDot } from "@/components/hosts/HostDot";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import type { Agent } from "@/types";
export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
const qc = useQueryClient();
const [busyId, setBusyId] = useState<string | null>(null);
const online = agent.status === "online";
const stacks = useQuery({
queryKey: ["agent-stacks", agent.id],
queryFn: () => agentsApi.stacks(agent.id),
enabled: online,
refetchInterval: 8000,
});
const run = async (action: string, label: string, id: string) => {
setBusyId(id);
const t = toast.loading(`${label} ${id} on ${agent.name}`);
try {
await agentsApi.action(agent.id, id, action);
toast.success(`${label} ${id}`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusyId(null);
}
};
return (
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Server className="h-4 w-4" />
{agent.name}
<HostDot status={agent.status} />
{agent.hostname && (
<span className="font-mono text-xs normal-case text-slate-400">{agent.hostname}</span>
)}
</h2>
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : stacks.data && stacks.data.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{stacks.data.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
linkBase={`/hosts/${agent.id}/stacks`}
showEdit={false}
onStart={(id) => run("start", "Starting", id)}
onStop={(id) => run("stop", "Stopping", id)}
onRestart={(id) => run("restart", "Restarting", id)}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks on this host.</p>
</Card>
)}
</section>
);
}
+14 -8
View File
@@ -11,6 +11,8 @@ interface Props {
onRestart: (id: string) => void;
busy?: boolean;
isAdmin?: boolean;
linkBase?: string; // detail/edit route prefix, default "/stacks"
showEdit?: boolean; // hide edit for remote stacks (no remote editor yet)
}
export function StackCard({
@@ -20,11 +22,13 @@ export function StackCard({
onRestart,
busy,
isAdmin,
linkBase = "/stacks",
showEdit = true,
}: Props) {
return (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<Link to={`/stacks/${stack.id}`} className="min-w-0">
<Link to={`${linkBase}/${stack.id}`} className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="truncate font-semibold hover:underline">
@@ -59,13 +63,15 @@ export function StackCard({
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4 text-sky-500" />
</IconBtn>
<Link
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
{showEdit && (
<Link
to={`${linkBase}/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
)}
</div>
)}
</Card>
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Power,
ArrowLeft,
Save,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { HostDot } from "@/components/hosts/HostDot";
import { agentsApi } from "@/api/agents";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
export function RemoteStackDetail() {
const { agentId = "", id = "" } = useParams();
const aid = Number(agentId);
const qc = useQueryClient();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const [busy, setBusy] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["agent-stack", aid, id],
queryFn: () => agentsApi.stack(aid, id),
refetchInterval: 5000,
});
const run = async (action: string, label: string) => {
setBusy(true);
const t = toast.loading(`${label} ${id}`);
try {
await agentsApi.action(aid, id, action);
toast.success(`${label} ${id}`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusy(false);
}
};
if (isLoading || !data) return <Spinner />;
return (
<div className="flex h-full flex-col space-y-4">
<Link
to="/stacks"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<ArrowLeft className="h-4 w-4" /> All stacks
</Link>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
<p className="mt-1 flex items-center gap-1 text-sm text-slate-500">
on <span className="font-medium">{data.agent_name}</span>
<HostDot status="online" />
</p>
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => run("start", "Starting")} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => run("stop", "Stopping")} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => run("restart", "Restarting")} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => run("pull", "Pulling")} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => run("update", "Updating")} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => run("down", "Tearing down")} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
</div>
)}
</div>
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview containers={data.containers} />}
{tab === "Logs" && <RemoteLogs agentId={aid} stackId={id} />}
{tab === "Environment" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="env"
value={data.env}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
{tab === "Compose" && (
<RemoteEditor
agentId={aid}
stackId={id}
field="yaml"
value={data.yaml}
canEdit={isAdmin}
queryKey={["agent-stack", aid, id]}
/>
)}
</div>
</div>
);
}
function Overview({ containers }: { containers: any[] }) {
return (
<div className="space-y-2 overflow-auto">
{containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">No containers running.</p>
</Card>
)}
{containers.map((c) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
</div>
</Card>
))}
</div>
);
}
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({
agentId,
stackId,
field,
value,
canEdit,
queryKey,
}: {
agentId: number;
stackId: string;
field: "yaml" | "env";
value: string;
canEdit: boolean;
queryKey: unknown[];
}) {
const qc = useQueryClient();
const [text, setText] = useState(value);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!editing) setText(value);
}, [value, editing]);
const save = async () => {
setSaving(true);
try {
const body = field === "yaml" ? { yaml: text } : { env: text };
await agentsApi.update_stack(agentId, stackId, body);
toast.success("Saved. Restart or update the stack to apply.");
setEditing(false);
qc.invalidateQueries({ queryKey });
} catch (e) {
toast.error(apiErrorMessage(e));
} finally {
setSaving(false);
}
};
if (!editing) {
return (
<Card className="flex h-full flex-col overflow-hidden">
{canEdit && (
<div className="mb-2 flex justify-end">
<Button variant="outline" onClick={() => setEditing(true)}>
Edit
</Button>
</div>
)}
{value ? (
<pre className="flex-1 overflow-auto whitespace-pre-wrap font-mono text-xs">{value}</pre>
) : (
<p className="text-sm text-slate-500">
{field === "env" ? "No .env file for this stack." : "Empty compose file."}
</p>
)}
</Card>
);
}
return (
<Card className="flex h-full flex-col gap-2 overflow-hidden">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
spellCheck={false}
className="flex-1 resize-none rounded-lg border border-slate-300 bg-white p-3 font-mono text-xs outline-none focus:border-accent dark:border-slate-600 dark:bg-slate-900"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditing(false)}>
Cancel
</Button>
<Button onClick={save} loading={saving}>
<Save className="h-4 w-4" /> Save
</Button>
</div>
</Card>
);
}
+135 -1
View File
@@ -9,6 +9,8 @@ import {
Users as UsersIcon,
ShieldCheck,
Power,
Server,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
@@ -18,9 +20,11 @@ import {
type Webhook,
type WebhookInput,
} from "@/api/settings";
import { agentsApi } from "@/api/agents";
import { HostDot } from "@/components/hosts/HostDot";
import { apiErrorMessage } from "@/api/client";
import { useAuthStore } from "@/store/auth";
import type { User } from "@/types";
import type { Agent, User } from "@/types";
export function Settings() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
@@ -40,12 +44,142 @@ export function Settings() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<GeneralSection />
<HostsSection />
<NotificationsSection />
<UsersSection />
</div>
);
}
/* -------------------------------------------------------------------------- */
/* Remote hosts (agents) */
/* -------------------------------------------------------------------------- */
function HostsSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const [adding, setAdding] = useState(false);
const invalidate = () => qc.invalidateQueries({ queryKey: ["agents"] });
return (
<section>
<SectionTitle icon={<Server className="h-4 w-4" />}>Remote hosts</SectionTitle>
<div className="space-y-3">
{isLoading ? (
<Spinner />
) : (
data?.map((a) => <HostRow key={a.id} agent={a} onChange={invalidate} />)
)}
{data?.length === 0 && !adding && (
<Card>
<p className="text-sm text-slate-500">
No remote hosts. Deploy <code>stackpilot-agent</code> on another host and
add it here to manage its stacks from this dashboard.
</p>
</Card>
)}
{adding ? (
<AddHostForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
) : (
<Button variant="outline" onClick={() => setAdding(true)}>
<Plus className="h-4 w-4" /> Add host
</Button>
)}
</div>
</section>
);
}
function HostRow({ agent, onChange }: { agent: Agent; onChange: () => void }) {
const ping = useMutation({
mutationFn: () => agentsApi.ping(agent.id),
onSuccess: (r) => {
toast[r.status === "online" ? "success" : "error"](`Host is ${r.status}`);
onChange();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
const remove = useMutation({
mutationFn: () => agentsApi.remove(agent.id),
onSuccess: () => { toast.success("Host removed"); onChange(); },
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<HostDot status={agent.status} />
<span className="font-medium">{agent.name}</span>
<span className="text-xs text-slate-400">{agent.status}</span>
{agent.hostname && (
<span className="font-mono text-xs text-slate-400">({agent.hostname})</span>
)}
</div>
<p className="break-all font-mono text-xs text-slate-500">{agent.url}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => ping.mutate()} loading={ping.isPending}>
<RefreshCw className="h-4 w-4" /> Check
</Button>
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</Card>
);
}
function AddHostForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [token, setToken] = useState("");
const create = useMutation({
mutationFn: () => agentsApi.create({ name, url, token }),
onSuccess: (a) => {
toast[a.status === "online" ? "success" : "error"](
a.status === "online" ? "Host added and reachable" : `Host added but ${a.status}`
);
onDone();
},
onError: (e) => toast.error(apiErrorMessage(e)),
});
return (
<Card className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Name</span>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="nas" />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-slate-500">Agent URL</span>
<Input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="http://10.0.0.5:5010" />
</label>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-slate-500">Shared token (AGENT_TOKEN)</span>
<Input type="password" value={token} onChange={(e) => setToken(e.target.value)} />
</label>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onCancel}>Cancel</Button>
<Button
onClick={() => create.mutate()}
loading={create.isPending}
disabled={!name.trim() || !url.trim() || !token}
>
Add host
</Button>
</div>
</Card>
);
}
/* -------------------------------------------------------------------------- */
/* General */
/* -------------------------------------------------------------------------- */
+42 -22
View File
@@ -1,11 +1,13 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search } from "lucide-react";
import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input, Spinner, Card } from "@/components/ui";
import { StackCard } from "@/components/stacks/StackCard";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
@@ -23,6 +25,13 @@ export function Stacks() {
refetchInterval: 5000,
});
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
const filtered = useMemo(() => {
let list = (data ?? []).filter(
(s) =>
@@ -68,27 +77,38 @@ export function Stacks() {
)}
</div>
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
<section>
{hasAgents && (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<HardDrive className="h-4 w-4" /> This host
</h2>
)}
{isLoading ? (
<Spinner />
) : filtered.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((s) => (
<StackCard
key={s.id}
stack={s}
isAdmin={isAdmin}
busy={busyId === s.id}
onStart={start}
onStop={stop}
onRestart={restart}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks match your search.</p>
</Card>
)}
</section>
{agents.data?.map((agent) => (
<AgentStacksSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
</div>
);
}
+14
View File
@@ -15,6 +15,20 @@ export interface StackSummary {
running_count: number;
created_at: string;
updated_at: string;
// present on stacks proxied from a remote host
agent_id?: number;
agent_name?: string;
}
export interface Agent {
id: number;
name: string;
url: string;
status: "online" | "offline" | "unauthorized" | "unknown";
hostname?: string | null;
last_seen?: string | null;
created_at: string;
token_set: boolean;
}
export interface ContainerInfo {