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>
157 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
}
|