feat: ws-scrcpy/NeKo/Chromium hub links, light theme, ECharts, H5 nav, optional Neko Docker
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -26,6 +26,7 @@ configure_mdns
|
||||
configure_wifi_profile
|
||||
install_shellcrash
|
||||
install_browser_stack
|
||||
install_neko_browser
|
||||
install_webtty
|
||||
install_cockpit
|
||||
install_netdata
|
||||
|
||||
@@ -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
|
||||
|
||||
15
services/neko/docker-compose.yml
Normal file
15
services/neko/docker-compose.yml
Normal 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"
|
||||
@@ -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}",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:本机+油猴+RemoteDebug;Neko:可选 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
128
web-console/components/OverviewCharts.tsx
Normal file
128
web-console/components/OverviewCharts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
web-console/package-lock.json
generated
32
web-console/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user