From 8fc61b1531d05dcd3d1c9a172da6f42394d983dc Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 16:56:53 +0000 Subject: [PATCH] Stacks page: table layout with usage meters, matching the dashboard (0.21.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stacks tab now lists stacks in the shared StacksTable (status, CPU and memory meters, inline start/stop/restart) instead of cards, for both the local host and per-agent sections — same look as the dashboard. StacksTable gained optional showEdit/showDelete props so the management surface keeps the Edit link and local Delete (with confirm). Search and sort are unchanged. Removed the now-unused StackCard component. Co-Authored-By: Claude Opus 4.8 --- backend/agent_app.py | 2 +- backend/main.py | 2 +- frontend/package.json | 2 +- .../components/stacks/AgentStacksSection.tsx | 47 +++--- frontend/src/components/stacks/StackCard.tsx | 154 ------------------ .../src/components/stacks/StacksTable.tsx | 62 ++++++- frontend/src/pages/Stacks.tsx | 52 +++--- 7 files changed, 120 insertions(+), 201 deletions(-) delete mode 100644 frontend/src/components/stacks/StackCard.tsx diff --git a/backend/agent_app.py b/backend/agent_app.py index cecdd55..abf1eea 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -62,7 +62,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.21.3" +AGENT_VERSION = "0.21.4" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index 163cfd4..3364377 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.21.3", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.21.4", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/frontend/package.json b/frontend/package.json index 23ba9f4..f0c4e2f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.21.3", + "version": "0.21.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/stacks/AgentStacksSection.tsx b/frontend/src/components/stacks/AgentStacksSection.tsx index a4e14e2..14d655e 100644 --- a/frontend/src/components/stacks/AgentStacksSection.tsx +++ b/frontend/src/components/stacks/AgentStacksSection.tsx @@ -3,7 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Server } from "lucide-react"; import { toast } from "sonner"; import { Card } from "@/components/ui"; -import { StackCard } from "@/components/stacks/StackCard"; +import { StacksTable } from "@/components/stacks/StacksTable"; import { RestoreButton } from "@/components/stacks/BackupRestore"; import { HostDot } from "@/components/hosts/HostDot"; import { agentsApi } from "@/api/agents"; @@ -21,6 +21,18 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: enabled: online, refetchInterval: 8000, }); + const stats = useQuery({ + queryKey: ["agent-stack-stats", agent.id], + queryFn: () => agentsApi.stackStats(agent.id), + enabled: online, + refetchInterval: 5000, + }); + const sys = useQuery({ + queryKey: ["agent-system", agent.id], + queryFn: () => agentsApi.system(agent.id), + enabled: online, + refetchInterval: 30000, + }); const run = async (action: string, label: string, id: string) => { setBusyId(id); @@ -56,26 +68,21 @@ export function AgentStacksSection({ agent, isAdmin }: { agent: Agent; isAdmin: Host is {agent.status}. Check it under Settings → Remote hosts.

- ) : stacks.data && stacks.data.length > 0 ? ( -
- {stacks.data.map((s) => ( - run("start", "Starting", id)} - onStop={(id) => run("stop", "Stopping", id)} - onRestart={(id) => run("restart", "Restarting", id)} - /> - ))} -
) : ( - -

No stacks on this host.

-
+ run("start", "Starting", id)} + onStop={(id) => run("stop", "Stopping", id)} + onRestart={(id) => run("restart", "Restarting", id)} + emptyText="No stacks on this host." + /> )} ); diff --git a/frontend/src/components/stacks/StackCard.tsx b/frontend/src/components/stacks/StackCard.tsx deleted file mode 100644 index f82a0ee..0000000 --- a/frontend/src/components/stacks/StackCard.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useState } from "react"; -import { Link } from "react-router-dom"; -import { useQueryClient } from "@tanstack/react-query"; -import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react"; -import { toast } from "sonner"; -import { Card, StatusDot, Badge } from "@/components/ui"; -import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; -import { cn, relativeTime } from "@/lib/utils"; -import { stacksApi } from "@/api/stacks"; -import { apiErrorMessage } from "@/api/client"; -import type { StackSummary } from "@/types"; - -interface Props { - stack: StackSummary; - onStart: (id: string) => void; - onStop: (id: string) => void; - onRestart: (id: string) => void; - busy?: boolean; - isAdmin?: boolean; - linkBase?: string; // detail/edit route prefix, default "/stacks" - showEdit?: boolean; // hide edit for remote stacks (no remote editor yet) -} - -export function StackCard({ - stack, - onStart, - onStop, - onRestart, - busy, - isAdmin, - linkBase = "/stacks", - showEdit = true, -}: Props) { - const qc = useQueryClient(); - const [confirming, setConfirming] = useState(false); - const [deleting, setDeleting] = useState(false); - const isLocal = !stack.agent_id; - - const remove = async () => { - setDeleting(true); - const t = toast.loading(`Deleting ${stack.id}…`); - try { - await stacksApi.remove(stack.id, true); - toast.success(`Deleted ${stack.id}`, { id: t }); - qc.invalidateQueries({ queryKey: ["stacks"] }); - } catch (e) { - toast.error(apiErrorMessage(e), { id: t }); - } finally { - setDeleting(false); - setConfirming(false); - } - }; - - return ( - -
- -
- - - {stack.name} - -
- {stack.description && ( -

- {stack.description} -

- )} - - {stack.status} -
- -
- - {stack.running_count}/{stack.service_count} services - - · - updated {relativeTime(stack.updated_at)} -
- - {isAdmin && ( -
- onStart(stack.id)} disabled={busy}> - - - onStop(stack.id)} disabled={busy}> - - - onRestart(stack.id)} disabled={busy}> - - - {showEdit && ( - - - - )} - {isLocal && ( - setConfirming(true)} - className={showEdit ? "" : "ml-auto"} - > - - - )} -
- )} - - {confirming && ( - setConfirming(false)} - /> - )} -
- ); -} - -function IconBtn({ - children, - title, - onClick, - disabled, - className, -}: { - children: React.ReactNode; - title: string; - onClick: () => void; - disabled?: boolean; - className?: string; -}) { - return ( - - ); -} diff --git a/frontend/src/components/stacks/StacksTable.tsx b/frontend/src/components/stacks/StacksTable.tsx index c0821bd..2ab1134 100644 --- a/frontend/src/components/stacks/StacksTable.tsx +++ b/frontend/src/components/stacks/StacksTable.tsx @@ -1,7 +1,13 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; -import { Play, Square, RotateCw } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Play, Square, RotateCw, Pencil, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { Card, Spinner, StatusDot, Badge } from "@/components/ui"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { formatBytes } from "@/lib/utils"; +import { stacksApi } from "@/api/stacks"; +import { apiErrorMessage } from "@/api/client"; import type { StackStats, StackSummary } from "@/types"; /** Stack list rendered as a table with live CPU/memory usage meters and inline @@ -15,6 +21,8 @@ export function StacksTable({ busyId, loading, linkBase = "/stacks", + showEdit = false, + showDelete = false, onStart, onStop, onRestart, @@ -28,6 +36,8 @@ export function StacksTable({ busyId: string | null; loading: boolean; linkBase?: string; + showEdit?: boolean; + showDelete?: boolean; onStart: (id: string) => void; onStop: (id: string) => void; onRestart: (id: string) => void; @@ -63,6 +73,8 @@ export function StacksTable({ isAdmin={isAdmin} busy={busyId === s.id} linkBase={linkBase} + showEdit={showEdit} + showDelete={showDelete} onStart={onStart} onStop={onStop} onRestart={onRestart} @@ -82,6 +94,8 @@ function StackRow({ isAdmin, busy, linkBase, + showEdit, + showDelete, onStart, onStop, onRestart, @@ -93,11 +107,32 @@ function StackRow({ isAdmin: boolean; busy: boolean; linkBase: string; + showEdit: boolean; + showDelete: boolean; onStart: (id: string) => void; onStop: (id: string) => void; onRestart: (id: string) => void; }) { + const qc = useQueryClient(); const running = stack.running_count > 0; + const canDelete = showDelete && !stack.agent_id; + const [confirming, setConfirming] = useState(false); + const [deleting, setDeleting] = useState(false); + + const remove = async () => { + setDeleting(true); + const t = toast.loading(`Deleting ${stack.id}…`); + try { + await stacksApi.remove(stack.id, true); + toast.success(`Deleted ${stack.id}`, { id: t }); + qc.invalidateQueries({ queryKey: ["stacks"] }); + } catch (e) { + toast.error(apiErrorMessage(e), { id: t }); + } finally { + setDeleting(false); + setConfirming(false); + } + }; return ( @@ -160,7 +195,32 @@ function StackRow({ )} + {showEdit && ( + + + + )} + {canDelete && ( + setConfirming(true)} disabled={busy}> + + + )} + {confirming && ( + setConfirming(false)} + /> + )} )} diff --git a/frontend/src/pages/Stacks.tsx b/frontend/src/pages/Stacks.tsx index 48c937a..3873822 100644 --- a/frontend/src/pages/Stacks.tsx +++ b/frontend/src/pages/Stacks.tsx @@ -2,11 +2,12 @@ import { useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Plus, Search, HardDrive } from "lucide-react"; -import { Button, Input, Spinner, Card } from "@/components/ui"; -import { StackCard } from "@/components/stacks/StackCard"; +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"; @@ -25,6 +26,17 @@ export function Stacks() { refetchInterval: 5000, }); + const stats = useQuery({ + queryKey: ["stack-stats"], + queryFn: stacksApi.stats, + refetchInterval: 5000, + }); + const info = useQuery({ + queryKey: ["system"], + queryFn: systemApi.info, + refetchInterval: 5000, + }); + const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), @@ -83,27 +95,21 @@ export function Stacks() { This host )} - {isLoading ? ( - - ) : filtered.length > 0 ? ( -
- {filtered.map((s) => ( - - ))} -
- ) : ( - -

No stacks match your search.

-
- )} + {agents.data?.map((agent) => (