Initial commit: StackPilot Phase 1 (Core)
Self-hosted Docker Compose manager. - Backend: FastAPI + docker-py + SQLite (JWT auth, file-first stacks, lifecycle, live status, WebSocket logs, docker-run converter, audit log) - Frontend: React + Vite + Tailwind (login/setup, dashboard, stacks, stack detail, Monaco editor, dark/light theme) - Deployment: docker-compose.yml, Dockerfiles, nginx reverse proxy Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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 { stacksApi } from "@/api/stacks";
|
||||
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 existing = useQuery({
|
||||
queryKey: ["stack", id],
|
||||
queryFn: () => stacksApi.get(id!),
|
||||
enabled: !isNew,
|
||||
});
|
||||
|
||||
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 {
|
||||
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) {
|
||||
const t = toast.loading("Deploying…");
|
||||
await stacksApi.start(stackId);
|
||||
toast.success("Deployed ✓", { id: t });
|
||||
}
|
||||
navigate(`/stacks/${stackId}`);
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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)}
|
||||
/>
|
||||
<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="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 }}
|
||||
/>
|
||||
) : (
|
||||
<Editor
|
||||
height="100%"
|
||||
language="ini"
|
||||
theme={theme === "dark" ? "vs-dark" : "light"}
|
||||
value={env}
|
||||
onChange={(v) => setEnv(v ?? "")}
|
||||
options={{ minimap: { enabled: false }, fontSize: 13 }}
|
||||
/>
|
||||
)}
|
||||
</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={() => save(true)} loading={saving}>
|
||||
<Rocket className="h-4 w-4" /> Deploy
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user