From 5a75f18887893ed94dc52bdb5a573d38c5271d39 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 28 Mar 2026 12:19:08 -0500 Subject: [PATCH] feat: ws-scrcpy/NeKo/Chromium hub links, light theme, ECharts, H5 nav, optional Neko Docker Made-with: Cursor --- config/system-stack.env.example | 9 + scripts/linux/install_business.sh | 1 + scripts/linux/install_hub.sh | 1 + scripts/linux/service_ctl.sh | 64 ++++++- services/neko/docker-compose.yml | 15 ++ src/control_plane.py | 32 +++- web-console/app/globals.css | 48 ++++- web-console/app/layout.tsx | 10 +- web-console/app/page.tsx | 216 ++++++++++++++++++++-- web-console/components/OverviewCharts.tsx | 128 +++++++++++++ web-console/package-lock.json | 32 ++++ web-console/package.json | 11 +- 12 files changed, 531 insertions(+), 36 deletions(-) create mode 100644 services/neko/docker-compose.yml create mode 100644 web-console/components/OverviewCharts.tsx diff --git a/config/system-stack.env.example b/config/system-stack.env.example index cf808c0..4b9a2e0 100644 --- a/config/system-stack.env.example +++ b/config/system-stack.env.example @@ -47,6 +47,15 @@ WIFI_SSID=live WIFI_PSK=12345678 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_NAME=live-android-gateway ANDROID_UPLINK_IF=eth0 diff --git a/scripts/linux/install_business.sh b/scripts/linux/install_business.sh index af97be6..8c41e8d 100644 --- a/scripts/linux/install_business.sh +++ b/scripts/linux/install_business.sh @@ -27,6 +27,7 @@ install_live_console_service configure_sudoers configure_mdns install_browser_stack +install_neko_browser prepare_srs run_hardware_probe print_summary "business" diff --git a/scripts/linux/install_hub.sh b/scripts/linux/install_hub.sh index 2649f5f..60cfa15 100644 --- a/scripts/linux/install_hub.sh +++ b/scripts/linux/install_hub.sh @@ -26,6 +26,7 @@ configure_mdns configure_wifi_profile install_shellcrash install_browser_stack +install_neko_browser install_webtty install_cockpit install_netdata diff --git a/scripts/linux/service_ctl.sh b/scripts/linux/service_ctl.sh index eab2808..3756e5c 100644 --- a/scripts/linux/service_ctl.sh +++ b/scripts/linux/service_ctl.sh @@ -11,6 +11,7 @@ HW_ENV="$CONFIG_DIR/hardware.env" SRS_COMPOSE="$ROOT_DIR/services/srs/docker-compose.yml" HOMEPAGE_COMPOSE="$ROOT_DIR/services/homepage/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" [ -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_GATEWAY_CIDR="${ANDROID_GATEWAY_CIDR:-192.168.51.1/24}" ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}" +NEKO_HTTP_PORT="${NEKO_HTTP_PORT:-9200}" CONSOLE_BASE_URL="http://${HOSTNAME_ALIAS}.local:${PORT}" kv() { @@ -416,7 +418,7 @@ service_android_panel_status() { kv host "${HOSTNAME_ALIAS}.local" kv port "$ANDROID_PANEL_PORT" 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() { @@ -459,6 +461,66 @@ service_chromium_stop() { 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() { if ! have nmcli || [ -z "$ANDROID_DOWNLINK_IF" ]; then kv available 0 diff --git a/services/neko/docker-compose.yml b/services/neko/docker-compose.yml new file mode 100644 index 0000000..f2ab2c9 --- /dev/null +++ b/services/neko/docker-compose.yml @@ -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" diff --git a/src/control_plane.py b/src/control_plane.py index 5772593..b31d126 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -65,8 +65,19 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = ( ServiceSpec("homepage", "Homepage", "admin", "Start page for all LAN services"), ServiceSpec("netdata", "Netdata", "monitor", "System metrics and health monitoring"), ServiceSpec("adb", "ADB", "android", "Android bridge and device inspection"), - ServiceSpec("android_panel", "Android Web Panel", "android", "Browser-based Android control with web-scrcpy"), - ServiceSpec("chromium", "Chromium", "browser", "Persistent browser profile launcher"), + ServiceSpec( + "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 Studio;ENABLE_NEKO=1 时安装", + ), ServiceSpec( "android_gateway", "Android Gateway", @@ -449,6 +460,13 @@ def stack_summary() -> dict: web_port = env.get("PORT", os.environ.get("PORT", "8001")) homepage_port = env.get("HOMEPAGE_PORT", "80") 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 { "hostname": hostname, "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}", "control_url": f"http://{mdns_host}:{web_port}", "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}", + }, } diff --git a/web-console/app/globals.css b/web-console/app/globals.css index 7b936e1..7ffb47a 100644 --- a/web-console/app/globals.css +++ b/web-console/app/globals.css @@ -11,6 +11,15 @@ --ok: #34d399; --text: #e8edf7; --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 { @@ -18,6 +27,31 @@ body { background: var(--bg-deep); min-height: 100vh; 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 { @@ -37,7 +71,19 @@ body { } .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; } } diff --git a/web-console/app/layout.tsx b/web-console/app/layout.tsx index e1ee693..09ff9b2 100644 --- a/web-console/app/layout.tsx +++ b/web-console/app/layout.tsx @@ -25,15 +25,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + -
+
{children} diff --git a/web-console/app/page.tsx b/web-console/app/page.tsx index c711b8d..32c9830 100644 --- a/web-console/app/page.tsx +++ b/web-console/app/page.tsx @@ -2,7 +2,10 @@ import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useState } from "react"; +import OverviewCharts from "@/components/OverviewCharts"; + type Lang = "zh" | "en"; +type ThemeMode = "dark" | "light"; type TabKey = "overview" | "business" | "modules" | "shellcrash" | "android" | "config"; type LoadAvg = { "1m"?: number; "5m"?: number; "15m"?: number }; @@ -37,6 +40,7 @@ type StackSummary = { ips?: string[]; dashboard_url?: string; control_url?: string; + quick_links?: Record; }; type ServiceItem = { service_id: string; @@ -164,17 +168,21 @@ function Button({ className, disabled, onClick, + title, }: { children: React.ReactNode; className?: string; disabled?: boolean; onClick?: () => void; + title?: string; }) { return ( @@ -184,6 +192,7 @@ function Button({ export default function Home() { const base = apiBase(); const [lang, setLang] = useState("zh"); + const [theme, setTheme] = useState("dark"); const [tab, setTab] = useState("overview"); 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 shellcrashService = services.find((item) => item.service_id === "shellcrash") ?? null; const androidService = services.find((item) => item.service_id === "android_panel") ?? null; + const ql = stack?.quick_links ?? {}; const rawAndroidPanelUrl = - androidService?.host && androidService.port + ql.ws_scrcpy || + (androidService?.host && androidService.port ? `http://${androidService.host}:${androidService.port}` : stack?.mdns_host ? `http://${stack.mdns_host}:5000` - : ""; + : ""); const androidResolution = parseResolution(androidOverview?.resolution); const notify = useCallback((message: string) => { @@ -404,8 +415,15 @@ export default function Home() { else if (isTabKey(nextTab)) setTab(nextTab); if (nextLang === "zh" || nextLang === "en") setLang(nextLang); 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(() => { syncUrl(tab, lang); }, [lang, syncUrl, tab]); @@ -672,8 +690,18 @@ export default function Home() { const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots; + const tabEmoji: Record = { + overview: "📊", + business: "🎬", + modules: "🧩", + shellcrash: "🛡️", + android: "🤖", + config: "⚙️", + }; + return ( -
+ <> +
{toast ? (
{toast} @@ -686,13 +714,13 @@ export default function Home() {

{tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")}

-

- {tr("Linux 超级业务中心", "Linux Super Business Center")} +

+ {tr("无人直播 · Linux 超级业务中心", "Unmanned Live · Linux Super Hub")}

{tr( - "系统服务、业务模块、ShellCrash 配置与安卓控制保持解耦,一个模块异常不应拖垮整个平台。", - "Services, business modules, ShellCrash configs and Android control stay decoupled.", + "抖音 → YouTube / HDMI 转播、ws-scrcpy、Chromium 与可选 Neko 浏览器、ShellCrash 等模块解耦,单点故障不拖垮整机。", + "Douyin→YouTube/HDMI relay, ws-scrcpy, Chromium & optional Neko, ShellCrash — decoupled modules.", )}

@@ -716,6 +744,20 @@ export default function Home() { > EN + + @@ -764,20 +806,123 @@ export default function Home() { <>
-

{tr("常用入口", "Quick Links")}

-
+

+ {tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")} +

+

+ {tr( + "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug;Neko:可选 Docker 远程桌面开 YouTube。", + "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.", + )} +

+
{stack?.dashboard_url ? ( - Homepage + + 🏠 Homepage + ) : null} {stack?.control_url ? ( <> - {tr("打开 ShellCrash 工作区", "Open ShellCrash workspace")} - {tr("打开安卓设备中心", "Open Android device center")} - {tr("打开配置中心", "Open Config center")} + + 🛡️ ShellCrash + + + 🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")} + + + ⚙️ {tr("配置中心", "Config")} + ) : null} - {rawAndroidPanelUrl ? ( - web-scrcpy + {ql.ws_scrcpy ? ( + + 📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")}) + + ) : null} + {ql.chromium_remote_debug ? ( + + 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")} + + ) : null} + {ql.neko_browser ? ( + + 🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")} + + ) : null} + {ql.webtty ? ( + + ⌨️ WebTTY + + ) : null} + {ql.filebrowser ? ( + + 📂 File Browser + + ) : null} + {ql.netdata ? ( + + 📈 Netdata + + ) : null} + {ql.srs_http ? ( + + 🎞️ SRS HTTP + ) : null}
@@ -793,8 +938,8 @@ export default function Home() {
-

{tr("系统快照", "System Snapshot")}

-
+

{tr("系统快照", "System Snapshot")}

+

CPU:{" "} {[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]] @@ -805,6 +950,7 @@ export default function Home() {

Disk: {formatBytes(snapshot?.disk?.free)} / {formatBytes(snapshot?.disk?.total)}

Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}

+
@@ -1170,10 +1316,10 @@ export default function Home() { {rawAndroidPanelUrl ? (
-