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,131 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Cpu, MemoryStick, HardDrive, Container, Clock } from "lucide-react";
|
||||
import { Card, Spinner } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { systemApi } from "@/api/system";
|
||||
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
|
||||
export function Dashboard() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const { busyId, start, stop, restart } = useStackActions();
|
||||
|
||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
||||
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
||||
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Resource bar */}
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
||||
<Stat
|
||||
icon={<MemoryStick className="h-5 w-5" />}
|
||||
label="Memory"
|
||||
value={
|
||||
info.data
|
||||
? `${formatBytes(info.data.ram.used)} / ${formatBytes(info.data.ram.total)}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
icon={<Container className="h-5 w-5" />}
|
||||
label="Containers"
|
||||
value={
|
||||
info.data
|
||||
? `${info.data.containers_running} / ${info.data.containers_total}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
icon={<HardDrive className="h-5 w-5" />}
|
||||
label="Docker"
|
||||
value={info.data?.docker_version ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stacks grid */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
Stacks
|
||||
</h2>
|
||||
{stacks.isLoading ? (
|
||||
<Spinner />
|
||||
) : stacks.data && stacks.data.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{stacks.data.map((s) => (
|
||||
<StackCard
|
||||
key={s.id}
|
||||
stack={s}
|
||||
isAdmin={isAdmin}
|
||||
busy={busyId === s.id}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRestart={restart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
No stacks yet. Create one from the Stacks page.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent activity */}
|
||||
<section>
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Clock className="h-4 w-4" /> Recent activity
|
||||
</h2>
|
||||
<Card>
|
||||
{audit.data && audit.data.length > 0 ? (
|
||||
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
|
||||
{audit.data.map((a) => (
|
||||
<li key={a.id} className="flex items-center justify-between py-2">
|
||||
<span>
|
||||
<span className="font-medium">{a.user}</span>{" "}
|
||||
<span className="text-slate-500">{a.action}</span>{" "}
|
||||
<span className="font-mono text-xs text-accent dark:text-accent-dark">
|
||||
{a.target}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{relativeTime(a.timestamp)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No activity yet.</p>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-slate-500">{label}</p>
|
||||
<p className="truncate text-sm font-semibold">{value}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { Ship } from "lucide-react";
|
||||
import { Button, Card, Input } from "@/components/ui";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login, setup, accessToken } = useAuthStore();
|
||||
const [needsSetup, setNeedsSetup] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) navigate("/");
|
||||
axios
|
||||
.get("/api/auth/needs-setup")
|
||||
.then((r) => setNeedsSetup(r.data.needs_setup))
|
||||
.catch(() => {});
|
||||
}, [accessToken, navigate]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (needsSetup && password !== confirm) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (needsSetup) {
|
||||
await setup(username, password);
|
||||
toast.success("Admin account created");
|
||||
} else {
|
||||
await login(username, password);
|
||||
}
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-bg dark:bg-bg-dark">
|
||||
<Card className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<Ship className="h-10 w-10 text-accent dark:text-accent-dark" />
|
||||
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
StackPilot
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
{needsSetup ? "Create your admin account" : "Sign in to continue"}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<Input
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
autoFocus
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
{needsSetup && (
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<Button type="submit" loading={loading} className="w-full">
|
||||
{needsSetup ? "Create account" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Construction } from "lucide-react";
|
||||
import { Card } from "@/components/ui";
|
||||
|
||||
export function Placeholder({ title, phase }: { title: string; phase: string }) {
|
||||
return (
|
||||
<Card className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<Construction className="h-10 w-10 text-slate-400" />
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<p className="max-w-md text-sm text-slate-500">
|
||||
This section is part of {phase}. The backend foundation is ready — the UI
|
||||
lands in an upcoming build phase.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export const Networks = () => <Placeholder title="Networks" phase="Phase 2" />;
|
||||
export const Images = () => <Placeholder title="Images" phase="Phase 3" />;
|
||||
export const Templates = () => <Placeholder title="Templates" phase="Phase 3" />;
|
||||
export const Settings = () => <Placeholder title="Settings" phase="Phase 4" />;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { Button, Input, Spinner, Card } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
|
||||
type SortKey = "name" | "status" | "updated";
|
||||
|
||||
export function Stacks() {
|
||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||
const { busyId, start, stop, restart } = useStackActions();
|
||||
const [q, setQ] = useState("");
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stacks"],
|
||||
queryFn: stacksApi.list,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = (data ?? []).filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(q.toLowerCase()) ||
|
||||
s.id.toLowerCase().includes(q.toLowerCase())
|
||||
);
|
||||
list = [...list].sort((a, b) => {
|
||||
if (sort === "name") return a.name.localeCompare(b.name);
|
||||
if (sort === "status") return a.status.localeCompare(b.status);
|
||||
return b.updated_at.localeCompare(a.updated_at);
|
||||
});
|
||||
return list;
|
||||
}, [data, q, sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search stacks…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortKey)}
|
||||
className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
>
|
||||
<option value="name">Sort: Name</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="updated">Sort: Last updated</option>
|
||||
</select>
|
||||
{isAdmin && (
|
||||
<Link to="/stacks/new">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" /> New Stack
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : filtered.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((s) => (
|
||||
<StackCard
|
||||
key={s.id}
|
||||
stack={s}
|
||||
isAdmin={isAdmin}
|
||||
busy={busyId === s.id}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRestart={restart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">No stacks match your search.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user