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
+17
View File
@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
export function Layout() {
return (
<div className="flex h-screen bg-bg text-slate-900 dark:bg-bg-dark dark:text-slate-100">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
import { NavLink } from "react-router-dom";
import {
LayoutDashboard,
Boxes,
Network,
Image,
LayoutTemplate,
Settings,
Moon,
Sun,
LogOut,
Ship,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useThemeStore } from "@/store/theme";
const nav = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image },
{ to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/settings", label: "Settings", icon: Settings },
];
export function Sidebar() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore();
return (
<aside className="flex w-60 flex-col border-r border-slate-200 bg-card dark:border-slate-700 dark:bg-card-dark">
<div className="flex items-center gap-2 px-5 py-5">
<Ship className="h-7 w-7 text-accent dark:text-accent-dark" />
<span className="text-lg font-bold">StackPilot</span>
</div>
<nav className="flex-1 space-y-1 px-3">
{nav.map(({ to, label, icon: Icon, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-accent/10 text-accent dark:bg-accent-dark/10 dark:text-accent-dark"
: "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
)
}
>
<Icon className="h-5 w-5" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-2 border-t border-slate-200 p-3 dark:border-slate-700">
<div className="flex items-center justify-between px-2">
<span className="text-sm text-slate-500 dark:text-slate-400">
{user?.username ?? "—"}
{user?.role === "admin" && (
<span className="ml-1 text-xs text-accent dark:text-accent-dark">
admin
</span>
)}
</span>
<button
onClick={toggle}
className="rounded p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
title="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
</div>
<button
onClick={logout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</div>
</aside>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useLocation } from "react-router-dom";
const titles: Record<string, string> = {
"": "Dashboard",
stacks: "Stacks",
networks: "Networks",
images: "Images",
templates: "Templates",
settings: "Settings",
};
export function Topbar() {
const { pathname } = useLocation();
const segment = pathname.split("/")[1] ?? "";
const title = titles[segment] ?? "StackPilot";
return (
<header className="flex h-14 items-center justify-between border-b border-slate-200 bg-card px-6 dark:border-slate-700 dark:bg-card-dark">
<h1 className="text-base font-semibold">{title}</h1>
</header>
);
}
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from "react";
import { ArrowDownToLine, Pause, Play } from "lucide-react";
import { Button } from "@/components/ui";
import { useAuthStore } from "@/store/auth";
const MAX_LINES = 2000;
const serviceColors = [
"text-sky-400",
"text-emerald-400",
"text-amber-400",
"text-fuchsia-400",
"text-rose-400",
"text-lime-400",
];
function colorFor(service: string | null): string {
if (!service) return "text-slate-300";
let h = 0;
for (const c of service) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return serviceColors[h % serviceColors.length];
}
export function LogViewer({ stackId }: { stackId: string }) {
const [lines, setLines] = useState<{ service: string | null; line: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [connected, setConnected] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const token = useAuthStore((s) => s.accessToken);
useEffect(() => {
if (!token) return;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${window.location.host}/ws/logs/${stackId}?token=${token}`;
const ws = new WebSocket(url);
ws.onopen = () => setConnected(true);
ws.onclose = () => setConnected(false);
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "log") {
setLines((prev) => {
const next = [...prev, { service: msg.service, line: msg.line }];
return next.length > MAX_LINES ? next.slice(-MAX_LINES) : next;
});
}
} catch {
/* ignore */
}
};
return () => ws.close();
}, [stackId, token]);
useEffect(() => {
if (autoScroll && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [lines, autoScroll]);
const download = () => {
const blob = new Blob([lines.map((l) => l.line).join("\n")], {
type: "text/plain",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${stackId}-logs.txt`;
a.click();
};
return (
<div className="flex h-full flex-col">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs text-slate-500">
{connected ? (
<span className="text-green-500"> live</span>
) : (
<span className="text-slate-400"> disconnected</span>
)}
<span className="ml-2">{lines.length} lines</span>
</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setAutoScroll((v) => !v)}>
{autoScroll ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{autoScroll ? "Pause scroll" : "Auto-scroll"}
</Button>
<Button variant="outline" onClick={download}>
<ArrowDownToLine className="h-4 w-4" /> Download
</Button>
</div>
</div>
<div
ref={containerRef}
className="flex-1 overflow-auto rounded-lg bg-slate-950 p-3 font-mono text-xs leading-relaxed"
>
{lines.length === 0 && (
<div className="text-slate-500">Waiting for log output</div>
)}
{lines.map((l, i) => (
<div key={i} className="whitespace-pre-wrap break-all">
{l.service && (
<span className={`mr-2 ${colorFor(l.service)}`}>{l.service}</span>
)}
<span className="text-slate-200">{l.line}</span>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,96 @@
import { Link } from "react-router-dom";
import { Play, Square, RotateCw, Pencil } from "lucide-react";
import { Card, StatusDot, Badge } from "@/components/ui";
import { relativeTime } from "@/lib/utils";
import type { StackSummary } from "@/types";
interface Props {
stack: StackSummary;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
busy?: boolean;
isAdmin?: boolean;
}
export function StackCard({
stack,
onStart,
onStop,
onRestart,
busy,
isAdmin,
}: Props) {
return (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<Link to={`/stacks/${stack.id}`} className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="truncate font-semibold hover:underline">
{stack.name}
</span>
</div>
{stack.description && (
<p className="mt-1 truncate text-sm text-slate-500">
{stack.description}
</p>
)}
</Link>
<Badge status={stack.status}>{stack.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>
{stack.running_count}/{stack.service_count} services
</span>
<span>·</span>
<span>updated {relativeTime(stack.updated_at)}</span>
</div>
{isAdmin && (
<div className="flex gap-1 border-t border-slate-100 pt-3 dark:border-slate-700">
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4 text-sky-500" />
</IconBtn>
<Link
to={`/stacks/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
</div>
)}
</Card>
);
}
function IconBtn({
children,
title,
onClick,
disabled,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
{children}
</button>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { StackStatus } from "@/types";
export function Card({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn(
"rounded-xl border border-slate-200 bg-card p-4 shadow-sm",
"dark:border-slate-700 dark:bg-card-dark",
className
)}
>
{children}
</div>
);
}
type Variant = "primary" | "ghost" | "danger" | "outline";
const variantClasses: Record<Variant, string> = {
primary:
"bg-accent text-white hover:bg-sky-600 dark:bg-accent-dark dark:text-slate-900 dark:hover:bg-sky-300",
ghost:
"bg-transparent text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700",
danger: "bg-red-600 text-white hover:bg-red-700",
outline:
"border border-slate-300 bg-transparent text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-700",
};
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
loading?: boolean;
}
export function Button({
variant = "primary",
loading,
className,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
"disabled:cursor-not-allowed disabled:opacity-50",
variantClasses[variant],
className
)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
);
}
export function Input({
className,
...props
}: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 outline-none",
"focus:border-accent focus:ring-1 focus:ring-accent",
"dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:border-accent-dark",
className
)}
{...props}
/>
);
}
const statusColor: Record<StackStatus, string> = {
running: "bg-green-500",
partial: "bg-yellow-500",
stopped: "bg-slate-400",
error: "bg-red-500",
updating: "bg-sky-500 animate-pulse",
unknown: "bg-slate-300",
};
export function StatusDot({ status }: { status: StackStatus }) {
return (
<span
className={cn("inline-block h-2.5 w-2.5 rounded-full", statusColor[status])}
title={status}
/>
);
}
export function Badge({
status,
children,
}: {
status?: StackStatus;
children: ReactNode;
}) {
const tone = status
? {
running: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
partial: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
stopped: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300",
error: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
updating: "bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300",
unknown: "bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400",
}[status]
: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium",
tone
)}
>
{children}
</span>
);
}
export function Spinner() {
return (
<div className="flex h-full w-full items-center justify-center p-8">
<Loader2 className="h-6 w-6 animate-spin text-accent dark:text-accent-dark" />
</div>
);
}