Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveControlApp.tsx
eric 92ecc14e43 s'
2026-03-28 14:16:42 -05:00

1015 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { 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<string, string>;
ips?: string[];
};
services?: Array<{
service_id: string;
label: string;
description: string;
available: boolean;
running: boolean;
status: string;
detail?: string;
url?: 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<Lang>("zh");
const [view, setView] = useState<ViewKey>("dashboard");
const [mobileNav, setMobileNav] = useState(false);
const [dash, setDash] = useState<HubDashboard | null>(null);
const [dashErr, setDashErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [entries, setEntries] = useState<ProcessEntry[]>([]);
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<string | null>(null);
const [androidShotUrl, setAndroidShotUrl] = useState<string | null>(null);
const [androidShotErr, setAndroidShotErr] = useState<string | null>(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 <T,>(path: string, init?: RequestInit): Promise<T> => {
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<HubDashboard>("/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(() => {
if (view !== "android" || !androidSerial || !dash?.android?.adb_available) {
setAndroidShotErr(null);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
return;
}
let cancelled = false;
const tick = async () => {
try {
const res = await fetch(
`${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}`,
{ cache: "no-store" },
);
const ct = res.headers.get("content-type") || "";
if (!res.ok) {
let msg = `HTTP ${res.status}`;
if (ct.includes("json")) {
try {
const j = (await res.json()) as { error?: string };
if (j.error) msg = j.error;
} catch {
/* ignore */
}
}
if (!cancelled) setAndroidShotErr(msg);
return;
}
const blob = await res.blob();
if (cancelled) return;
setAndroidShotErr(null);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
} catch (e) {
if (!cancelled) setAndroidShotErr((e as Error).message);
}
};
void tick();
const tid = window.setInterval(() => void tick(), 2500);
return () => {
cancelled = true;
window.clearInterval(tid);
setAndroidShotUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
};
}, [view, androidSerial, base, dash?.android?.adb_available]);
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<Record<string, unknown>>("/hub/config");
setHubCfgText(JSON.stringify(cfg, null, 2));
} catch {
setHubCfgText("{}");
}
})();
}, [fetchJson, view]);
const ql = useMemo(() => {
const raw = dash?.stack?.quick_links || {};
const o: Record<string, string> = {};
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; status?: string; detail?: string }>("/service_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service, action }),
});
if (data.status === "error") {
notify(data.detail || data.message || t("操作失败", "Action failed"));
} else {
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<string, unknown> = {}) => {
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 (
<div className="lp-root min-h-screen bg-[#050508] text-zinc-100 selection:bg-violet-500/30">
<div className="lp-noise pointer-events-none fixed inset-0 z-0 opacity-[0.22]" aria-hidden />
<div className="lp-aurora pointer-events-none fixed -left-1/4 top-0 h-[42rem] w-[42rem] rounded-full bg-violet-600/20 blur-[120px]" aria-hidden />
<div className="lp-aurora2 pointer-events-none fixed bottom-0 right-0 h-[36rem] w-[36rem] rounded-full bg-cyan-500/10 blur-[100px]" aria-hidden />
{toast ? (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
className="fixed bottom-6 left-1/2 z-[100] max-w-md -translate-x-1/2 rounded-2xl border border-white/10 bg-zinc-950/95 px-5 py-3 text-center text-sm text-zinc-100 shadow-2xl shadow-black/50 backdrop-blur-xl"
>
{toast}
</motion.div>
) : null}
<div className="relative z-10 flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/[0.06] bg-zinc-950/40 px-4 py-6 backdrop-blur-2xl lg:flex">
<div className="flex items-center gap-2 px-2">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 shadow-lg shadow-violet-500/25">
<Sparkles className="h-4 w-4 text-white" />
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-zinc-500">Live Hub</p>
<p className="text-sm font-semibold text-white">{t("超级业务中心", "Control Center")}</p>
</div>
</div>
<nav className="mt-10 flex flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = view === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => setView(item.id)}
className={`flex items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm font-medium transition ${
active
? "bg-white/[0.08] text-white shadow-inner shadow-white/5"
: "text-zinc-500 hover:bg-white/[0.04] hover:text-zinc-200"
}`}
>
<Icon className={`h-4 w-4 ${active ? "text-violet-300" : "text-zinc-600"}`} />
{t(item.labelZh, item.labelEn)}
{active ? <ChevronRight className="ml-auto h-4 w-4 text-zinc-600" /> : null}
</button>
);
})}
</nav>
<div className="mt-auto space-y-2 border-t border-white/[0.06] pt-6">
<a
href="/console"
className="flex items-center justify-between rounded-xl border border-white/[0.08] bg-white/[0.03] px-3 py-2.5 text-xs text-zinc-400 transition hover:border-violet-500/30 hover:text-zinc-200"
>
{t("高级控制台", "Advanced console")}
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
<div className="flex gap-1 rounded-lg bg-black/30 p-1">
<button
type="button"
className={`flex-1 rounded-md py-1.5 text-xs font-medium ${lang === "zh" ? "bg-white/10 text-white" : "text-zinc-600"}`}
onClick={() => setLang("zh")}
>
</button>
<button
type="button"
className={`flex-1 rounded-md py-1.5 text-xs font-medium ${lang === "en" ? "bg-white/10 text-white" : "text-zinc-600"}`}
onClick={() => setLang("en")}
>
EN
</button>
</div>
</div>
</aside>
{/* Main */}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex items-center justify-between gap-3 border-b border-white/[0.06] bg-zinc-950/70 px-4 py-3 backdrop-blur-xl lg:px-8">
<div className="flex items-center gap-3">
<button
type="button"
className="rounded-xl border border-white/10 bg-white/5 p-2 lg:hidden"
onClick={() => setMobileNav(true)}
aria-label="Menu"
>
<Menu className="h-5 w-5" />
</button>
<div>
<h1 className="text-lg font-semibold tracking-tight text-white lg:text-xl">
{t(
navItems.find((n) => n.id === view)?.labelZh || "",
navItems.find((n) => n.id === view)?.labelEn || "",
)}
</h1>
<p className="text-xs text-zinc-500">
{dash?.stack?.mdns_host || "live.local"} · {t("暗黑", "Dark")} · {t("自动刷新", "Auto refresh")}
</p>
</div>
</div>
<button
type="button"
disabled={!!busy}
onClick={() => void refreshDashboard()}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-medium text-zinc-300 transition hover:bg-white/[0.07] disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
{t("刷新", "Refresh")}
</button>
</header>
<main className="flex-1 px-4 py-6 lg:px-8 lg:py-8">
<AnimatePresence mode="wait">
<motion.div
key={view}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{view === "dashboard" ? (
<div className="space-y-6">
{dashErr ? (
<div className="rounded-2xl border border-rose-500/25 bg-rose-950/20 p-4 text-sm text-rose-200">
{t("无法加载仪表盘:", "Dashboard error: ")}
{dashErr}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[
{
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) => (
<motion.div
key={card.label}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
whileHover={{ y: -3 }}
className="group relative overflow-hidden rounded-2xl border border-white/[0.07] bg-gradient-to-br from-white/[0.06] to-transparent p-5 shadow-xl shadow-black/20"
>
<div className="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-violet-500/10 blur-2xl transition group-hover:bg-violet-500/20" />
<card.icon className="h-5 w-5 text-violet-300/90" />
<p className="mt-4 text-[11px] font-semibold uppercase tracking-wider text-zinc-500">{card.label}</p>
<p className="mt-1 font-mono text-2xl font-semibold text-white">{loading ? "…" : card.value}</p>
<p className="mt-1 text-xs text-zinc-500">{card.sub}</p>
</motion.div>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="xl:col-span-2 rounded-2xl border border-white/[0.07] bg-zinc-950/30 p-2 backdrop-blur-xl"
>
<OverviewCharts snapshot={dash?.snapshot ?? null} theme="dark" tr={t} />
</motion.div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("安卓", "Android")}</h3>
<p className="mt-2 text-xs text-zinc-500">
{dash?.android?.adb_available
? t(`已连接 ${dash.android?.devices?.length ?? 0} 台设备`, `${dash?.android?.devices?.length ?? 0} device(s)`)
: t("ADB 不可用", "ADB unavailable")}
</p>
<a
href="/android"
className="mt-4 inline-flex items-center gap-2 text-xs font-medium text-violet-300 hover:text-violet-200"
>
{t("打开画面工作室", "Open screen studio")}
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
</div>
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
<ul className="mt-3 max-h-40 space-y-2 overflow-y-auto text-xs">
{(dash?.live?.processes || []).slice(0, 8).map((p) => (
<li key={p.pm2} className="flex justify-between gap-2 rounded-lg bg-black/30 px-3 py-2">
<span className="truncate text-zinc-300">{p.label || p.pm2}</span>
<span className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ${statusPill(p.process_status || "")}`}>
{p.process_status || "—"}
</span>
</li>
))}
{!dash?.live?.processes?.length ? (
<li className="text-zinc-600">{t("暂无数据", "No data")}</li>
) : null}
</ul>
<button
type="button"
onClick={() => setView("live")}
className="mt-4 w-full rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-xs font-semibold text-violet-200"
>
{t("去控制台", "Go to live ops")}
</button>
</div>
</div>
</div>
</div>
) : null}
{view === "services" ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{(dash?.services || []).map((s, i) => (
<motion.div
key={s.service_id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.04 }}
whileHover={{ y: -2 }}
className="rounded-2xl border border-white/[0.07] bg-gradient-to-b from-white/[0.05] to-transparent p-5"
>
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-semibold text-white">{s.label}</h3>
<p className="mt-1 text-xs text-zinc-500 line-clamp-2">{s.description}</p>
</div>
<span className={`shrink-0 rounded-full px-2.5 py-1 text-[10px] font-semibold ${statusPill(s.running ? "online" : s.status)}`}>
{s.running ? t("运行中", "Running") : s.status}
</span>
</div>
{s.detail ? (
<p className="mt-2 text-[11px] leading-snug text-amber-200/85 line-clamp-4">{s.detail}</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
{s.url ? (
<a
href={normalizeBrowserReachableHttpUrl(s.url)}
target="_blank"
rel="noreferrer"
className="rounded-lg bg-violet-500/15 px-3 py-1.5 text-xs font-medium text-violet-200 ring-1 ring-violet-500/25"
>
{t("打开面板", "Open panel")}
</a>
) : null}
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "start")}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-200 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
Start
</button>
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "restart")}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-200 ring-1 ring-amber-500/25 disabled:opacity-30"
>
Restart
</button>
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
onClick={() => void runService(s.service_id, "stop")}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-200 ring-1 ring-rose-500/25 disabled:opacity-30"
>
Stop
</button>
</div>
</motion.div>
))}
{!dash?.services?.length ? (
<p className="text-sm text-zinc-500">{t("暂无服务数据", "No services")}</p>
) : null}
</div>
) : null}
{view === "live" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<label className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("选择进程", "Process")}</label>
<select
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm text-white"
value={currentProc}
onChange={(e) => setCurrentProc(e.target.value)}
>
{entries.map((e) => (
<option key={e.pm2} value={e.pm2}>
{e.label} ({e.pm2})
</option>
))}
</select>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("start")}
className="rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-200 ring-1 ring-emerald-500/30"
>
{t("启动", "Start")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("restart")}
className="rounded-xl bg-amber-500/15 px-4 py-2 text-sm font-medium text-amber-200 ring-1 ring-amber-500/25"
>
{t("重启", "Restart")}
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void runProcess("stop")}
className="rounded-xl bg-rose-500/15 px-4 py-2 text-sm font-medium text-rose-200 ring-1 ring-rose-500/25"
>
{t("停止", "Stop")}
</button>
</div>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("抖音 / TikTok 拉流地址", "Douyin / TikTok URL")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("写入业务元数据config center推流细节仍以 URL_config.ini / PM2 为准。", "Stored in config center; streaming still uses URL_config.ini.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={douyinUrl}
onChange={(e) => setDouyinUrl(e.target.value)}
placeholder="https://live.douyin.com/..."
/>
<button
type="button"
disabled={!!busy}
onClick={() => void saveBusinessMeta()}
className="mt-3 rounded-xl bg-violet-500/25 px-4 py-2 text-sm font-medium text-violet-100 ring-1 ring-violet-500/35"
>
{t("保存到配置中心", "Save to config hub")}
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">URL_config.ini</h3>
<textarea
className="mt-4 h-48 w-full resize-y rounded-xl border border-white/10 bg-black/50 p-4 font-mono text-xs text-zinc-300"
value={urlConfig}
onChange={(e) => setUrlConfig(e.target.value)}
spellCheck={false}
/>
<button
type="button"
disabled={!!busy}
onClick={() => void saveUrlConfig()}
className="mt-3 rounded-xl bg-cyan-500/20 px-4 py-2 text-sm font-medium text-cyan-100 ring-1 ring-cyan-500/30"
>
{t("保存 URL 配置", "Save URL config")}
</button>
</div>
<p className="text-center text-xs text-zinc-600">
{t(
"FFmpeg 自动重启:请在 PM2 ecosystem 或 systemd 中配置 restart 策略business.json 中 ffmpeg.auto_restart 供运维工具读取。",
"For FFmpeg auto-restart, configure PM2 or systemd; business.json flags are for automation hooks.",
)}
</p>
</div>
) : null}
{view === "android" ? (
<div className="mx-auto max-w-5xl space-y-6">
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("设备", "Devices")}</h3>
<div className="mt-4 flex flex-wrap gap-2">
{(dash?.android?.devices || []).map((d) => (
<button
key={d.serial}
type="button"
onClick={() => setAndroidSerial(d.serial)}
className={`rounded-xl border px-4 py-2 text-left text-xs ${
androidSerial === d.serial
? "border-violet-500/50 bg-violet-500/10 text-white"
: "border-white/10 bg-black/30 text-zinc-400"
}`}
>
<div className="font-medium text-zinc-200">{d.model || d.serial}</div>
<div className="text-[10px] text-zinc-600">{d.serial}</div>
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!!busy}
onClick={() =>
void androidAction("launch_package", { package: "com.zhiliaoapp.musically" })
}
className="rounded-xl bg-gradient-to-r from-pink-500/30 to-violet-500/30 px-4 py-2 text-sm font-medium ring-1 ring-white/10"
>
TikTok
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void androidAction("wake")}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm"
>
Wake
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={!!busy}
onClick={() => void androidAction("install_apk", { path: apkPath })}
className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
>
pm install
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新一次;无需 ws-scrcpy。点击下方「新窗口」可尝试流式画面。",
"~2.5s refresh via ADB; no ws-scrcpy required. Open ws-scrcpy in a new tab for streaming.",
)}
</p>
{!dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && dash?.android?.adb_available ? (
<p className="mt-3 text-sm text-rose-300">{androidShotErr}</p>
) : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
/>
) : null}
{dash?.android?.adb_available && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<span className="text-xs font-medium text-zinc-400">ws-scrcpy</span>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
</div>
) : null}
</div>
) : null}
{view === "network" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-transparent p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-cyan-300" />
{t("逻辑拓扑", "Topology")}
</h3>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
{[
{ t: t("互联网", "Internet"), c: "from-cyan-400 to-blue-500" },
{ t: "ShellCrash", c: "from-violet-500 to-fuchsia-500" },
{ t: t("本机服务", "Host"), c: "from-emerald-400 to-teal-500" },
{ t: "AOSP", c: "from-amber-400 to-orange-500" },
].map((n, i) => (
<div key={n.t} className="flex items-center gap-4">
<div
className={`flex h-16 w-28 items-center justify-center rounded-2xl bg-gradient-to-br ${n.c} text-xs font-bold text-white shadow-lg`}
>
{n.t}
</div>
{i < 3 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" /> : null}
</div>
))}
</div>
<p className="mt-6 text-center text-xs text-zinc-500">
{t("安卓可走透明代理或 WiFi 代理,由 ShellCrash 与网络脚本实现。", "Android egress via transparent or WiFi proxy.")}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<a
href="/shellcrash"
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30"
>
ShellCrash
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
{[
["Netdata", ql.netdata],
["SRS", ql.srs_http],
["Neko", ql.neko_browser],
["WebTTY", ql.webtty],
].map(([name, href]) => (
<a
key={name}
href={href || "#"}
target="_blank"
rel="noreferrer"
className={`flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30 ${href ? "" : "pointer-events-none opacity-40"}`}
onClick={(e) => {
if (!href) {
e.preventDefault();
notify(t("仪表盘未返回该链接", "Hub did not return this link"));
}
}}
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
))}
</div>
<p className="text-center text-xs text-zinc-500">
{t(
"若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko并确认防火墙未拦端口。",
"If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.",
)}
</p>
</div>
) : null}
{view === "settings" ? (
<div className="mx-auto max-w-3xl space-y-4">
<p className="text-sm text-zinc-400">
{t(
"配置中心路径:/opt/live/configJSON。以下为只读快照。",
"Config root: /opt/live/config (JSON). Read-only snapshot.",
)}
</p>
<pre className="max-h-[480px] overflow-auto rounded-2xl border border-white/10 bg-black/50 p-4 font-mono text-[11px] leading-relaxed text-zinc-400">
{hubCfgText || t("加载中…", "Loading…")}
</pre>
<a
href="/console?tab=config"
className="inline-flex items-center gap-2 text-sm text-violet-300"
>
{t("托管文件编辑器", "Managed file editor")}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
) : null}
</motion.div>
</AnimatePresence>
</main>
</div>
</div>
{/* Mobile drawer */}
<AnimatePresence>
{mobileNav ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setMobileNav(false)}
>
<motion.aside
initial={{ x: -280 }}
animate={{ x: 0 }}
exit={{ x: -280 }}
transition={{ type: "spring", damping: 28, stiffness: 320 }}
className="absolute left-0 top-0 h-full w-[min(88vw,280px)] border-r border-white/10 bg-zinc-950 p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-end">
<button type="button" onClick={() => setMobileNav(false)} className="rounded-lg p-2">
<X className="h-5 w-5" />
</button>
</div>
<nav className="mt-4 flex flex-col gap-1">
{navItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => {
setView(item.id);
setMobileNav(false);
}}
className="flex items-center gap-3 rounded-xl px-3 py-3 text-left text-sm text-zinc-300"
>
<Icon className="h-4 w-4 text-violet-400" />
{t(item.labelZh, item.labelEn)}
</button>
);
})}
</nav>
</motion.aside>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}