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>
513 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|