feat(nodes): live CPU/RAM/disk monitoring in the node list
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 51s
Build backend images / build content-svc (push) Failing after 45s
Build backend images / build file-svc (push) Failing after 55s
Build backend images / build gateway (push) Failing after 53s
Build backend images / build identity-svc (push) Failing after 54s
Build backend images / build notification-svc (push) Failing after 53s
Build backend images / build render-svc (push) Failing after 47s
Build backend images / build studio-svc (push) Failing after 51s
- node-agent: internal/metrics — read CPU% (GetSystemTimes), RAM (GlobalMemoryStatusEx), disk used%/total (GetDiskFreeSpaceEx) via stdlib kernel32 (no external dep; windows build + non-windows stub). Heartbeat now reports cpu_pct/ram_available_mb/disk_used_pct/ disk_total_gb + ae_running. - render-svc: heartbeat persists last_disk_pct + disk_total_gb (migration 29); RenderNode model + node SELECT/scan carry them. - admin: rewrite NodesTable to the real RenderNode shape (fixes a pre-existing items/V2Node mismatch that left the list empty) + a CPU/RAM/disk bars column + stale-heartbeat flag. - assets-bundle ingestion: ProjectMediaBundle (jszip) auto-maps project.zip → project/scene image/demo/colour + music; PatchProject gains image/full_demo/shared_colors_svg. - scan: RGBA (4-number) colours recognised + frshare single-int controls detected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,31 +1,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
import { adminGet } from "@/lib/api/admin-gateway";
|
||||
import { NodesTable } from "@/components/admin/NodesTable";
|
||||
import { NodesTable, type RenderNode } from "@/components/admin/NodesTable";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface V2NodeList {
|
||||
items: V2Node[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default async function AdminNodesPage() {
|
||||
const data = await adminGet<V2NodeList>("/v1/nodes?pageSize=100");
|
||||
const nodes = data?.items ?? [];
|
||||
// render-svc returns { data: RenderNode[] }
|
||||
const res = await adminGet<{ data: RenderNode[] }>("/v1/nodes");
|
||||
const nodes = res?.data ?? [];
|
||||
const t = await getTranslations("auto.appAdminNodesPage");
|
||||
|
||||
return (
|
||||
|
||||
+121
-128
@@ -1,45 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { apiFetch } from "@/lib/api/fetch";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { NodeDetail } from "@/components/admin/NodeDetail";
|
||||
|
||||
interface V2Node {
|
||||
// Matches render-svc RenderNode (snake_case JSON).
|
||||
export interface RenderNode {
|
||||
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;
|
||||
region: string;
|
||||
status: string; // Ready | Busy | Offline | Maintenance | Crashed | Updating | Disabled
|
||||
current_ae_version?: string | null;
|
||||
node_kind?: string | null;
|
||||
ram_gb?: number | null;
|
||||
cpu_cores?: number | null;
|
||||
last_heartbeat_at?: string | null;
|
||||
current_job_id?: string | null;
|
||||
last_cpu_pct?: number | null;
|
||||
last_ram_available_mb?: number | null;
|
||||
last_disk_pct?: number | null;
|
||||
disk_total_gb?: number | null;
|
||||
ae_running?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<V2Node["status"], string> = {
|
||||
Online: "bg-emerald-500/20 text-emerald-300 border-emerald-500/30",
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
Ready: "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",
|
||||
Maintenance: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
|
||||
Crashed: "bg-red-500/20 text-red-300 border-red-500/30",
|
||||
Updating: "bg-purple-500/20 text-purple-300 border-purple-500/30",
|
||||
Disabled: "bg-gray-500/20 text-gray-500 border-gray-500/30",
|
||||
};
|
||||
|
||||
function heartbeatAge(iso: string): string {
|
||||
function heartbeatAge(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
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`;
|
||||
if (diff < 0) return "now";
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
return `${Math.floor(diff / 3600)}h`;
|
||||
}
|
||||
const isStale = (iso?: string | null) => !iso || Date.now() - new Date(iso).getTime() > 30000;
|
||||
|
||||
function Bar({ pct }: { pct: number }) {
|
||||
const color = pct >= 90 ? "bg-red-500" : pct >= 75 ? "bg-amber-500" : "bg-emerald-500";
|
||||
return (
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[#1e2235]">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.max(0, Math.min(100, pct))}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AE_VERSIONS = ["2025", "2024", "2023", "2022", "2021", "2020"];
|
||||
function Metric({ label, pct, sub }: { label: string; pct: number | null | undefined; sub?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-9 text-[10px] text-gray-500">{label}</span>
|
||||
{pct == null ? (
|
||||
<span className="text-[11px] text-gray-600">—</span>
|
||||
) : (
|
||||
<>
|
||||
<Bar pct={pct} />
|
||||
<span className="w-8 tabular-nums text-[11px] text-gray-300">{pct}%</span>
|
||||
{sub && <span className="text-[10px] text-gray-600">{sub}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AE_VERSIONS = ["2026", "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 emptyNode = { name: "", region: "", node_ip: "", worker_port: 8088, current_ae_version: "2026", 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");
|
||||
export function NodesTable({ nodes }: { nodes: RenderNode[] }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<Record<string, boolean>>({});
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
@@ -51,7 +87,7 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
const action = async (nodeId: string, endpoint: string) => {
|
||||
setLoading((p) => ({ ...p, [nodeId]: true }));
|
||||
try {
|
||||
await apiFetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
|
||||
await fetch(`/api/admin/nodes/${nodeId}/${endpoint}`, { method: "POST" });
|
||||
router.refresh();
|
||||
} finally {
|
||||
setLoading((p) => ({ ...p, [nodeId]: false }));
|
||||
@@ -129,120 +165,77 @@ export function NodesTable({ nodes }: { nodes: V2Node[] }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
const detailModal = detailNode && (
|
||||
<NodeDetail nodeId={detailNode.id} nodeName={detailNode.name} onClose={() => setDetailNode(null)} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3" dir="rtl">
|
||||
<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>
|
||||
<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={() => 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"}
|
||||
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>
|
||||
<button
|
||||
onClick={() => deleteNode(node.id)}
|
||||
disabled={loading[node.id]}
|
||||
className="rounded px-2.5 py-1 text-xs text-red-400 border border-red-600/50 hover:bg-red-600/20 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{nodes.length === 0 ? (
|
||||
<div className="rounded-xl border border-[#1e2235] bg-[#0f1120] px-6 py-16 text-center text-sm text-gray-500">
|
||||
هنوز نودی ثبت نشده.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-[#1e2235]">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[#1e2235] bg-[#0f1120] text-right text-xs font-medium text-gray-500">
|
||||
<th className="px-4 py-3">نود</th>
|
||||
<th className="px-4 py-3">وضعیت</th>
|
||||
<th className="px-4 py-3">منابع (CPU / RAM / دیسک)</th>
|
||||
<th className="px-4 py-3">ضربان</th>
|
||||
<th className="px-4 py-3">کار فعلی</th>
|
||||
<th className="px-4 py-3 text-left">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#1e2235] bg-[#0c0e1a]">
|
||||
{nodes.map((node) => {
|
||||
const ramUsedPct = node.ram_gb && node.last_ram_available_mb != null
|
||||
? Math.round((1 - node.last_ram_available_mb / (node.ram_gb * 1024)) * 100) : null;
|
||||
const stale = isStale(node.last_heartbeat_at);
|
||||
return (
|
||||
<tr key={node.id} className="transition-colors hover:bg-[#0f1120]/60">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-white">{node.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-gray-600">{node.region} · AE {node.current_ae_version ?? "—"} · {node.node_kind}</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] ?? STATUS_COLORS.Offline}`}>
|
||||
{node.status}{node.ae_running ? " · AE" : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Metric label="CPU" pct={node.last_cpu_pct ?? null} />
|
||||
<Metric label="RAM" pct={ramUsedPct} sub={node.last_ram_available_mb != null ? `${node.last_ram_available_mb}MB آزاد` : undefined} />
|
||||
<Metric label="دیسک" pct={node.last_disk_pct ?? null} sub={node.disk_total_gb ? `از ${node.disk_total_gb}GB` : undefined} />
|
||||
</div>
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-xs ${stale ? "text-red-400" : "text-gray-400"}`}>
|
||||
{heartbeatAge(node.last_heartbeat_at)}{stale ? " ⚠" : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[11px] text-gray-500">
|
||||
{node.current_job_id ? node.current_job_id.slice(0, 12) + "…" : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button onClick={() => setDetailNode({ id: node.id, name: node.name })} className="rounded border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e]">جزئیات</button>
|
||||
<button onClick={() => action(node.id, "restart")} disabled={loading[node.id]} className="rounded border border-blue-500/30 px-2.5 py-1 text-xs text-blue-300 hover:bg-blue-500/10 disabled:opacity-40">ریاستارت</button>
|
||||
<button onClick={() => action(node.id, "close-ae")} disabled={loading[node.id]} className="rounded border border-orange-500/30 px-2.5 py-1 text-xs text-orange-300 hover:bg-orange-500/10 disabled:opacity-40">بستن AE</button>
|
||||
<button onClick={() => action(node.id, "release")} disabled={loading[node.id]} className="rounded border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10 disabled:opacity-40">آزادسازی</button>
|
||||
<button onClick={() => deleteNode(node.id)} disabled={loading[node.id]} className="rounded border border-red-600/50 px-2.5 py-1 text-xs text-red-400 hover:bg-red-600/20 disabled:opacity-40">حذف</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import JSZip from "jszip";
|
||||
|
||||
// Auto-ingest the project assets bundle (project.zip): extract in-browser, upload
|
||||
// each file, and map by name → project + scene fields (the §11.5 convention).
|
||||
// After this the admin can still modify any of them in the scene editor.
|
||||
//
|
||||
// p.jpg/png/webp → project image p.mp4 → project demo p.svg → project colour svg
|
||||
// s{i}.jpg/png → scene[i] image s{i}.mp4 → scene[i] demo s{i}.svg → scene[i] colour svg
|
||||
// <name>.mp3 (not "sfx") → project default music (stored as a project asset)
|
||||
// (s{i} maps to the i-th scene by sort order — scenes must exist first, via the scan)
|
||||
|
||||
interface Scene {
|
||||
id: string; key: string; title: string; localized_title?: string | null; scene_type: string;
|
||||
image?: string | null; demo?: string | null; scene_color_svg?: string | null; snapshot_url?: string | null;
|
||||
generate_kf: boolean; default_duration_sec?: number | null; min_duration_sec?: number | null;
|
||||
max_duration_sec?: number | null; overlap_at_end_sec: number; can_handle_duration: boolean;
|
||||
manual_color_selection: boolean; sort: number; is_active: boolean;
|
||||
}
|
||||
|
||||
const isImg = (e: string) => /^(jpg|jpeg|png|webp|gif)$/.test(e);
|
||||
|
||||
export function ProjectMediaBundle({ projectId, onDone }: { projectId: string; onDone: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const add = (m: string) => setLog((l) => [...l, m]);
|
||||
|
||||
const uploadBlob = async (filename: string, blob: Blob): Promise<string> => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", new File([blob], filename));
|
||||
const r = await fetch("/api/admin/files/upload", { method: "POST", body: fd });
|
||||
const d = await r.json().catch(() => null);
|
||||
if (!r.ok || !d?.url) throw new Error(`بارگذاری «${filename}» ناموفق بود`);
|
||||
return d.url as string;
|
||||
};
|
||||
|
||||
const run = async (file: File) => {
|
||||
setBusy(true); setErr(null); setLog([]);
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
// scenes (sorted by sort) — s{i} maps to the i-th
|
||||
const sr = await fetch(`/api/admin/resource/scenes?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||||
const scenes: Scene[] = (Array.isArray(sr) ? sr : sr?.data ?? []).slice().sort((a: Scene, b: Scene) => a.sort - b.sort);
|
||||
add(`صحنههای موجود: ${scenes.length}`);
|
||||
|
||||
const projPatch: Record<string, string> = {};
|
||||
const sceneOver: Record<number, { image?: string; demo?: string; scene_color_svg?: string }> = {};
|
||||
let music: { url: string; name: string } | null = null;
|
||||
|
||||
const entries = Object.values(zip.files).filter((f) => !f.dir && !/(^|\/)(__MACOSX|\._)/.test(f.name));
|
||||
for (const entry of entries) {
|
||||
const base = (entry.name.split("/").pop() || "").toLowerCase();
|
||||
const ext = (base.match(/\.([^.]+)$/)?.[1]) || "";
|
||||
const stem = base.replace(/\.[^.]+$/, "");
|
||||
const blob = await entry.async("blob");
|
||||
const url = await uploadBlob(base, blob);
|
||||
add(`↑ ${base}`);
|
||||
|
||||
const sm = stem.match(/^s(\d+)$/);
|
||||
if (stem === "p") {
|
||||
if (isImg(ext)) projPatch.image = url;
|
||||
else if (ext === "mp4") projPatch.full_demo = url;
|
||||
else if (ext === "svg") projPatch.shared_colors_svg = url;
|
||||
} else if (sm) {
|
||||
const i = parseInt(sm[1], 10);
|
||||
sceneOver[i] = sceneOver[i] || {};
|
||||
if (isImg(ext)) sceneOver[i].image = url;
|
||||
else if (ext === "mp4") sceneOver[i].demo = url;
|
||||
else if (ext === "svg") sceneOver[i].scene_color_svg = url;
|
||||
} else if (ext === "mp3" && stem !== "sfx") {
|
||||
music = { url, name: base };
|
||||
}
|
||||
// demo.mp4 / sfx / other → ignored (sfx ships inside the render footage)
|
||||
}
|
||||
|
||||
// project fields
|
||||
if (Object.keys(projPatch).length) {
|
||||
await fetch(`/api/admin/resource/projects/${projectId}`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(projPatch),
|
||||
});
|
||||
add(`پروژه بهروزرسانی شد (${Object.keys(projPatch).join(", ")})`);
|
||||
}
|
||||
|
||||
// scene fields (PUT full scene with the new media merged in)
|
||||
let sceneHits = 0;
|
||||
for (const key of Object.keys(sceneOver)) {
|
||||
const i = parseInt(key, 10);
|
||||
const scene = scenes[i - 1];
|
||||
if (!scene) { add(`⚠ صحنهٔ ${i} وجود ندارد — رد شد`); continue; }
|
||||
const o = sceneOver[i];
|
||||
const body = {
|
||||
key: scene.key, title: scene.title, localized_title: scene.localized_title ?? null, scene_type: scene.scene_type,
|
||||
image: o.image ?? scene.image ?? null, demo: o.demo ?? scene.demo ?? null,
|
||||
scene_color_svg: o.scene_color_svg ?? scene.scene_color_svg ?? null, snapshot_url: scene.snapshot_url ?? null,
|
||||
generate_kf: scene.generate_kf, default_duration_sec: scene.default_duration_sec,
|
||||
min_duration_sec: scene.min_duration_sec, max_duration_sec: scene.max_duration_sec,
|
||||
overlap_at_end_sec: scene.overlap_at_end_sec ?? 0, can_handle_duration: scene.can_handle_duration,
|
||||
manual_color_selection: scene.manual_color_selection, sort: scene.sort, is_active: scene.is_active,
|
||||
};
|
||||
await fetch(`/api/admin/resource/scenes/${scene.id}`, {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
sceneHits++;
|
||||
}
|
||||
if (sceneHits) add(`${sceneHits.toLocaleString("fa-IR")} صحنه با رسانه بهروزرسانی شد`);
|
||||
|
||||
// music → project asset
|
||||
if (music) {
|
||||
await fetch(`/api/admin/resource/projects/${projectId}/assets`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: music.name, kind: "audio", url: music.url, sort: 0 }),
|
||||
});
|
||||
add(`موسیقی افزوده شد: ${music.name}`);
|
||||
}
|
||||
|
||||
add("✓ بستهٔ رسانه با موفقیت اعمال شد");
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : "اعمال بستهٔ رسانه ناموفق بود");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[#262b40] p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-gray-300">بستهٔ رسانه (zip)</p>
|
||||
<p className="text-[10px] text-gray-500">تصاویر/دموهای صحنهها و پروژه + موسیقی را خودکار اعمال میکند (p.jpg، p.mp4، s۱.jpg…). بعداً قابل ویرایش است.</p>
|
||||
</div>
|
||||
<label className="shrink-0 cursor-pointer rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500">
|
||||
{busy ? "در حال اعمال…" : "بارگذاری بستهٔ رسانه"}
|
||||
<input type="file" accept=".zip" className="hidden" disabled={busy}
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) run(f); e.currentTarget.value = ""; }} />
|
||||
</label>
|
||||
</div>
|
||||
{err && <p className="mt-2 rounded bg-red-500/10 px-2 py-1 text-[11px] text-red-300">{err}</p>}
|
||||
{log.length > 0 && (
|
||||
<ul className="mt-2 max-h-32 space-y-0.5 overflow-y-auto text-[10px] text-gray-500" dir="ltr">
|
||||
{log.map((l, i) => <li key={i}>{l}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||
import { ProjectAssets } from "@/components/admin/ProjectAssets";
|
||||
import { ProjectMediaBundle } from "@/components/admin/ProjectMediaBundle";
|
||||
import { ProjectScenes } from "@/components/admin/ProjectScenes";
|
||||
|
||||
interface Proj {
|
||||
@@ -262,6 +263,7 @@ export function ProjectsAdmin() {
|
||||
<p className="mt-1 text-[11px] text-gray-500">برای پروژههایی که فوتیج/فونت دارند، کل پروژه را بهصورت فایل zip آپلود کنید؛ هنگام رندر روی نود استخراج میشود.</p>
|
||||
{aepMsg && <p className="mt-1 text-[11px] text-indigo-300">{aepMsg}</p>}
|
||||
</div>
|
||||
<ProjectMediaBundle projectId={openAssets.id} onDone={() => load()} />
|
||||
<ProjectAssets projectId={openAssets.id} />
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
|
||||
Reference in New Issue
Block a user