Refactor: extract shared Dashboard StacksTable component (0.21.3)

The Dashboard's stacks-usage table (CPU/mem meters + inline
start/stop/restart) is now a reusable components/stacks/StacksTable.tsx
used by both the local and per-agent host sections. Removes the
duplicate inline definition that was left behind by the half-finished
extraction (which broke the build: redeclared StacksTable + dangling
imports). No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 16:42:19 +00:00
co-authored by Claude Opus 4.8
parent f65ec5f268
commit ffb4ee39a0
5 changed files with 237 additions and 232 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.1"
AGENT_VERSION = "0.21.3"
# --------------------------------------------------------------------------- #
+1 -1
View File
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.21.2", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.21.3", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.21.1",
"version": "0.21.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -0,0 +1,231 @@
import { Link } from "react-router-dom";
import { Play, Square, RotateCw } from "lucide-react";
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
import { formatBytes } from "@/lib/utils";
import type { StackStats, StackSummary } from "@/types";
/** Stack list rendered as a table with live CPU/memory usage meters and inline
* start/stop/restart, shared by the Dashboard and the Stacks page. */
export function StacksTable({
stacks,
stats,
hostCpus,
hostMem,
isAdmin,
busyId,
loading,
linkBase = "/stacks",
onStart,
onStop,
onRestart,
emptyText,
}: {
stacks: StackSummary[] | undefined;
stats: Record<string, StackStats> | undefined;
hostCpus: number;
hostMem: number;
isAdmin: boolean;
busyId: string | null;
loading: boolean;
linkBase?: string;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
emptyText: string;
}) {
if (loading) return <Spinner />;
if (!stacks || stacks.length === 0) {
return (
<Card>
<p className="text-sm text-slate-500">{emptyText}</p>
</Card>
);
}
return (
<Card className="overflow-x-auto p-0">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">Stack</th>
<th className="px-4 py-2 w-48">CPU</th>
<th className="px-4 py-2 w-48">Memory</th>
{isAdmin && <th className="px-4 py-2 w-px"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{stacks.map((s) => (
<StackRow
key={s.id}
stack={s}
stats={stats?.[s.id]}
hostCpus={hostCpus}
hostMem={hostMem}
isAdmin={isAdmin}
busy={busyId === s.id}
linkBase={linkBase}
onStart={onStart}
onStop={onStop}
onRestart={onRestart}
/>
))}
</tbody>
</table>
</Card>
);
}
function StackRow({
stack,
stats,
hostCpus,
hostMem,
isAdmin,
busy,
linkBase,
onStart,
onStop,
onRestart,
}: {
stack: StackSummary;
stats?: StackStats;
hostCpus: number;
hostMem: number;
isAdmin: boolean;
busy: boolean;
linkBase: string;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
}) {
const running = stack.running_count > 0;
return (
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5">
<Link to={`${linkBase}/${stack.id}`} className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
</Link>
</td>
<td className="px-4 py-2.5">
{running && stats ? (
<Meter
used={stats.cpu_used}
limit={stats.cpu_limit}
hostMax={hostCpus}
label={
stats.cpu_limit != null
? `${stats.cpu_used.toFixed(2)} / ${stats.cpu_limit} cores`
: `${stats.cpu_used.toFixed(2)} cores`
}
/>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-4 py-2.5">
{running && stats ? (
<Meter
used={stats.mem_used}
limit={stats.mem_limit}
hostMax={hostMem}
label={
stats.mem_limit != null
? `${formatBytes(stats.mem_used)} / ${formatBytes(stats.mem_limit)}`
: formatBytes(stats.mem_used)
}
/>
) : (
<span className="text-slate-400"></span>
)}
</td>
{isAdmin && (
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{running ? (
<>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
</>
) : (
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
)}
</div>
</td>
)}
</tr>
);
}
function IconBtn({
title,
onClick,
disabled,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
{children}
</button>
);
}
/** A compact usage bar. When a limit is set the bar fills toward the limit;
* otherwise it fills toward the host total as a faint reference. */
function Meter({
used,
limit,
hostMax,
label,
}: {
used: number;
limit: number | null;
hostMax: number;
label: string;
}) {
const denom = limit ?? (hostMax || 0);
const pct = denom > 0 ? Math.min((used / denom) * 100, 100) : 0;
const over = limit != null && used > limit * 1.001;
const bar =
over || pct >= 90
? "bg-red-500"
: pct >= 75
? "bg-amber-500"
: limit != null
? "bg-sky-500"
: "bg-slate-400";
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-600 dark:text-slate-300">{label}</span>
{denom > 0 && (
<span className="text-[10px] tabular-nums text-slate-400">{Math.round(pct)}%</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div className={`h-full rounded-full ${bar}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
+3 -229
View File
@@ -8,15 +8,13 @@ import {
Container,
Clock,
ArrowUpCircle,
Play,
Square,
RotateCw,
Server,
Database,
} from "lucide-react";
import { toast } from "sonner";
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
import { Card } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader";
import { StacksTable } from "@/components/stacks/StacksTable";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { imagesApi } from "@/api/images";
@@ -26,7 +24,7 @@ import { apiErrorMessage } from "@/api/client";
import { formatBytes, relativeTime } from "@/lib/utils";
import { useAuthStore } from "@/store/auth";
import { useStackActions } from "@/hooks/useStackActions";
import type { Agent, StackStats, StackSummary } from "@/types";
import type { Agent } from "@/types";
const sumSizes = (m: Record<string, number | null>) =>
Object.values(m).reduce<number>((a, b) => a + (b ?? 0), 0);
@@ -216,230 +214,6 @@ function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: bool
);
}
function StacksTable({
stacks,
stats,
hostCpus,
hostMem,
isAdmin,
busyId,
loading,
linkBase = "/stacks",
onStart,
onStop,
onRestart,
emptyText,
}: {
stacks: StackSummary[] | undefined;
stats: Record<string, StackStats> | undefined;
hostCpus: number;
hostMem: number;
isAdmin: boolean;
busyId: string | null;
loading: boolean;
linkBase?: string;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
emptyText: string;
}) {
if (loading) return <Spinner />;
if (!stacks || stacks.length === 0) {
return (
<Card>
<p className="text-sm text-slate-500">{emptyText}</p>
</Card>
);
}
return (
<Card className="overflow-x-auto p-0">
<table className="w-full text-left text-sm">
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
<tr>
<th className="px-4 py-2">Stack</th>
<th className="px-4 py-2 w-48">CPU</th>
<th className="px-4 py-2 w-48">Memory</th>
{isAdmin && <th className="px-4 py-2 w-px"></th>}
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
{stacks.map((s) => (
<StackRow
key={s.id}
stack={s}
stats={stats?.[s.id]}
hostCpus={hostCpus}
hostMem={hostMem}
isAdmin={isAdmin}
busy={busyId === s.id}
linkBase={linkBase}
onStart={onStart}
onStop={onStop}
onRestart={onRestart}
/>
))}
</tbody>
</table>
</Card>
);
}
function StackRow({
stack,
stats,
hostCpus,
hostMem,
isAdmin,
busy,
linkBase,
onStart,
onStop,
onRestart,
}: {
stack: StackSummary;
stats?: StackStats;
hostCpus: number;
hostMem: number;
isAdmin: boolean;
busy: boolean;
linkBase: string;
onStart: (id: string) => void;
onStop: (id: string) => void;
onRestart: (id: string) => void;
}) {
const running = stack.running_count > 0;
return (
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-2.5">
<Link to={`${linkBase}/${stack.id}`} className="flex items-center gap-2">
<StatusDot status={stack.status} />
<span className="font-medium">{stack.name}</span>
<Badge status={stack.status}>{stack.status}</Badge>
<span className="text-xs text-slate-400">
{stack.running_count}/{stack.service_count} svc
</span>
</Link>
</td>
<td className="px-4 py-2.5">
{running && stats ? (
<Meter
used={stats.cpu_used}
limit={stats.cpu_limit}
hostMax={hostCpus}
label={
stats.cpu_limit != null
? `${stats.cpu_used.toFixed(2)} / ${stats.cpu_limit} cores`
: `${stats.cpu_used.toFixed(2)} cores`
}
/>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-4 py-2.5">
{running && stats ? (
<Meter
used={stats.mem_used}
limit={stats.mem_limit}
hostMax={hostMem}
label={
stats.mem_limit != null
? `${formatBytes(stats.mem_used)} / ${formatBytes(stats.mem_limit)}`
: formatBytes(stats.mem_used)
}
/>
) : (
<span className="text-slate-400"></span>
)}
</td>
{isAdmin && (
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{running ? (
<>
<IconBtn title="Restart" onClick={() => onRestart(stack.id)} disabled={busy}>
<RotateCw className="h-4 w-4" />
</IconBtn>
<IconBtn title="Stop" onClick={() => onStop(stack.id)} disabled={busy}>
<Square className="h-4 w-4 text-red-500" />
</IconBtn>
</>
) : (
<IconBtn title="Start" onClick={() => onStart(stack.id)} disabled={busy}>
<Play className="h-4 w-4 text-green-500" />
</IconBtn>
)}
</div>
</td>
)}
</tr>
);
}
function IconBtn({
title,
onClick,
disabled,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
title={title}
onClick={onClick}
disabled={disabled}
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-100 disabled:opacity-40 dark:hover:bg-slate-700"
>
{children}
</button>
);
}
/** A compact usage bar. When a limit is set the bar fills toward the limit;
* otherwise it fills toward the host total as a faint reference. */
function Meter({
used,
limit,
hostMax,
label,
}: {
used: number;
limit: number | null;
hostMax: number;
label: string;
}) {
const denom = limit ?? (hostMax || 0);
const pct = denom > 0 ? Math.min((used / denom) * 100, 100) : 0;
const over = limit != null && used > limit * 1.001;
const bar =
over || pct >= 90
? "bg-red-500"
: pct >= 75
? "bg-amber-500"
: limit != null
? "bg-sky-500"
: "bg-slate-400";
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-slate-600 dark:text-slate-300">{label}</span>
{denom > 0 && (
<span className="text-[10px] tabular-nums text-slate-400">{Math.round(pct)}%</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div className={`h-full rounded-full ${bar}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function ResourceBar({
cpuCores,
memUsed,