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,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