Files
stackpilot/frontend/src/pages/Stacks.tsx
T
menzeljandClaude Opus 4.8 cd15cdc75e 0.36.0: per-stack image-update indicator on the Stacks overview
Shows an amber "Update" pill next to a stack's status (and highlights the
inline Update button) when any of the stack's images has a newer digest in
the registry. Reuses the existing background image-update check — a new
update_service.stacks_update_summary() reads the cached digests in a single
container sweep (no extra registry calls), exposed as GET /api/stacks/updates
and proxied per agent at GET /api/agents/{id}/stacks/updates. The Stacks page
and each remote-host section poll it every 60s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:52:51 +00:00

157 lines
5.3 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Plus, Search, HardDrive } from "lucide-react";
import { Button, Input } from "@/components/ui";
import { StacksTable } from "@/components/stacks/StacksTable";
import { RestoreButton } from "@/components/stacks/BackupRestore";
import { AgentStacksSection } from "@/components/stacks/AgentStacksSection";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { agentsApi } from "@/api/agents";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
type SortKey = "name" | "status" | "updated";
type StatusFilter = "all" | "running" | "stopped" | "attention";
const STATUS_FILTERS: Record<string, StatusFilter> = {
running: "running",
stopped: "stopped",
attention: "attention",
};
export function Stacks() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart, updateImages } = useStackActions();
// The dashboard explore bar deep-links here with ?q= / ?filter=.
const [params] = useSearchParams();
const [q, setQ] = useState(params.get("q") ?? "");
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
STATUS_FILTERS[params.get("filter") ?? ""] ?? "all"
);
const [sort, setSort] = useState<SortKey>("name");
const { data, isLoading } = useQuery({
queryKey: ["stacks"],
queryFn: stacksApi.list,
refetchInterval: 5000,
});
const stats = useQuery({
queryKey: ["stack-stats"],
queryFn: stacksApi.stats,
refetchInterval: 5000,
});
const updates = useQuery({
queryKey: ["stack-updates"],
queryFn: stacksApi.updates,
refetchInterval: 60000,
});
const info = useQuery({
queryKey: ["system"],
queryFn: systemApi.info,
refetchInterval: 5000,
});
const agents = useQuery({
queryKey: ["agents"],
queryFn: () => agentsApi.list(),
refetchInterval: 15000,
});
const hasAgents = (agents.data?.length ?? 0) > 0;
const filtered = useMemo(() => {
let list = (data ?? []).filter(
(s) =>
s.name.toLowerCase().includes(q.toLowerCase()) ||
s.id.toLowerCase().includes(q.toLowerCase())
);
if (statusFilter !== "all") {
list = list.filter((s) =>
statusFilter === "attention"
? s.status === "error" || s.status === "partial"
: s.status === statusFilter
);
}
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, statusFilter, 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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
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="all">All statuses</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
<option value="attention">Needs attention</option>
</select>
<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 && <RestoreButton />}
{isAdmin && (
<Link to="/stacks/new">
<Button>
<Plus className="h-4 w-4" /> New Stack
</Button>
</Link>
)}
</div>
<section>
{hasAgents && (
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<HardDrive className="h-4 w-4" /> This host
</h2>
)}
<StacksTable
stacks={filtered}
stats={stats.data}
updates={updates.data}
hostCpus={info.data?.cpu_cores ?? 0}
hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin}
busyId={busyId}
loading={isLoading}
showEdit
showDelete
onStart={start}
onStop={stop}
onRestart={restart}
onUpdate={isAdmin ? updateImages : undefined}
emptyText={q ? "No stacks match your search." : "No stacks yet. Create one with “New Stack”."}
/>
</section>
{agents.data?.map((agent) => (
<AgentStacksSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
</div>
);
}