Files
stackpilot/frontend/src/pages/Dashboard.tsx
T
menzeljandClaude Opus 4.8 bb29ef1c98 Dashboard: per-host Docker volumes total (0.21.1)
The per-host resource bar gained a "Volumes" stat showing the total size of
that host's Docker volumes. It reuses the existing cached /volumes/sizes lookup
(docker system df, ~60s TTL) summed client-side, polled every 60s per host so
the slow df walk never blocks the fast system-info poll.

Frontend-only. Fixed sumSizes to reduce<number> so the value is number|undefined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:42:22 +00:00

513 lines
16 KiB
TypeScript

import { useState } from "react";
import { Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Cpu,
MemoryStick,
HardDrive,
Container,
Clock,
ArrowUpCircle,
Play,
Square,
RotateCw,
Server,
Database,
} from "lucide-react";
import { toast } from "sonner";
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
import { HostHeader } from "@/components/hosts/HostHeader";
import { stacksApi } from "@/api/stacks";
import { systemApi } from "@/api/system";
import { imagesApi } from "@/api/images";
import { agentsApi } from "@/api/agents";
import { volumesApi } from "@/api/volumes";
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";
const sumSizes = (m: Record<string, number | null>) =>
Object.values(m).reduce<number>((a, b) => a + (b ?? 0), 0);
export function Dashboard() {
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const { busyId, start, stop, restart } = useStackActions();
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
// Volumes total is from `docker system df` (slow, cached ~60s server-side).
const volSize = useQuery({
queryKey: ["volumes-size", "local"],
queryFn: () => volumesApi.sizes().then(sumSizes),
refetchInterval: 60000,
});
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
const hasAgents = (agents.data?.length ?? 0) > 0;
return (
<div className="space-y-6">
{updateCount > 0 && (
<Link
to="/images"
className="flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
<ArrowUpCircle className="h-5 w-5" />
{updateCount} image update{updateCount > 1 ? "s" : ""} available view on the Images page.
</Link>
)}
{/* Local host */}
<section>
{hasAgents ? (
<HostHeader />
) : (
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">This host</h2>
)}
<ResourceBar
cpuCores={info.data?.cpu_cores ?? 0}
memUsed={info.data?.ram.used ?? 0}
memTotal={info.data?.ram.total ?? 0}
diskUsed={info.data?.disk.used ?? 0}
diskTotal={info.data?.disk.total ?? 0}
volumesSize={volSize.data}
containersRunning={info.data?.containers_running ?? 0}
containersTotal={info.data?.containers_total ?? 0}
dockerVersion={info.data?.docker_version ?? ""}
/>
<StacksTable
stacks={stacks.data}
stats={stats.data}
hostCpus={info.data?.cpu_cores ?? 0}
hostMem={info.data?.ram.total ?? 0}
isAdmin={isAdmin}
busyId={busyId}
loading={stacks.isLoading}
onStart={start}
onStop={stop}
onRestart={restart}
emptyText="No stacks yet. Create one from the Stacks page."
/>
</section>
{/* Remote hosts */}
{agents.data?.map((agent) => (
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))}
{/* Recent activity */}
<section>
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
<Clock className="h-4 w-4" /> Recent activity
</h2>
<Card>
{audit.data && audit.data.length > 0 ? (
<ul className="divide-y divide-slate-100 text-sm dark:divide-slate-700">
{audit.data.map((a) => (
<li key={a.id} className="flex items-center justify-between py-2">
<span>
<span className="font-medium">{a.user}</span>{" "}
<span className="text-slate-500">{a.action}</span>{" "}
<span className="font-mono text-xs text-accent dark:text-accent-dark">{a.target}</span>
</span>
<span className="text-xs text-slate-400">{relativeTime(a.timestamp)}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-slate-500">No activity yet.</p>
)}
</Card>
</section>
</div>
);
}
function AgentDashboardSection({ agent, isAdmin }: { agent: Agent; isAdmin: boolean }) {
const qc = useQueryClient();
const online = agent.status === "online";
const [busyId, setBusyId] = useState<string | null>(null);
const stacks = useQuery({
queryKey: ["agent-stacks", agent.id],
queryFn: () => agentsApi.stacks(agent.id),
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 volSize = useQuery({
queryKey: ["volumes-size", agent.id],
queryFn: () => volumesApi.sizes(false, agent.id).then(sumSizes),
enabled: online,
refetchInterval: 60000,
});
const run = async (action: string, label: string, id: string) => {
setBusyId(id);
const t = toast.loading(`${label} ${id} on ${agent.name}…`);
try {
await agentsApi.action(agent.id, id, action);
toast.success(`${label} ${id} ✓`, { id: t });
qc.invalidateQueries({ queryKey: ["agent-stacks", agent.id] });
qc.invalidateQueries({ queryKey: ["agent-stack-stats", agent.id] });
} catch (e) {
toast.error(apiErrorMessage(e), { id: t });
} finally {
setBusyId(null);
}
};
return (
<section>
<HostHeader agent={agent} />
{!online ? (
<Card>
<p className="text-sm text-slate-500">
Host is {agent.status}. Check it under Settings Remote hosts.
</p>
</Card>
) : (
<>
<ResourceBar
cpuCores={sys.data?.cpu_cores ?? 0}
memUsed={sys.data?.mem_used ?? 0}
memTotal={sys.data?.mem_total ?? 0}
diskUsed={sys.data?.disk_used ?? 0}
diskTotal={sys.data?.disk_total ?? 0}
volumesSize={volSize.data}
containersRunning={sys.data?.containers_running ?? 0}
containersTotal={sys.data?.containers_total ?? 0}
dockerVersion={sys.data?.docker_version ?? ""}
/>
<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>
);
}
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,
memTotal,
diskUsed,
diskTotal,
volumesSize,
containersRunning,
containersTotal,
dockerVersion,
}: {
cpuCores: number;
memUsed: number;
memTotal: number;
diskUsed: number;
diskTotal: number;
volumesSize?: number;
containersRunning: number;
containersTotal: number;
dockerVersion: string;
}) {
return (
<div className="mb-4 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={cpuCores || "—"} />
<Stat
icon={<MemoryStick className="h-5 w-5" />}
label="Memory"
value={memTotal ? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}` : "—"}
/>
<Stat
icon={<HardDrive className="h-5 w-5" />}
label="Disk"
value={diskTotal ? `${formatBytes(diskUsed)} / ${formatBytes(diskTotal)}` : "—"}
/>
<Stat
icon={<Database className="h-5 w-5" />}
label="Volumes"
value={volumesSize === undefined ? "…" : formatBytes(volumesSize)}
/>
<Stat
icon={<Container className="h-5 w-5" />}
label="Containers"
value={`${containersRunning} / ${containersTotal}`}
/>
<Stat icon={<Server className="h-5 w-5" />} label="Docker" value={dockerVersion || "—"} />
</div>
);
}
function Stat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: React.ReactNode;
}) {
return (
<Card className="flex items-center gap-3">
<div className="rounded-lg bg-accent/10 p-2 text-accent dark:bg-accent-dark/10 dark:text-accent-dark">
{icon}
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500">{label}</p>
<p className="truncate text-sm font-semibold">{value}</p>
</div>
</Card>
);
}