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:
co-authored by
Claude Opus 4.8
parent
8d19b09abd
commit
59037f4287
@@ -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.
|
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) complete. Multi-host agents are planned for a
|
> Life) + Phase 4 (Operations) + Phase 5 (Multi-host) complete. Remote backup
|
||||||
> later phase.
|
> destinations (SFTP/S3) are planned for a later phase.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## 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.
|
- **Audit log page**: searchable, paginated view of all recorded actions.
|
||||||
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
|
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
|
||||||
|
|
||||||
> **Not yet:** multi-host agents (a second deployable agent app + remote proxying)
|
### Phase 5 — Multi-host
|
||||||
> and remote backup destinations (SFTP/S3) are intentionally deferred to a future
|
|
||||||
> phase — backups currently download to / upload from the browser.
|
- **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
|
## Architecture
|
||||||
|
|
||||||
@@ -174,6 +195,21 @@ GET /api/auth/users POST /api/auth/users
|
|||||||
PATCH /api/auth/users/{id} DELETE /api/auth/users/{id}
|
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
|
## 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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"}
|
||||||
@@ -35,6 +35,10 @@ class Settings(BaseSettings):
|
|||||||
# Throwaway image used to read/write named-volume contents during backup.
|
# Throwaway image used to read/write named-volume contents during backup.
|
||||||
BACKUP_HELPER_IMAGE: str = "alpine:latest"
|
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
|
# Host browser sandbox roots
|
||||||
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
|
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
|
||||||
"/", "/mnt", "/media", "/srv", "/opt",
|
"/", "/mnt", "/media", "/srv", "/opt",
|
||||||
|
|||||||
+3
-1
@@ -14,6 +14,7 @@ from config import settings
|
|||||||
from database import engine, init_db
|
from database import engine, init_db
|
||||||
from docker_client import DockerError
|
from docker_client import DockerError
|
||||||
from routers import (
|
from routers import (
|
||||||
|
agents,
|
||||||
audit,
|
audit,
|
||||||
auth,
|
auth,
|
||||||
backups,
|
backups,
|
||||||
@@ -48,7 +49,7 @@ async def lifespan(app: FastAPI):
|
|||||||
update_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -78,6 +79,7 @@ app.include_router(templates.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(settings_router.router)
|
app.include_router(settings_router.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
|
app.include_router(agents.router)
|
||||||
app.include_router(ws.router)
|
app.include_router(ws.router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""SQLModel table models. Importing this package registers all tables."""
|
"""SQLModel table models. Importing this package registers all tables."""
|
||||||
|
from models.agent import Agent
|
||||||
from models.audit import AuditLog
|
from models.audit import AuditLog
|
||||||
from models.setting import Setting, Webhook
|
from models.setting import Setting, Webhook
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
|
||||||
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook"]
|
__all__ = ["User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent"]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Dashboard } from "@/pages/Dashboard";
|
|||||||
import { Stacks } from "@/pages/Stacks";
|
import { Stacks } from "@/pages/Stacks";
|
||||||
import { StackDetail } from "@/pages/StackDetail";
|
import { StackDetail } from "@/pages/StackDetail";
|
||||||
import { StackEditor } from "@/pages/StackEditor";
|
import { StackEditor } from "@/pages/StackEditor";
|
||||||
|
import { RemoteStackDetail } from "@/pages/RemoteStackDetail";
|
||||||
import { Images } from "@/pages/Images";
|
import { Images } from "@/pages/Images";
|
||||||
import { Templates } from "@/pages/Templates";
|
import { Templates } from "@/pages/Templates";
|
||||||
import { Settings } from "@/pages/Settings";
|
import { Settings } from "@/pages/Settings";
|
||||||
@@ -43,6 +44,7 @@ export default function App() {
|
|||||||
<Route path="/stacks/new" element={<StackEditor />} />
|
<Route path="/stacks/new" element={<StackEditor />} />
|
||||||
<Route path="/stacks/:id" element={<StackDetail />} />
|
<Route path="/stacks/:id" element={<StackDetail />} />
|
||||||
<Route path="/stacks/:id/edit" element={<StackEditor />} />
|
<Route path="/stacks/:id/edit" element={<StackEditor />} />
|
||||||
|
<Route path="/hosts/:agentId/stacks/:id" element={<RemoteStackDetail />} />
|
||||||
<Route path="/networks" element={<Networks />} />
|
<Route path="/networks" element={<Networks />} />
|
||||||
<Route path="/images" element={<Images />} />
|
<Route path="/images" element={<Images />} />
|
||||||
<Route path="/templates" element={<Templates />} />
|
<Route path="/templates" element={<Templates />} />
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ interface Props {
|
|||||||
onRestart: (id: string) => void;
|
onRestart: (id: string) => void;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
isAdmin?: 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({
|
export function StackCard({
|
||||||
@@ -20,11 +22,13 @@ export function StackCard({
|
|||||||
onRestart,
|
onRestart,
|
||||||
busy,
|
busy,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
linkBase = "/stacks",
|
||||||
|
showEdit = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
|
||||||
<div className="flex items-start justify-between">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<StatusDot status={stack.status} />
|
<StatusDot status={stack.status} />
|
||||||
<span className="truncate font-semibold hover:underline">
|
<span className="truncate font-semibold hover:underline">
|
||||||
@@ -59,13 +63,15 @@ export function StackCard({
|
|||||||
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
|
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
|
||||||
<RotateCw className="h-4 w-4 text-sky-500" />
|
<RotateCw className="h-4 w-4 text-sky-500" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<Link
|
{showEdit && (
|
||||||
to={`/stacks/${stack.id}/edit`}
|
<Link
|
||||||
title="Edit"
|
to={`${linkBase}/${stack.id}/edit`}
|
||||||
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
|
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>
|
<Pencil className="h-4 w-4 text-slate-500" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
Users as UsersIcon,
|
Users as UsersIcon,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Power,
|
Power,
|
||||||
|
Server,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} 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";
|
||||||
@@ -18,9 +20,11 @@ import {
|
|||||||
type Webhook,
|
type Webhook,
|
||||||
type WebhookInput,
|
type WebhookInput,
|
||||||
} from "@/api/settings";
|
} from "@/api/settings";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import type { User } from "@/types";
|
import type { Agent, User } from "@/types";
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
@@ -40,12 +44,142 @@ export function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="mx-auto max-w-3xl space-y-6">
|
||||||
<GeneralSection />
|
<GeneralSection />
|
||||||
|
<HostsSection />
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
<UsersSection />
|
<UsersSection />
|
||||||
</div>
|
</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 */
|
/* General */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { Button, Input, Spinner, Card } from "@/components/ui";
|
||||||
import { StackCard } from "@/components/stacks/StackCard";
|
import { StackCard } from "@/components/stacks/StackCard";
|
||||||
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
import { RestoreButton } from "@/components/stacks/BackupRestore";
|
||||||
|
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { agentsApi } from "@/api/agents";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
|
|
||||||
@@ -23,6 +25,13 @@ export function Stacks() {
|
|||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agents = useQuery({
|
||||||
|
queryKey: ["agents"],
|
||||||
|
queryFn: () => agentsApi.list(),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
const hasAgents = (agents.data?.length ?? 0) > 0;
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = (data ?? []).filter(
|
let list = (data ?? []).filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
@@ -68,27 +77,38 @@ export function Stacks() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<section>
|
||||||
<Spinner />
|
{hasAgents && (
|
||||||
) : filtered.length > 0 ? (
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<HardDrive className="h-4 w-4" /> This host
|
||||||
{filtered.map((s) => (
|
</h2>
|
||||||
<StackCard
|
)}
|
||||||
key={s.id}
|
{isLoading ? (
|
||||||
stack={s}
|
<Spinner />
|
||||||
isAdmin={isAdmin}
|
) : filtered.length > 0 ? (
|
||||||
busy={busyId === s.id}
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
onStart={start}
|
{filtered.map((s) => (
|
||||||
onStop={stop}
|
<StackCard
|
||||||
onRestart={restart}
|
key={s.id}
|
||||||
/>
|
stack={s}
|
||||||
))}
|
isAdmin={isAdmin}
|
||||||
</div>
|
busy={busyId === s.id}
|
||||||
) : (
|
onStart={start}
|
||||||
<Card>
|
onStop={stop}
|
||||||
<p className="text-sm text-slate-500">No stacks match your search.</p>
|
onRestart={restart}
|
||||||
</Card>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ export interface StackSummary {
|
|||||||
running_count: number;
|
running_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export interface ContainerInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user