's'
This commit is contained in:
@@ -1006,7 +1006,12 @@ export function LiveConsoleWorkspace() {
|
||||
) : null}
|
||||
{qlNorm.srs_http ? (
|
||||
<a className="link-tile" href={qlNorm.srs_http} target="_blank" rel="noreferrer">
|
||||
🎞️ SRS HTTP
|
||||
🎞️ SRS :8080
|
||||
</a>
|
||||
) : null}
|
||||
{qlNorm.srs_api ? (
|
||||
<a className="link-tile" href={qlNorm.srs_api} target="_blank" rel="noreferrer">
|
||||
🎞️ SRS API :1985
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Layers,
|
||||
Loader2,
|
||||
Package,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type DeviceRow = { serial: string; model?: string; state: string };
|
||||
@@ -58,8 +49,6 @@ type Props = {
|
||||
devices: DeviceRow[];
|
||||
adbAvailable: boolean;
|
||||
scrcpyUrl: string;
|
||||
androidShotUrl: string | null;
|
||||
androidShotErr: string | null;
|
||||
apkPath: string;
|
||||
setApkPath: (v: string) => void;
|
||||
onAndroidAction: (action: string, payload?: Record<string, unknown>) => Promise<void>;
|
||||
@@ -76,8 +65,6 @@ export function LiveAndroidWorkstation({
|
||||
devices,
|
||||
adbAvailable,
|
||||
scrcpyUrl,
|
||||
androidShotUrl,
|
||||
androidShotErr,
|
||||
apkPath,
|
||||
setApkPath,
|
||||
onAndroidAction,
|
||||
@@ -96,9 +83,11 @@ export function LiveAndroidWorkstation({
|
||||
const [uiErr, setUiErr] = useState<string | null>(null);
|
||||
const [openUrl, setOpenUrl] = useState("https://");
|
||||
const [adbInputText, setAdbInputText] = useState("");
|
||||
const [embedScrcpy, setEmbedScrcpy] = useState(false);
|
||||
const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host");
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/";
|
||||
|
||||
const lock = !!(busy || pending);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
@@ -280,6 +269,104 @@ export function LiveAndroidWorkstation({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-500/[0.08] to-black/50">
|
||||
<div className="flex flex-wrap gap-2 border-b border-white/10 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorMode("host")}
|
||||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||||
mirrorMode === "host"
|
||||
? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/40"
|
||||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{t("宿主投屏 (板子 ws-scrcpy)", "Host ws-scrcpy")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMirrorMode("webusb")}
|
||||
className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
|
||||
mirrorMode === "webusb"
|
||||
? "bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
|
||||
: "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
WebUSB · Panda
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/PandaTestGrid/panda-web-scrcpy"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ml-auto self-center text-[11px] text-zinc-500 underline-offset-2 hover:text-violet-300 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
{mirrorMode === "host" ? (
|
||||
<div>
|
||||
<div className="space-y-2 border-b border-white/5 px-4 py-3">
|
||||
<p className="text-[11px] text-zinc-400">
|
||||
{t(
|
||||
"手机接在 live 板子 USB 时使用本页:先在 ws-scrcpy 里选设备并连接;黑屏多为未连 WebSocket / 屏幕休眠——可先 Wake 再刷新或新窗口打开面板。",
|
||||
"USB to the board: pick device in ws-scrcpy; black screen often means WS down or screen off—Wake, refresh, or open panel in a new tab.",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{adbAvailable && androidSerial ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => void onAndroidAction("wake")}
|
||||
className="rounded-lg bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-500/30"
|
||||
>
|
||||
Wake
|
||||
</button>
|
||||
) : null}
|
||||
{scrcpyUrl ? (
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
|
||||
>
|
||||
{t("新窗口打开 :5000", "Open :5000 tab")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{scrcpyUrl ? (
|
||||
<iframe
|
||||
title="ws-scrcpy"
|
||||
src={scrcpyUrl}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
|
||||
/>
|
||||
) : (
|
||||
<p className="px-4 py-8 text-center text-sm text-zinc-500">{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
|
||||
{t(
|
||||
"「Panda / WebUSB」在浏览器里直连手机:手机必须插在您正在用浏览器看页面的这台电脑的 USB 上(走 WebUSB,不是板子上的 ADB)。官方演示见 ",
|
||||
"Panda uses WebUSB: phone must plug into the PC running this browser, not the board. Demo: ",
|
||||
)}
|
||||
<a className="text-cyan-300 hover:underline" href={PANDA_WEB_SCRCPY} target="_blank" rel="noreferrer">
|
||||
panda-web-scrcpy
|
||||
</a>
|
||||
。
|
||||
</p>
|
||||
<iframe
|
||||
title="panda-web-scrcpy"
|
||||
src={PANDA_WEB_SCRCPY}
|
||||
className="h-[min(72vh,640px)] w-full bg-black"
|
||||
allow="fullscreen; usb"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adbAvailable && androidSerial ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
|
||||
@@ -672,87 +759,25 @@ export function LiveAndroidWorkstation({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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={lock || !adbAvailable || !androidSerial}
|
||||
onClick={() => void onAndroidAction("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">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-white">{t("实时画面(ADB 截图)", "Live screen (ADB)")}</h3>
|
||||
{scrcpyUrl ? (
|
||||
<a
|
||||
href={scrcpyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
|
||||
>
|
||||
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
{t(
|
||||
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
|
||||
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
|
||||
)}
|
||||
</p>
|
||||
{!adbAvailable ? (
|
||||
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
|
||||
) : null}
|
||||
{androidShotErr && adbAvailable ? <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}
|
||||
{adbAvailable && androidSerial ? (
|
||||
<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)}
|
||||
/>
|
||||
) : null}
|
||||
{adbAvailable && !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 flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEmbedScrcpy((v) => !v)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
|
||||
>
|
||||
ws-scrcpy
|
||||
{embedScrcpy ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
<span className="font-normal text-zinc-600">
|
||||
{embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
|
||||
</span>
|
||||
</button>
|
||||
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
|
||||
{t("新窗口打开", "Open tab")}
|
||||
</a>
|
||||
</div>
|
||||
{embedScrcpy ? (
|
||||
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
|
||||
) : (
|
||||
<p className="px-4 py-3 text-center text-[11px] text-zinc-600">
|
||||
{t("默认不加载 iframe,避免占用带宽与解码;需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock || !adbAvailable || !androidSerial}
|
||||
onClick={() => void onAndroidAction("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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowUpRight,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
Clock,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
@@ -51,11 +52,20 @@ type HubDashboard = {
|
||||
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;
|
||||
quick_links?: Record<string, string>;
|
||||
machine?: string;
|
||||
kernel?: string;
|
||||
python?: string;
|
||||
platform?: string;
|
||||
ips?: string[];
|
||||
quick_links?: Record<string, string>;
|
||||
neko_login?: { user_password?: string; admin_password?: string; note_zh?: string };
|
||||
neko_firefox_enabled?: boolean;
|
||||
};
|
||||
services?: Array<{
|
||||
service_id: string;
|
||||
@@ -77,13 +87,35 @@ type HubDashboard = {
|
||||
script?: string;
|
||||
}>;
|
||||
};
|
||||
network?: {
|
||||
interfaces?: Array<{
|
||||
name?: string;
|
||||
rx_bytes?: number;
|
||||
tx_bytes?: number;
|
||||
rx_packets?: number;
|
||||
tx_packets?: number;
|
||||
}>;
|
||||
};
|
||||
live_events?: {
|
||||
pm2_tail?: string[];
|
||||
process_digest?: Array<{ pm2?: string; label?: string; status?: 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 };
|
||||
};
|
||||
neko?: {
|
||||
neko_firefox?: {
|
||||
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 };
|
||||
@@ -91,8 +123,21 @@ type HubDashboard = {
|
||||
};
|
||||
};
|
||||
|
||||
type SrsProbe = NonNullable<HubDashboard["probes"]>["srs"];
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
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"];
|
||||
@@ -108,6 +153,8 @@ function formatBytes(value?: number) {
|
||||
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 === "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";
|
||||
@@ -131,9 +178,9 @@ export default function LiveControlApp() {
|
||||
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 [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | null>(null);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||||
const [showStreamProbes, setShowStreamProbes] = useState(true);
|
||||
|
||||
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
|
||||
|
||||
@@ -176,63 +223,20 @@ export default function LiveControlApp() {
|
||||
}, [lang]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDashboard();
|
||||
const id = window.setInterval(() => void refreshDashboard(), 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshDashboard]);
|
||||
try {
|
||||
if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false);
|
||||
if (localStorage.getItem("livehub.showStreamProbes") === "0") setShowStreamProbes(false);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
void refreshDashboard();
|
||||
if (!autoRefreshEnabled) return;
|
||||
const id = window.setInterval(() => void refreshDashboard(), 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshDashboard, autoRefreshEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@@ -278,9 +282,9 @@ export default function LiveControlApp() {
|
||||
void (async () => {
|
||||
try {
|
||||
const cfg = await fetchJson<Record<string, unknown>>("/hub/config");
|
||||
setHubCfgText(JSON.stringify(cfg, null, 2));
|
||||
setHubCfgSnapshot(cfg);
|
||||
} catch {
|
||||
setHubCfgText("{}");
|
||||
setHubCfgSnapshot(null);
|
||||
}
|
||||
})();
|
||||
}, [fetchJson, view]);
|
||||
@@ -545,7 +549,7 @@ export default function LiveControlApp() {
|
||||
{dashErr}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{[
|
||||
{
|
||||
icon: Cpu,
|
||||
@@ -571,6 +575,12 @@ export default function LiveControlApp() {
|
||||
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}
|
||||
@@ -589,32 +599,332 @@ 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 (
|
||||
{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("登录口令", "passwords")}</h3>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{dash.stack.neko_login.note_zh ||
|
||||
t("Chromium :9200 与 Firefox :9201(若启用)共用下列口令。", "Chromium :9200 and Firefox :9201 share these.")}
|
||||
</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("用户", "User")}</p>
|
||||
<p className="mt-1 text-fuchsia-200">{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")}</p>
|
||||
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_password ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[10px] text-zinc-600">
|
||||
{t(
|
||||
"Google 无官方 ARM 桌面 Chrome;Neko 使用 Chromium / Firefox 多架构镜像(非 Chrome 品牌)。",
|
||||
"No official ARM desktop Chrome; Neko uses Chromium/Firefox multi-arch images.",
|
||||
)}
|
||||
</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={key}
|
||||
className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm"
|
||||
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-white">{title}</p>
|
||||
<p className="mt-1 font-mono text-[11px] text-zinc-500">
|
||||
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
|
||||
<p className="text-xs font-semibold text-cyan-200/90">{iface.name}</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-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 className="mt-0.5 text-zinc-500">
|
||||
↑ {formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
|
||||
{iface.tx_packets?.toLocaleString() ?? "—"} pkt
|
||||
</p>
|
||||
{!http?.reachable && http?.error ? (
|
||||
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
|
||||
) : null}
|
||||
</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 || "")}`}
|
||||
>
|
||||
{row.status || "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
{dash.probes.neko_firefox ? (
|
||||
(() => {
|
||||
const p = dash.probes?.neko_firefox;
|
||||
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 Firefox</p>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t(
|
||||
"第二桌面(ENABLE_NEKO_FF=1);口令与 Chromium 相同。",
|
||||
"Second desktop (ENABLE_NEKO_FF=1); same passwords as Chromium.",
|
||||
)}
|
||||
</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}
|
||||
|
||||
@@ -628,48 +938,49 @@ export default function LiveControlApp() {
|
||||
<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 || "")}`}>
|
||||
<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 || "")}`}>
|
||||
{p.process_status || "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!dash?.live?.processes?.length ? (
|
||||
<li className="text-zinc-600">{t("暂无数据", "No data")}</li>
|
||||
<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-4 grid grid-cols-2 gap-2">
|
||||
<div className="mt-5 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"
|
||||
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-500/30 bg-cyan-500/10 py-2 text-[11px] font-semibold text-cyan-200"
|
||||
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>
|
||||
@@ -701,7 +1012,21 @@ export default function LiveControlApp() {
|
||||
</span>
|
||||
</div>
|
||||
{s.detail ? (
|
||||
<p className="mt-2 text-[11px] leading-snug text-amber-200/85 line-clamp-4">{s.detail}</p>
|
||||
<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 ? (
|
||||
@@ -716,8 +1041,14 @@ export default function LiveControlApp() {
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !s.available}
|
||||
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
|
||||
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"
|
||||
>
|
||||
@@ -725,8 +1056,14 @@ export default function LiveControlApp() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !s.available}
|
||||
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
|
||||
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"
|
||||
>
|
||||
@@ -734,8 +1071,14 @@ export default function LiveControlApp() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !s.available}
|
||||
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
|
||||
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"
|
||||
>
|
||||
@@ -798,8 +1141,6 @@ export default function LiveControlApp() {
|
||||
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 ?? {})}
|
||||
@@ -808,90 +1149,128 @@ export default function LiveControlApp() {
|
||||
) : 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">
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5 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")}
|
||||
<Wifi className="h-4 w-4 text-violet-300" />
|
||||
{t("ShellCrash 网络面板", "ShellCrash panel")}
|
||||
</h3>
|
||||
<p className="mt-2 text-xs text-zinc-400">
|
||||
{t(
|
||||
"此页仅用于透明代理 / 规则分流。点击下方进入 ShellCrash 专属控制台;流媒体与其它服务请在「服务」页管理。",
|
||||
"Proxy rules only. Open ShellCrash below; use Services for SRS/Neko/etc.",
|
||||
)}
|
||||
</p>
|
||||
<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" },
|
||||
{ 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.t} className="flex items-center gap-4">
|
||||
<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.t}
|
||||
{n.tx}
|
||||
</div>
|
||||
{i < 3 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" /> : null}
|
||||
{i < 2 ? <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>
|
||||
<a
|
||||
href="/shellcrash"
|
||||
className="flex items-center justify-between rounded-2xl border border-violet-400/30 bg-gradient-to-r from-violet-500/15 to-fuchsia-500/10 px-5 py-4 text-sm font-semibold text-violet-100 shadow-lg shadow-violet-500/10 transition hover:border-violet-400/50"
|
||||
>
|
||||
{t("打开 ShellCrash 控制台", "Open ShellCrash console")}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === "settings" ? (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<div className="mx-auto max-w-lg space-y-6">
|
||||
<p className="text-sm text-zinc-400">
|
||||
{t(
|
||||
"配置中心路径:/opt/live/config(JSON)。以下为只读快照。",
|
||||
"Config root: /opt/live/config (JSON). Read-only snapshot.",
|
||||
)}
|
||||
{t("常用选项(本机偏好);写入 JSON 配置请用高级控制台。", "Local preferences; use Advanced console for JSON.")}
|
||||
</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>
|
||||
<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 items-center gap-2 text-sm text-violet-300"
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] py-3 text-sm text-violet-200 transition hover:bg-white/[0.08]"
|
||||
>
|
||||
{t("托管文件编辑器", "Managed file editor")}
|
||||
{t("高级控制台 · 文件与 JSON", "Advanced console · files / JSON")}
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
import { Cable, Copy, Monitor, Radio } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { extractDouyinLiveUrls } from "@/lib/youtubeConfigUtils";
|
||||
|
||||
type ProcessEntry = { pm2: string; script: string; label: string };
|
||||
|
||||
type TFn = (zh: string, en: string) => string;
|
||||
@@ -133,7 +131,6 @@ export function LiveTiktokHdmiView({
|
||||
}
|
||||
};
|
||||
|
||||
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlConfig), [urlConfig]);
|
||||
const lock = busy || urlReloading;
|
||||
const presetLabel = PRESETS.find((p) => p.id === selectedPreset);
|
||||
|
||||
@@ -330,16 +327,6 @@ export function LiveTiktokHdmiView({
|
||||
{t("保存 URL 配置", "Save URL config")}
|
||||
</button>
|
||||
</div>
|
||||
{douyinUrls.length > 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
|
||||
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
|
||||
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
|
||||
{douyinUrls.map((u) => (
|
||||
<li key={u}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
buildYoutubeIniFromFields,
|
||||
extractDouyinLiveUrls,
|
||||
parseYoutubeIniFields,
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
@@ -200,9 +199,6 @@ export function LiveYoutubeUnmannedView({
|
||||
return parseYoutubeStreamKeySuffix(`key = ${k}`);
|
||||
}, [mode, ytFields.key, active?.streamKey]);
|
||||
|
||||
const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
|
||||
const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
|
||||
|
||||
const saveProUrlAndServer = async () => {
|
||||
if (!active) return;
|
||||
updateActive({ urlLines: proUrlDraft });
|
||||
@@ -610,16 +606,6 @@ export function LiveYoutubeUnmannedView({
|
||||
) : (
|
||||
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
|
||||
)}
|
||||
{douyinUrls.length > 0 ? (
|
||||
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
|
||||
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
|
||||
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
|
||||
{douyinUrls.map((u) => (
|
||||
<li key={u}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user