1015 lines
46 KiB
TypeScript
1015 lines
46 KiB
TypeScript
"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("安卓可走透明代理或 Wi‑Fi 代理,由 ShellCrash 与网络脚本实现。", "Android egress via transparent or Wi‑Fi 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/config(JSON)。以下为只读快照。",
|
||
"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>
|
||
);
|
||
}
|