feat: ws-scrcpy/NeKo/Chromium hub links, light theme, ECharts, H5 nav, optional Neko Docker

Made-with: Cursor
This commit is contained in:
eric
2026-03-28 12:19:08 -05:00
parent 9b6570d06e
commit 5a75f18887
12 changed files with 531 additions and 36 deletions

View File

@@ -47,6 +47,15 @@ WIFI_SSID=live
WIFI_PSK=12345678 WIFI_PSK=12345678
DISABLE_IPV6=1 DISABLE_IPV6=1
# ws-scrcpy 克隆地址(可改为 NetrisTV 上游https://github.com/NetrisTV/ws-scrcpy.git
# ANDROID_PANEL_GIT_URL=https://github.com/baixin1228/web-scrcpy.git
# 可选Docker Neko 浏览器桌面m1k1o/neko与 SRS 8080 错开端口
ENABLE_NEKO=0
NEKO_HTTP_PORT=9200
NEKO_USER_PASS=live
NEKO_ADMIN_PASS=admin
# Android gateway mode # Android gateway mode
ANDROID_GATEWAY_NAME=live-android-gateway ANDROID_GATEWAY_NAME=live-android-gateway
ANDROID_UPLINK_IF=eth0 ANDROID_UPLINK_IF=eth0

View File

@@ -27,6 +27,7 @@ install_live_console_service
configure_sudoers configure_sudoers
configure_mdns configure_mdns
install_browser_stack install_browser_stack
install_neko_browser
prepare_srs prepare_srs
run_hardware_probe run_hardware_probe
print_summary "business" print_summary "business"

View File

@@ -26,6 +26,7 @@ configure_mdns
configure_wifi_profile configure_wifi_profile
install_shellcrash install_shellcrash
install_browser_stack install_browser_stack
install_neko_browser
install_webtty install_webtty
install_cockpit install_cockpit
install_netdata install_netdata

View File

@@ -11,6 +11,7 @@ HW_ENV="$CONFIG_DIR/hardware.env"
SRS_COMPOSE="$ROOT_DIR/services/srs/docker-compose.yml" SRS_COMPOSE="$ROOT_DIR/services/srs/docker-compose.yml"
HOMEPAGE_COMPOSE="$ROOT_DIR/services/homepage/docker-compose.yml" HOMEPAGE_COMPOSE="$ROOT_DIR/services/homepage/docker-compose.yml"
FILEBROWSER_COMPOSE="$ROOT_DIR/services/filebrowser/docker-compose.yml" FILEBROWSER_COMPOSE="$ROOT_DIR/services/filebrowser/docker-compose.yml"
NEKO_COMPOSE="$ROOT_DIR/services/neko/docker-compose.yml"
BROWSER_LAUNCHER="$ROOT_DIR/scripts/linux/launch_browser.sh" BROWSER_LAUNCHER="$ROOT_DIR/scripts/linux/launch_browser.sh"
[ -f "$STACK_ENV" ] && set -a && . "$STACK_ENV" && set +a [ -f "$STACK_ENV" ] && set -a && . "$STACK_ENV" && set +a
@@ -39,6 +40,7 @@ ANDROID_UPLINK_IF="${ANDROID_UPLINK_IF:-eth0}"
ANDROID_DOWNLINK_IF="${ANDROID_DOWNLINK_IF:-}" ANDROID_DOWNLINK_IF="${ANDROID_DOWNLINK_IF:-}"
ANDROID_GATEWAY_CIDR="${ANDROID_GATEWAY_CIDR:-192.168.51.1/24}" ANDROID_GATEWAY_CIDR="${ANDROID_GATEWAY_CIDR:-192.168.51.1/24}"
ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}" ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}"
NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}"
CONSOLE_BASE_URL="http://${HOSTNAME_ALIAS}.local:${PORT}" CONSOLE_BASE_URL="http://${HOSTNAME_ALIAS}.local:${PORT}"
kv() { kv() {
@@ -416,7 +418,7 @@ service_android_panel_status() {
kv host "${HOSTNAME_ALIAS}.local" kv host "${HOSTNAME_ALIAS}.local"
kv port "$ANDROID_PANEL_PORT" kv port "$ANDROID_PANEL_PORT"
kv url "${CONSOLE_BASE_URL}/android" kv url "${CONSOLE_BASE_URL}/android"
kv detail "android-center=${CONSOLE_BASE_URL}/android;raw-web-scrcpy=http://${HOSTNAME_ALIAS}.local:${ANDROID_PANEL_PORT}" kv detail "android-center=${CONSOLE_BASE_URL}/android;ws-scrcpy=http://${HOSTNAME_ALIAS}.local:${ANDROID_PANEL_PORT};git=ANDROID_PANEL_GIT_URL"
} }
service_android_panel_start() { service_android_panel_start() {
@@ -459,6 +461,66 @@ service_chromium_stop() {
kv message "Chromium stopped" kv message "Chromium stopped"
} }
service_neko_status() {
if [ ! -f "$NEKO_COMPOSE" ]; then
kv available 0
kv running 0
kv status unavailable
kv detail "services/neko/docker-compose.yml missing"
return 0
fi
if ! have docker; then
kv available 0
kv running 0
kv status unavailable
kv detail "docker not installed"
return 0
fi
kv available 1
local state
state="$(docker_container_status live-neko-chromium || true)"
kv running "$(echo "$state" | grep -qi running && echo 1 || echo 0)"
kv status "$(echo "$state" | grep -qi running && echo online || echo stopped)"
kv host "${HOSTNAME_ALIAS}.local"
kv port "$NEKO_HTTP_PORT"
kv url "http://${HOSTNAME_ALIAS}.local:${NEKO_HTTP_PORT}"
kv detail "m1k1o/neko Chromium in Docker; ENABLE_NEKO=1 + install_neko_browser or Modules start"
}
service_neko_start() {
if [ ! -f "$NEKO_COMPOSE" ]; then
kv message "Neko compose missing"
return 0
fi
cmd="$(compose_bin)" || {
kv message "docker compose unavailable"
return 0
}
if [ "$cmd" = "docker compose" ]; then
docker compose -f "$NEKO_COMPOSE" up -d
else
docker-compose -f "$NEKO_COMPOSE" up -d
fi
kv message "Neko stack started"
}
service_neko_stop() {
if [ ! -f "$NEKO_COMPOSE" ]; then
kv message "Neko compose missing"
return 0
fi
cmd="$(compose_bin)" || {
kv message "docker compose unavailable"
return 0
}
if [ "$cmd" = "docker compose" ]; then
docker compose -f "$NEKO_COMPOSE" down
else
docker-compose -f "$NEKO_COMPOSE" down
fi
kv message "Neko stack stopped"
}
service_android_gateway_status() { service_android_gateway_status() {
if ! have nmcli || [ -z "$ANDROID_DOWNLINK_IF" ]; then if ! have nmcli || [ -z "$ANDROID_DOWNLINK_IF" ]; then
kv available 0 kv available 0

View File

@@ -0,0 +1,15 @@
# 可选m1k1o/neko 浏览器桌面ARM64/x86。需在 system-stack.env 设 ENABLE_NEKO=1 并由安装脚本拉起。
# 默认映射宿主机 NEKO_HTTP_PORT默认 9200→ 容器 8080避免与 SRS 8080 冲突。
services:
neko:
image: ghcr.io/m1k1o/neko/chromium:latest
container_name: live-neko-chromium
restart: unless-stopped
shm_size: "2gb"
environment:
NEKO_PASSWORD: "${NEKO_USER_PASS:-live}"
NEKO_PASSWORD_ADMIN: "${NEKO_ADMIN_PASS:-admin}"
NEKO_BIND: "0.0.0.0:8080"
ports:
- "${NEKO_HTTP_PORT:-9200}:8080"
- "52000-52100:52000-52100/udp"

View File

@@ -65,8 +65,19 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = (
ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"), ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"),
ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"), ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"),
ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"), ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"),
ServiceSpec("android_panel", "Android Web Panel", "android", "Browser-based Android control with web-scrcpy"), ServiceSpec(
ServiceSpec("chromium", "Chromium", "browser", "Persistent browser profile launcher"), "android_panel",
"ws-scrcpy / Web Android",
"android",
"Browser ADB control (ws-scrcpy stack; clone URL configurable in system-stack.env)",
),
ServiceSpec("chromium", "Chromium 浏览器", "browser", "本机 Chromium + 持久化配置 + Remote DevTools非 Docker"),
ServiceSpec(
"neko",
"Neko 浏览器 (Docker)",
"browser",
"可选Docker 内嵌 Chromium 桌面,适合远程开 YouTube StudioENABLE_NEKO=1 时安装",
),
ServiceSpec( ServiceSpec(
"android_gateway", "android_gateway",
"Android Gateway", "Android Gateway",
@@ -449,6 +460,13 @@ def stack_summary() -> dict:
web_port = env.get("PORT", os.environ.get("PORT", "8001")) web_port = env.get("PORT", os.environ.get("PORT", "8001"))
homepage_port = env.get("HOMEPAGE_PORT", "80") homepage_port = env.get("HOMEPAGE_PORT", "80")
mdns_host = f"{hostname}.local" mdns_host = f"{hostname}.local"
webtty = env.get("WEBTTY_PORT", "7681")
fb = env.get("FILEBROWSER_PORT", "8082")
netd = env.get("NETDATA_PORT", "19999")
srs_http = env.get("SRS_HTTP_PORT", "8080")
android_panel = env.get("ANDROID_PANEL_PORT", "5000")
chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222")
neko_port = env.get("NEKO_HTTP_PORT", "9200")
return { return {
"hostname": hostname, "hostname": hostname,
"mdns_host": mdns_host, "mdns_host": mdns_host,
@@ -460,6 +478,16 @@ def stack_summary() -> dict:
"dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}", "dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}",
"control_url": f"http://{mdns_host}:{web_port}", "control_url": f"http://{mdns_host}:{web_port}",
"stack_env_present": STACK_ENV.is_file(), "stack_env_present": STACK_ENV.is_file(),
"quick_links": {
"control_plane": f"http://{mdns_host}:{web_port}",
"webtty": f"http://{mdns_host}:{webtty}",
"filebrowser": f"http://{mdns_host}:{fb}",
"netdata": f"http://{mdns_host}:{netd}",
"srs_http": f"http://{mdns_host}:{srs_http}",
"ws_scrcpy": f"http://{mdns_host}:{android_panel}",
"chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}",
"neko_browser": f"http://{mdns_host}:{neko_port}",
},
} }

View File

@@ -11,6 +11,15 @@
--ok: #34d399; --ok: #34d399;
--text: #e8edf7; --text: #e8edf7;
--muted: #94a3b8; --muted: #94a3b8;
--ring-offset: #060912;
}
html[data-theme="light"] {
--bg-deep: #f1f5f9;
--bg-card: rgba(255, 255, 255, 0.88);
--text: #0f172a;
--muted: #64748b;
--ring-offset: #f1f5f9;
} }
body { body {
@@ -18,6 +27,31 @@ body {
background: var(--bg-deep); background: var(--bg-deep);
min-height: 100vh; min-height: 100vh;
font-family: var(--font-geist-sans), system-ui, sans-serif; font-family: var(--font-geist-sans), system-ui, sans-serif;
transition: background-color 0.35s ease, color 0.25s ease;
}
.app-bg-layer {
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.92;
pointer-events: none;
background: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(34, 211, 238, 0.22), transparent),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(167, 139, 250, 0.18), transparent),
radial-gradient(ellipse 50% 30% at 0% 100%, rgba(59, 130, 246, 0.12), transparent);
transition: opacity 0.35s ease;
}
html[data-theme="light"] .app-bg-layer {
opacity: 0.75;
background: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(14, 165, 233, 0.12), transparent),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(139, 92, 246, 0.1), transparent),
radial-gradient(ellipse 50% 30% at 0% 100%, rgba(59, 130, 246, 0.08), transparent);
}
html[data-theme="light"] .chart-surface {
border-color: rgba(15, 23, 42, 0.12) !important;
background: rgba(255, 255, 255, 0.65) !important;
} }
::selection { ::selection {
@@ -37,7 +71,19 @@ body {
} }
.btn-focus { .btn-focus {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50 focus-visible:ring-offset-2 focus-visible:ring-offset-[#060912]; @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/50 focus-visible:ring-offset-2;
--tw-ring-offset-color: var(--ring-offset);
}
}
/* H5 底部导航安全区 */
.safe-pb-nav {
padding-bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 768px) {
.safe-pb-nav {
padding-bottom: 4rem;
} }
} }

View File

@@ -25,15 +25,9 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN" data-theme="dark" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
<div <div className="app-bg-layer" aria-hidden />
className="fixed inset-0 -z-10 opacity-90"
style={{
background:
"radial-gradient(ellipse 80% 50% at 50% -20%, rgba(34,211,238,0.22), transparent), radial-gradient(ellipse 60% 40% at 100% 0%, rgba(167,139,250,0.18), transparent), radial-gradient(ellipse 50% 30% at 0% 100%, rgba(59,130,246,0.12), transparent)",
}}
/>
{children} {children}
</body> </body>
</html> </html>

View File

@@ -2,7 +2,10 @@
import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useState } from "react"; import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useState } from "react";
import OverviewCharts from "@/components/OverviewCharts";
type Lang = "zh" | "en"; type Lang = "zh" | "en";
type ThemeMode = "dark" | "light";
type TabKey = "overview" | "business" | "modules" | "shellcrash" | "android" | "config"; type TabKey = "overview" | "business" | "modules" | "shellcrash" | "android" | "config";
type LoadAvg = { "1m"?: number; "5m"?: number; "15m"?: number }; type LoadAvg = { "1m"?: number; "5m"?: number; "15m"?: number };
@@ -37,6 +40,7 @@ type StackSummary = {
ips?: string[]; ips?: string[];
dashboard_url?: string; dashboard_url?: string;
control_url?: string; control_url?: string;
quick_links?: Record<string, string>;
}; };
type ServiceItem = { type ServiceItem = {
service_id: string; service_id: string;
@@ -164,17 +168,21 @@ function Button({
className, className,
disabled, disabled,
onClick, onClick,
title,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
onClick?: () => void; onClick?: () => void;
title?: string;
}) { }) {
return ( return (
<button <button
type="button"
className={`btn-focus rounded-xl px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40 ${className || ""}`} className={`btn-focus rounded-xl px-3 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40 ${className || ""}`}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
title={title}
> >
{children} {children}
</button> </button>
@@ -184,6 +192,7 @@ function Button({
export default function Home() { export default function Home() {
const base = apiBase(); const base = apiBase();
const [lang, setLang] = useState<Lang>("zh"); const [lang, setLang] = useState<Lang>("zh");
const [theme, setTheme] = useState<ThemeMode>("dark");
const [tab, setTab] = useState<TabKey>("overview"); const [tab, setTab] = useState<TabKey>("overview");
const tr = useCallback((zh: string, en: string) => (lang === "zh" ? zh : en), [lang]); const tr = useCallback((zh: string, en: string) => (lang === "zh" ? zh : en), [lang]);
@@ -231,12 +240,14 @@ export default function Home() {
const otherRoots = roots.filter((item) => !SHELLCRASH_ROOT_IDS.has(item.root_id)); const otherRoots = roots.filter((item) => !SHELLCRASH_ROOT_IDS.has(item.root_id));
const shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null; const shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null;
const androidService = services.find((item) => item.service_id === "android_panel") ?? null; const androidService = services.find((item) => item.service_id === "android_panel") ?? null;
const ql = stack?.quick_links ?? {};
const rawAndroidPanelUrl = const rawAndroidPanelUrl =
androidService?.host && androidService.port ql.ws_scrcpy ||
(androidService?.host && androidService.port
? `http://${androidService.host}:${androidService.port}` ? `http://${androidService.host}:${androidService.port}`
: stack?.mdns_host : stack?.mdns_host
? `http://${stack.mdns_host}:5000` ? `http://${stack.mdns_host}:5000`
: ""; : "");
const androidResolution = parseResolution(androidOverview?.resolution); const androidResolution = parseResolution(androidOverview?.resolution);
const notify = useCallback((message: string) => { const notify = useCallback((message: string) => {
@@ -404,8 +415,15 @@ export default function Home() {
else if (isTabKey(nextTab)) setTab(nextTab); else if (isTabKey(nextTab)) setTab(nextTab);
if (nextLang === "zh" || nextLang === "en") setLang(nextLang); if (nextLang === "zh" || nextLang === "en") setLang(nextLang);
else if (storedLang === "zh" || storedLang === "en") setLang(storedLang); else if (storedLang === "zh" || storedLang === "en") setLang(storedLang);
const storedTheme = window.localStorage.getItem("live-console-theme");
if (storedTheme === "light" || storedTheme === "dark") setTheme(storedTheme);
}, []); }, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
window.localStorage.setItem("live-console-theme", theme);
}, [theme]);
useEffect(() => { useEffect(() => {
syncUrl(tab, lang); syncUrl(tab, lang);
}, [lang, syncUrl, tab]); }, [lang, syncUrl, tab]);
@@ -672,8 +690,18 @@ export default function Home() {
const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots; const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots;
const tabEmoji: Record<TabKey, string> = {
overview: "📊",
business: "🎬",
modules: "🧩",
shellcrash: "🛡️",
android: "🤖",
config: "⚙️",
};
return ( return (
<main className="mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-10"> <>
<main className="safe-pb-nav mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-10">
{toast ? ( {toast ? (
<div className="animate-toast-in fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-sm text-cyan-50"> <div className="animate-toast-in fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-sm text-cyan-50">
{toast} {toast}
@@ -686,13 +714,13 @@ export default function Home() {
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90"> <p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
{tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")} {tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")}
</p> </p>
<h1 className="mt-2 text-3xl font-bold text-white sm:text-4xl"> <h1 className="mt-2 text-3xl font-bold text-[color:var(--text)] sm:text-4xl">
{tr("Linux 超级业务中心", "Linux Super Business Center")} {tr("无人直播 · Linux 超级业务中心", "Unmanned Live · Linux Super Hub")}
</h1> </h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400"> <p className="mt-3 max-w-3xl text-sm text-slate-400">
{tr( {tr(
"系统服务、业务模块、ShellCrash 配置与安卓控制保持解耦,一个模块异常不应拖垮整个平台。", "抖音 → YouTube / HDMI 转播、ws-scrcpy、Chromium 与可选 Neko 浏览器、ShellCrash 等模块解耦,单点故障不拖垮整机。",
"Services, business modules, ShellCrash configs and Android control stay decoupled.", "Douyin→YouTube/HDMI relay, ws-scrcpy, Chromium & optional Neko, ShellCrash — decoupled modules.",
)} )}
</p> </p>
</div> </div>
@@ -716,6 +744,20 @@ export default function Home() {
> >
EN EN
</Button> </Button>
<Button
className={theme === "light" ? "border border-amber-400/50 bg-amber-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("light")}
title={tr("日间主题", "Light theme")}
>
{tr("☀️ 日间", "☀️ Light")}
</Button>
<Button
className={theme === "dark" ? "border border-indigo-400/50 bg-indigo-500/15" : "border border-white/12 bg-black/20"}
onClick={() => setTheme("dark")}
title={tr("夜间主题", "Dark theme")}
>
{tr("🌙 夜间", "🌙 Dark")}
</Button>
<Button className="border border-white/12 bg-black/20" onClick={() => void boot()}> <Button className="border border-white/12 bg-black/20" onClick={() => void boot()}>
{tr("刷新", "Refresh")} {tr("刷新", "Refresh")}
</Button> </Button>
@@ -764,20 +806,123 @@ export default function Home() {
<> <>
<section className="grid gap-6 xl:grid-cols-12"> <section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("常用入口", "Quick Links")}</h2> <h2 className="text-lg font-semibold text-[color:var(--text)]">
<div className="mt-4 grid gap-3"> {tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}
</h2>
<p className="mt-1 text-xs text-slate-400 [html[data-theme=light]_&]:text-slate-600">
{tr(
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebugNeko可选 Docker 远程桌面开 YouTube。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.",
)}
</p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll">
{stack?.dashboard_url ? ( {stack?.dashboard_url ? (
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={stack.dashboard_url} target="_blank" rel="noreferrer">Homepage</a> <a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={stack.dashboard_url}
target="_blank"
rel="noreferrer"
>
🏠 Homepage
</a>
) : null} ) : null}
{stack?.control_url ? ( {stack?.control_url ? (
<> <>
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=shellcrash&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开 ShellCrash 工作区", "Open ShellCrash workspace")}</a> <a
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=android&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开安卓设备中心", "Open Android device center")}</a> className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={`${stack.control_url}?tab=config&lang=${lang}`} target="_blank" rel="noreferrer">{tr("打开配置中心", "Open Config center")}</a> href={`${stack.control_url}?tab=shellcrash&lang=${lang}`}
target="_blank"
rel="noreferrer"
>
🛡 ShellCrash
</a>
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={`${stack.control_url}?tab=android&lang=${lang}`}
target="_blank"
rel="noreferrer"
>
🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")}
</a>
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={`${stack.control_url}?tab=config&lang=${lang}`}
target="_blank"
rel="noreferrer"
>
{tr("配置中心", "Config")}
</a>
</> </>
) : null} ) : null}
{rawAndroidPanelUrl ? ( {ql.ws_scrcpy ? (
<a className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-100" href={rawAndroidPanelUrl} target="_blank" rel="noreferrer">web-scrcpy</a> <a
className="btn-focus rounded-2xl border border-cyan-500/25 bg-cyan-500/10 px-4 py-2.5 text-sm text-cyan-50 transition hover:border-cyan-400/50 [html[data-theme=light]_&]:border-cyan-300 [html[data-theme=light]_&]:bg-cyan-50 [html[data-theme=light]_&]:text-cyan-950"
href={ql.ws_scrcpy}
target="_blank"
rel="noreferrer"
>
📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")})
</a>
) : null}
{ql.chromium_remote_debug ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-violet-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.chromium_remote_debug}
target="_blank"
rel="noreferrer"
>
🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a>
) : null}
{ql.neko_browser ? (
<a
className="btn-focus rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-sm text-amber-50 transition hover:border-amber-400/40 [html[data-theme=light]_&]:border-amber-200 [html[data-theme=light]_&]:bg-amber-50 [html[data-theme=light]_&]:text-amber-950"
href={ql.neko_browser}
target="_blank"
rel="noreferrer"
>
🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")}
</a>
) : null}
{ql.webtty ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.webtty}
target="_blank"
rel="noreferrer"
>
WebTTY
</a>
) : null}
{ql.filebrowser ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.filebrowser}
target="_blank"
rel="noreferrer"
>
📂 File Browser
</a>
) : null}
{ql.netdata ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.netdata}
target="_blank"
rel="noreferrer"
>
📈 Netdata
</a>
) : null}
{ql.srs_http ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.srs_http}
target="_blank"
rel="noreferrer"
>
🎞 SRS HTTP
</a>
) : null} ) : null}
</div> </div>
</article> </article>
@@ -793,8 +938,8 @@ export default function Home() {
</article> </article>
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("系统快照", "System Snapshot")}</h2> <h2 className="text-lg font-semibold text-[color:var(--text)]">{tr("系统快照", "System Snapshot")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300"> <div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300 [html[data-theme=light]_&]:border-slate-200/80 [html[data-theme=light]_&]:bg-white/50 [html[data-theme=light]_&]:text-slate-700">
<p> <p>
CPU:{" "} CPU:{" "}
{[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]] {[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]]
@@ -805,6 +950,7 @@ export default function Home() {
<p className="mt-2">Disk: {formatBytes(snapshot?.disk?.free)} / {formatBytes(snapshot?.disk?.total)}</p> <p className="mt-2">Disk: {formatBytes(snapshot?.disk?.free)} / {formatBytes(snapshot?.disk?.total)}</p>
<p className="mt-2">Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}</p> <p className="mt-2">Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}</p>
</div> </div>
<OverviewCharts snapshot={snapshot} theme={theme} tr={tr} />
</article> </article>
</section> </section>
@@ -1170,10 +1316,10 @@ export default function Home() {
{rawAndroidPanelUrl ? ( {rawAndroidPanelUrl ? (
<div className="mt-6 rounded-3xl border border-white/8 bg-black/20 p-5"> <div className="mt-6 rounded-3xl border border-white/8 bg-black/20 p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-base font-semibold text-white">web-scrcpy</h3> <h3 className="text-base font-semibold text-white">ws-scrcpy</h3>
<a className="btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-200" href={rawAndroidPanelUrl} target="_blank" rel="noreferrer">{tr("打开原始面板", "Open raw panel")}</a> <a className="btn-focus rounded-xl border border-white/12 px-3 py-2 text-xs text-slate-200" href={rawAndroidPanelUrl} target="_blank" rel="noreferrer">{tr("打开原始面板", "Open raw panel")}</a>
</div> </div>
<iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="web-scrcpy" /> <iframe className="mt-4 h-[30rem] w-full rounded-2xl border border-white/10 bg-black" src={rawAndroidPanelUrl} title="ws-scrcpy" />
</div> </div>
) : null} ) : null}
</article> </article>
@@ -1237,5 +1383,37 @@ export default function Home() {
</div> </div>
) : null} ) : null}
</main> </main>
<nav
className="fixed bottom-0 left-0 right-0 z-40 flex justify-around border-t border-white/15 bg-black/75 py-1.5 backdrop-blur-lg [html[data-theme=light]_&]:border-slate-200/60 [html[data-theme=light]_&]:bg-white/90 md:hidden"
style={{ paddingBottom: "max(0.35rem, env(safe-area-inset-bottom))" }}
aria-label={tr("主导航", "Main nav")}
>
{TABS.map((item) => (
<button
key={item}
type="button"
className={`btn-focus flex min-w-0 flex-1 flex-col items-center gap-0.5 rounded-xl px-1 py-1 text-[10px] font-medium ${
tab === item ? "text-cyan-300 [html[data-theme=light]_&]:text-cyan-700" : "text-slate-400 [html[data-theme=light]_&]:text-slate-600"
}`}
onClick={() => setTab(item)}
>
<span className="text-lg leading-none" aria-hidden>
{tabEmoji[item]}
</span>
<span className="truncate">
{{
overview: tr("总览", "Home"),
business: tr("业务", "Biz"),
modules: tr("模块", "Mods"),
shellcrash: "SC",
android: tr("安卓", "Droid"),
config: tr("配置", "Cfg"),
}[item]}
</span>
</button>
))}
</nav>
</>
); );
} }

View File

@@ -0,0 +1,128 @@
"use client";
import { useEffect, useRef } from "react";
export type SnapshotLite = {
cpu_load?: { "1m"?: number; "5m"?: number; "15m"?: number };
disk?: { total?: number; free?: number; used?: number };
memory?: { total?: number; available?: number; free?: number };
};
export default function OverviewCharts({
snapshot,
theme,
tr,
}: {
snapshot: SnapshotLite | null;
theme: "dark" | "light";
tr: (zh: string, en: string) => string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || !snapshot) return;
let disposed = false;
let chart: import("echarts").ECharts | null = null;
const fg = theme === "light" ? "#0f172a" : "#e2e8f0";
const muted = theme === "light" ? "#64748b" : "#94a3b8";
const gridLine = theme === "light" ? "rgba(15,23,42,0.1)" : "rgba(148,163,184,0.12)";
import("echarts").then((echarts) => {
if (disposed || !ref.current) return;
chart = echarts.init(ref.current, undefined, { renderer: "canvas" });
const l1 = snapshot.cpu_load?.["1m"] ?? 0;
const l5 = snapshot.cpu_load?.["5m"] ?? 0;
const l15 = snapshot.cpu_load?.["15m"] ?? 0;
const memTotal = snapshot.memory?.total || 1;
const memAvail = snapshot.memory?.available ?? snapshot.memory?.free ?? 0;
const memUsed = Math.max(0, memTotal - memAvail);
const diskTotal = snapshot.disk?.total || 1;
const diskFree = snapshot.disk?.free || 0;
const diskUsed = Math.max(0, diskTotal - diskFree);
chart.setOption({
animationDurationUpdate: 450,
textStyle: { color: fg },
title: {
text: tr("无人直播节点 · 资源", "Unmanned live · resources"),
left: 12,
top: 8,
textStyle: { color: fg, fontSize: 12, fontWeight: 600 },
},
tooltip: { trigger: "axis" },
grid: { left: 52, right: "48%", top: 52, bottom: 36 },
xAxis: {
type: "category",
data: ["1m", "5m", "15m"],
axisLabel: { color: muted, fontSize: 10 },
axisLine: { lineStyle: { color: gridLine } },
},
yAxis: {
type: "value",
name: tr("负载", "Load"),
nameTextStyle: { color: muted, fontSize: 10 },
axisLabel: { color: muted, fontSize: 10 },
splitLine: { lineStyle: { color: gridLine } },
},
series: [
{
name: tr("CPU", "CPU"),
type: "bar",
data: [l1, l5, l15],
barMaxWidth: 28,
itemStyle: {
borderRadius: [6, 6, 0, 0],
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: "#22d3ee" },
{ offset: 1, color: "#6366f1" },
]),
},
},
{
name: tr("内存", "RAM"),
type: "pie",
radius: ["26%", "40%"],
center: ["78%", "38%"],
label: { color: fg, fontSize: 9 },
data: [
{ value: memUsed, name: tr("已用", "Used") },
{ value: Math.max(0, memAvail), name: tr("可用", "Free") },
],
},
{
name: tr("磁盘", "Disk"),
type: "pie",
radius: ["20%", "32%"],
center: ["78%", "72%"],
label: { color: fg, fontSize: 9 },
data: [
{ value: diskUsed, name: tr("已用", "Used") },
{ value: Math.max(0, diskFree), name: tr("空闲", "Free") },
],
},
],
});
});
const ro = new ResizeObserver(() => chart?.resize());
ro.observe(el);
return () => {
disposed = true;
ro.disconnect();
chart?.dispose();
};
}, [snapshot, theme, tr]);
if (!snapshot) return null;
return (
<div className="mt-4">
<p className="mb-2 text-xs opacity-70">{tr("ECharts 动态图表", "ECharts live charts")}</p>
<div
ref={ref}
className="chart-surface h-64 w-full rounded-2xl border border-white/[0.08] bg-black/15 md:h-72"
/>
</div>
);
}

View File

@@ -8,6 +8,7 @@
"name": "web-console", "name": "web-console",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"echarts": "^5.6.0",
"next": "14.2.35", "next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"
@@ -1906,6 +1907,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/echarts": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "5.6.1"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -5999,6 +6016,21 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
} }
} }
} }

View File

@@ -9,19 +9,20 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"echarts": "^5.6.0",
"next": "14.2.35",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18"
"next": "14.2.35"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"eslint": "^8", "typescript": "^5"
"eslint-config-next": "14.2.35"
}, },
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b" "packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
} }