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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user