Phase 15: dashboard stack resource usage (0.16.0)
The dashboard now lists stacks in a table with live CPU and memory usage per stack. Usage is sampled from docker stats (one-shot read per running container, using the daemon-provided precpu for the CPU delta) and aggregated by compose project. - services/stats_service.py + GET /api/stacks/stats: per-stack cpu_used (cores), mem_used (bytes minus reclaimable cache), and the summed assigned cpu/mem limits (null when none set), read concurrently across containers. - Dashboard: stacks render as a table with a CPU and a Memory meter. When a limit is assigned the bar fills toward it (used / limit + %); otherwise it fills toward the host total. Inline start/stop/restart per row for admins. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c5f591749f
commit
19cc92dc94
@@ -1,8 +1,9 @@
|
||||
import api from "./client";
|
||||
import type { StackDetail, StackSummary } from "@/types";
|
||||
import type { StackDetail, StackStats, StackSummary } from "@/types";
|
||||
|
||||
export const stacksApi = {
|
||||
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
|
||||
stats: () => api.get<Record<string, StackStats>>("/api/stacks/stats").then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
|
||||
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react";
|
||||
import { Card, Spinner } from "@/components/ui";
|
||||
import { StackCard } from "@/components/stacks/StackCard";
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Container,
|
||||
Clock,
|
||||
ArrowUpCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
|
||||
import { stacksApi } from "@/api/stacks";
|
||||
import { systemApi } from "@/api/system";
|
||||
import { imagesApi } from "@/api/images";
|
||||
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
||||
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useStackActions } from "@/hooks/useStackActions";
|
||||
import type { StackStats, StackSummary } from "@/types";
|
||||
|
||||
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 updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
||||
const hostCpus = info.data?.cpu_cores ?? 0;
|
||||
const hostMem = info.data?.ram.total ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -61,7 +74,7 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stacks grid */}
|
||||
{/* Stacks list */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
Stacks
|
||||
@@ -69,19 +82,34 @@ export function Dashboard() {
|
||||
{stacks.isLoading ? (
|
||||
<Spinner />
|
||||
) : 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}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRestart={restart}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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.data.map((s) => (
|
||||
<StackRow
|
||||
key={s.id}
|
||||
stack={s}
|
||||
stats={stats.data?.[s.id]}
|
||||
hostCpus={hostCpus}
|
||||
hostMem={hostMem}
|
||||
isAdmin={isAdmin}
|
||||
busy={busyId === s.id}
|
||||
onStart={start}
|
||||
onStop={stop}
|
||||
onRestart={restart}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
@@ -123,6 +151,160 @@ export function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
function StackRow({
|
||||
stack,
|
||||
stats,
|
||||
hostCpus,
|
||||
hostMem,
|
||||
isAdmin,
|
||||
busy,
|
||||
onStart,
|
||||
onStop,
|
||||
onRestart,
|
||||
}: {
|
||||
stack: StackSummary;
|
||||
stats?: StackStats;
|
||||
hostCpus: number;
|
||||
hostMem: number;
|
||||
isAdmin: boolean;
|
||||
busy: boolean;
|
||||
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={`/stacks/${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 Stat({
|
||||
icon,
|
||||
label,
|
||||
|
||||
@@ -20,6 +20,14 @@ export interface StackSummary {
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
export interface StackStats {
|
||||
cpu_used: number; // cores in use (1.0 = one full core)
|
||||
cpu_limit: number | null; // summed assigned CPU limit, or null
|
||||
mem_used: number; // bytes
|
||||
mem_limit: number | null; // summed assigned memory limit in bytes, or null
|
||||
containers: number;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user