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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user