Files
stackpilot/frontend/src/pages/StackEditor.tsx
T
menzeljandClaude Opus 4.8 d46a6c3576 Remote deploy console: stream agent compose up to the browser (0.23.0)
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>
2026-06-08 19:44:31 +00:00

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>
);
}