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
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

View File

@@ -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"

View File

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

View File

@@ -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

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("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 StudioENABLE_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}",
},
}

View File

@@ -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;
}
}

View File

@@ -25,15 +25,9 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<html lang="zh-CN" data-theme="dark" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}>
<div
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)",
}}
/>
<div className="app-bg-layer" aria-hidden />
{children}
</body>
</html>

View File

@@ -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<string, string>;
};
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 (
<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 || ""}`}
disabled={disabled}
onClick={onClick}
title={title}
>
{children}
</button>
@@ -184,6 +192,7 @@ function Button({
export default function Home() {
const base = apiBase();
const [lang, setLang] = useState<Lang>("zh");
const [theme, setTheme] = useState<ThemeMode>("dark");
const [tab, setTab] = useState<TabKey>("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<TabKey, string> = {
overview: "📊",
business: "🎬",
modules: "🧩",
shellcrash: "🛡️",
android: "🤖",
config: "⚙️",
};
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 ? (
<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}
@@ -686,13 +714,13 @@ export default function Home() {
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-cyan-400/90">
{tr("超级业务中心 / 模块控制台", "Super Hub / Control Plane")}
</p>
<h1 className="mt-2 text-3xl font-bold text-white sm:text-4xl">
{tr("Linux 超级业务中心", "Linux Super Business Center")}
<h1 className="mt-2 text-3xl font-bold text-[color:var(--text)] sm:text-4xl">
{tr("无人直播 · Linux 超级业务中心", "Unmanned Live · Linux Super Hub")}
</h1>
<p className="mt-3 max-w-3xl text-sm text-slate-400">
{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.",
)}
</p>
</div>
@@ -716,6 +744,20 @@ export default function Home() {
>
EN
</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()}>
{tr("刷新", "Refresh")}
</Button>
@@ -764,20 +806,123 @@ export default function Home() {
<>
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("常用入口", "Quick Links")}</h2>
<div className="mt-4 grid gap-3">
<h2 className="text-lg font-semibold text-[color:var(--text)]">
{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 ? (
<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}
{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 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>
<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>
<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=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}
{rawAndroidPanelUrl ? (
<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>
{ql.ws_scrcpy ? (
<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}
</div>
</article>
@@ -793,8 +938,8 @@ export default function Home() {
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("系统快照", "System Snapshot")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300">
<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 [html[data-theme=light]_&]:border-slate-200/80 [html[data-theme=light]_&]:bg-white/50 [html[data-theme=light]_&]:text-slate-700">
<p>
CPU:{" "}
{[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">Netdata: {snapshot?.netdata?.available ? snapshot.netdata.version || "online" : "offline"}</p>
</div>
<OverviewCharts snapshot={snapshot} theme={theme} tr={tr} />
</article>
</section>
@@ -1170,10 +1316,10 @@ export default function Home() {
{rawAndroidPanelUrl ? (
<div className="mt-6 rounded-3xl border border-white/8 bg-black/20 p-5">
<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>
</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>
) : null}
</article>
@@ -1237,5 +1383,37 @@ export default function Home() {
</div>
) : null}
</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",
"version": "0.1.0",
"dependencies": {
"echarts": "^5.6.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18"
@@ -1906,6 +1907,22 @@
"dev": true,
"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": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -5999,6 +6016,21 @@
"funding": {
"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"
},
"dependencies": {
"echarts": "^5.6.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"next": "14.2.35"
"react-dom": "^18"
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^8",
"eslint-config-next": "14.2.35"
"typescript": "^5"
},
"packageManager": "pnpm@10.25.0+sha1.2cfb3ab644446565c127f58165cc76368c9c920b"
}