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:
co-authored by
Claude Opus 4.8
parent
ffb4ee39a0
commit
8fc61b1531
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user