Files
gitlab-instance-0a899031_do…/web-console/components/live-product/LiveControlApp.tsx
2026-03-28 15:08:59 -05:00

952 lines
40 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,
CirclePlay,
Cpu,
HardDrive,
Loader2,
Menu,
MonitorPlay,
Network,
RefreshCw,
Server,
Settings,
Smartphone,
Sparkles,
Wifi,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
type ViewKey =
| "dashboard"
| "services"
| "live_youtube"
| "live_tiktok"
| "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;
}>;
};
probes?: {
srs?: {
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
};
neko?: {
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: 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_youtube" && view !== "live_tiktok")) 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 (overrideContent?: string) => {
if (!currentProc) return;
const content = overrideContent ?? urlConfig;
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 }),
});
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_youtube", icon: CirclePlay, labelZh: "YouTube 无人直播", labelEn: "YouTube unmanned" },
{ id: "live_tiktok", icon: MonitorPlay, labelZh: "TikTok 无人直播", labelEn: "TikTok unmanned" },
{ id: "android", icon: Smartphone, labelZh: "安卓", labelEn: "Android" },
{ id: "network", icon: Network, labelZh: "网络", labelEn: "Network" },
{ id: "settings", icon: Settings, labelZh: "设置", labelEn: "Settings" },
];
const copyText = (text: string) => {
void navigator.clipboard.writeText(text).then(
() => notify(t("已复制", "Copied")),
() => notify(t("复制失败", "Copy failed")),
);
};
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>
{dash?.probes ? (
<div className="grid gap-3 sm:grid-cols-2">
{(["srs", "neko"] as const).map((key) => {
const p = dash.probes?.[key];
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
const title = key === "srs" ? "SRS" : "Neko Chromium";
return (
<div
key={key}
className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm"
>
<p className="text-xs font-semibold text-white">{title}</p>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null ? `${http.latency_ms} ms` : p?.tcp?.latency_ms != null ? `${p.tcp.latency_ms} ms` : "—"}
</p>
{!http?.reachable && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})}
</div>
) : null}
<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>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setView("live_youtube")}
className="rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-[11px] font-semibold text-violet-200"
>
YouTube
</button>
<button
type="button"
onClick={() => setView("live_tiktok")}
className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 py-2 text-[11px] font-semibold text-cyan-200"
>
TikTok
</button>
</div>
</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_youtube" ? (
<LiveYoutubeUnmannedView
t={t}
busy={!!busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
fetchJson={fetchJson}
/>
) : null}
{view === "live_tiktok" ? (
<LiveTiktokHdmiView
t={t}
busy={!!busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
douyinUrl={douyinUrl}
setDouyinUrl={setDouyinUrl}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => void saveUrlConfig(c)}
onSaveBusinessMeta={() => void saveBusinessMeta()}
onCopy={copyText}
/>
) : null}
{view === "android" ? (
<LiveAndroidWorkstation
t={t}
notify={notify}
fetchJson={fetchJson}
busy={busy}
androidSerial={androidSerial}
setAndroidSerial={setAndroidSerial}
devices={dash?.android?.devices || []}
adbAvailable={!!dash?.android?.adb_available}
scrcpyUrl={scrcpyUrl}
androidShotUrl={androidShotUrl}
androidShotErr={androidShotErr}
apkPath={apkPath}
setApkPath={setApkPath}
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
/>
) : 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>
);
}