Phase 20: per-container inspect + start/stop/restart, local + agent (0.26.0)
Stack Overview now renders each service as an expandable ContainerCard with a
curated single-container inspect view and admin start/stop/restart buttons,
both for local stacks (GET/POST /api/containers/{id}[/{action}]) and remote
stacks (proxied via /api/agents/{id}/containers/* to the agent's new
/agent/containers/* endpoints). Only compose-managed containers are exposed.
Also bumps version 0.23.0 -> 0.26.0 (the bumps for the already-committed
Phase 18 image-prune / Phase 19 compose-validate were missed) and backfills
README sections for Phase 18/19/20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9c4d319f8f
commit
2f63247fc1
@@ -153,6 +153,33 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/disconnect`).
|
||||||
|
|
||||||
|
### Phase 20 — Container management
|
||||||
|
|
||||||
|
- The stack **Overview** tab now renders each service as an expandable
|
||||||
|
**container card** instead of a static row. Expanding it fetches a curated
|
||||||
|
single-container inspect view (image, state + exit code, restart count,
|
||||||
|
started-at, networks, mounts, and environment) via
|
||||||
|
`GET /api/containers/{id}`.
|
||||||
|
- Admins get **per-container start / stop / restart** buttons directly on the
|
||||||
|
card (`POST /api/containers/{id}/{action}`), so a single misbehaving service
|
||||||
|
can be bounced without touching the rest of the stack.
|
||||||
|
- Works for **remote stacks** too — the same card is used on the remote stack
|
||||||
|
detail page, proxied through `/api/agents/{id}/containers/*` to the agent's
|
||||||
|
new `/agent/containers/*` endpoints.
|
||||||
|
- Only containers carrying the `com.docker.compose.project` label are exposed,
|
||||||
|
so this never becomes a generic "control any container on the host" backdoor.
|
||||||
|
|
||||||
|
### Phase 19 — Compose validate & diff
|
||||||
|
|
||||||
|
- The editor can **validate** a compose file (`docker compose config`) before
|
||||||
|
deploying and show a **diff against the currently deployed** definition, so
|
||||||
|
you can see exactly what a re-deploy will change.
|
||||||
|
|
||||||
|
### Phase 18 — Image prune
|
||||||
|
|
||||||
|
- **Prune images** (dangling, or all unused) from the Images page, on the local
|
||||||
|
host and on each agent.
|
||||||
|
|
||||||
### Phase 17 — Multi-host dashboard
|
### Phase 17 — Multi-host dashboard
|
||||||
|
|
||||||
- The dashboard now shows, **per host** (local + each registered agent, online
|
- The dashboard now shows, **per host** (local + each registered agent, online
|
||||||
|
|||||||
+17
-1
@@ -40,6 +40,7 @@ from docker_client import DockerError, get_client, safe_call
|
|||||||
from services import (
|
from services import (
|
||||||
backup_service,
|
backup_service,
|
||||||
compose_service,
|
compose_service,
|
||||||
|
container_service,
|
||||||
device_service,
|
device_service,
|
||||||
file_service,
|
file_service,
|
||||||
image_service,
|
image_service,
|
||||||
@@ -62,7 +63,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.23.0"
|
AGENT_VERSION = "0.26.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -504,6 +505,21 @@ def image_prune(all_unused: bool = Query(False, alias="all")) -> dict:
|
|||||||
return image_service.prune_images(all_unused)
|
return image_service.prune_images(all_unused)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Containers (single-container inspect + lifecycle)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent/containers/{container_id}", dependencies=[Depends(verify_token)])
|
||||||
|
def inspect_container(container_id: str) -> dict:
|
||||||
|
return container_service.inspect_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/agent/containers/{container_id}/{action}", dependencies=[Depends(verify_token)])
|
||||||
|
def container_action(container_id: str, action: str) -> dict:
|
||||||
|
return container_service.container_action(container_id, action)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Volumes
|
# Volumes
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+3
-1
@@ -18,6 +18,7 @@ from routers import (
|
|||||||
audit,
|
audit,
|
||||||
auth,
|
auth,
|
||||||
backups,
|
backups,
|
||||||
|
containers,
|
||||||
destinations,
|
destinations,
|
||||||
editor,
|
editor,
|
||||||
files,
|
files,
|
||||||
@@ -55,7 +56,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.23.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.26.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -76,6 +77,7 @@ async def docker_error_handler(_request: Request, exc: DockerError):
|
|||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(stacks.router)
|
app.include_router(stacks.router)
|
||||||
|
app.include_router(containers.router)
|
||||||
app.include_router(system.router)
|
app.include_router(system.router)
|
||||||
app.include_router(volumes.router)
|
app.include_router(volumes.router)
|
||||||
app.include_router(editor.router)
|
app.include_router(editor.router)
|
||||||
|
|||||||
@@ -947,3 +947,39 @@ async def agent_files_upload(
|
|||||||
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
|
target=f"{agent.name}:{path}", detail=rel_path or file.filename, ip=_ip(request),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Containers (proxied)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/containers/{container_id}")
|
||||||
|
async def agent_container_inspect(
|
||||||
|
agent_id: int,
|
||||||
|
container_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
return await _proxy(session, agent, "GET", f"/agent/containers/{container_id}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{agent_id}/containers/{container_id}/{action}")
|
||||||
|
async def agent_container_action(
|
||||||
|
agent_id: int,
|
||||||
|
container_id: str,
|
||||||
|
action: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
result = await _proxy(
|
||||||
|
session, agent, "POST", f"/agent/containers/{container_id}/{action}"
|
||||||
|
)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action=f"agent.container.{action}",
|
||||||
|
target=f"{agent.name}/{container_id[:12]}", ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Single-container inspect + lifecycle for compose-managed containers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from auth import get_current_user, require_admin
|
||||||
|
from database import get_session
|
||||||
|
from models.user import User
|
||||||
|
from services import audit_service, container_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/containers", tags=["containers"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ip(request: Request) -> str:
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{container_id}")
|
||||||
|
def inspect(container_id: str, _user: User = Depends(get_current_user)) -> dict:
|
||||||
|
return container_service.inspect_container(container_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{container_id}/{action}")
|
||||||
|
def action(
|
||||||
|
container_id: str,
|
||||||
|
action: str,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
result = container_service.container_action(container_id, action)
|
||||||
|
audit_service.record(
|
||||||
|
session, user=user.username, action=f"container.{action}",
|
||||||
|
target=container_id[:12], ip=_ip(request),
|
||||||
|
)
|
||||||
|
return result
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Single-container inspect + lifecycle — shared by the central app and agent.
|
||||||
|
|
||||||
|
Only containers that belong to a compose-managed stack (i.e. carry the
|
||||||
|
``com.docker.compose.project`` label) are exposed, so this never becomes a
|
||||||
|
generic "control any container on the host" backdoor.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
|
||||||
|
COMPOSE_LABEL = "com.docker.compose.project"
|
||||||
|
SERVICE_LABEL = "com.docker.compose.service"
|
||||||
|
|
||||||
|
ACTIONS = {"start", "stop", "restart"}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_managed(container_id: str):
|
||||||
|
client = get_client()
|
||||||
|
container = safe_call(client.containers.get, container_id)
|
||||||
|
if COMPOSE_LABEL not in (container.labels or {}):
|
||||||
|
raise DockerError("not_managed", "container is not part of a managed stack")
|
||||||
|
return container
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_container(container_id: str) -> dict:
|
||||||
|
"""Return a curated inspect view for a single managed container."""
|
||||||
|
c = _get_managed(container_id)
|
||||||
|
attrs = c.attrs
|
||||||
|
state = attrs.get("State", {}) or {}
|
||||||
|
config = attrs.get("Config", {}) or {}
|
||||||
|
health = (state.get("Health") or {}).get("Status")
|
||||||
|
network_settings = attrs.get("NetworkSettings", {}) or {}
|
||||||
|
networks = network_settings.get("Networks", {}) or {}
|
||||||
|
|
||||||
|
mounts = []
|
||||||
|
for m in attrs.get("Mounts", []) or []:
|
||||||
|
mounts.append(
|
||||||
|
{
|
||||||
|
"type": m.get("Type"),
|
||||||
|
"source": m.get("Source") or m.get("Name"),
|
||||||
|
"destination": m.get("Destination"),
|
||||||
|
"mode": m.get("Mode"),
|
||||||
|
"rw": m.get("RW"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ports = []
|
||||||
|
for container_port, bindings in (network_settings.get("Ports") or {}).items():
|
||||||
|
if bindings:
|
||||||
|
for b in bindings:
|
||||||
|
ports.append(
|
||||||
|
{"container": container_port, "host_ip": b.get("HostIp"), "host_port": b.get("HostPort")}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ports.append({"container": container_port, "host_port": None})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"name": c.name,
|
||||||
|
"service": c.labels.get(SERVICE_LABEL, c.name),
|
||||||
|
"stack": c.labels.get(COMPOSE_LABEL),
|
||||||
|
"image": config.get("Image", "") or attrs.get("Image", ""),
|
||||||
|
"command": config.get("Cmd"),
|
||||||
|
"entrypoint": config.get("Entrypoint"),
|
||||||
|
"state": state.get("Status", c.status),
|
||||||
|
"status": state.get("Status", c.status),
|
||||||
|
"health": health,
|
||||||
|
"restart_count": attrs.get("RestartCount", 0),
|
||||||
|
"exit_code": state.get("ExitCode"),
|
||||||
|
"created": attrs.get("Created"),
|
||||||
|
"started_at": state.get("StartedAt"),
|
||||||
|
"finished_at": state.get("FinishedAt"),
|
||||||
|
"env": config.get("Env") or [],
|
||||||
|
"mounts": mounts,
|
||||||
|
"ports": ports,
|
||||||
|
"networks": sorted(networks.keys()),
|
||||||
|
"labels": c.labels or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def container_action(container_id: str, action: str) -> dict:
|
||||||
|
"""Start / stop / restart a single managed container."""
|
||||||
|
if action not in ACTIONS:
|
||||||
|
raise DockerError("bad_action", f"unsupported action '{action}'")
|
||||||
|
c = _get_managed(container_id)
|
||||||
|
if action == "start":
|
||||||
|
safe_call(c.start)
|
||||||
|
elif action == "stop":
|
||||||
|
safe_call(c.stop)
|
||||||
|
else:
|
||||||
|
safe_call(c.restart)
|
||||||
|
return {"ok": True, "action": action, "id": c.id}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.23.0",
|
"version": "0.26.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import api from "./client";
|
||||||
|
|
||||||
|
export interface ContainerDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
service: string;
|
||||||
|
stack: string | null;
|
||||||
|
image: string;
|
||||||
|
command: string[] | null;
|
||||||
|
entrypoint: string[] | null;
|
||||||
|
state: string;
|
||||||
|
status: string;
|
||||||
|
health?: string | null;
|
||||||
|
restart_count: number;
|
||||||
|
exit_code?: number | null;
|
||||||
|
created?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
env: string[];
|
||||||
|
mounts: {
|
||||||
|
type?: string;
|
||||||
|
source?: string;
|
||||||
|
destination?: string;
|
||||||
|
mode?: string;
|
||||||
|
rw?: boolean;
|
||||||
|
}[];
|
||||||
|
ports: { container: string; host_ip?: string; host_port?: string | null }[];
|
||||||
|
networks: string[];
|
||||||
|
labels: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContainerAction = "start" | "stop" | "restart";
|
||||||
|
|
||||||
|
// Base path for the local host or, when agentId is given, a remote agent.
|
||||||
|
const base = (agentId?: number) =>
|
||||||
|
agentId != null ? `/api/agents/${agentId}/containers` : "/api/containers";
|
||||||
|
|
||||||
|
export const containersApi = {
|
||||||
|
inspect: (id: string, agentId?: number) =>
|
||||||
|
api.get<ContainerDetail>(`${base(agentId)}/${id}`).then((r) => r.data),
|
||||||
|
action: (id: string, action: ContainerAction, agentId?: number) =>
|
||||||
|
api.post(`${base(agentId)}/${id}/${action}`).then((r) => r.data),
|
||||||
|
};
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Play, Square, RotateCw, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
|
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
||||||
|
import { containersApi, type ContainerAction } from "@/api/containers";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
import type { ContainerInfo } from "@/types";
|
||||||
|
|
||||||
|
export function ContainerCard({
|
||||||
|
container,
|
||||||
|
agentId,
|
||||||
|
host,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
container: ContainerInfo;
|
||||||
|
agentId?: number;
|
||||||
|
host?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const running = container.state === "running";
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["container", agentId ?? "local", container.id],
|
||||||
|
queryFn: () => containersApi.inspect(container.id, agentId),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const act = async (action: ContainerAction) => {
|
||||||
|
setBusy(true);
|
||||||
|
const t = toast.loading(`${action} ${container.service}…`);
|
||||||
|
try {
|
||||||
|
await containersApi.action(container.id, action, agentId);
|
||||||
|
toast.success(`${container.service}: ${action} ok`, { id: t });
|
||||||
|
onChanged?.();
|
||||||
|
if (open) detail.refetch();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e), { id: t });
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-0">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 p-3">
|
||||||
|
<button
|
||||||
|
className="flex min-w-0 items-center gap-3 text-left"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
)}
|
||||||
|
<StatusDot status={running ? "running" : "stopped"} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{container.service}</p>
|
||||||
|
<p className="truncate font-mono text-xs text-slate-500">{container.image}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||||||
|
{container.health && <Badge>{container.health}</Badge>}
|
||||||
|
<span>{container.status}</span>
|
||||||
|
<ContainerPorts ports={container.ports} host={host} />
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="outline" className="px-2 py-1" onClick={() => act("start")} disabled={busy || running}>
|
||||||
|
<Play className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="px-2 py-1" onClick={() => act("stop")} disabled={busy || !running}>
|
||||||
|
<Square className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="px-2 py-1" onClick={() => act("restart")} disabled={busy || !running}>
|
||||||
|
<RotateCw className="h-3.5 w-3.5 text-sky-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-slate-200 p-3 text-xs dark:border-slate-700">
|
||||||
|
{detail.isLoading ? (
|
||||||
|
<Spinner />
|
||||||
|
) : detail.data ? (
|
||||||
|
<div className="grid gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||||
|
<Field label="Container">
|
||||||
|
<span className="font-mono">{detail.data.name}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="ID">
|
||||||
|
<span className="font-mono">{detail.data.id.slice(0, 12)}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="State">
|
||||||
|
{detail.data.state}
|
||||||
|
{detail.data.exit_code != null && detail.data.state !== "running"
|
||||||
|
? ` (exit ${detail.data.exit_code})`
|
||||||
|
: ""}
|
||||||
|
</Field>
|
||||||
|
<Field label="Restarts">{detail.data.restart_count}</Field>
|
||||||
|
{detail.data.started_at && (
|
||||||
|
<Field label="Started">
|
||||||
|
{new Date(detail.data.started_at).toLocaleString()}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{detail.data.networks.length > 0 && (
|
||||||
|
<Field label="Networks">{detail.data.networks.join(", ")}</Field>
|
||||||
|
)}
|
||||||
|
{detail.data.mounts.length > 0 && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-1 font-medium text-slate-500">Mounts</p>
|
||||||
|
<ul className="space-y-0.5 font-mono">
|
||||||
|
{detail.data.mounts.map((m, i) => (
|
||||||
|
<li key={i} className="truncate">
|
||||||
|
{m.source} → {m.destination} {m.rw ? "(rw)" : "(ro)"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.data.env.length > 0 && (
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<p className="mb-1 font-medium text-slate-500">Environment</p>
|
||||||
|
<pre className="max-h-48 overflow-auto whitespace-pre-wrap font-mono">
|
||||||
|
{detail.data.env.join("\n")}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500">{apiErrorMessage(detail.error)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="w-20 shrink-0 font-medium text-slate-500">{label}</span>
|
||||||
|
<span className="min-w-0 break-words">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,11 +15,12 @@ import { toast } from "sonner";
|
|||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
import { HostDot } from "@/components/hosts/HostDot";
|
import { HostDot } from "@/components/hosts/HostDot";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
|
import type { ContainerInfo } from "@/types";
|
||||||
|
|
||||||
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
@@ -129,7 +130,15 @@ export function RemoteStackDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{tab === "Overview" && <Overview containers={data.containers} host={agentHost} />}
|
{tab === "Overview" && (
|
||||||
|
<Overview
|
||||||
|
containers={data.containers}
|
||||||
|
host={agentHost}
|
||||||
|
agentId={aid}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onChanged={() => qc.invalidateQueries({ queryKey: ["agent-stack", aid, id] })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "Logs" && (
|
{tab === "Logs" && (
|
||||||
<Card className="h-full overflow-hidden">
|
<Card className="h-full overflow-hidden">
|
||||||
<LogViewer stackId={id} agentId={aid} />
|
<LogViewer stackId={id} agentId={aid} />
|
||||||
@@ -160,7 +169,19 @@ export function RemoteStackDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Overview({ containers, host }: { containers: any[]; host?: string }) {
|
function Overview({
|
||||||
|
containers,
|
||||||
|
host,
|
||||||
|
agentId,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
containers: ContainerInfo[];
|
||||||
|
host?: string;
|
||||||
|
agentId: number;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 overflow-auto">
|
<div className="space-y-2 overflow-auto">
|
||||||
{containers.length === 0 && (
|
{containers.length === 0 && (
|
||||||
@@ -169,20 +190,14 @@ function Overview({ containers, host }: { containers: any[]; host?: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{containers.map((c) => (
|
{containers.map((c) => (
|
||||||
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
|
<ContainerCard
|
||||||
<div className="flex items-center gap-3">
|
key={c.id}
|
||||||
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
|
container={c}
|
||||||
<div>
|
agentId={agentId}
|
||||||
<p className="font-medium">{c.service}</p>
|
host={host}
|
||||||
<p className="font-mono text-xs text-slate-500">{c.image}</p>
|
isAdmin={isAdmin}
|
||||||
</div>
|
onChanged={onChanged}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
|
||||||
{c.health && <Badge>{c.health}</Badge>}
|
|
||||||
<span>{c.status}</span>
|
|
||||||
<ContainerPorts ports={c.ports ?? []} host={host} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import { toast } from "sonner";
|
|||||||
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
|
||||||
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerPorts } from "@/components/stacks/ContainerPorts";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
|
import type { ContainerInfo } from "@/types";
|
||||||
|
|
||||||
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
|
||||||
type Tab = (typeof TABS)[number];
|
type Tab = (typeof TABS)[number];
|
||||||
@@ -30,6 +31,7 @@ export function StackDetail() {
|
|||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
const [tab, setTab] = useState<Tab>("Overview");
|
const [tab, setTab] = useState<Tab>("Overview");
|
||||||
const actions = useStackActions();
|
const actions = useStackActions();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["stack", id],
|
queryKey: ["stack", id],
|
||||||
@@ -102,7 +104,13 @@ export function StackDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{tab === "Overview" && <Overview data={data} />}
|
{tab === "Overview" && (
|
||||||
|
<Overview
|
||||||
|
data={data}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onChanged={() => queryClient.invalidateQueries({ queryKey: ["stack", id] })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{tab === "Logs" && <LogViewer stackId={id} />}
|
{tab === "Logs" && <LogViewer stackId={id} />}
|
||||||
{tab === "Environment" && <EnvView env={data.env} />}
|
{tab === "Environment" && <EnvView env={data.env} />}
|
||||||
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
|
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
|
||||||
@@ -111,7 +119,15 @@ export function StackDetail() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
|
function Overview({
|
||||||
|
data,
|
||||||
|
isAdmin,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
data: ReturnType<typeof Object> & any;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 overflow-auto">
|
<div className="space-y-2 overflow-auto">
|
||||||
{data.containers.length === 0 && (
|
{data.containers.length === 0 && (
|
||||||
@@ -121,21 +137,8 @@ function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
|
|||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{data.containers.map((c: any) => (
|
{data.containers.map((c: ContainerInfo) => (
|
||||||
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
|
<ContainerCard key={c.id} container={c} isAdmin={isAdmin} onChanged={onChanged} />
|
||||||
<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>
|
|
||||||
<ContainerPorts ports={c.ports} />
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user