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:
menzelj
2026-06-07 16:04:58 +00:00
co-authored by Claude Opus 4.8
commit f732cb080b
59 changed files with 3677 additions and 0 deletions
+189
View File
@@ -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>
);
}