diff --git a/src/components/admin/NodeDetail.tsx b/src/components/admin/NodeDetail.tsx new file mode 100644 index 0000000..f675fce --- /dev/null +++ b/src/components/admin/NodeDetail.tsx @@ -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(null); + const [history, setHistory] = useState([]); + const [crashes, setCrashes] = useState([]); + 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) => ( +
+
{label}
+
{value}
+
+ ); + + return ( +
+
e.stopPropagation()}> +
+

جزئیات نود: {nodeName}

+
+ + +
+
+ +
+ {loading ? ( +

در حال بارگذاری…

+ ) : ( + <> +
+

وضعیت لحظه‌ای

+
+ {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))} +
+
+ +
+

نمودار پردازنده (۲۴ ساعت اخیر)

+ {history.length === 0 ? ( +

داده‌ای ثبت نشده.

+ ) : ( +
+ {history.map((p, i) => ( +
+
+
+ ))} +
+ )} +
+ +
+

کرش‌های اخیر ({crashes.length.toLocaleString("fa-IR")})

+ {crashes.length === 0 ? ( +

کرشی ثبت نشده. 🎉

+ ) : ( +
+ {crashes.map((c) => ( +
+
+ {faTime(c.crashed_at)} + + {c.crash_signal && {c.crash_signal}} + {c.auto_recovered + ? بازیابی خودکار + : بدون بازیابی} + +
+ {c.last_known_frame != null &&

آخرین فریم: {c.last_known_frame}

} + {c.error_log &&
{c.error_log.slice(0, 600)}
} + {c.log_file_url && فایل لاگ کامل} +
+ ))} +
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/src/components/admin/NodesTable.tsx b/src/components/admin/NodesTable.tsx index 233e431..29223c3 100644 --- a/src/components/admin/NodesTable.tsx +++ b/src/components/admin/NodesTable.tsx @@ -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>({}); 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(null); @@ -138,10 +141,15 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) { ); } + const detailModal = detailNode && ( + setDetailNode(null)} /> + ); + return (
{addBtn}
{addModal} + {detailModal}
@@ -187,6 +195,12 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
+