feat(admin): node detail view (health + history + crashes)

Per-node "جزئیات" button opens a modal with live health (status/CPU/RAM/AE/cache/
last heartbeat), a 24h CPU history mini-chart, and the recent crash log (signal,
auto-recovered, last frame, error log, log-file link). Uses existing render-svc
GET /v1/nodes/:id/health, /health/history, /crashes endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 07:17:48 +03:30
parent 928956689b
commit 4253d2fad5
2 changed files with 140 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface Health {
status?: string; cpu_pct?: number | null; ram_available_mb?: number | null;
ae_running?: boolean; current_job_id?: string | null; cache_used_gb?: number | null; recorded_at?: string | null;
}
interface HealthLog { recorded_at?: string; cpu_pct?: number | null; ram_available_mb?: number | null; status?: string }
interface Crash {
id: string; crashed_at: string; crash_signal?: string | null; error_log?: string | null;
last_known_frame?: number | null; auto_recovered: boolean; log_file_url?: string | null;
}
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
function faTime(iso?: string | null) {
if (!iso) return "—";
try { return new Date(iso).toLocaleString("fa-IR"); } catch { return iso; }
}
/** Per-node detail modal: live health + recent health history + crash log. */
export function NodeDetail({ nodeId, nodeName, onClose }: { nodeId: string; nodeName: string; onClose: () => void }) {
const [health, setHealth] = useState<Health | null>(null);
const [history, setHistory] = useState<HealthLog[]>([]);
const [crashes, setCrashes] = useState<Crash[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
const base = `/api/admin/resource/nodes/${nodeId}`;
const [h, hh, cr] = await Promise.all([
fetch(`${base}/health`, { cache: "no-store" }).then((r) => r.json()).catch(() => null),
fetch(`${base}/health/history`, { cache: "no-store" }).then((r) => r.json()).catch(() => null),
fetch(`${base}/crashes`, { cache: "no-store" }).then((r) => r.json()).catch(() => null),
]);
setHealth(h ?? null);
setHistory(hh?.data ?? []);
setCrashes(cr?.data ?? []);
setLoading(false);
}, [nodeId]);
useEffect(() => { load(); }, [load]);
const maxCpu = Math.max(1, ...history.map((p) => p.cpu_pct ?? 0));
const stat = (label: string, value: string) => (
<div className="rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2">
<div className="text-[10px] text-gray-500">{label}</div>
<div className="mt-0.5 text-sm text-gray-200">{value}</div>
</div>
);
return (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={onClose}>
<div className={`${card} flex max-h-full w-full max-w-3xl flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">جزئیات نود: {nodeName}</h2>
<div className="flex items-center gap-2">
<button className="rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={load}>بروزرسانی</button>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={onClose}></button>
</div>
</div>
<div className="flex-1 space-y-5 overflow-y-auto p-5">
{loading ? (
<p className="py-8 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : (
<>
<section>
<h3 className="mb-2 text-xs font-semibold text-gray-400">وضعیت لحظهای</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{stat("وضعیت", health?.status ?? "—")}
{stat("پردازنده", health?.cpu_pct != null ? `${health.cpu_pct}٪` : "—")}
{stat("RAM آزاد", health?.ram_available_mb != null ? `${health.ram_available_mb} MB` : "—")}
{stat("After Effects", health?.ae_running ? "در حال اجرا" : "خاموش")}
{stat("کش", health?.cache_used_gb != null ? `${health.cache_used_gb} GB` : "—")}
{stat("آخرین ضربان", faTime(health?.recorded_at))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold text-gray-400">نمودار پردازنده (۲۴ ساعت اخیر)</h3>
{history.length === 0 ? (
<p className="text-xs text-gray-600">دادهای ثبت نشده.</p>
) : (
<div className="flex h-24 items-end gap-px overflow-x-auto rounded-lg border border-[#262b40] bg-[#0c0e1a] p-2">
{history.map((p, i) => (
<div key={i} className="flex min-w-[3px] flex-1 flex-col justify-end" title={`${faTime(p.recorded_at)}${p.cpu_pct ?? 0}٪`}>
<div className="w-full rounded-t bg-indigo-500/70" style={{ height: `${((p.cpu_pct ?? 0) / maxCpu) * 100}%` }} />
</div>
))}
</div>
)}
</section>
<section>
<h3 className="mb-2 text-xs font-semibold text-gray-400">کرشهای اخیر ({crashes.length.toLocaleString("fa-IR")})</h3>
{crashes.length === 0 ? (
<p className="text-xs text-gray-600">کرشی ثبت نشده. 🎉</p>
) : (
<div className="space-y-2">
{crashes.map((c) => (
<div key={c.id} className="rounded-lg border border-[#262b40] bg-[#0c0e1a] p-3 text-xs">
<div className="flex items-center justify-between">
<span className="text-gray-300">{faTime(c.crashed_at)}</span>
<span className="flex items-center gap-2">
{c.crash_signal && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-300">{c.crash_signal}</span>}
{c.auto_recovered
? <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-emerald-300">بازیابی خودکار</span>
: <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-amber-300">بدون بازیابی</span>}
</span>
</div>
{c.last_known_frame != null && <p className="mt-1 text-gray-500">آخرین فریم: {c.last_known_frame}</p>}
{c.error_log && <pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap text-[11px] text-gray-400" dir="ltr">{c.error_log.slice(0, 600)}</pre>}
{c.log_file_url && <a href={c.log_file_url} target="_blank" rel="noreferrer" className="text-indigo-400 hover:underline">فایل لاگ کامل</a>}
</div>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
</div>
);
}
+14
View File
@@ -5,6 +5,8 @@ import { useTranslations } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { useRouter } from "next/navigation";
import { NodeDetail } from "@/components/admin/NodeDetail";
interface V2Node {
id: string;
name: string;
@@ -41,6 +43,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [showAdd, setShowAdd] = useState(false);
const [detailNode, setDetailNode] = useState<{ id: string; name: string } | null>(null);
const [nf, setNf] = useState({ ...emptyNode });
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -138,10 +141,15 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
);
}
const detailModal = detailNode && (
<NodeDetail nodeId={detailNode.id} nodeName={detailNode.name} onClose={() => setDetailNode(null)} />
);
return (
<div className="space-y-3">
<div className="flex justify-end">{addBtn}</div>
{addModal}
{detailModal}
<div className="overflow-hidden rounded-xl border border-[#1e2235]">
<table className="w-full text-sm">
<thead>
@@ -187,6 +195,12 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => setDetailNode({ id: node.id, name: node.name })}
className="rounded px-2.5 py-1 text-xs text-gray-300 border border-[#262b40] hover:bg-[#161a2e] transition-colors"
>
جزئیات
</button>
<button
onClick={() => action(node.id, "drain")}
disabled={loading[node.id] || node.status === "Offline"}