's'
This commit is contained in:
@@ -5,12 +5,13 @@ import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Menu,
|
||||
MonitorPlay,
|
||||
Network,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
@@ -22,10 +23,20 @@ import {
|
||||
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" | "android" | "network" | "settings";
|
||||
type ViewKey =
|
||||
| "dashboard"
|
||||
| "services"
|
||||
| "live_youtube"
|
||||
| "live_tiktok"
|
||||
| "android"
|
||||
| "network"
|
||||
| "settings";
|
||||
|
||||
const apiBase = () =>
|
||||
(typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || "";
|
||||
@@ -66,6 +77,18 @@ type HubDashboard = {
|
||||
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 };
|
||||
@@ -225,7 +248,7 @@ export default function LiveControlApp() {
|
||||
}, [currentProc, fetchJson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentProc || view !== "live") return;
|
||||
if (!currentProc || (view !== "live_youtube" && view !== "live_tiktok")) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await fetchJson<{ content?: string }>(
|
||||
@@ -317,15 +340,16 @@ export default function LiveControlApp() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveUrlConfig = async () => {
|
||||
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: urlConfig }),
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
notify(t("URL 配置已保存", "URL config saved"));
|
||||
} catch (e) {
|
||||
@@ -374,12 +398,20 @@ export default function LiveControlApp() {
|
||||
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: "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;
|
||||
@@ -557,6 +589,35 @@ export default function LiveControlApp() {
|
||||
))}
|
||||
</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 }}
|
||||
@@ -597,13 +658,22 @@ export default function LiveControlApp() {
|
||||
<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 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>
|
||||
@@ -680,192 +750,59 @@ export default function LiveControlApp() {
|
||||
</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>
|
||||
{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" ? (
|
||||
<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>
|
||||
<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" ? (
|
||||
|
||||
Reference in New Issue
Block a user