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
@@ -8,7 +8,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
> destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups)
|
||||||
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
|
> + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX &
|
||||||
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks &
|
> network attach) + Phase 12 (File browser) + Phase 13 (Multi-host networks &
|
||||||
> images) + Phase 14 (Multi-host file browser) complete.
|
> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack
|
||||||
|
> resource usage) complete.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## What works today (Phase 1)
|
||||||
|
|
||||||
@@ -146,6 +147,16 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
container or connect any container on the host (`POST /api/networks/{id}/connect`
|
||||||
/ `/disconnect`).
|
/ `/disconnect`).
|
||||||
|
|
||||||
|
### Phase 15 — Dashboard stack resource usage
|
||||||
|
|
||||||
|
- **The dashboard now lists stacks in a table** (status, services) with live
|
||||||
|
**CPU** and **memory** usage per stack, sampled from `docker stats` and
|
||||||
|
aggregated by compose project.
|
||||||
|
- When a stack has `deploy.resources.limits` assigned, the bar fills toward that
|
||||||
|
limit and shows usage vs the limit (e.g. `0.42 / 1 cores`, `310 MB / 512 MB`);
|
||||||
|
otherwise it shows absolute usage against the host total. Inline start/stop/
|
||||||
|
restart actions per row for admins. New endpoint `GET /api/stacks/stats`.
|
||||||
|
|
||||||
### Phase 14 — Multi-host file browser
|
### Phase 14 — Multi-host file browser
|
||||||
|
|
||||||
- **The Files page now has a host switcher.** When agents are registered, a
|
- **The Files page now has a host switcher.** When agents are registered, a
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.15.0"
|
AGENT_VERSION = "0.16.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.15.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.16.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from models.setting import (
|
|||||||
EVENT_STACK_STOP,
|
EVENT_STACK_STOP,
|
||||||
)
|
)
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import audit_service, compose_service, notify_service
|
from services import audit_service, compose_service, notify_service, stats_service
|
||||||
from services.convert_service import convert_docker_run
|
from services.convert_service import convert_docker_run
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
router = APIRouter(prefix="/api/stacks", tags=["stacks"])
|
||||||
@@ -116,6 +116,12 @@ def create_stack(
|
|||||||
return _stack_summary(stack)
|
return _stack_summary(stack)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def stacks_stats(_user: User = Depends(get_current_user)) -> dict:
|
||||||
|
"""Live CPU (cores) and memory usage per stack, with assigned limits."""
|
||||||
|
return stats_service.stack_stats()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{stack_id}")
|
@router.get("/{stack_id}")
|
||||||
def get_stack(
|
def get_stack(
|
||||||
stack_id: str,
|
stack_id: str,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Live CPU/memory stats for running containers, aggregated per compose stack.
|
||||||
|
|
||||||
|
Reads a one-shot ``docker stats`` sample per running container (the daemon
|
||||||
|
includes ``precpu_stats`` so a single read yields a usable CPU delta) and sums
|
||||||
|
them by ``com.docker.compose.project`` label, which equals the stack id.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from docker_client import DockerError, get_client, safe_call
|
||||||
|
|
||||||
|
COMPOSE_PROJECT_LABEL = "com.docker.compose.project"
|
||||||
|
|
||||||
|
|
||||||
|
def _container_stats(container) -> dict | None:
|
||||||
|
try:
|
||||||
|
s = container.stats(stream=False)
|
||||||
|
except Exception: # noqa: BLE001 - skip containers that fail to report
|
||||||
|
return None
|
||||||
|
|
||||||
|
cpu = s.get("cpu_stats", {}) or {}
|
||||||
|
pre = s.get("precpu_stats", {}) or {}
|
||||||
|
cpu_delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - pre.get("cpu_usage", {}).get(
|
||||||
|
"total_usage", 0
|
||||||
|
)
|
||||||
|
sys_delta = cpu.get("system_cpu_usage", 0) - pre.get("system_cpu_usage", 0)
|
||||||
|
online = (
|
||||||
|
cpu.get("online_cpus")
|
||||||
|
or len(cpu.get("cpu_usage", {}).get("percpu_usage") or [])
|
||||||
|
or 1
|
||||||
|
)
|
||||||
|
cores_used = (cpu_delta / sys_delta) * online if sys_delta > 0 and cpu_delta > 0 else 0.0
|
||||||
|
|
||||||
|
mem = s.get("memory_stats", {}) or {}
|
||||||
|
usage = mem.get("usage", 0) or 0
|
||||||
|
detail = mem.get("stats", {}) or {}
|
||||||
|
# Match `docker stats`: exclude reclaimable page cache from "used".
|
||||||
|
inactive = detail.get("inactive_file") or detail.get("total_inactive_file") or 0
|
||||||
|
mem_used = max(usage - inactive, 0)
|
||||||
|
|
||||||
|
hc = container.attrs.get("HostConfig", {}) or {}
|
||||||
|
nano = hc.get("NanoCpus") or 0
|
||||||
|
quota = hc.get("CpuQuota") or 0
|
||||||
|
period = hc.get("CpuPeriod") or 0
|
||||||
|
if nano:
|
||||||
|
cpu_limit = nano / 1e9
|
||||||
|
elif quota and period:
|
||||||
|
cpu_limit = quota / period
|
||||||
|
else:
|
||||||
|
cpu_limit = None
|
||||||
|
mem_limit = hc.get("Memory") or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": (container.labels or {}).get(COMPOSE_PROJECT_LABEL),
|
||||||
|
"cores_used": cores_used,
|
||||||
|
"cpu_limit": cpu_limit,
|
||||||
|
"mem_used": mem_used,
|
||||||
|
"mem_limit": mem_limit or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stack_stats() -> dict:
|
||||||
|
"""Return {stack_id: {cpu_used, cpu_limit, mem_used, mem_limit, containers}}.
|
||||||
|
|
||||||
|
Limits are the summed assigned limits across the stack's containers, or null
|
||||||
|
when none of them have that limit set.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = get_client()
|
||||||
|
containers = safe_call(client.containers.list) # running only
|
||||||
|
except DockerError:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
samples = list(pool.map(_container_stats, containers))
|
||||||
|
|
||||||
|
agg: dict[str, dict] = {}
|
||||||
|
for st in samples:
|
||||||
|
if not st or not st["project"]:
|
||||||
|
continue
|
||||||
|
a = agg.setdefault(
|
||||||
|
st["project"],
|
||||||
|
{"cpu_used": 0.0, "cpu_limit": 0.0, "has_cpu": False,
|
||||||
|
"mem_used": 0, "mem_limit": 0, "has_mem": False, "containers": 0},
|
||||||
|
)
|
||||||
|
a["cpu_used"] += st["cores_used"]
|
||||||
|
a["mem_used"] += st["mem_used"]
|
||||||
|
a["containers"] += 1
|
||||||
|
if st["cpu_limit"]:
|
||||||
|
a["cpu_limit"] += st["cpu_limit"]
|
||||||
|
a["has_cpu"] = True
|
||||||
|
if st["mem_limit"]:
|
||||||
|
a["mem_limit"] += st["mem_limit"]
|
||||||
|
a["has_mem"] = True
|
||||||
|
|
||||||
|
return {
|
||||||
|
proj: {
|
||||||
|
"cpu_used": round(a["cpu_used"], 3),
|
||||||
|
"cpu_limit": round(a["cpu_limit"], 3) if a["has_cpu"] else None,
|
||||||
|
"mem_used": a["mem_used"],
|
||||||
|
"mem_limit": a["mem_limit"] if a["has_mem"] else None,
|
||||||
|
"containers": a["containers"],
|
||||||
|
}
|
||||||
|
for proj, a in agg.items()
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.15.0",
|
"version": "0.16.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { StackDetail, StackSummary } from "@/types";
|
import type { StackDetail, StackStats, StackSummary } from "@/types";
|
||||||
|
|
||||||
export const stacksApi = {
|
export const stacksApi = {
|
||||||
list: () => api.get<StackSummary[]>("/api/stacks").then((r) => r.data),
|
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) =>
|
get: (id: string) =>
|
||||||
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
|
api.get<StackDetail>(`/api/stacks/${id}`).then((r) => r.data),
|
||||||
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
|
create: (body: { name: string; description?: string; yaml?: string; env?: string }) =>
|
||||||
|
|||||||
@@ -1,25 +1,38 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Cpu, MemoryStick, HardDrive, Container, Clock, ArrowUpCircle } from "lucide-react";
|
import {
|
||||||
import { Card, Spinner } from "@/components/ui";
|
Cpu,
|
||||||
import { StackCard } from "@/components/stacks/StackCard";
|
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 { stacksApi } from "@/api/stacks";
|
||||||
import { systemApi } from "@/api/system";
|
import { systemApi } from "@/api/system";
|
||||||
import { imagesApi } from "@/api/images";
|
import { imagesApi } from "@/api/images";
|
||||||
import { formatBytes, formatUptime, relativeTime } from "@/lib/utils";
|
import { formatBytes, relativeTime } from "@/lib/utils";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
|
import type { StackStats, StackSummary } from "@/types";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
const { busyId, start, stop, restart } = useStackActions();
|
const { busyId, start, stop, restart } = useStackActions();
|
||||||
|
|
||||||
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
|
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 info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 5000 });
|
||||||
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 });
|
||||||
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
|
const updates = useQuery({ queryKey: ["image-updates"], queryFn: () => imagesApi.updates(), refetchInterval: 60000 });
|
||||||
|
|
||||||
const updateCount = Object.values(updates.data ?? {}).filter((u) => u.update_available).length;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -61,7 +74,7 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stacks grid */}
|
{/* Stacks list */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Stacks
|
Stacks
|
||||||
@@ -69,19 +82,34 @@ export function Dashboard() {
|
|||||||
{stacks.isLoading ? (
|
{stacks.isLoading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : stacks.data && stacks.data.length > 0 ? (
|
) : stacks.data && stacks.data.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<Card className="overflow-x-auto p-0">
|
||||||
{stacks.data.map((s) => (
|
<table className="w-full text-left text-sm">
|
||||||
<StackCard
|
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
||||||
key={s.id}
|
<tr>
|
||||||
stack={s}
|
<th className="px-4 py-2">Stack</th>
|
||||||
isAdmin={isAdmin}
|
<th className="px-4 py-2 w-48">CPU</th>
|
||||||
busy={busyId === s.id}
|
<th className="px-4 py-2 w-48">Memory</th>
|
||||||
onStart={start}
|
{isAdmin && <th className="px-4 py-2 w-px"></th>}
|
||||||
onStop={stop}
|
</tr>
|
||||||
onRestart={restart}
|
</thead>
|
||||||
/>
|
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||||
))}
|
{stacks.data.map((s) => (
|
||||||
</div>
|
<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>
|
<Card>
|
||||||
<p className="text-sm text-slate-500">
|
<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({
|
function Stat({
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ export interface StackSummary {
|
|||||||
agent_name?: string;
|
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 {
|
export interface Agent {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user