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