"use client"; import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, useState } from "react"; import OverviewCharts from "@/components/OverviewCharts"; import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel"; import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls"; type Lang = "zh" | "en"; type ThemeMode = "dark" | "light"; type TabKey = "overview" | "business" | "modules" | "shellcrash" | "android" | "config"; type LoadAvg = { "1m"?: number; "5m"?: number; "15m"?: number }; type Capacity = { total?: number; free?: number; used?: number; available?: number }; type ProcessEntry = { pm2: string; script: string; label: string }; type ProcessStatusResponse = { process_status?: string; recent_log?: string; recent_error?: string; output?: string; pm2_output?: string; message?: string; }; type ManagedRoot = { root_id: string; label: string; description: string; path: string; exists: boolean; allow_create: boolean; allow_delete: boolean; recursive: boolean; default_file?: string | null; file_count: number; }; type ManagedFile = { path: string; size: number; mtime: number }; type StackSummary = { hostname?: string; mdns_host?: string; machine?: string; kernel?: string; ips?: string[]; dashboard_url?: string; control_url?: string; quick_links?: Record; }; type ServiceItem = { service_id: string; label: string; description: string; available: boolean; running: boolean; status: string; detail?: string; url?: string; host?: string; port?: string; message?: string; }; type SystemSnapshot = { cpu_load?: LoadAvg; disk?: Capacity; memory?: Capacity; netdata?: { available?: boolean; version?: string }; }; type HardwareProfile = { arch?: string; ffmpeg_hwaccels?: string[]; gpu?: { present?: boolean; driver?: string }; npu?: { present?: boolean; devices?: string[] }; recommendation?: { video_decoder?: string; hdmi_player?: string; stability_mode?: string; }; }; type AndroidDevice = { serial: string; state: string; usb?: string; product?: string; model?: string; device?: string; }; type AndroidOverview = { serial?: string; model?: string; brand?: string; android_version?: string; sdk?: string; battery?: string; focus?: string; resolution?: string; rotation?: string; screen_state?: string; }; type AndroidNode = { label: string; text?: string; content_desc?: string; resource_id?: string; class_name?: string; package?: string; bounds?: string; center_x: number; center_y: number; clickable?: boolean; focusable?: boolean; scrollable?: boolean; enabled?: boolean; }; type AndroidPackage = { package: string; label?: string; }; type PreviewMode = "tap" | "double_tap" | "long_press"; const TABS: TabKey[] = ["overview", "business", "modules", "shellcrash", "android", "config"]; const SHELLCRASH_ROOT_IDS = new Set(["shellcrash-configs", "shellcrash-yamls", "shellcrash-jsons"]); const apiBase = () => (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || ""; function formatBytes(value?: number) { if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; let amount = value; let index = 0; while (amount >= 1024 && index < units.length - 1) { amount /= 1024; index += 1; } return `${amount.toFixed(amount >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; } function statusTone(status: string) { if (status === "online") return "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"; if (status === "stopped") return "border-slate-500/30 bg-slate-500/10 text-slate-300"; if (status === "configured") return "border-violet-500/30 bg-violet-500/10 text-violet-200"; if (status === "unavailable") return "border-slate-700/30 bg-slate-800/50 text-slate-500"; if (status === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-200"; return "border-cyan-500/30 bg-cyan-500/10 text-cyan-200"; } function isTabKey(value: string | null): value is TabKey { return value !== null && (TABS as string[]).includes(value); } function pathnameToTab(pathname: string): TabKey | null { const normalized = pathname.replace(/\/+$/, "") || "/"; if (normalized === "/shellcrash") return "shellcrash"; if (normalized === "/android") return "android"; return null; } function tabPath(tab: TabKey) { if (tab === "shellcrash") return "/shellcrash"; if (tab === "android") return "/android"; return "/console"; } function parseResolution(resolution?: string) { const match = resolution?.match(/(\d+)x(\d+)/); if (!match) return null; return { width: Number(match[1]), height: Number(match[2]) }; } function Button({ children, className, disabled, onClick, title, }: { children: React.ReactNode; className?: string; disabled?: boolean; onClick?: () => void; title?: string; }) { return ( ); } export type LiveConsoleWorkspaceProps = { /** 嵌入门户「网络」页:只渲染 ShellCrash 面板,不改写地址栏、不显示顶栏/Tab/底栏 */ embeddedShellCrash?: boolean; /** 与门户首页语言同步 */ portalLang?: Lang; }; /** 全功能控制台:默认从门户 `/` 进入后打开 `/console`。 */ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: LiveConsoleWorkspaceProps) { const base = apiBase(); const [lang, setLang] = useState("zh"); const [theme, setTheme] = useState("dark"); const [tab, setTab] = useState("overview"); const tr = useCallback((zh: string, en: string) => (lang === "zh" ? zh : en), [lang]); const [booting, setBooting] = useState(true); const [error, setError] = useState(null); const [toast, setToast] = useState(null); const [busy, setBusy] = useState(null); const [entries, setEntries] = useState([]); const [currentProcess, setCurrentProcess] = useState(""); const [processStatus, setProcessStatus] = useState("unknown"); const [recentLog, setRecentLog] = useState(""); const [recentError, setRecentError] = useState(""); const [stack, setStack] = useState(null); const [services, setServices] = useState([]); const [snapshot, setSnapshot] = useState(null); const [hardware, setHardware] = useState(null); const [roots, setRoots] = useState([]); const [rootId, setRootId] = useState(""); const [files, setFiles] = useState([]); const [filePath, setFilePath] = useState(""); const [fileContent, setFileContent] = useState(""); const [androidAvailable, setAndroidAvailable] = useState(false); const [androidDevices, setAndroidDevices] = useState([]); const [androidSerial, setAndroidSerial] = useState(""); const [androidOverview, setAndroidOverview] = useState(null); const [androidNodes, setAndroidNodes] = useState([]); const [androidPackages, setAndroidPackages] = useState([]); const [androidPackageQuery, setAndroidPackageQuery] = useState(""); const [androidText, setAndroidText] = useState(""); const [androidPackage, setAndroidPackage] = useState("com.zhiliaoapp.musically"); const [androidUrl, setAndroidUrl] = useState("https://www.tiktok.com"); const [androidShell, setAndroidShell] = useState(""); const [androidOutput, setAndroidOutput] = useState(""); const [shotNonce, setShotNonce] = useState(Date.now()); const [previewMode, setPreviewMode] = useState("tap"); const [previewPoint, setPreviewPoint] = useState<{ x: number; y: number } | null>(null); const [fileImportKey, setFileImportKey] = useState(0); const [configInsightVersion, setConfigInsightVersion] = useState(0); const [shotObjectUrl, setShotObjectUrl] = useState(null); const [shotFetchErr, setShotFetchErr] = useState(null); const [shotLoading, setShotLoading] = useState(false); const currentRoot = roots.find((item) => item.root_id === rootId) ?? null; const shellRoots = roots.filter((item) => SHELLCRASH_ROOT_IDS.has(item.root_id)); const otherRoots = roots.filter((item) => !SHELLCRASH_ROOT_IDS.has(item.root_id)); const shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null; const androidService = services.find((item) => item.service_id === "android_panel") ?? null; const qlNorm = useMemo(() => { const raw = stack?.quick_links ?? {}; const out: Record = {}; for (const [k, v] of Object.entries(raw)) { const s = String(v); out[k] = /^https?:\/\//i.test(s) ? normalizeBrowserReachableHttpUrl(s) : s; } return out; }, [stack]); const rawAndroidPanelUrl = useMemo(() => { const baseUrl = qlNorm.ws_scrcpy || (androidService?.host && androidService.port ? `http://${androidService.host}:${androidService.port}` : stack?.mdns_host ? `http://${stack.mdns_host}:5000` : ""); return baseUrl ? normalizeBrowserReachableHttpUrl(baseUrl) : ""; }, [androidService?.host, androidService?.port, qlNorm.ws_scrcpy, stack?.mdns_host]); const androidResolution = parseResolution(androidOverview?.resolution); const notify = useCallback((message: string) => { setToast(message); window.setTimeout(() => setToast(null), 2500); }, []); const fetchJson = useCallback( async (path: string, init?: RequestInit): Promise => { const response = await fetch(`${base}${path}`, init); const data = (await response.json()) as T & { error?: string }; if (!response.ok) { throw new Error(data.error || `HTTP ${response.status}`); } return data; }, [base], ); const syncUrl = useCallback((nextTab: TabKey, nextLang: Lang) => { const url = new URL(window.location.href); url.pathname = tabPath(nextTab); if (url.pathname === "/console") { url.searchParams.set("tab", nextTab); } else { url.searchParams.delete("tab"); } url.searchParams.set("lang", nextLang); window.history.replaceState(null, "", url.toString()); window.localStorage.setItem("live-console-lang", nextLang); }, []); const refreshProcess = useCallback(async () => { if (!currentProcess) return; const data = await fetchJson( `/status?process=${encodeURIComponent(currentProcess)}`, ); setProcessStatus(data.process_status || "unknown"); setRecentLog(data.recent_log || ""); setRecentError(data.recent_error || ""); }, [currentProcess, fetchJson]); const refreshServices = useCallback(async () => { const [serviceData, snapshotData, hardwareData] = await Promise.all([ fetchJson<{ services?: ServiceItem[] }>("/stack/services"), fetchJson("/system_snapshot"), fetchJson("/hardware_profile"), ]); setServices(serviceData.services || []); setSnapshot(snapshotData); setHardware(hardwareData); }, [fetchJson]); const refreshFiles = useCallback( async (selectedRoot: string, preferredPath?: string) => { if (!selectedRoot) return; const data = await fetchJson<{ files?: ManagedFile[] }>( `/managed_files?root=${encodeURIComponent(selectedRoot)}`, ); const nextFiles = data.files || []; setFiles(nextFiles); const expected = preferredPath || filePath; if (expected && nextFiles.some((item) => item.path === expected)) { setFilePath(expected); } else { setFilePath(nextFiles[0]?.path || ""); } }, [fetchJson, filePath], ); const refreshAndroidDevices = useCallback(async () => { const data = await fetchJson<{ available?: boolean; devices?: AndroidDevice[] }>("/android/devices"); const nextDevices = data.devices || []; const nextSerial = androidSerial && nextDevices.some((item) => item.serial === androidSerial) ? androidSerial : nextDevices[0]?.serial || ""; setAndroidAvailable(Boolean(data.available)); setAndroidDevices(nextDevices); setAndroidSerial(nextSerial); return nextSerial; }, [androidSerial, fetchJson]); const refreshAndroidOverview = useCallback( async (serial: string) => { if (!serial) { setAndroidOverview(null); return; } const data = await fetchJson( `/android/overview?serial=${encodeURIComponent(serial)}`, ); setAndroidOverview(data); }, [fetchJson], ); const refreshAndroidPackages = useCallback( async (serial: string, query = androidPackageQuery) => { if (!serial) { setAndroidPackages([]); return; } const data = await fetchJson<{ packages?: AndroidPackage[] }>( `/android/packages?serial=${encodeURIComponent(serial)}&query=${encodeURIComponent(query)}&limit=60`, ); setAndroidPackages(data.packages || []); }, [androidPackageQuery, fetchJson], ); const refreshAndroidUi = useCallback( async (serial: string) => { if (!serial) { setAndroidNodes([]); return; } const data = await fetchJson<{ nodes?: AndroidNode[] }>( `/android/ui?serial=${encodeURIComponent(serial)}&limit=80`, ); setAndroidNodes(data.nodes || []); }, [fetchJson], ); const openManagedFile = useCallback( async (nextRoot: string, nextPath: string) => { setRootId(nextRoot); setFilePath(nextPath); await refreshFiles(nextRoot, nextPath); }, [refreshFiles], ); const jumpToConfig = useCallback( (root: string, path: string) => { setTab("config"); window.setTimeout(() => { void openManagedFile(root, path); }, 0); }, [openManagedFile], ); const probeHardware = useCallback(async () => { setBusy("hardware:probe"); try { const data = await fetchJson("/hardware_profile?refresh=true"); setHardware(data); notify(tr("硬件探测已更新", "Hardware profile refreshed")); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }, [fetchJson, notify, tr]); const boot = useCallback(async () => { setBooting(true); setError(null); try { const [monitor, info, managed] = await Promise.all([ fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor"), fetchJson<{ stack?: StackSummary }>("/server_info"), fetchJson<{ roots?: ManagedRoot[] }>("/managed_roots"), ]); setEntries(monitor.entries || []); setCurrentProcess(monitor.entries?.[0]?.pm2 || ""); setStack(info.stack || null); setRoots(managed.roots || []); setRootId(managed.roots?.[0]?.root_id || ""); await Promise.all([refreshServices(), refreshAndroidDevices()]); } catch (reason) { setError((reason as Error).message); } finally { setBooting(false); } }, [fetchJson, refreshAndroidDevices, refreshServices]); useEffect(() => { if (embeddedShellCrash) { setTab("shellcrash"); if (portalLang === "zh" || portalLang === "en") { setLang(portalLang); } else { const hub = window.localStorage.getItem("live-hub-lang"); if (hub === "zh" || hub === "en") setLang(hub); } return; } const params = new URLSearchParams(window.location.search); const pathTab = pathnameToTab(window.location.pathname); const nextTab = params.get("tab"); const nextLang = params.get("lang"); const storedLang = window.localStorage.getItem("live-console-lang"); if (pathTab) setTab(pathTab); else if (isTabKey(nextTab)) setTab(nextTab); if (nextLang === "zh" || nextLang === "en") setLang(nextLang); else if (storedLang === "zh" || storedLang === "en") setLang(storedLang); const storedTheme = window.localStorage.getItem("live-console-theme"); if (storedTheme === "light" || storedTheme === "dark") setTheme(storedTheme); }, [embeddedShellCrash, portalLang]); useEffect(() => { if (embeddedShellCrash) return; document.documentElement.setAttribute("data-theme", theme); window.localStorage.setItem("live-console-theme", theme); }, [theme, embeddedShellCrash]); useEffect(() => { if (!embeddedShellCrash) return; if (portalLang === "zh" || portalLang === "en") setLang(portalLang); }, [embeddedShellCrash, portalLang]); useEffect(() => { if (embeddedShellCrash) return; syncUrl(tab, lang); }, [embeddedShellCrash, lang, syncUrl, tab]); useEffect(() => { void boot(); }, [boot]); useEffect(() => { if (!currentProcess) return; void refreshProcess(); const timer = window.setInterval(() => void refreshProcess(), 1500); return () => window.clearInterval(timer); }, [currentProcess, refreshProcess]); useEffect(() => { if (booting || error) return; if (tab !== "overview" && tab !== "modules" && tab !== "shellcrash") return; void refreshServices(); const timer = window.setInterval(() => void refreshServices(), 10000); return () => clearInterval(timer); }, [booting, error, refreshServices, tab]); useEffect(() => { const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots; if (!visibleRoots.length) return; if (!visibleRoots.some((item) => item.root_id === rootId)) { setRootId(visibleRoots[0].root_id); setFilePath(""); } }, [otherRoots, rootId, roots, shellRoots, tab]); useEffect(() => { if (!rootId) return; void refreshFiles(rootId); }, [refreshFiles, rootId]); useEffect(() => { if (!rootId || !filePath) { setFileContent(""); return; } (async () => { try { const data = await fetchJson<{ content?: string }>( `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, ); setFileContent(data.content || ""); } catch (reason) { notify((reason as Error).message); } })(); }, [fetchJson, filePath, notify, rootId]); useEffect(() => { if (tab !== "android") return; void (async () => { const serial = await refreshAndroidDevices(); if (serial) { await Promise.all([ refreshAndroidOverview(serial), refreshAndroidUi(serial), refreshAndroidPackages(serial), ]); setShotNonce(Date.now()); } })(); }, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]); useEffect(() => { if (tab !== "android" || !androidSerial) return; const timer = window.setInterval(() => { void refreshAndroidOverview(androidSerial); setShotNonce(Date.now()); }, 5000); return () => window.clearInterval(timer); }, [androidSerial, refreshAndroidOverview, tab]); useEffect(() => { if (!androidSerial) { setShotObjectUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); setShotFetchErr(null); setShotLoading(false); return; } const ac = new AbortController(); const shotUrl = `${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}&ts=${shotNonce}`; setShotLoading(true); setShotFetchErr(null); fetch(shotUrl, { signal: ac.signal }) .then(async (response) => { if (!response.ok) { let msg = response.statusText; try { const body = (await response.json()) as { error?: string }; if (body.error) msg = body.error; } catch { /* ignore */ } throw new Error(msg); } return response.blob(); }) .then((blob) => { if (!blob.type.startsWith("image/")) { throw new Error("Not an image"); } const obj = URL.createObjectURL(blob); setShotObjectUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return obj; }); }) .catch((reason) => { if ((reason as Error).name === "AbortError") return; setShotFetchErr((reason as Error).message); setShotObjectUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); }) .finally(() => { if (!ac.signal.aborted) setShotLoading(false); }); return () => { ac.abort(); }; }, [androidSerial, shotNonce, base]); const processAction = async (action: "start" | "restart" | "stop") => { if (!currentProcess) return; setBusy(`process:${action}`); try { const data = await fetchJson(`/${action}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ process: currentProcess }), }); notify(String(data.output || data.pm2_output || data.message || "OK")); await refreshProcess(); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const serviceAction = async (serviceId: string, action: "start" | "restart" | "stop") => { setBusy(`${serviceId}:${action}`); try { const data = await fetchJson("/service_action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ service: serviceId, action }), }); notify(data.message || "OK"); await refreshServices(); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const saveFile = async () => { if (!rootId || !filePath) return; setBusy("file:save"); try { await fetchJson("/managed_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }), }); notify(tr("文件已保存", "File saved")); await refreshFiles(rootId, filePath); setConfigInsightVersion((value) => value + 1); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const createFile = async () => { if (!currentRoot?.allow_create) { notify(tr("当前目录不允许新建文件", "The current root does not allow new files")); return; } const nextPath = window.prompt(tr("请输入新的相对路径", "Enter a new relative path"), currentRoot.default_file || ""); if (!nextPath) return; setBusy("file:create"); try { await fetchJson("/managed_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: rootId, path: nextPath, content: "" }), }); notify(tr("文件已创建", "File created")); await refreshFiles(rootId, nextPath); setConfigInsightVersion((value) => value + 1); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const deleteFile = async () => { if (!rootId || !filePath) return; if (!currentRoot?.allow_delete) { notify(tr("当前目录不允许删除文件", "The current root does not allow deletion")); return; } if (!window.confirm(tr("确定删除该文件?", "Delete this file?"))) return; setBusy("file:delete"); try { await fetchJson( `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, { method: "DELETE" }, ); notify(tr("文件已删除", "File deleted")); await refreshFiles(rootId); setConfigInsightVersion((value) => value + 1); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const importManagedFile = async (event: ChangeEvent) => { const upload = event.target.files?.[0]; event.target.value = ""; setFileImportKey((value) => value + 1); if (!upload || !rootId) return; setBusy("file:import"); try { const content = await upload.text(); await fetchJson("/managed_file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root: rootId, path: upload.name, content }), }); notify(tr("导入完成,已替换同名文件", "Import finished and replaced matching file")); setFilePath(upload.name); await refreshFiles(rootId, upload.name); setConfigInsightVersion((value) => value + 1); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const sendAndroidAction = async (action: string, payload?: Record) => { if (!androidSerial) { notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); return; } setBusy(`android:${action}`); try { const data = await fetchJson<{ message?: string; output?: string }>("/android/action", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, serial: androidSerial, payload: payload || {} }), }); notify(data.message || "OK"); if (data.output) setAndroidOutput(data.output); setShotNonce(Date.now()); await refreshAndroidOverview(androidSerial); } catch (reason) { notify((reason as Error).message); } finally { setBusy(null); } }; const previewActionLabel = previewMode === "tap" ? tr("点击", "Tap") : previewMode === "double_tap" ? tr("双击", "Double Tap") : tr("长按", "Long Press"); const handlePreviewClick = async (event: MouseEvent) => { if (!androidSerial) { notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); return; } const rect = event.currentTarget.getBoundingClientRect(); const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth || rect.width; const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight || rect.height; const x = Math.max( 0, Math.min(sourceWidth, Math.round((event.clientX - rect.left) * (sourceWidth / rect.width))), ); const y = Math.max( 0, Math.min(sourceHeight, Math.round((event.clientY - rect.top) * (sourceHeight / rect.height))), ); setPreviewPoint({ x, y }); if (previewMode === "long_press") { await sendAndroidAction("long_press", { x, y, duration: 750 }); return; } await sendAndroidAction(previewMode, { x, y }); }; const serviceHref = (item: ServiceItem) => { if (item.service_id === "shellcrash" && stack?.control_url) { return `${stack.control_url}/shellcrash?lang=${lang}`; } if (item.service_id === "android_panel" && stack?.control_url) { return `${stack.control_url}/android?lang=${lang}`; } return item.url || ""; }; const statusText = (status: string) => ({ online: tr("在线", "Online"), stopped: tr("已停止", "Stopped"), configured: tr("已配置", "Configured"), unavailable: tr("未安装", "Unavailable"), }[status] || status); const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots; const tabEmoji: Record = { overview: "📊", business: "🎬", modules: "🧩", shellcrash: "🛡️", android: "🤖", config: "⚙️", }; return ( <> {!embeddedShellCrash ? (
← {tr("返回门户首页", "Back to portal")} {tr("无人直播 · 本页需与 API 同域(推荐 live.local:8001)", "Unmanned live · same origin as API (live.local:8001)")}
) : null}
{toast ? (
{toast}
) : null} {!embeddedShellCrash ? (

{tr("控制平面 · ARM Linux 开发板", "Control plane · ARM Linux SBC")}

{tr("无人直播控制中心", "Unmanned live control center")}

{tr( "ShellCrash 负责代理上网;本机 FFmpeg 处理推流或 HDMI;Chromium 负责浏览器侧;开发板通过 USB 双头线 ADB 控制 AOSP。各模块独立,可分别启停。", "ShellCrash for egress; FFmpeg for stream or HDMI; Chromium for browser; USB ADB to AOSP. Modules are isolated.", )}

{(stack?.mdns_host || "live.local") + " · " + (stack?.machine || "unknown") + " · " + (stack?.kernel || "unknown")}
) : (

ShellCrash

)} {booting ? (
{tr("正在连接控制台...", "Connecting to the control plane...")}
) : null} {!booting && error ? (
{error}
) : null} {!booting && !error ? (
{tab === "overview" ? ( <>

{tr("第一次用?按这条线理解整套板子", "New here? Follow this mental model")}

  1. {tr( "插电、接网线或连好 Wi‑Fi 后,用浏览器打开本页(验收域名一般为 http://live.local:8001/ ,门户在根路径 /,控制台在 /console)。", "After power and Ethernet/Wi‑Fi, open this stack in a browser (often http://live.local:8001/ — portal at /, full console at /console).", )}
  2. {tr( "「直播业务」里启停 PM2 脚本(抖音→YouTube、HDMI 等);「系统服务」看 ShellCrash、SRS、ws-scrcpy 等是否在跑。", "Use Live ops for PM2 scripts; Services for ShellCrash, SRS, ws-scrcpy, etc.", )}
  3. {tr( "「安卓设备」需要 USB 调试授权;截图预览 + ws-scrcpy 低延迟画面二选一或同用。TikTok 等场景常配合板载 AOSP。", "Android tab needs USB debugging; use screenshot taps and/or ws-scrcpy. TikTok workflows often use on-board AOSP.", )}
  4. {tr( "深度指标点 Netdata;命令行点 WebTTY;代理规则与 YAML 在 ShellCrash 页。", "Netdata for metrics; WebTTY for shell; ShellCrash tab for proxy rules and YAML.", )}

{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}

{tr( "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug。", "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222.", )}

{stack?.dashboard_url ? ( 🏠 Homepage ) : null} {stack?.control_url ? ( <> 🛡️ ShellCrash 🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")} ⚙️ {tr("配置中心", "Config")} ) : null} {qlNorm.ws_scrcpy ? ( 📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")}) ) : null} {qlNorm.chromium_remote_debug ? ( 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")} ) : null} {qlNorm.webtty ? ( ⌨️ WebTTY ) : null} {qlNorm.filebrowser ? ( 📂 File Browser ) : null} {qlNorm.netdata ? ( 📈 Netdata ) : null} {qlNorm.srs_http ? ( 🎞️ SRS :8080 ) : null} {qlNorm.srs_api ? ( 🎞️ SRS API :1985 ) : null}

{tr("节点状态", "Node Status")}

{stack?.mdns_host || "live.local"}

IP: {(stack?.ips || []).join(" / ") || "-"}

{stack?.machine || "-"}

{stack?.kernel || "-"}

{tr("系统快照", "System Snapshot")}

{tr("总览页每 10 秒自动刷新服务与监控数据。", "Overview auto-refreshes every 10s.")}

CPU:{" "} {[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]] .map((item) => (typeof item === "number" ? item.toFixed(2) : "0.00")) .join(" / ")}

RAM: {formatBytes(snapshot?.memory?.available)} / {formatBytes(snapshot?.memory?.total)}

Disk: {formatBytes(snapshot?.disk?.free)} / {formatBytes(snapshot?.disk?.total)}

Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}

{tr("模块概况", "Module Summary")}

{services.map((item) => (

{item.label}

{item.description}

{statusText(item.status)}

{item.detail || "-"}

{serviceHref(item) ? ( {tr("打开", "Open")} ) : null}
))}
) : null} {tab === "business" ? (

{tr("⚡ 快捷配置", "Quick config")}

{tr("跳转配置中心编辑推流与录制相关文件。", "Jump to the config editor for stream keys and URLs.")}

{tr("业务运行时", "Business Runtime")}

{tr("这里只放业务脚本本身,Hub 基座可单独运行。", "Only business processes live here.")}

{entries.length > 0 ? ( <>
{statusText(processStatus)}
) : (
{tr("未发现业务进程。若只部署了 Hub 基座,这是正常状态。", "No business process discovered.")}
)}

{tr("业务日志", "Process Logs")}

{recentLog || tr("暂无输出", "No stdout yet.")}
{recentError || tr("暂无报错", "No stderr yet.")}
) : null} {tab === "modules" ? (

{tr("系统模块", "System Modules")}

{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}

{services.map((item) => (

{item.label}

{item.description}

{statusText(item.status)}

{item.detail || "-"}

{serviceHref(item) ? {tr("打开", "Open")} : null}
))}

{tr("硬件与系统", "Hardware and System")}

Arch: {hardware?.arch || "-"}

FFmpeg: {(hardware?.ffmpeg_hwaccels || []).join(", ") || "-"}

GPU: {hardware?.gpu?.present ? hardware.gpu.driver || "present" : "none"}

NPU: {hardware?.npu?.present ? (hardware.npu.devices || []).join(", ") : "none"}

Decoder: {hardware?.recommendation?.video_decoder || "cpu"}

HDMI: {hardware?.recommendation?.hdmi_player || "cpu"}

Mode: {hardware?.recommendation?.stability_mode || "safe"}

) : null} {tab === "shellcrash" ? (

ShellCrash

{tr("这里不再依赖命令行菜单,直接做 YAML / JSON / CFG 编辑与服务控制。", "Manage ShellCrash from the web instead of the shell menu.")}

{shellcrashService ? (

{shellcrashService.label}

{shellcrashService.description}

{statusText(shellcrashService.status)}

{shellcrashService.detail || "-"}

) : null}

{tr("快捷文件", "Quick Files")}

{tr("ShellCrash 文件编辑器", "ShellCrash Editor")}

{tr("支持查看、修改、新建、删除,路径被限制在 ShellCrash 托管目录内。", "View, edit, create and delete files inside managed ShellCrash roots.")}

{files.map((item) => ( ))}
{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}