"use client"; import { AnimatePresence, motion } from "framer-motion"; import { Activity, ArrowUpRight, ChevronRight, Cpu, HardDrive, Loader2, Menu, Network, Radio, RefreshCw, Server, Settings, Smartphone, Sparkles, Wifi, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import OverviewCharts from "@/components/OverviewCharts"; import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls"; type Lang = "zh" | "en"; type ViewKey = "dashboard" | "services" | "live" | "android" | "network" | "settings"; const apiBase = () => (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || ""; function tr(lang: Lang, zh: string, en: string) { return lang === "zh" ? zh : en; } type HubDashboard = { snapshot?: { cpu_load?: { "1m"?: number; "5m"?: number; "15m"?: number }; memory?: { total?: number; available?: number; free?: number }; disk?: { total?: number; free?: number; used?: number }; netdata?: { available?: boolean; version?: string }; }; stack?: { mdns_host?: string; quick_links?: Record; ips?: string[]; }; services?: Array<{ service_id: string; label: string; description: string; available: boolean; running: boolean; status: string; detail?: string; }>; android?: { adb_available: boolean; devices: Array<{ serial: string; model?: string; state: string }> }; live?: { backend_mode?: string; processes?: Array<{ pm2: string; label?: string; process_status?: string; script?: string; }>; }; }; type ProcessEntry = { pm2: string; script: string; label: string }; function formatBytes(value?: number) { if (!value) return "—"; const u = ["B", "KB", "MB", "GB", "TB"]; let n = value; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i += 1; } return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`; } function statusPill(status: string) { if (status === "online" || status === "running") return "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30"; if (status === "stopped" || status === "not_found") return "bg-zinc-500/15 text-zinc-400 ring-1 ring-zinc-500/25"; if (status === "error") return "bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/35"; return "bg-violet-500/15 text-violet-200 ring-1 ring-violet-500/30"; } export default function LiveControlApp() { const base = apiBase(); const [lang, setLang] = useState("zh"); const [view, setView] = useState("dashboard"); const [mobileNav, setMobileNav] = useState(false); const [dash, setDash] = useState(null); const [dashErr, setDashErr] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [toast, setToast] = useState(null); const [entries, setEntries] = useState([]); const [currentProc, setCurrentProc] = useState(""); const [urlConfig, setUrlConfig] = useState(""); const [douyinUrl, setDouyinUrl] = useState(""); const [apkPath, setApkPath] = useState("/sdcard/app.apk"); const [androidSerial, setAndroidSerial] = useState(""); const [hubCfgText, setHubCfgText] = useState(null); const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]); const notify = useCallback((msg: string) => { setToast(msg); window.setTimeout(() => setToast(null), 2800); }, []); const fetchJson = useCallback( async (path: string, init?: RequestInit): Promise => { const res = await fetch(`${base}${path}`, init); const data = (await res.json()) as T & { error?: string }; if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; }, [base], ); const refreshDashboard = useCallback(async () => { try { const d = await fetchJson("/hub/dashboard"); setDash(d); setDashErr(null); const devs = d.android?.devices || []; if (devs.length && !androidSerial) setAndroidSerial(devs[0].serial); } catch (e) { setDashErr((e as Error).message); } finally { setLoading(false); } }, [androidSerial, fetchJson]); useEffect(() => { const s = window.localStorage.getItem("live-hub-lang"); if (s === "zh" || s === "en") setLang(s); }, []); useEffect(() => { window.localStorage.setItem("live-hub-lang", lang); }, [lang]); useEffect(() => { void refreshDashboard(); const id = window.setInterval(() => void refreshDashboard(), 10000); return () => clearInterval(id); }, [refreshDashboard]); useEffect(() => { void (async () => { try { const data = await fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor"); const list = data.entries || []; setEntries(list); if (!currentProc && list[0]) setCurrentProc(list[0].pm2); } catch { /* ignore */ } })(); }, [currentProc, fetchJson]); useEffect(() => { if (!currentProc || view !== "live") return; void (async () => { try { const data = await fetchJson<{ content?: string }>( `/get_url_config?process=${encodeURIComponent(currentProc)}`, ); setUrlConfig(data.content || ""); } catch { setUrlConfig(""); } })(); }, [currentProc, fetchJson, view]); useEffect(() => { void (async () => { try { const b = await fetchJson<{ streams?: { douyin_input_url?: string } }>("/hub/business/douyinyoutube"); const u = b.streams?.douyin_input_url; if (u) setDouyinUrl(u); } catch { /* ignore */ } })(); }, [fetchJson]); useEffect(() => { if (view !== "settings") return; void (async () => { try { const cfg = await fetchJson>("/hub/config"); setHubCfgText(JSON.stringify(cfg, null, 2)); } catch { setHubCfgText("{}"); } })(); }, [fetchJson, view]); const ql = useMemo(() => { const raw = dash?.stack?.quick_links || {}; const o: Record = {}; for (const [k, v] of Object.entries(raw)) { if (/^https?:\/\//i.test(v)) o[k] = normalizeBrowserReachableHttpUrl(v); else o[k] = v; } return o; }, [dash?.stack?.quick_links]); const scrcpyUrl = useMemo( () => (ql.ws_scrcpy ? normalizeBrowserReachableHttpUrl(ql.ws_scrcpy) : ""), [ql.ws_scrcpy], ); const runService = async (service: string, action: "start" | "stop" | "restart") => { setBusy(`svc:${service}`); try { const data = await fetchJson<{ message?: string }>("/service_action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ service, action }), }); notify(data.message || "OK"); await refreshDashboard(); } catch (e) { notify((e as Error).message); } finally { setBusy(null); } }; const runProcess = async (action: "start" | "stop" | "restart") => { if (!currentProc) return; setBusy(`proc:${action}`); try { const path = `/${action}`; const data = await fetchJson<{ output?: string; message?: string }>(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ process: currentProc }), }); notify(String(data.output || data.message || "OK")); await refreshDashboard(); } catch (e) { notify((e as Error).message); } finally { setBusy(null); } }; const saveUrlConfig = async () => { if (!currentProc) return; setBusy("save:url"); try { const params = new URLSearchParams({ process: currentProc }); await fetchJson(`/save_url_config?${params}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: urlConfig }), }); notify(t("URL 配置已保存", "URL config saved")); } catch (e) { notify((e as Error).message); } finally { setBusy(null); } }; const saveBusinessMeta = async () => { setBusy("save:meta"); try { await fetchJson("/hub/business/douyinyoutube", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ streams: { douyin_input_url: douyinUrl } }), }); notify(t("业务元数据已同步", "Business metadata saved")); } catch (e) { notify((e as Error).message); } finally { setBusy(null); } }; const androidAction = async (action: string, payload: Record = {}) => { if (!androidSerial) { notify(t("请选择设备", "Pick a device")); return; } setBusy(`adb:${action}`); try { const data = await fetchJson<{ message?: string }>("/android/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, serial: androidSerial, payload }), }); notify(data.message || "OK"); } catch (e) { notify((e as Error).message); } finally { setBusy(null); } }; const navItems: { id: ViewKey; icon: typeof Activity; labelZh: string; labelEn: string }[] = [ { id: "dashboard", icon: Activity, labelZh: "总览", labelEn: "Dashboard" }, { id: "services", icon: Server, labelZh: "服务", labelEn: "Services" }, { id: "live", icon: Radio, labelZh: "无人直播", labelEn: "Live ops" }, { id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" }, { id: "network", icon: Network, labelZh: "网络", labelEn: "Network" }, { id: "settings", icon: Settings, labelZh: "设置", labelEn: "Settings" }, ]; const onlineServices = dash?.services?.filter((s) => s.running && s.available).length ?? 0; const totalServices = dash?.services?.length ?? 0; return (
{toast ? ( {toast} ) : null}
{/* Desktop sidebar */} {/* Main */}

{t( navItems.find((n) => n.id === view)?.labelZh || "", navItems.find((n) => n.id === view)?.labelEn || "", )}

{dash?.stack?.mdns_host || "live.local"} · {t("暗黑", "Dark")} · {t("自动刷新", "Auto refresh")}

{view === "dashboard" ? (
{dashErr ? (
{t("无法加载仪表盘:", "Dashboard error: ")} {dashErr}
) : null}
{[ { icon: Cpu, label: t("CPU 1m", "CPU 1m"), value: dash?.snapshot?.cpu_load?.["1m"]?.toFixed(2) ?? "—", sub: t("负载均值", "Load avg"), }, { icon: Activity, label: t("内存", "Memory"), value: formatBytes(dash?.snapshot?.memory?.available), sub: `/ ${formatBytes(dash?.snapshot?.memory?.total)}`, }, { icon: HardDrive, label: t("磁盘可用", "Disk free"), value: formatBytes(dash?.snapshot?.disk?.free), sub: `/ ${formatBytes(dash?.snapshot?.disk?.total)}`, }, { icon: Server, label: t("服务在线", "Services up"), value: `${onlineServices}/${totalServices || "—"}`, sub: t("基础服务层", "Infra layer"), }, ].map((card, i) => (

{card.label}

{loading ? "…" : card.value}

{card.sub}

))}

{t("安卓", "Android")}

{dash?.android?.adb_available ? t(`已连接 ${dash.android?.devices?.length ?? 0} 台设备`, `${dash?.android?.devices?.length ?? 0} device(s)`) : t("ADB 不可用", "ADB unavailable")}

{t("打开画面工作室", "Open screen studio")}

{t("直播进程", "Live processes")}

    {(dash?.live?.processes || []).slice(0, 8).map((p) => (
  • {p.label || p.pm2} {p.process_status || "—"}
  • ))} {!dash?.live?.processes?.length ? (
  • {t("暂无数据", "No data")}
  • ) : null}
) : null} {view === "services" ? (
{(dash?.services || []).map((s, i) => (

{s.label}

{s.description}

{s.running ? t("运行中", "Running") : s.status}
))} {!dash?.services?.length ? (

{t("暂无服务数据", "No services")}

) : null}
) : null} {view === "live" ? (

{t("抖音 / TikTok 拉流地址", "Douyin / TikTok URL")}

{t("写入业务元数据(config center),推流细节仍以 URL_config.ini / PM2 为准。", "Stored in config center; streaming still uses URL_config.ini.")}

setDouyinUrl(e.target.value)} placeholder="https://live.douyin.com/..." />

URL_config.ini