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

- 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:
soroush.asadi
2026-06-04 20:01:18 +03:30
parent 6661f53734
commit 0a7dd9b84c
18 changed files with 651 additions and 2834 deletions
+4 -20
View File
@@ -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
View File
@@ -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>
);
}
+149
View File
@@ -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>
);
}
+2
View File
@@ -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">