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
+161
View File
@@ -0,0 +1,161 @@
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Play,
Square,
RotateCw,
DownloadCloud,
ArrowUpCircle,
Pencil,
Power,
} from "lucide-react";
import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui";
import { LogViewer } from "@/components/stacks/LogViewer";
import { stacksApi } from "@/api/stacks";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
const TABS = ["Overview", "Logs", "Environment", "Compose"] as const;
type Tab = (typeof TABS)[number];
export function StackDetail() {
const { id = "" } = useParams();
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const [tab, setTab] = useState<Tab>("Overview");
const actions = useStackActions();
const { data, isLoading } = useQuery({
queryKey: ["stack", id],
queryFn: () => stacksApi.get(id),
refetchInterval: 5000,
});
if (isLoading || !data) return <Spinner />;
const busy = actions.busyId === id;
return (
<div className="flex h-full flex-col space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<StatusDot status={data.status} />
<h1 className="text-xl font-bold">{data.name}</h1>
<Badge status={data.status}>{data.status}</Badge>
</div>
{data.description && (
<p className="mt-1 text-sm text-slate-500">{data.description}</p>
)}
</div>
{isAdmin && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => actions.start(id)} loading={busy}>
<Play className="h-4 w-4 text-green-500" /> Start
</Button>
<Button variant="outline" onClick={() => actions.stop(id)} loading={busy}>
<Square className="h-4 w-4 text-red-500" /> Stop
</Button>
<Button variant="outline" onClick={() => actions.restart(id)} loading={busy}>
<RotateCw className="h-4 w-4 text-sky-500" /> Restart
</Button>
<Button variant="outline" onClick={() => actions.pull(id)} loading={busy}>
<DownloadCloud className="h-4 w-4" /> Pull
</Button>
<Button variant="outline" onClick={() => actions.updateImages(id)} loading={busy}>
<ArrowUpCircle className="h-4 w-4" /> Update
</Button>
<Button variant="outline" onClick={() => actions.down(id)} loading={busy}>
<Power className="h-4 w-4" /> Down
</Button>
<Link to={`/stacks/${id}/edit`}>
<Button>
<Pencil className="h-4 w-4" /> Edit
</Button>
</Link>
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{TABS.map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={
tab === t
? "border-b-2 border-accent px-4 py-2 text-sm font-medium text-accent dark:border-accent-dark dark:text-accent-dark"
: "px-4 py-2 text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
}
>
{t}
</button>
))}
</div>
<div className="flex-1 overflow-hidden">
{tab === "Overview" && <Overview data={data} />}
{tab === "Logs" && <LogViewer stackId={id} />}
{tab === "Environment" && <EnvView env={data.env} />}
{tab === "Compose" && <ComposeView yaml={data.yaml} />}
</div>
</div>
);
}
function Overview({ data }: { data: ReturnType<typeof Object> & any }) {
return (
<div className="space-y-2 overflow-auto">
{data.containers.length === 0 && (
<Card>
<p className="text-sm text-slate-500">
No containers running. Start the stack to see services.
</p>
</Card>
)}
{data.containers.map((c: any) => (
<Card key={c.id} className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<StatusDot status={c.state === "running" ? "running" : "stopped"} />
<div>
<p className="font-medium">{c.service}</p>
<p className="font-mono text-xs text-slate-500">{c.image}</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
{c.health && <Badge>{c.health}</Badge>}
<span>{c.status}</span>
{c.ports.length > 0 && (
<span className="font-mono">
{c.ports
.filter((p: any) => p.host_port)
.map((p: any) => `${p.host_port}${p.container}`)
.join(", ")}
</span>
)}
</div>
</Card>
))}
</div>
);
}
function EnvView({ env }: { env: string }) {
return (
<Card className="h-full overflow-auto">
{env ? (
<pre className="whitespace-pre-wrap font-mono text-xs">{env}</pre>
) : (
<p className="text-sm text-slate-500">No .env file for this stack.</p>
)}
</Card>
);
}
function ComposeView({ yaml }: { yaml: string }) {
return (
<Card className="h-full overflow-auto">
<pre className="whitespace-pre-wrap font-mono text-xs">{yaml}</pre>
</Card>
);
}