Stacks page: table layout with usage meters, matching the dashboard (0.21.4)

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 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 16:56:53 +00:00
co-authored by Claude Opus 4.8
parent ffb4ee39a0
commit 8fc61b1531
7 changed files with 120 additions and 201 deletions
+1 -1
View File
@@ -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"
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.21.3",
"version": "0.21.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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.
</p>
</Card>
) : 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}
linkBase={`/hosts/${agent.id}/stacks`}
showEdit={false}
onStart={(id) => run("start", "Starting", id)}
onStop={(id) => run("stop", "Stopping", id)}
onRestart={(id) => run("restart", "Restarting", id)}
/>
))}
</div>
) : (
<Card>
<p className="text-sm text-slate-500">No stacks on this host.</p>
</Card>
<StacksTable
stacks={stacks.data}
stats={stats.data}
hostCpus={sys.data?.cpu_cores ?? 0}
hostMem={sys.data?.mem_total ?? 0}
isAdmin={isAdmin}
busyId={busyId}
loading={stacks.isLoading}
linkBase={`/hosts/${agent.id}/stacks`}
onStart={(id) => run("start", "Starting", id)}
onStop={(id) => run("stop", "Stopping", id)}
onRestart={(id) => run("restart", "Restarting", id)}
emptyText="No stacks on this host."
/>
)}
</section>
);
@@ -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 (
<Card className="flex flex-col gap-3 transition-shadow hover:shadow-md">
<div className="flex items-start justify-between">
<Link to={`${linkBase}/${stack.id}`} className="min-w-0">
<div className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="truncate font-semibold hover:underline">
{stack.name}
</span>
</div>
{stack.description && (
<p className="mt-1 truncate text-sm text-slate-500">
{stack.description}
</p>
)}
</Link>
<Badge status={stack.status}>{stack.status}</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span>
{stack.running_count}/{stack.service_count} services
</span>
<span>·</span>
<span>updated {relativeTime(stack.updated_at)}</span>
</div>
{isAdmin && (
<div className="flex gap-1 border-t border-slate-100 pt-3 dark:border-slate-700">
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4 text-sky-500" />
</IconBtn>
{showEdit && (
<Link
to={`${linkBase}/${stack.id}/edit`}
title="Edit"
className="ml-auto rounded-lg p-2 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4 text-slate-500" />
</Link>
)}
{isLocal && (
<IconBtn
title="Delete"
onClick={() => setConfirming(true)}
className={showEdit ? "" : "ml-auto"}
>
<Trash2 className="h-4 w-4 text-red-500" />
</IconBtn>
)}
</div>
)}
{confirming && (
<ConfirmDialog
title={`Delete stack “${stack.id}”?`}
message="The stack is stopped and its compose files are removed. This cannot be undone."
confirmLabel="Delete stack"
danger
busy={deleting}
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>
)}
</Card>
);
}
function IconBtn({
children,
title,
onClick,
disabled,
className,
}: {
children: React.ReactNode;
title: string;
onClick: () => void;
disabled?: boolean;
className?: string;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className={cn(
"rounded-lg p-2 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700",
className
)}
>
{children}
</button>
);
}
+61 -1
View File
@@ -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 (
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
@@ -160,7 +195,32 @@ function StackRow({
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
)}
{showEdit && (
<Link
to={`${linkBase}/${stack.id}/edit`}
title="Edit"
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<Pencil className="h-4 w-4" />
</Link>
)}
{canDelete && (
<IconBtn title="Delete" onClick={() => setConfirming(true)} disabled={busy}>
<Trash2 className="h-4 w-4 text-red-500" />
</IconBtn>
)}
</div>
{confirming && (
<ConfirmDialog
title={`Delete stack “${stack.id}”?`}
message="The stack is stopped and its compose files are removed. This cannot be undone."
confirmLabel="Delete stack"
danger
busy={deleting}
onConfirm={remove}
onCancel={() => setConfirming(false)}
/>
)}
</td>
)}
</tr>
+29 -23
View File
@@ -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() {
<HardDrive className="h-4 w-4" /> This host
</h2>
)}
{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>
)}
<StacksTable
stacks={filtered}
stats={stats.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}
emptyText={q ? "No stacks match your search." : "No stacks yet. Create one with “New Stack”."}
/>
</section>
{agents.data?.map((agent) => (