Extends the live deploy console to remote/agent stacks. New agent WS endpoint
`/agent/ws/deploy/{stack_id}` runs `compose up -d` and streams its output; the
central app proxies it through `/ws/agent-deploy/{agent_id}/{stack_id}` (same
pattern + token URL-encoding as the agent-logs proxy) and records an
`agent.stack.start` audit entry. The editor's remote Deploy path now opens the
DeployConsole (agentId) instead of the blocking `agentsApi.action(start)`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
4.2 KiB
TypeScript
115 lines
4.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
|
import { Button } from "@/components/ui";
|
|
import { useAuthStore } from "@/store/auth";
|
|
|
|
const MAX_LINES = 2000;
|
|
|
|
type Phase = "running" | "success" | "failed" | "error";
|
|
|
|
/**
|
|
* Modal that runs `compose up -d` over the `/ws/deploy/{id}` WebSocket and
|
|
* streams its output (image pulls, container creation) live, so the user sees
|
|
* deploy progress instead of a blind spinner. The compose subprocess keeps
|
|
* running on the server even if this modal is closed early.
|
|
*/
|
|
export function DeployConsole({
|
|
stackId,
|
|
agentId,
|
|
onClose,
|
|
}: {
|
|
stackId: string;
|
|
agentId?: number;
|
|
onClose: () => void;
|
|
}) {
|
|
const [lines, setLines] = useState<string[]>([]);
|
|
const [phase, setPhase] = useState<Phase>("running");
|
|
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
const token = useAuthStore((s) => s.accessToken);
|
|
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const path =
|
|
agentId != null
|
|
? `/ws/agent-deploy/${agentId}/${stackId}`
|
|
: `/ws/deploy/${stackId}`;
|
|
const url = `${proto}://${window.location.host}${path}?token=${token}`;
|
|
const ws = new WebSocket(url);
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === "log") {
|
|
setLines((prev) => {
|
|
const next = [...prev, msg.line as string];
|
|
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
|
|
});
|
|
} else if (msg.type === "done") {
|
|
setPhase(msg.returncode === 0 ? "success" : "failed");
|
|
} else if (msg.type === "error") {
|
|
setPhase("error");
|
|
setErrorDetail(msg.detail || "Deploy error");
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
ws.onclose = () => {
|
|
// If the socket dropped before a done/error frame, surface it.
|
|
setPhase((p) => (p === "running" ? "error" : p));
|
|
};
|
|
return () => ws.close();
|
|
}, [stackId, agentId, token]);
|
|
|
|
useEffect(() => {
|
|
if (boxRef.current) boxRef.current.scrollTop = boxRef.current.scrollHeight;
|
|
}, [lines]);
|
|
|
|
const done = phase !== "running";
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
|
<div className="flex max-h-[80vh] w-full max-w-3xl flex-col rounded-xl border border-slate-200 bg-card shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
|
<div className="flex items-center gap-2 border-b border-slate-200 px-5 py-3 dark:border-slate-700">
|
|
{phase === "running" && (
|
|
<Loader2 className="h-5 w-5 animate-spin text-accent dark:text-accent-dark" />
|
|
)}
|
|
{phase === "success" && <CheckCircle2 className="h-5 w-5 text-green-500" />}
|
|
{(phase === "failed" || phase === "error") && (
|
|
<XCircle className="h-5 w-5 text-red-500" />
|
|
)}
|
|
<h2 className="text-lg font-semibold">
|
|
{phase === "running" && `Deploying ${stackId}…`}
|
|
{phase === "success" && `Deployed ${stackId} ✓`}
|
|
{phase === "failed" && `Deploy of ${stackId} failed`}
|
|
{phase === "error" && `Deploy of ${stackId} errored`}
|
|
</h2>
|
|
</div>
|
|
|
|
<div
|
|
ref={boxRef}
|
|
className="min-h-[200px] flex-1 overflow-auto bg-slate-950 p-3 font-mono text-xs leading-relaxed text-slate-200"
|
|
>
|
|
{lines.length === 0 && phase === "running" ? (
|
|
<span className="text-slate-500">Starting compose up…</span>
|
|
) : (
|
|
lines.map((l, i) => (
|
|
<div key={i} className="whitespace-pre-wrap break-all">
|
|
{l}
|
|
</div>
|
|
))
|
|
)}
|
|
{errorDetail && <div className="mt-2 text-red-400">{errorDetail}</div>}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2 border-t border-slate-200 px-5 py-3 dark:border-slate-700">
|
|
<Button variant={done ? "primary" : "outline"} onClick={onClose}>
|
|
{done ? "Close" : "Close (deploy continues)"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|