Phase 17: multi-host dashboard (0.19.0)
The dashboard now renders a stacks-with-usage table per host: the local host
plus a section for each registered agent (online dot + offline notice), reusing
the same CPU/memory meters and inline start/stop/restart actions.
- agent_app.py: GET /agent/stacks/stats (reuses stats_service); /agent/system
now also returns cpu_cores + mem_total for remote meter references.
- routers/agents.py: proxy GET /api/agents/{id}/stacks/stats (declared before
/{agent_id}/stacks/{stack_id}).
- Frontend: agentsApi.system + stackStats; Dashboard refactored into a shared
StacksTable used by the local section and a per-agent AgentDashboardSection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
56450efd82
commit
8fbacd200a
@@ -9,7 +9,8 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
|
|||||||
> + 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) + Phase 15 (Dashboard stack
|
> images) + Phase 14 (Multi-host file browser) + Phase 15 (Dashboard stack
|
||||||
> resource usage) + Phase 16 (Volumes page, multi-host) complete.
|
> resource usage) + Phase 16 (Volumes page, multi-host) + Phase 17 (Multi-host
|
||||||
|
> dashboard) complete.
|
||||||
|
|
||||||
## What works today (Phase 1)
|
## What works today (Phase 1)
|
||||||
|
|
||||||
@@ -147,6 +148,15 @@ 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 17 — Multi-host dashboard
|
||||||
|
|
||||||
|
- The dashboard now shows a **stacks-with-usage table per host** — the local host
|
||||||
|
plus a section for each registered agent (online dot, offline notice), with the
|
||||||
|
same CPU/memory meters and inline start/stop/restart as the local list.
|
||||||
|
- New agent endpoint `/agent/stacks/stats` (proxied at
|
||||||
|
`/api/agents/{id}/stacks/stats`); `/agent/system` now also reports `cpu_cores`
|
||||||
|
and `mem_total` so remote meters have a host reference.
|
||||||
|
|
||||||
### Phase 16 — Volumes page (multi-host)
|
### Phase 16 — Volumes page (multi-host)
|
||||||
|
|
||||||
- **New Volumes page** (sidebar) with per-host sections (local + each online
|
- **New Volumes page** (sidebar) with per-host sections (local + each online
|
||||||
|
|||||||
+21
-1
@@ -43,6 +43,7 @@ from services import (
|
|||||||
file_service,
|
file_service,
|
||||||
image_service,
|
image_service,
|
||||||
network_service,
|
network_service,
|
||||||
|
stats_service,
|
||||||
update_service,
|
update_service,
|
||||||
volume_service,
|
volume_service,
|
||||||
)
|
)
|
||||||
@@ -60,7 +61,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.18.0"
|
AGENT_VERSION = "0.19.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -158,6 +159,18 @@ def _hostname() -> str:
|
|||||||
return os.uname().nodename
|
return os.uname().nodename
|
||||||
|
|
||||||
|
|
||||||
|
def _mem_total() -> int:
|
||||||
|
for base in (settings.HOST_PROC_PATH, "/proc"):
|
||||||
|
try:
|
||||||
|
with open(os.path.join(base, "meminfo"), "r", encoding="utf-8") as fh:
|
||||||
|
for line in fh:
|
||||||
|
if line.startswith("MemTotal:"):
|
||||||
|
return int(line.split()[1]) * 1024 # kB -> bytes
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _system_info() -> dict:
|
def _system_info() -> dict:
|
||||||
docker_version = ""
|
docker_version = ""
|
||||||
host_os = ""
|
host_os = ""
|
||||||
@@ -175,6 +188,8 @@ def _system_info() -> dict:
|
|||||||
"hostname": _hostname(),
|
"hostname": _hostname(),
|
||||||
"docker_version": docker_version,
|
"docker_version": docker_version,
|
||||||
"host_os": host_os,
|
"host_os": host_os,
|
||||||
|
"cpu_cores": os.cpu_count() or 0,
|
||||||
|
"mem_total": _mem_total(),
|
||||||
"containers_running": running,
|
"containers_running": running,
|
||||||
"containers_total": total,
|
"containers_total": total,
|
||||||
}
|
}
|
||||||
@@ -207,6 +222,11 @@ def list_stacks() -> list[dict]:
|
|||||||
return [_summary(sid) for sid in compose_service.discover_stacks()]
|
return [_summary(sid) for sid in compose_service.discover_stacks()]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent/stacks/stats", dependencies=[Depends(verify_token)])
|
||||||
|
def stacks_stats() -> dict:
|
||||||
|
return stats_service.stack_stats()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
@app.get("/agent/stacks/{stack_id}", dependencies=[Depends(verify_token)])
|
||||||
def get_stack(stack_id: str) -> dict:
|
def get_stack(stack_id: str) -> dict:
|
||||||
directory = compose_service.stack_dir(stack_id)
|
directory = compose_service.stack_dir(stack_id)
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
schedule_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="StackPilot", version="0.18.0", lifespan=lifespan)
|
app = FastAPI(title="StackPilot", version="0.19.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -213,6 +213,16 @@ async def agent_stacks(
|
|||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{agent_id}/stacks/stats")
|
||||||
|
async def agent_stacks_stats(
|
||||||
|
agent_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
agent = _get_or_404(session, agent_id)
|
||||||
|
return await _proxy(session, agent, "GET", "/agent/stacks/stats")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{agent_id}/stacks/{stack_id}")
|
@router.get("/{agent_id}/stacks/{stack_id}")
|
||||||
async def agent_stack_detail(
|
async def agent_stack_detail(
|
||||||
agent_id: int,
|
agent_id: int,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.0",
|
"version": "0.19.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
import type { Agent, StackDetail, StackSummary } from "@/types";
|
import type { Agent, StackDetail, StackStats, StackSummary } from "@/types";
|
||||||
|
|
||||||
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
|
export type RemoteStackSummary = StackSummary & { agent_id: number; agent_name: string };
|
||||||
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
|
export type RemoteStackDetail = StackDetail & { agent_id: number; agent_name: string };
|
||||||
|
|
||||||
|
export interface AgentSystem {
|
||||||
|
hostname: string;
|
||||||
|
docker_version: string;
|
||||||
|
host_os: string;
|
||||||
|
cpu_cores: number;
|
||||||
|
mem_total: number;
|
||||||
|
containers_running: number;
|
||||||
|
containers_total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
list: (refresh = true) =>
|
list: (refresh = true) =>
|
||||||
api.get<Agent[]>(`/api/agents?refresh=${refresh}`).then((r) => r.data),
|
api.get<Agent[]>(`/api/agents?refresh=${refresh}`).then((r) => r.data),
|
||||||
@@ -15,8 +25,12 @@ export const agentsApi = {
|
|||||||
ping: (id: number) =>
|
ping: (id: number) =>
|
||||||
api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data),
|
api.post<{ status: string; hostname?: string }>(`/api/agents/${id}/ping`).then((r) => r.data),
|
||||||
|
|
||||||
|
system: (id: number) =>
|
||||||
|
api.get<AgentSystem>(`/api/agents/${id}/system`).then((r) => r.data),
|
||||||
stacks: (id: number) =>
|
stacks: (id: number) =>
|
||||||
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
|
api.get<RemoteStackSummary[]>(`/api/agents/${id}/stacks`).then((r) => r.data),
|
||||||
|
stackStats: (id: number) =>
|
||||||
|
api.get<Record<string, StackStats>>(`/api/agents/${id}/stacks/stats`).then((r) => r.data),
|
||||||
createStack: (id: number, body: { name: string; yaml: string; env?: string }) =>
|
createStack: (id: number, body: { name: string; yaml: string; env?: string }) =>
|
||||||
api
|
api
|
||||||
.post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body)
|
.post<{ id: string; name: string }>(`/api/agents/${id}/stacks`, body)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
MemoryStick,
|
MemoryStick,
|
||||||
@@ -11,14 +12,18 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
|
import { Card, Spinner, StatusDot, Badge } from "@/components/ui";
|
||||||
|
import { HostHeader } from "@/components/hosts/HostHeader";
|
||||||
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 { agentsApi } from "@/api/agents";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { formatBytes, 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";
|
import type { Agent, 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");
|
||||||
@@ -29,10 +34,10 @@ export function Dashboard() {
|
|||||||
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 agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
|
||||||
|
|
||||||
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 hasAgents = (agents.data?.length ?? 0) > 0;
|
||||||
const hostMem = info.data?.ram.total ?? 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -46,7 +51,7 @@ export function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Resource bar */}
|
{/* Local host resource bar */}
|
||||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
<Stat icon={<Cpu className="h-5 w-5" />} label="CPU cores" value={info.data?.cpu_cores ?? "—"} />
|
||||||
<Stat
|
<Stat
|
||||||
@@ -61,64 +66,38 @@ export function Dashboard() {
|
|||||||
<Stat
|
<Stat
|
||||||
icon={<Container className="h-5 w-5" />}
|
icon={<Container className="h-5 w-5" />}
|
||||||
label="Containers"
|
label="Containers"
|
||||||
value={
|
value={info.data ? `${info.data.containers_running} / ${info.data.containers_total}` : "—"}
|
||||||
info.data
|
|
||||||
? `${info.data.containers_running} / ${info.data.containers_total}`
|
|
||||||
: "—"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stat
|
|
||||||
icon={<HardDrive className="h-5 w-5" />}
|
|
||||||
label="Docker"
|
|
||||||
value={info.data?.docker_version ?? "—"}
|
|
||||||
/>
|
/>
|
||||||
|
<Stat icon={<HardDrive className="h-5 w-5" />} label="Docker" value={info.data?.docker_version ?? "—"} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stacks list */}
|
{/* Local stacks */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
{hasAgents ? (
|
||||||
Stacks
|
<HostHeader />
|
||||||
</h2>
|
) : (
|
||||||
{stacks.isLoading ? (
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Stacks</h2>
|
||||||
<Spinner />
|
)}
|
||||||
) : stacks.data && stacks.data.length > 0 ? (
|
<StacksTable
|
||||||
<Card className="overflow-x-auto p-0">
|
stacks={stacks.data}
|
||||||
<table className="w-full text-left text-sm">
|
stats={stats.data}
|
||||||
<thead className="border-b border-slate-200 text-xs uppercase text-slate-500 dark:border-slate-700">
|
hostCpus={info.data?.cpu_cores ?? 0}
|
||||||
<tr>
|
hostMem={info.data?.ram.total ?? 0}
|
||||||
<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}
|
isAdmin={isAdmin}
|
||||||
busy={busyId === s.id}
|
busyId={busyId}
|
||||||
|
loading={stacks.isLoading}
|
||||||
onStart={start}
|
onStart={start}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onRestart={restart}
|
onRestart={restart}
|
||||||
|
emptyText="No stacks yet. Create one from the Stacks page."
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card>
|
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
No stacks yet. Create one from the Stacks page.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Remote hosts */}
|
||||||
|
{agents.data?.map((agent) => (
|
||||||
|
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Recent activity */}
|
{/* Recent activity */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
@@ -132,13 +111,9 @@ export function Dashboard() {
|
|||||||
<span>
|
<span>
|
||||||
<span className="font-medium">{a.user}</span>{" "}
|
<span className="font-medium">{a.user}</span>{" "}
|
||||||
<span className="text-slate-500">{a.action}</span>{" "}
|
<span className="text-slate-500">{a.action}</span>{" "}
|
||||||
<span className="font-mono text-xs text-accent dark:text-accent-dark">
|
<span className="font-mono text-xs text-accent dark:text-accent-dark">{a.target}</span>
|
||||||
{a.target}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400">
|
|
||||||
{relativeTime(a.timestamp)}
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="text-xs text-slate-400">{relativeTime(a.timestamp)}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -151,6 +126,142 @@ export function Dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
) : (
|
||||||
|
<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({
|
function StackRow({
|
||||||
stack,
|
stack,
|
||||||
stats,
|
stats,
|
||||||
@@ -158,6 +269,7 @@ function StackRow({
|
|||||||
hostMem,
|
hostMem,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
busy,
|
busy,
|
||||||
|
linkBase,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -168,6 +280,7 @@ function StackRow({
|
|||||||
hostMem: number;
|
hostMem: number;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
|
linkBase: string;
|
||||||
onStart: (id: string) => void;
|
onStart: (id: string) => void;
|
||||||
onStop: (id: string) => void;
|
onStop: (id: string) => void;
|
||||||
onRestart: (id: string) => void;
|
onRestart: (id: string) => void;
|
||||||
@@ -177,7 +290,7 @@ function StackRow({
|
|||||||
return (
|
return (
|
||||||
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
<tr className="hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<Link to={`/stacks/${stack.id}`} className="flex items-center gap-2">
|
<Link to={`${linkBase}/${stack.id}`} className="flex items-center gap-2">
|
||||||
<StatusDot status={stack.status} />
|
<StatusDot status={stack.status} />
|
||||||
<span className="font-medium">{stack.name}</span>
|
<span className="font-medium">{stack.name}</span>
|
||||||
<Badge status={stack.status}>{stack.status}</Badge>
|
<Badge status={stack.status}>{stack.status}</Badge>
|
||||||
|
|||||||
Reference in New Issue
Block a user