Files
flatrender/src/components/admin/NodesTable.tsx
T
soroush.asadi ffc0c5d6d5 feat(admin): add-node form + After Effects version dropdown
- Nodes page: "+ افزودن نود" opens a full-screen form (name, region, IP, worker
  port, AE version, node kind, RAM, CPU, priority) → POST /v1/nodes
- current_ae_version is now a dropdown (2025…2020, matching the ae_version DB
  enum) instead of free text; node_kind is a dropdown (Shared/Dedicated/Spot)
- new POST /api/admin/nodes proxy route (forwards body; admin-gated). The backend
  POST /v1/nodes existed but had no UI — you couldn't define nodes before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 01:00:14 +03:30

217 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { apiFetch } from "@/lib/api/fetch";
import { useRouter } from "next/navigation";
interface V2Node {
id: string;
name: string;
status: "Online" | "Busy" | "Offline" | "Draining";
last_heartbeat: string;
active_job_id: string | null;
slots_total: number;
slots_used: number;
version: string | null;
tags: string[] | null;
}
const STATUS_COLORS: Record<V2Node["status"], string> = {
Online: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
Busy: "bg-blue-500/20 text-blue-300 border-blue-500/30",
Offline: "bg-gray-500/20 text-gray-400 border-gray-500/30",
Draining: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
};
function heartbeatAge(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
const AE_VERSIONS = ["2025", "2024", "2023", "2022", "2021", "2020"];
const NODE_KINDS = ["Shared", "Dedicated", "Spot"];
const emptyNode = { name: "", region: "", node_ip: "", worker_port: 8088, current_ae_version: "2024", node_kind: "Dedicated", ram_gb: "", cpu_cores: "", priority: 5 };
const fldCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
export function NodesTable({ nodes }: { nodes: V2Node[] }) {
const t = useTranslations("auto.componentsAdminNodesTable");
const router = useRouter();
const [loading, setLoading] = useState<Record<string, boolean>>({});
const [showAdd, setShowAdd] = useState(false);
const [nf, setNf] = useState({ ...emptyNode });
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const action = async (nodeId: string, endpoint: string) => {
setLoading((p) => ({ ...p, [nodeId]: true }));
try {
await apiFetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
router.refresh();
} finally {
setLoading((p) => ({ ...p, [nodeId]: false }));
}
};
const addNode = async () => {
setSaving(true); setErr(null);
const res = await fetch("/api/admin/nodes", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: nf.name, region: nf.region, node_ip: nf.node_ip, worker_port: Number(nf.worker_port),
current_ae_version: nf.current_ae_version, node_kind: nf.node_kind,
ram_gb: nf.ram_gb ? Number(nf.ram_gb) : null, cpu_cores: nf.cpu_cores ? Number(nf.cpu_cores) : null,
priority: Number(nf.priority) || 5,
}),
});
const d = await res.json().catch(() => null);
if (res.ok) { setShowAdd(false); setNf({ ...emptyNode }); router.refresh(); }
else setErr(d?.error ?? "ساخت نود ناموفق بود");
setSaving(false);
};
const addBtn = (
<button onClick={() => { setShowAdd(true); setErr(null); }} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
+ افزودن نود
</button>
);
const addModal = showAdd && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setShowAdd(false)}>
<div className="flex max-h-full w-full max-w-2xl flex-col rounded-xl border border-[#1e2235] bg-[#0f1120]" 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">افزودن نود رندر</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setShowAdd(false)}></button>
</div>
<div className="grid flex-1 gap-3 overflow-y-auto p-5 sm:grid-cols-2">
{err && <p className="sm:col-span-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
<div><label className="mb-1 block text-xs text-gray-400">نام نود *</label><input className={fldCls} value={nf.name} onChange={(e) => setNf({ ...nf, name: e.target.value })} /></div>
<div><label className="mb-1 block text-xs text-gray-400">منطقه (Region) *</label><input className={fldCls} value={nf.region} onChange={(e) => setNf({ ...nf, region: e.target.value })} placeholder="ir-tehran" dir="ltr" /></div>
<div><label className="mb-1 block text-xs text-gray-400">آدرس IP *</label><input className={fldCls} value={nf.node_ip} onChange={(e) => setNf({ ...nf, node_ip: e.target.value })} placeholder="192.168.1.10" dir="ltr" /></div>
<div><label className="mb-1 block text-xs text-gray-400">پورت Worker *</label><input className={fldCls} type="number" value={nf.worker_port} onChange={(e) => setNf({ ...nf, worker_port: Number(e.target.value) })} dir="ltr" /></div>
<div>
<label className="mb-1 block text-xs text-gray-400">نسخهٔ افترافکت *</label>
<select className={fldCls} value={nf.current_ae_version} onChange={(e) => setNf({ ...nf, current_ae_version: e.target.value })}>
{AE_VERSIONS.map((v) => <option key={v} value={v}>After Effects {v}</option>)}
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">نوع نود</label>
<select className={fldCls} value={nf.node_kind} onChange={(e) => setNf({ ...nf, node_kind: e.target.value })}>
{NODE_KINDS.map((k) => <option key={k} value={k}>{k}</option>)}
</select>
</div>
<div><label className="mb-1 block text-xs text-gray-400">RAM (GB)</label><input className={fldCls} type="number" value={nf.ram_gb} onChange={(e) => setNf({ ...nf, ram_gb: e.target.value })} dir="ltr" /></div>
<div><label className="mb-1 block text-xs text-gray-400">هستههای CPU</label><input className={fldCls} type="number" value={nf.cpu_cores} onChange={(e) => setNf({ ...nf, cpu_cores: e.target.value })} dir="ltr" /></div>
<div><label className="mb-1 block text-xs text-gray-400">اولویت</label><input className={fldCls} type="number" value={nf.priority} onChange={(e) => setNf({ ...nf, priority: Number(e.target.value) })} dir="ltr" /></div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
<button className="rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]" onClick={() => setShowAdd(false)}>انصراف</button>
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50" onClick={addNode} disabled={saving || !nf.name || !nf.region || !nf.node_ip}>{saving ? "در حال ذخیره…" : "ذخیره نود"}</button>
</div>
</div>
</div>
);
if (nodes.length === 0) {
return (
<div className="space-y-3">
<div className="flex justify-end">{addBtn}</div>
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
{t("emptyState")}
</div>
{addModal}
</div>
);
}
return (
<div className="space-y-3">
<div className="flex justify-end">{addBtn}</div>
{addModal}
<div className="overflow-hidden rounded-xl border border-[#1e2235]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-left text-xs font-medium uppercase tracking-wider text-gray-500">
<th className="px-4 py-3">{t("colNode")}</th>
<th className="px-4 py-3">{t("colStatus")}</th>
<th className="px-4 py-3">{t("colSlots")}</th>
<th className="px-4 py-3">{t("colHeartbeat")}</th>
<th className="px-4 py-3">{t("colActiveJob")}</th>
<th className="px-4 py-3">{t("colTags")}</th>
<th className="px-4 py-3">{t("colActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
{nodes.map((node) => (
<tr key={node.id} className="hover:bg-[#0f1120]/60 transition-colors">
<td className="px-4 py-3">
<div className="font-medium text-white">{node.name}</div>
<div className="text-[11px] text-gray-600 font-mono mt-0.5">{node.id.slice(0, 8)}</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATUS_COLORS[node.status]}`}>
{node.status}
</span>
</td>
<td className="px-4 py-3 tabular-nums text-gray-300">
{node.slots_used} / {node.slots_total}
</td>
<td className="px-4 py-3 text-gray-400">
{heartbeatAge(node.last_heartbeat)}
</td>
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
{node.active_job_id ? node.active_job_id.slice(0, 12) + "…" : "—"}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(node.tags ?? []).map((t) => (
<span key={t} className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">
{t}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button
onClick={() => action(node.id, "drain")}
disabled={loading[node.id] || node.status === "Offline"}
className="rounded px-2.5 py-1 text-xs text-yellow-300 border border-yellow-500/30 hover:bg-yellow-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionDrain")}
</button>
<button
onClick={() => action(node.id, "restart")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-blue-300 border border-blue-500/30 hover:bg-blue-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionRestart")}
</button>
<button
onClick={() => action(node.id, "close-ae")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-orange-300 border border-orange-500/30 hover:bg-orange-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionCloseAe")}
</button>
<button
onClick={() => action(node.id, "release")}
disabled={loading[node.id]}
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{t("actionRelease")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}