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>
299 lines
9.4 KiB
TypeScript
299 lines
9.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import Editor from "@monaco-editor/react";
|
|
import { Rocket, Save, Wand2, FileCode } from "lucide-react";
|
|
import { Button, Card, Input } from "@/components/ui";
|
|
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
|
|
import { EnvEditor } from "@/components/env/EnvEditor";
|
|
import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
|
|
import { DeployConsole } from "@/components/stacks/DeployConsole";
|
|
import { stacksApi } from "@/api/stacks";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { portsApi, type PortConflict } from "@/api/ports";
|
|
import { apiErrorMessage } from "@/api/client";
|
|
import { useThemeStore } from "@/store/theme";
|
|
import { toast } from "sonner";
|
|
|
|
const STARTER = `services:
|
|
app:
|
|
image: nginx:alpine
|
|
restart: unless-stopped
|
|
ports:
|
|
- "8080:80"
|
|
`;
|
|
|
|
export function StackEditor() {
|
|
const { id } = useParams();
|
|
const isNew = !id;
|
|
const navigate = useNavigate();
|
|
const qc = useQueryClient();
|
|
const theme = useThemeStore((s) => s.theme);
|
|
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [yaml, setYaml] = useState(STARTER);
|
|
const [env, setEnv] = useState("");
|
|
const [tab, setTab] = useState<"compose" | "env">("compose");
|
|
const [saving, setSaving] = useState(false);
|
|
const [convertOpen, setConvertOpen] = useState(false);
|
|
const [runCmd, setRunCmd] = useState("");
|
|
const [conflicts, setConflicts] = useState<PortConflict[] | null>(null);
|
|
const [checking, setChecking] = useState(false);
|
|
const [host, setHost] = useState("local");
|
|
const [deployId, setDeployId] = useState<string | null>(null);
|
|
const [deployAgentId, setDeployAgentId] = useState<number | undefined>(undefined);
|
|
|
|
const existing = useQuery({
|
|
queryKey: ["stack", id],
|
|
queryFn: () => stacksApi.get(id!),
|
|
enabled: !isNew,
|
|
});
|
|
|
|
const agents = useQuery({
|
|
queryKey: ["agents"],
|
|
queryFn: () => agentsApi.list(),
|
|
enabled: isNew,
|
|
});
|
|
const onlineAgents = (agents.data ?? []).filter((a) => a.status === "online");
|
|
const remote = isNew && host !== "local";
|
|
|
|
useEffect(() => {
|
|
if (existing.data) {
|
|
setName(existing.data.name);
|
|
setDescription(existing.data.description ?? "");
|
|
setYaml(existing.data.yaml || STARTER);
|
|
setEnv(existing.data.env || "");
|
|
}
|
|
}, [existing.data]);
|
|
|
|
const save = async (deploy: boolean) => {
|
|
if (isNew && !name.trim()) {
|
|
toast.error("Stack name is required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
// Remote host: create the stack on the agent, then optionally start it.
|
|
if (remote) {
|
|
const aid = Number(host);
|
|
const created = await agentsApi.createStack(aid, { name, yaml, env });
|
|
qc.invalidateQueries({ queryKey: ["agent-stacks", aid] });
|
|
toast.success("Saved");
|
|
if (deploy) {
|
|
// Stream the remote deploy live through the agent-deploy WS proxy.
|
|
setDeployAgentId(aid);
|
|
setDeployId(created.id);
|
|
return;
|
|
}
|
|
navigate(`/hosts/${aid}/stacks/${created.id}`);
|
|
return;
|
|
}
|
|
|
|
let stackId = id;
|
|
if (isNew) {
|
|
const created = await stacksApi.create({ name, description, yaml, env });
|
|
stackId = created.id;
|
|
} else {
|
|
await stacksApi.update(id!, { name, description, yaml, env });
|
|
}
|
|
qc.invalidateQueries({ queryKey: ["stacks"] });
|
|
qc.invalidateQueries({ queryKey: ["stack", stackId] });
|
|
toast.success("Saved");
|
|
if (deploy && stackId) {
|
|
// Stream the deploy live in the console modal instead of a blind
|
|
// spinner; navigation happens when the user closes it.
|
|
setDeployId(stackId);
|
|
return;
|
|
}
|
|
navigate(`/stacks/${stackId}`);
|
|
} catch (err) {
|
|
toast.error(apiErrorMessage(err));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const onDeploy = async () => {
|
|
// The local port-conflict check doesn't apply to remote hosts.
|
|
if (remote) {
|
|
save(true);
|
|
return;
|
|
}
|
|
setChecking(true);
|
|
try {
|
|
const found = await portsApi.conflicts(yaml, id);
|
|
if (found.length > 0) {
|
|
setConflicts(found);
|
|
return;
|
|
}
|
|
} catch {
|
|
/* if the check fails, fall through and let compose surface errors */
|
|
} finally {
|
|
setChecking(false);
|
|
}
|
|
save(true);
|
|
};
|
|
|
|
const convert = async () => {
|
|
try {
|
|
const { yaml: converted } = await stacksApi.convert(runCmd);
|
|
setYaml(converted);
|
|
setConvertOpen(false);
|
|
setRunCmd("");
|
|
toast.success("Converted to compose");
|
|
} catch (err) {
|
|
toast.error(apiErrorMessage(err));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col space-y-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Input
|
|
className="max-w-xs"
|
|
placeholder="Stack name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
disabled={!isNew}
|
|
/>
|
|
<Input
|
|
className="max-w-md flex-1"
|
|
placeholder="Description (optional)"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
/>
|
|
{isNew && onlineAgents.length > 0 && (
|
|
<select
|
|
value={host}
|
|
onChange={(e) => setHost(e.target.value)}
|
|
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
|
title="Target host"
|
|
>
|
|
<option value="local">This host</option>
|
|
{onlineAgents.map((a) => (
|
|
<option key={a.id} value={String(a.id)}>
|
|
{a.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
<Button variant="outline" onClick={() => setConvertOpen((v) => !v)}>
|
|
<Wand2 className="h-4 w-4" /> Convert docker run
|
|
</Button>
|
|
</div>
|
|
|
|
{convertOpen && (
|
|
<Card className="flex items-center gap-2">
|
|
<Input
|
|
placeholder="docker run -d --name web -p 8080:80 nginx:alpine"
|
|
value={runCmd}
|
|
onChange={(e) => setRunCmd(e.target.value)}
|
|
/>
|
|
<Button onClick={convert}>Convert</Button>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
|
|
<TabBtn active={tab === "compose"} onClick={() => setTab("compose")}>
|
|
<FileCode className="h-4 w-4" /> compose.yaml
|
|
</TabBtn>
|
|
<TabBtn active={tab === "env"} onClick={() => setTab("env")}>
|
|
.env
|
|
</TabBtn>
|
|
</div>
|
|
|
|
<div className="flex min-h-0 flex-1 gap-3">
|
|
{/* Editor */}
|
|
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
|
{tab === "compose" ? (
|
|
<Editor
|
|
height="100%"
|
|
language="yaml"
|
|
theme={theme === "dark" ? "vs-dark" : "light"}
|
|
value={yaml}
|
|
onChange={(v) => setYaml(v ?? "")}
|
|
options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }}
|
|
/>
|
|
) : (
|
|
<div className="h-full p-3">
|
|
<EnvEditor value={env} onChange={setEnv} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Helper panel (Volumes / GPU / Devices) */}
|
|
{tab === "compose" && (
|
|
<div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
|
|
<EditorHelperPanel yaml={yaml} onYaml={setYaml} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => save(false)} loading={saving}>
|
|
<Save className="h-4 w-4" /> Save Draft
|
|
</Button>
|
|
<Button onClick={onDeploy} loading={saving || checking}>
|
|
<Rocket className="h-4 w-4" /> Deploy
|
|
</Button>
|
|
</div>
|
|
|
|
{conflicts && (
|
|
<PortConflictDialog
|
|
conflicts={conflicts}
|
|
onContinue={() => {
|
|
setConflicts(null);
|
|
save(true);
|
|
}}
|
|
onCancel={() => setConflicts(null)}
|
|
/>
|
|
)}
|
|
|
|
{deployId && (
|
|
<DeployConsole
|
|
stackId={deployId}
|
|
agentId={deployAgentId}
|
|
onClose={() => {
|
|
const sid = deployId;
|
|
const aid = deployAgentId;
|
|
setDeployId(null);
|
|
setDeployAgentId(undefined);
|
|
if (aid != null) {
|
|
qc.invalidateQueries({ queryKey: ["agent-stacks", aid] });
|
|
navigate(`/hosts/${aid}/stacks/${sid}`);
|
|
} else {
|
|
qc.invalidateQueries({ queryKey: ["stacks"] });
|
|
qc.invalidateQueries({ queryKey: ["stack", sid] });
|
|
navigate(`/stacks/${sid}`);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TabBtn({
|
|
active,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={
|
|
active
|
|
? "flex items-center gap-1 border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
|
|
: "flex items-center gap-1 px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|