Files
gitlab-instance-0a899031_sh/d2ypp2/web-console/components/live-product/LiveControlApp.tsx
root 71ea4da318 s
2026-05-17 05:52:35 +00:00

1543 lines
76 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,
Clock,
Cpu,
HardDrive,
Languages,
Loader2,
Menu,
MonitorPlay,
Moon,
Network,
RefreshCw,
Server,
Settings,
Smartphone,
Sparkles,
Sun,
Wifi,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LiveStudioScene3D } from "@/components/LiveStudioScene3D";
import OverviewCharts from "@/components/OverviewCharts";
import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace";
import { LiveAndroidWorkstation } from "@/components/live-product/LiveAndroidWorkstation";
import { LiveTiktokHdmiView } from "@/components/live-product/LiveTiktokHdmiView";
import type { PortalNavTarget } from "@/components/live-product/LivePipelineStrip";
import { LiveYoutubeUnmannedView } from "@/components/live-product/LiveYoutubeUnmannedView";
import { fetchJsonResponse } from "@/lib/fetchJson";
import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls";
type Lang = "zh" | "en";
type PortalTheme = "dark" | "light";
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 };
uptime_seconds?: number;
};
hardware?: Record<string, unknown>;
stack?: {
hostname?: string;
mdns_host?: string;
machine?: string;
kernel?: string;
python?: string;
platform?: string;
ips?: string[];
quick_links?: Record<string, string>;
neko_login?: {
user_login?: string;
user_password?: string;
admin_login?: string;
admin_password?: string;
note_zh?: 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; transport?: string }>;
};
live?: {
backend_mode?: string;
processes?: Array<{
pm2: string;
label?: string;
process_status?: string;
script?: string;
}>;
};
network?: {
interfaces?: Array<{
name?: string;
rx_bytes?: number;
tx_bytes?: number;
rx_packets?: number;
tx_packets?: number;
speed_mbps?: number | null;
operstate?: string;
}>;
};
live_events?: {
pm2_tail?: string[];
process_digest?: Array<{ pm2?: string; label?: string; status?: string }>;
};
hdmi?: {
status?: string;
can_stream_hdmi?: boolean;
preferred_device?: string;
video_devices?: string[];
usb_capture_matches?: string[];
issues?: string[];
suggested_ffmpeg_cmd?: string;
};
probes?: {
srs?: {
port?: number;
api_port?: number;
api_ok?: boolean;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
tcp_api?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
http_ui?: { 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 SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
type ProcessEntry = { pm2: string; script: string; label: string };
type ProcessMonitorRow = ProcessEntry & {
process_status?: string;
business_status?: string;
business_note?: string;
is_pushing?: boolean;
};
function formatUptime(sec?: number) {
if (sec == null || !Number.isFinite(sec)) return "—";
const s = Math.max(0, Math.floor(sec));
const d = Math.floor(s / 86400);
const h = Math.floor((s % 86400) / 3600);
const m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
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 === "configured")
return "bg-sky-500/15 text-sky-200 ring-1 ring-sky-500/25";
if (status === "skipped")
return "bg-slate-500/15 text-slate-300 ring-1 ring-slate-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";
}
function statusLabel(status: string, t: (zh: string, en: string) => string) {
const normalized = (status || "").trim().toLowerCase();
if (normalized === "online" || normalized === "running") return t("运行中", "Running");
if (normalized === "configured") return t("已配置", "Configured");
if (normalized === "not_found") return t("未注册", "Unregistered");
if (normalized === "stopped") return t("已停止", "Stopped");
if (normalized === "error") return t("异常", "Error");
return status || "—";
}
export default function LiveControlApp() {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
const [portalTheme, setPortalTheme] = useState<PortalTheme>("dark");
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 [processRows, setProcessRows] = useState<ProcessMonitorRow[]>([]);
const [currentProc, setCurrentProc] = useState("");
const [urlConfig, setUrlConfig] = useState("");
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState("");
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
const [showStreamProbes, setShowStreamProbes] = useState(true);
const dashboardInFlightRef = useRef<Promise<void> | null>(null);
const processMonitorInFlightRef = useRef<Promise<void> | 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> => {
return fetchJsonResponse<T>(`${base}${path}`, init);
},
[base],
);
const refreshDashboard = useCallback(async () => {
if (dashboardInFlightRef.current) return dashboardInFlightRef.current;
const task = (async () => {
try {
const d = await fetchJson<HubDashboard>("/hub/dashboard");
setDash(d);
setDashErr(null);
const devs = d.android?.devices || [];
if (devs.length && !androidSerial) {
const ready = devs.find((x) => (x.state || "").toLowerCase() === "device");
setAndroidSerial((ready ?? devs[0]).serial);
}
} catch (e) {
setDashErr((e as Error).message);
} finally {
setLoading(false);
dashboardInFlightRef.current = null;
}
})();
dashboardInFlightRef.current = task;
return task;
}, [androidSerial, fetchJson]);
const processMonitorPath = useMemo(() => {
const params = new URLSearchParams({ light: "1" });
if (view === "live_youtube") params.set("family", "youtube");
else if (view === "live_tiktok") params.set("family", "tiktok");
return `/process_monitor?${params.toString()}`;
}, [view]);
const refreshProcessMonitor = useCallback(async () => {
if (processMonitorInFlightRef.current) return processMonitorInFlightRef.current;
const task = (async () => {
try {
const data = await fetchJson<{ entries?: ProcessMonitorRow[] }>(processMonitorPath);
const list = data.entries || [];
setProcessRows(list);
setEntries(list.map((row) => ({ pm2: row.pm2, script: row.script, label: row.label })));
setCurrentProc((prev) => {
if (!list.length) return prev;
if (prev && list.some((row) => row.pm2 === prev)) return prev;
return list[0].pm2;
});
} catch {
/* ignore */
} finally {
processMonitorInFlightRef.current = null;
}
})();
processMonitorInFlightRef.current = task;
return task;
}, [fetchJson, processMonitorPath]);
useEffect(() => {
const s = window.localStorage.getItem("live-hub-lang");
if (s === "zh" || s === "en") setLang(s);
const th = window.localStorage.getItem("live-hub-theme");
if (th === "light" || th === "dark") setPortalTheme(th);
}, []);
useEffect(() => {
window.localStorage.setItem("live-hub-lang", lang);
}, [lang]);
useEffect(() => {
document.documentElement.setAttribute("data-theme", portalTheme);
try {
window.localStorage.setItem("live-hub-theme", portalTheme);
} catch {
/* ignore */
}
}, [portalTheme]);
useEffect(() => {
try {
if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false);
if (localStorage.getItem("livehub.showStreamProbes") === "0") setShowStreamProbes(false);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
void refreshDashboard();
}, [refreshDashboard]);
useEffect(() => {
void refreshProcessMonitor();
}, [refreshProcessMonitor]);
useEffect(() => {
if (!autoRefreshEnabled || view === "live_youtube" || view === "live_tiktok") return;
const id = window.setInterval(() => {
void refreshDashboard();
}, 10000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshDashboard, view]);
useEffect(() => {
if (!autoRefreshEnabled) return;
const id = window.setInterval(() => {
void refreshProcessMonitor();
}, view === "live_youtube" || view === "live_tiktok" ? 700 : 4000);
return () => clearInterval(id);
}, [autoRefreshEnabled, refreshProcessMonitor, view]);
useEffect(() => {
if (!currentProc || view !== "live_youtube") return;
void (async () => {
try {
const data = await fetchJson<{ content?: string }>(
`/get_url_config?process=${encodeURIComponent(currentProc)}`,
);
setUrlConfig(data.content || "");
} catch {
/* 保留当前草稿,避免临时读取失败把编辑器清空 */
}
})();
}, [currentProc, fetchJson, view]);
useEffect(() => {
if (view !== "settings") return;
void (async () => {
try {
const cfg = await fetchJson<Record<string, unknown>>("/hub/config");
setHubCfgSnapshot(cfg);
} catch {
setHubCfgSnapshot(null);
}
})();
}, [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 runService = async (
service: string,
action: "install" | "start" | "stop" | "restart" | "uninstall" | "upgrade",
) => {
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", processOverride?: string) => {
const proc = (processOverride ?? currentProc).trim();
if (!proc) return;
setBusy(`proc:${action}:${proc}`);
try {
const path = `/${action}`;
const data = await fetchJson<{ output?: string; message?: string }>(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ process: proc }),
});
notify(String(data.output || data.message || "OK"));
await refreshProcessMonitor();
if (view === "live_youtube" || view === "live_tiktok") {
void refreshDashboard();
} else {
await refreshDashboard();
}
} catch (e) {
notify((e as Error).message);
} finally {
setBusy(null);
}
};
const saveUrlConfig = async (overrideContent?: string, processOverride?: string) => {
const proc = (processOverride ?? currentProc).trim();
if (!proc) return;
const content = overrideContent ?? urlConfig;
setBusy("save:url");
try {
const params = new URLSearchParams({ process: proc });
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);
throw e;
} 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 transition-colors duration-300 ${
portalTheme === "light"
? "bg-slate-100 text-slate-900 selection:bg-violet-400/25"
: "bg-[#050508] text-zinc-100 selection:bg-violet-500/30"
}`}
>
<div
className={`lp-noise pointer-events-none fixed inset-0 z-0 ${portalTheme === "light" ? "opacity-[0.06]" : "opacity-[0.22]"}`}
aria-hidden
/>
<div
className={`lp-aurora pointer-events-none fixed -left-1/4 top-0 h-[42rem] w-[42rem] rounded-full blur-[120px] ${
portalTheme === "light" ? "bg-violet-400/15" : "bg-violet-600/20"
}`}
aria-hidden
/>
<div
className={`lp-aurora2 pointer-events-none fixed bottom-0 right-0 h-[36rem] w-[36rem] rounded-full blur-[100px] ${
portalTheme === "light" ? "bg-cyan-400/10" : "bg-cyan-500/10"
}`}
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 px-4 py-6 backdrop-blur-2xl lg:flex ${
portalTheme === "light" ? "border-slate-200/90 bg-white/70" : "border-white/[0.06] bg-zinc-950/40"
}`}
>
<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] ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
live.local
</p>
<p className={`text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}>
{t("无人直播系统", "Unmanned Live System")}
</p>
</div>
</div>
<nav className="mt-10 flex flex-1 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 ${
portalTheme === "light"
? active
? "bg-violet-100/90 text-slate-900 shadow-inner shadow-violet-200/50"
: "text-slate-600 hover:bg-slate-200/60 hover:text-slate-900"
: 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 ${
portalTheme === "light"
? active
? "text-violet-600"
: "text-slate-400"
: active
? "text-violet-300"
: "text-zinc-600"
}`}
/>
{t(item.labelZh, item.labelEn)}
{active ? (
<ChevronRight className={`ml-auto h-4 w-4 ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`} />
) : null}
</button>
);
})}
</nav>
</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 px-4 py-3 backdrop-blur-xl lg:px-8 ${
portalTheme === "light"
? "border-slate-200/80 bg-white/75 text-slate-900"
: "border-white/[0.06] bg-zinc-950/70 text-white"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<button
type="button"
className={`rounded-xl border p-2 lg:hidden ${portalTheme === "light" ? "border-slate-200 bg-white/80" : "border-white/10 bg-white/5"}`}
onClick={() => setMobileNav(true)}
aria-label="Menu"
>
<Menu className="h-5 w-5" />
</button>
<div className="min-w-0">
<h1
className={`truncate text-lg font-semibold tracking-tight lg:text-xl ${
portalTheme === "light" ? "text-slate-900" : "text-white"
}`}
>
{t(
navItems.find((n) => n.id === view)?.labelZh || "",
navItems.find((n) => n.id === view)?.labelEn || "",
)}
</h1>
<p className={`truncate text-xs ${portalTheme === "light" ? "text-slate-500" : "text-zinc-500"}`}>
{dash?.stack?.mdns_host || "live.local"} ·{" "}
{portalTheme === "dark" ? t("夜间", "Dark mode") : t("日间", "Light mode")} · {t("自动刷新", "Auto refresh")}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
<button
type="button"
onClick={() => setPortalTheme((prev) => (prev === "dark" ? "light" : "dark"))}
className={`rounded-xl border p-2 transition ${
portalTheme === "light"
? "border-slate-200 bg-white text-amber-500 shadow-sm"
: "border-white/10 bg-white/[0.06] text-violet-200"
}`}
title={portalTheme === "dark" ? t("切换到日间", "Switch to light") : t("切换到夜间", "Switch to dark")}
aria-label={portalTheme === "dark" ? t("日间模式", "Light mode") : t("夜间模式", "Dark mode")}
aria-pressed={portalTheme === "light"}
>
{portalTheme === "dark" ? <Moon className="h-5 w-5" strokeWidth={2} /> : <Sun className="h-5 w-5" strokeWidth={2} />}
</button>
<button
type="button"
onClick={() => setLang((prev) => (prev === "zh" ? "en" : "zh"))}
className={`relative rounded-xl border p-2 transition ${
portalTheme === "light"
? "border-slate-200 bg-white text-cyan-600 shadow-sm"
: "border-white/10 bg-white/[0.06] text-cyan-200"
}`}
title={lang === "zh" ? "English" : "中文"}
aria-label={t("切换语言", "Toggle language")}
aria-pressed={lang === "en"}
>
<Languages className="h-5 w-5" strokeWidth={2} />
<span
className={`absolute -bottom-0.5 -right-0.5 flex h-4 min-w-[1rem] items-center justify-center rounded px-0.5 text-[9px] font-bold leading-none ${
portalTheme === "light" ? "bg-cyan-100 text-cyan-800" : "bg-cyan-500/30 text-cyan-100"
}`}
>
{lang === "zh" ? "中" : "A"}
</span>
</button>
<button
type="button"
disabled={!!busy}
onClick={() => void Promise.all([refreshDashboard(), refreshProcessMonitor()])}
className={`flex items-center gap-2 rounded-xl border px-3 py-2 text-xs font-medium transition disabled:opacity-40 ${
portalTheme === "light"
? "border-slate-200 bg-white text-slate-700 hover:bg-slate-50"
: "border-white/10 bg-white/[0.04] text-zinc-300 hover:bg-white/[0.07]"
}`}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{t("刷新", "Refresh")}</span>
</button>
</div>
</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}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.02 }}
className={`rounded-2xl border p-2 backdrop-blur-xl ${
portalTheme === "light"
? "border-slate-200/90 bg-white/80 shadow-sm shadow-slate-200/30"
: "border-white/[0.07] bg-zinc-950/30"
}`}
>
<OverviewCharts snapshot={dash?.snapshot ?? null} theme={portalTheme} tr={t} />
</motion.div>
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className={`rounded-2xl border p-3 shadow-lg ${
portalTheme === "light"
? "border-fuchsia-200/80 bg-gradient-to-br from-fuchsia-50/90 via-white to-cyan-50/70 shadow-fuchsia-100/40"
: "border-fuchsia-500/20 bg-gradient-to-br from-fuchsia-500/10 via-zinc-950/60 to-cyan-500/10 shadow-fuchsia-900/10"
}`}
>
<LiveStudioScene3D tr={t} />
</motion.div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{[
{
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"),
},
{
icon: Clock,
label: t("运行时间", "Uptime"),
value: formatUptime(dash?.snapshot?.uptime_seconds),
sub: t("自开机", "Since boot"),
},
].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: -2 }}
className="group relative overflow-hidden rounded-xl border border-white/[0.07] bg-gradient-to-br from-white/[0.05] to-transparent p-3 shadow-lg shadow-black/20"
>
<div className="absolute -right-4 -top-4 h-16 w-16 rounded-full bg-violet-500/10 blur-xl transition group-hover:bg-violet-500/18" />
<card.icon className="h-4 w-4 text-violet-300/90" />
<p className="mt-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">{card.label}</p>
<p className="mt-0.5 font-mono text-lg font-semibold leading-tight text-white">{loading ? "…" : card.value}</p>
<p className="mt-0.5 text-[10px] text-zinc-500">{card.sub}</p>
</motion.div>
))}
</div>
{dash?.hardware && typeof dash.hardware === "object" ? (
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
<h3 className="text-base font-semibold text-white">
{t("系统硬件与软件能力", "Hardware & software profile")}
</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"来自 hardware_probe用于直播/解码策略参考。",
"From hardware_probe; guides decode/streaming posture.",
)}
</p>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-indigo-300/90">
{t("平台", "Platform")}
</p>
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">arch</dt>
<dd className="text-right text-zinc-200">{String(dash.hardware.arch ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">kernel</dt>
<dd className="text-right text-zinc-200">{String(dash.hardware.kernel ?? dash.stack?.kernel ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">Python</dt>
<dd className="text-right text-zinc-200">{String(dash.stack?.python ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">mDNS</dt>
<dd className="text-right text-cyan-200/90">{dash.stack?.mdns_host ?? "—"}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">IP</dt>
<dd className="text-right text-zinc-300">{(dash.stack?.ips || []).join(" · ") || "—"}</dd>
</div>
</dl>
</div>
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-fuchsia-300/90">GPU / NPU</p>
{(() => {
const gpu = dash.hardware.gpu as
| { present?: boolean; driver?: string; cards?: string[]; render_nodes?: string[] }
| undefined;
const npu = dash.hardware.npu as { present?: boolean; devices?: string[] } | undefined;
return (
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
<div>
<dt className="text-zinc-600">GPU</dt>
<dd className="mt-0.5 text-zinc-200">
{gpu?.present ? t("已检测", "present") : t("未检测", "absent")}
{gpu?.driver ? ` · ${gpu.driver}` : ""}
</dd>
</div>
<div>
<dt className="text-zinc-600">render</dt>
<dd className="mt-0.5 break-all text-zinc-500">
{(gpu?.render_nodes || []).join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-zinc-600">NPU</dt>
<dd className="mt-0.5 break-all text-zinc-500">
{npu?.present ? (npu.devices || []).join(", ") : t("—", "—")}
</dd>
</div>
</dl>
);
})()}
</div>
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4 lg:col-span-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-emerald-300/90">
ffmpeg /
</p>
{(() => {
const acc = (dash.hardware.ffmpeg_hwaccels as string[] | undefined) || [];
const rec = dash.hardware.recommendation as
| {
video_decoder?: string;
hdmi_player?: string;
browser_hwaccel?: string;
stability_mode?: string;
degradation_reasons?: string[];
}
| undefined;
return (
<div className="space-y-2 text-[11px] text-zinc-400">
<p>
<span className="text-zinc-600">hwaccels: </span>
<span className="font-mono text-emerald-200/90">{acc.length ? acc.join(", ") : "—"}</span>
</p>
<div className="grid gap-2 sm:grid-cols-2">
<p>
<span className="text-zinc-600">{t("解码", "Decoder")}: </span>
<span className="text-zinc-200">{rec?.video_decoder ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">HDMI: </span>
<span className="text-zinc-200">{rec?.hdmi_player ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">Browser: </span>
<span className="text-zinc-200">{rec?.browser_hwaccel ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">{t("稳定模式", "Stability")}: </span>
<span className="text-amber-200/90">{rec?.stability_mode ?? "—"}</span>
</p>
</div>
{(rec?.degradation_reasons || []).length ? (
<ul className="mt-2 list-inside list-disc space-y-1 text-[10px] text-amber-200/80">
{(rec?.degradation_reasons || []).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</div>
);
})()}
</div>
</div>
</div>
) : null}
{dash?.stack?.neko_login ? (
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
<h3 className="text-sm font-semibold text-white">Neko {t("登录", "sign-in")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{dash.stack.neko_login.note_zh ||
t("Neko Chromium :9200 使用下列账号策略。", "Neko Chromium :9200 uses the policy below.")}
</p>
<p className="mt-2 text-[11px] text-amber-200/85">
{t(
"重要:两栏分开填——昵称随意,密码必须是环境变量里的口令(默认 12345678。用错密码会像一直加载勿把「用户名/密码」写在一行。",
"Use two fields: display name is free-form; password must match NEKO_* (default 12345678). Wrong password looks like infinite loading.",
)}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("昵称示例", "Display Name Example")}</p>
<p className="mt-1 text-fuchsia-200">{dash.stack.neko_login.user_login ?? "live"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("连接口令", "Access Password")}</p>
<p className="mt-0.5 text-fuchsia-100">{dash.stack.neko_login.user_password ?? "—"}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("管理昵称示例", "Admin Display Name Example")}</p>
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_login ?? "admin"}</p>
<p className="mt-2 text-[10px] uppercase text-zinc-500">{t("管理口令", "Admin Password")}</p>
<p className="mt-0.5 text-violet-100">{dash.stack.neko_login.admin_password ?? "—"}</p>
</div>
</div>
<p className="mt-3 text-[10px] text-zinc-600">
{t(
"x86 默认使用 Neko 的 Chrome 镜像ARM 默认使用 Chromium 镜像;默认保留管理员会话、开发者工具和 Tampermonkey 持久化。本栈未启用 Firefox 版 Neko。",
"x86 defaults to Nekos Chrome image, ARM defaults to Chromium, and admin access, DevTools, plus persistent Tampermonkey stay enabled. This stack does not use Firefox Neko.",
)}
</p>
</div>
) : null}
{dash?.network?.interfaces?.length ? (
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.07] to-transparent p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-cyan-300" />
{t("网络流量(累计)", "Network I/O (cumulative)")}
</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t("自开机以来各接口收发字节;非实时速率。", "Per-interface RX/TX since boot (not a live bitrate).")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(dash.network.interfaces || []).slice(0, 6).map((iface) => (
<div
key={iface.name}
className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 font-mono text-[11px]"
>
<p className="text-xs font-semibold text-cyan-200/90">{iface.name}</p>
<p className="mt-2 text-zinc-500">
{t("链路", "Link")}: {iface.operstate || "unknown"} <span className="text-zinc-600">·</span>{" "}
{iface.speed_mbps ? `${iface.speed_mbps} Mbps` : t("未知速率", "unknown speed")}
</p>
<p className="mt-2 text-zinc-500">
{formatBytes(iface.rx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.rx_packets?.toLocaleString() ?? "—"} pkt
</p>
<p className="mt-0.5 text-zinc-500">
{formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.tx_packets?.toLocaleString() ?? "—"} pkt
</p>
</div>
))}
</div>
</div>
) : null}
{(dash?.live_events?.pm2_tail?.length || dash?.live_events?.process_digest?.length) ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-amber-500/15 bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("直播 / PM2 事件", "Live / PM2 feed")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">{t("PM2 合并日志尾部", "PM2 merged log tail")}</p>
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.05] bg-black/40 p-3 font-mono text-[10px] leading-relaxed text-zinc-400">
{(dash?.live_events?.pm2_tail || []).join("\n") || t("暂无 PM2 输出", "No PM2 output")}
</pre>
</div>
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("进程快照", "Process digest")}</h3>
<ul className="mt-3 space-y-2">
{(dash?.live_events?.process_digest || []).map((row) => (
<li
key={row.pm2}
className="flex items-center justify-between gap-2 rounded-lg border border-white/[0.05] bg-black/30 px-3 py-2 text-xs"
>
<span className="truncate text-zinc-300">{row.label || row.pm2}</span>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ${statusPill(row.status || "")}`}
>
{statusLabel(row.status || "", t)}
</span>
</li>
))}
</ul>
</div>
</div>
) : null}
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/50 to-fuchsia-500/[0.06] p-5 shadow-lg shadow-violet-500/5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
{dash?.live?.backend_mode || "—"}
</span>
</div>
<ul className="mt-4 space-y-2.5">
{(dash?.live?.processes || []).slice(0, 10).map((p) => (
<li
key={p.pm2}
className="group flex items-center gap-3 rounded-xl border border-white/[0.08] bg-black/35 px-3 py-2.5 transition hover:border-violet-400/25 hover:bg-black/45"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-[10px] font-bold text-violet-100 ring-1 ring-white/10">
LIVE
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-100">{p.label || p.pm2}</p>
<p className="truncate font-mono text-[10px] text-zinc-600">{p.script || p.pm2}</p>
</div>
<span className={`shrink-0 rounded-lg px-2.5 py-1 text-[10px] font-semibold ${statusPill(p.process_status || "")}`}>
{statusLabel(p.process_status || "", t)}
</span>
</li>
))}
{!dash?.live?.processes?.length ? (
<li className="rounded-xl border border-dashed border-white/10 py-8 text-center text-xs text-zinc-600">
{t("暂无直播进程", "No live processes")}
</li>
) : null}
</ul>
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setView("live_youtube")}
className="rounded-xl border border-violet-400/35 bg-violet-500/15 py-2.5 text-[11px] font-semibold text-violet-100 shadow-inner shadow-black/20 transition hover:bg-violet-500/25"
>
YouTube
</button>
<button
type="button"
onClick={() => setView("live_tiktok")}
className="rounded-xl border border-cyan-400/35 bg-cyan-500/15 py-2.5 text-[11px] font-semibold text-cyan-100 shadow-inner shadow-black/20 transition hover:bg-cyan-500/25"
>
TikTok
</button>
</div>
</div>
{dash?.probes && showStreamProbes ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{dash.probes.srs ? (
(() => {
const sp = dash.probes?.srs as SrsProbe | undefined;
const tcpOk = sp?.tcp?.reachable;
const tcpApiOk = sp?.tcp_api?.reachable;
const httpUi = sp?.http_ui;
const http = sp?.http;
const apiPort = sp?.api_port;
const apiHealthy = sp?.api_ok ?? (http?.http_code === 200);
const uiCode = httpUi?.http_code;
return (
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-white">SRS</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
apiHealthy
? "bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-500/30"
: "bg-rose-500/15 text-rose-200 ring-1 ring-rose-500/25"
}`}
>
{apiHealthy ? t("API 正常", "API OK") : t("API 异常", "API down")}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
:{sp?.port ?? "—"} / API :{apiPort ?? "—"} · TCP {tcpOk ? "OK" : "—"} · API TCP{" "}
{tcpApiOk ? "OK" : "—"}
</p>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
API HTTP {http?.http_code ?? "—"} · {t("根路径", "root")} {uiCode ?? "—"}
{uiCode === 404
? t("(无首页不影响推流)", " (no index is OK for streaming)")
: null}
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null
? `${http.latency_ms} ms`
: sp?.tcp?.latency_ms != null
? `${sp.tcp.latency_ms} ms`
: "—"}
</p>
{!apiHealthy && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})()
) : null}
{dash.probes.neko ? (
(() => {
const p = dash.probes?.neko;
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
return (
<div 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">Neko Chromium</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"Docker 内 Chromium非 Chrome 品牌arm64/x86 同一镜像。",
"Chromium in Docker (not Chrome branding); multi-arch.",
)}
</p>
<p className="mt-2 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>
);
})()
) : null}
</div>
) : null}
</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 line-clamp-6 ${
s.status === "skipped" ? "text-slate-300/90" : "text-amber-200/85"
}`}
>
{s.detail}
</p>
) : null}
{s.service_id === "shellcrash" && s.available && !s.running ? (
<p className="mt-2 text-[11px] text-zinc-500">
{t(
"stopped 为常态:仅当需要透明代理 / 规则分流时再点 Start或 systemctl enable --now shellcrash。",
"Stopped is normal; Start only when you need the proxy (or enable shellcrash.service).",
)}
</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 || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !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 || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "install")}
className="rounded-lg bg-sky-500/15 px-3 py-1.5 text-xs font-medium text-sky-200 ring-1 ring-sky-500/25 disabled:opacity-30"
>
Install
</button>
<button
type="button"
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !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 || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "upgrade")}
className="rounded-lg bg-indigo-500/15 px-3 py-1.5 text-xs font-medium text-indigo-200 ring-1 ring-indigo-500/25 disabled:opacity-30"
>
Upgrade
</button>
<button
type="button"
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !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>
<button
type="button"
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => void runService(s.service_id, "uninstall")}
className="rounded-lg bg-zinc-500/15 px-3 py-1.5 text-xs font-medium text-zinc-200 ring-1 ring-zinc-500/25 disabled:opacity-30"
>
Uninstall
</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}
onRunProcess={(a, pm) => void runProcess(a, pm)}
onSaveUrlConfig={(c, pm) => saveUrlConfig(c, pm)}
fetchJson={fetchJson}
liveProcesses={processRows.length ? processRows : (dash?.live?.processes ?? [])}
notify={notify}
quickLinks={ql}
onNavigate={(target: PortalNavTarget) => setView(target)}
/>
) : null}
{view === "live_tiktok" ? (
<LiveTiktokHdmiView
t={t}
busy={!!busy}
entries={entries}
currentProc={currentProc}
setCurrentProc={setCurrentProc}
urlConfig={urlConfig}
setUrlConfig={setUrlConfig}
onRunProcess={(a) => void runProcess(a)}
onSaveUrlConfig={(c) => saveUrlConfig(c)}
onReplayGoLive={async (replayUrl, label) => {
const safe = label.replace(/[,|]/g, "_");
await saveUrlConfig(`原画,${replayUrl},主播: ${safe}`);
await runProcess("start");
}}
onCopy={copyText}
fetchJson={fetchJson}
apiPrefix={base}
quickLinks={ql}
hdmiStatus={dash?.hdmi}
notify={notify}
onNavigate={(target: PortalNavTarget) => setView(target)}
/>
) : null}
{view === "android" ? (
<LiveAndroidWorkstation
t={t}
notify={notify}
fetchJson={fetchJson}
apiBase={base}
busy={busy}
androidSerial={androidSerial}
setAndroidSerial={setAndroidSerial}
devices={dash?.android?.devices || []}
adbAvailable={!!dash?.android?.adb_available}
apkPath={apkPath}
setApkPath={setApkPath}
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
onCopy={copyText}
onRefreshDevices={() => void refreshDashboard()}
/>
) : null}
{view === "network" ? (
<div className="mx-auto max-w-7xl space-y-6">
<div
className={`rounded-2xl border p-6 ${
portalTheme === "light"
? "border-violet-200/90 bg-gradient-to-br from-violet-50 via-white to-fuchsia-50/80"
: "border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5"
}`}
>
<h3
className={`flex items-center gap-2 text-sm font-semibold ${portalTheme === "light" ? "text-slate-900" : "text-white"}`}
>
<Wifi className={`h-4 w-4 ${portalTheme === "light" ? "text-violet-600" : "text-violet-300"}`} />
{t("ShellCrash 网络面板", "ShellCrash panel")}
</h3>
<p className={`mt-2 text-xs ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t(
"透明代理与规则分流直接在本页完成,无需跳转;流媒体与其它系统服务请在「服务」页管理。",
"Proxy rules and YAML editing are embedded here. Use Services for SRS/Neko/Chromium, etc.",
)}
</p>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
{[
{ tx: t("互联网", "Internet"), c: "from-cyan-400 to-blue-500" },
{ tx: "ShellCrash", c: "from-violet-500 to-fuchsia-500" },
{ tx: t("本机 / 安卓", "Host / Android"), c: "from-emerald-400 to-teal-500" },
].map((n, i) => (
<div key={n.tx} 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.tx}
</div>
{i < 2 ? (
<ChevronRight
className={`hidden h-5 w-5 sm:block ${portalTheme === "light" ? "text-slate-400" : "text-zinc-600"}`}
/>
) : null}
</div>
))}
</div>
</div>
<div
className={`overflow-hidden rounded-2xl border shadow-xl ${
portalTheme === "light" ? "border-slate-200/90 bg-white shadow-slate-200/40" : "border-white/[0.08] bg-zinc-950/50 shadow-black/30"
}`}
>
<div className="max-h-[min(78vh,56rem)] min-h-[22rem] overflow-y-auto overflow-x-hidden">
<LiveConsoleWorkspace embeddedShellCrash portalLang={lang} />
</div>
</div>
</div>
) : null}
{view === "settings" ? (
<div className="mx-auto max-w-lg space-y-6">
<p className={`text-sm ${portalTheme === "light" ? "text-slate-600" : "text-zinc-400"}`}>
{t("常用选项(本机偏好);完整文件与 JSON 编辑见下方链接。", "Local preferences; full file/JSON editor via the link below.")}
</p>
<div className="space-y-4 rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-white">{t("总览自动刷新", "Auto-refresh dashboard")}</p>
<p className="text-[11px] text-zinc-500">10s</p>
</div>
<input
type="checkbox"
className="h-5 w-5 accent-violet-500"
checked={autoRefreshEnabled}
onChange={(e) => {
const on = e.target.checked;
setAutoRefreshEnabled(on);
try {
localStorage.setItem("livehub.autorefresh", on ? "1" : "0");
} catch {
/* ignore */
}
notify(on ? t("已开启刷新", "Auto refresh on") : t("已暂停刷新", "Paused"));
}}
/>
</label>
<label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
<div>
<p className="text-sm font-medium text-white">{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}</p>
<p className="text-[11px] text-zinc-500">{t("关闭可精简首页", "Hide stream probe cards")}</p>
</div>
<input
type="checkbox"
className="h-5 w-5 accent-violet-500"
checked={showStreamProbes}
onChange={(e) => {
const on = e.target.checked;
setShowStreamProbes(on);
try {
localStorage.setItem("livehub.showStreamProbes", on ? "1" : "0");
} catch {
/* ignore */
}
}}
/>
</label>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/40 p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("配置快照", "Config snapshot")}</p>
<p className="mt-2 font-mono text-[11px] text-zinc-400">
{String((hubCfgSnapshot as { config_root?: string })?.config_root || "—")}
</p>
<p className="mt-3 text-sm text-zinc-300">
{t("主机名", "Hostname")}:{" "}
<span className="font-mono text-violet-200">
{(() => {
const sys = hubCfgSnapshot?.system as { identity?: { hostname_alias?: string } } | undefined;
const a = sys?.identity?.hostname_alias?.trim();
if (a) return a;
const h = dash?.stack?.mdns_host || "";
return h.replace(/\.local$/i, "") || "—";
})()}
</span>
</p>
<div className="mt-4 flex flex-wrap gap-2">
{(hubCfgSnapshot?.services as Array<{ id?: string; enabled?: boolean }> | undefined)?.slice(0, 8).map((svc) => (
<span
key={svc.id}
className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${
svc.enabled ? "bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-500/25" : "bg-zinc-500/15 text-zinc-400"
}`}
>
{svc.id} {svc.enabled ? "ON" : "off"}
</span>
))}
</div>
</div>
<a
href="/console?tab=config"
className={`inline-flex w-full items-center justify-center gap-2 rounded-xl border py-3 text-sm transition ${
portalTheme === "light"
? "border-slate-200 bg-slate-50 text-violet-700 hover:bg-slate-100"
: "border-white/10 bg-white/[0.05] text-violet-200 hover:bg-white/[0.08]"
}`}
>
{t("完整配置中心(文件 / JSON", "Full config center (files / JSON)")}
<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>
);
}