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(null); const [checking, setChecking] = useState(false); const [host, setHost] = useState("local"); const [deployId, setDeployId] = useState(null); const [deployAgentId, setDeployAgentId] = useState(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 (
setName(e.target.value)} disabled={!isNew} /> setDescription(e.target.value)} /> {isNew && onlineAgents.length > 0 && ( )}
{convertOpen && ( setRunCmd(e.target.value)} /> )}
setTab("compose")}> compose.yaml setTab("env")}> .env
{/* Editor */}
{tab === "compose" ? ( setYaml(v ?? "")} options={{ minimap: { enabled: false }, fontSize: 13, tabSize: 2 }} /> ) : (
)}
{/* Helper panel (Volumes / GPU / Devices) */} {tab === "compose" && (
)}
{conflicts && ( { setConflicts(null); save(true); }} onCancel={() => setConflicts(null)} /> )} {deployId && ( { 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}`); } }} /> )}
); } function TabBtn({ active, onClick, children, }: { active: boolean; onClick: () => void; children: React.ReactNode; }) { return ( ); }