diff --git a/README.md b/README.md index 1fd9423..703bedb 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,20 @@ This fork now includes a modular Linux deployment layout for ARM and X86 nodes. +### Live Platform(四层架构) + +- **配置中心**:`/opt/live/config`(模板见 `live-platform/config/defaults/`,开发时回退到仓库内同路径) +- **一键脚本**:`./install-core.sh`(系统 + 基础服务,与业务解耦)、`./install-live.sh`(仅无人直播业务)、`./doctor.sh`(体检) +- **统一 API**:`GET /hub/dashboard`、`GET /hub/config` 等(见 [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) +- **产品级 Web**:根路径 `/` 为 Live Hub 控制中心;`/console` 为高级全功能页 + - `install-hub.sh`: reusable Linux super hub with `live.local`, ShellCrash, WebTTY, Cockpit, File Browser, Homepage and Netdata - `install-business.sh`: Douyin / YouTube / HDMI business module with ffmpeg, mpv, ADB, Chromium, web-scrcpy and SRS - `install-all.sh`: installs both layers without tightly coupling them Detailed docs: +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) - [`docs/install-profiles.md`](docs/install-profiles.md) - [`docs/super-hub-architecture.md`](docs/super-hub-architecture.md) @@ -17,9 +25,11 @@ Detailed docs: After `install-hub.sh` or `install-all.sh`, the default service map is: -- `http://live.local:8001` for the main Chinese-first control plane -- `http://live.local:8001/shellcrash` for the dedicated ShellCrash web editor -- `http://live.local:8001/android` for the Android device center +- **`http://live.local`** (port 80, via **Caddy** `live-edge.service`) → same UI/API as the control plane +- `http://live.local:8001` for direct access to FastAPI (no edge proxy) +- `http://live.local/shellcrash` or `:8001/shellcrash` — ShellCrash 编辑器 +- `http://live.local/android` or `:8001/android` — 安卓中心 +- `http://live.local:3080` — Homepage 聚合(默认不再占用 80) - `http://live.local:7681` for WebTTY - `http://live.local:8082` for File Browser with `live / 12345678` - `http://live.local:19999` for Netdata diff --git a/config/system-stack.env.example b/config/system-stack.env.example index 4b9a2e0..37e047c 100644 --- a/config/system-stack.env.example +++ b/config/system-stack.env.example @@ -10,9 +10,13 @@ HOSTNAME_ALIAS=live # Shared control plane PORT=8001 +# Caddy 将 :80 反代到 PORT(http://live.local 无端口验收) +ENABLE_LIVE_EDGE_PROXY=1 + # Hub web services WEBTTY_PORT=7681 -HOMEPAGE_PORT=80 +# 默认 3080,避免与 Caddy 占用 80(live.local 无端口访问控制台) +HOMEPAGE_PORT=3080 FILEBROWSER_PORT=8082 NETDATA_PORT=19999 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ac30100 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,45 @@ +# Live Platform 四层架构 + +## 分层 + +| 层 | 职责 | 代码/路径 | +|----|------|-----------| +| Core OS | 用户、SSH、mDNS、Wi‑Fi、WebTTY、Docker、systemd、日志 | `scripts/linux/install_hub.sh` 及系统包 | +| Infra Service | ShellCrash、Caddy、Netdata、SRS、Neko、ws-scrcpy、FRP、WireGuard 占位 | `services/*`、`scripts/linux/service_ctl.sh` | +| Business | 无人直播 douyinyoutube(FFmpeg/HDMI/推流),可脱离控制台运行 | 仓库根业务脚本、`install-live.sh` | +| Web Console | 仅通过 API 与配置中心「控制」,不实现业务算法 | `web.py`、`web-console`、`/hub/*` API | + +## 配置中心 + +- 安装目标:`/opt/live/config/` +- 默认模板:`live-platform/config/defaults/` +- 文件:`system.json`、`network.json`、`services/*.json`、`business/*.json` +- 生成兼容层:`live-platform/tools/render_stack_env.py` → `/opt/live/generated/system-stack.env`(供现有 shell 读取,逐步淘汰分散 `.env`) + +## 一键脚本(幂等) + +| 脚本 | 作用 | +|------|------| +| `./install-core.sh` | 同步 JSON → `/opt/live/config`,生成 env,调用 `install_hub.sh`(无业务) | +| `./install-live.sh` | 仅业务模块,调用 `install_business.sh` | +| `./doctor.sh` | 网络、端口、adb、ffmpeg、docker、API 健康检查 | + +## API + +- `GET /hub/dashboard`:快照 + 服务 + 安卓 + 直播进程状态 +- `GET /hub/config`:配置中心只读聚合 +- `POST /hub/business/douyinyoutube`:业务元数据(如抖音 URL 声明) +- 既有:`/service_action`、`/android/*`、`/process_monitor`、`/save_url_config` 等 + +## 前端 + +- `/`:`LiveControlApp` 产品壳(总览、服务、直播、安卓、网络、设置) +- `/console`:高级遗留控制台(全功能) +- 开发:`middleware` 将 `/hub` 代理到本机 FastAPI + +## live.local(无端口验收) + +- **`install_hub.sh` 末尾**执行 **`install_live_edge_proxy`**:安装/启用 **`live-edge.service`(Caddy)**,将 **`http://live.local`(:80)** 反代到 **`127.0.0.1:${PORT}`**(默认 8001)。 +- **Homepage** 默认改绑 **`HOMEPAGE_PORT=3080`**,避免与 Caddy 抢 80。 +- 仅更新反代:`sudo bash scripts/linux/reinstall_live_edge.sh` +- `ENABLE_LIVE_EDGE_PROXY=0` 可关闭(`system-stack.env`)。 diff --git a/doctor.sh b/doctor.sh new file mode 100644 index 0000000..77bb462 --- /dev/null +++ b/doctor.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/live-platform/scripts/doctor.sh" "$@" diff --git a/install-core.sh b/install-core.sh new file mode 100644 index 0000000..9110a93 --- /dev/null +++ b/install-core.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/live-platform/scripts/install-core.sh" "$@" diff --git a/install-live.sh b/install-live.sh new file mode 100644 index 0000000..3d03024 --- /dev/null +++ b/install-live.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$ROOT_DIR/live-platform/scripts/install-live.sh" "$@" diff --git a/live-platform/config/defaults/business/douyinyoutube.json b/live-platform/config/defaults/business/douyinyoutube.json new file mode 100644 index 0000000..0013617 --- /dev/null +++ b/live-platform/config/defaults/business/douyinyoutube.json @@ -0,0 +1,19 @@ +{ + "id": "douyinyoutube", + "name": "无人直播(抖音/TikTok → YouTube / SRS / HDMI)", + "enabled": true, + "layer": "business", + "install_marker": "/opt/live/business/douyinyoutube/.installed", + "ffmpeg": { + "template_id": "default", + "auto_restart": true, + "retry_seconds": 15 + }, + "streams": { + "douyin_input_url": "", + "youtube_rtmp_key": "", + "srs_publish_url": "", + "hdmi_device": "" + }, + "note": "具体推流参数仍可由 PM2 脚本与 config/*.ini 管理;此处为声明式元数据,供控制台与恢复策略使用" +} diff --git a/live-platform/config/defaults/network.json b/live-platform/config/defaults/network.json new file mode 100644 index 0000000..38d72f0 --- /dev/null +++ b/live-platform/config/defaults/network.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "wifi": { + "enabled": true, + "connection_name": "live-wifi", + "ssid": "live", + "psk": "12345678" + }, + "ipv6": { + "disable": true + }, + "android_proxy": { + "mode": "gateway_optional", + "description": "可选透明网关或 ShellCrash 规则;具体由 ShellCrash 与 network 脚本实现" + } +} diff --git a/live-platform/config/defaults/services/android_panel.json b/live-platform/config/defaults/services/android_panel.json new file mode 100644 index 0000000..d5b99e1 --- /dev/null +++ b/live-platform/config/defaults/services/android_panel.json @@ -0,0 +1,12 @@ +{ + "id": "android_panel", + "layer": "infra", + "enabled": true, + "manager": "systemd", + "ports": { "http": 5000 }, + "env": { + "ENABLE_ANDROID_PANEL": "1", + "ANDROID_PANEL_PORT": "5000", + "ANDROID_PANEL_DIR": "/opt/live-modules/web-scrcpy" + } +} diff --git a/live-platform/config/defaults/services/caddy.json b/live-platform/config/defaults/services/caddy.json new file mode 100644 index 0000000..c8fb565 --- /dev/null +++ b/live-platform/config/defaults/services/caddy.json @@ -0,0 +1,9 @@ +{ + "id": "caddy", + "layer": "infra", + "enabled": false, + "manager": "systemd", + "description": "可选:统一 live.local:80 入口,反代控制台静态 + /api → FastAPI", + "ports": { "http": 80, "https": 443 }, + "template": "/opt/live/templates/Caddyfile" +} diff --git a/live-platform/config/defaults/services/filebrowser.json b/live-platform/config/defaults/services/filebrowser.json new file mode 100644 index 0000000..9e615b9 --- /dev/null +++ b/live-platform/config/defaults/services/filebrowser.json @@ -0,0 +1,13 @@ +{ + "id": "filebrowser", + "layer": "infra", + "enabled": true, + "manager": "docker", + "ports": { "http": 8082 }, + "env": { + "ENABLE_FILEBROWSER": "1", + "FILEBROWSER_PORT": "8082", + "FILEBROWSER_USER": "live", + "FILEBROWSER_PASS": "12345678" + } +} diff --git a/live-platform/config/defaults/services/frp.json b/live-platform/config/defaults/services/frp.json new file mode 100644 index 0000000..284ed5d --- /dev/null +++ b/live-platform/config/defaults/services/frp.json @@ -0,0 +1,8 @@ +{ + "id": "frp", + "layer": "infra", + "enabled": false, + "manager": "systemd", + "description": "占位:内网穿透,由运维填入 frpc 配置后启用", + "ports": {} +} diff --git a/live-platform/config/defaults/services/homepage.json b/live-platform/config/defaults/services/homepage.json new file mode 100644 index 0000000..a3831aa --- /dev/null +++ b/live-platform/config/defaults/services/homepage.json @@ -0,0 +1,8 @@ +{ + "id": "homepage", + "layer": "infra", + "enabled": true, + "manager": "docker", + "ports": { "http": 3080 }, + "env": { "ENABLE_HOMEPAGE": "1", "HOMEPAGE_PORT": "3080" } +} diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json new file mode 100644 index 0000000..8e4d219 --- /dev/null +++ b/live-platform/config/defaults/services/neko.json @@ -0,0 +1,13 @@ +{ + "id": "neko", + "layer": "infra", + "enabled": false, + "manager": "docker", + "ports": { "http": 9200 }, + "env": { + "ENABLE_NEKO": "0", + "NEKO_HTTP_PORT": "9200", + "NEKO_USER_PASS": "live", + "NEKO_ADMIN_PASS": "admin" + } +} diff --git a/live-platform/config/defaults/services/netdata.json b/live-platform/config/defaults/services/netdata.json new file mode 100644 index 0000000..aebcac1 --- /dev/null +++ b/live-platform/config/defaults/services/netdata.json @@ -0,0 +1,8 @@ +{ + "id": "netdata", + "layer": "infra", + "enabled": true, + "manager": "native", + "ports": { "http": 19999 }, + "env": { "NETDATA_PORT": "19999", "ENABLE_NETDATA": "1" } +} diff --git a/live-platform/config/defaults/services/shellcrash.json b/live-platform/config/defaults/services/shellcrash.json new file mode 100644 index 0000000..36829b2 --- /dev/null +++ b/live-platform/config/defaults/services/shellcrash.json @@ -0,0 +1,11 @@ +{ + "id": "shellcrash", + "layer": "infra", + "enabled": true, + "manager": "systemd", + "ports": {}, + "env": { + "SHELLCRASH_DIR": "/etc/ShellCrash", + "ENABLE_SHELLCRASH": "1" + } +} diff --git a/live-platform/config/defaults/services/srs.json b/live-platform/config/defaults/services/srs.json new file mode 100644 index 0000000..d1ff67d --- /dev/null +++ b/live-platform/config/defaults/services/srs.json @@ -0,0 +1,16 @@ +{ + "id": "srs", + "layer": "infra", + "enabled": true, + "manager": "docker", + "ports": { + "http": 8080, + "rtmp": 1935, + "api": 1985 + }, + "env": { + "SRS_HTTP_PORT": "8080", + "SRS_RTMP_PORT": "1935", + "SRS_API_PORT": "1985" + } +} diff --git a/live-platform/config/defaults/services/webtty.json b/live-platform/config/defaults/services/webtty.json new file mode 100644 index 0000000..3344cb0 --- /dev/null +++ b/live-platform/config/defaults/services/webtty.json @@ -0,0 +1,13 @@ +{ + "id": "webtty", + "layer": "core", + "enabled": true, + "manager": "systemd", + "ports": { "http": 7681 }, + "env": { + "ENABLE_WEBTTY": "1", + "WEBTTY_PORT": "7681", + "WEBTTY_USER": "live", + "WEBTTY_PASS": "12345678" + } +} diff --git a/live-platform/config/defaults/services/wireguard.json b/live-platform/config/defaults/services/wireguard.json new file mode 100644 index 0000000..7378af1 --- /dev/null +++ b/live-platform/config/defaults/services/wireguard.json @@ -0,0 +1,8 @@ +{ + "id": "wireguard", + "layer": "infra", + "enabled": false, + "manager": "systemd", + "description": "占位:VPN 接口,与 ShellCrash 并存时需规划路由", + "ports": {} +} diff --git a/live-platform/config/defaults/system.json b/live-platform/config/defaults/system.json new file mode 100644 index 0000000..2ba06b8 --- /dev/null +++ b/live-platform/config/defaults/system.json @@ -0,0 +1,21 @@ +{ + "version": 1, + "platform": { + "arch": "auto", + "description": "auto = detect via uname -m (aarch64/arm64/x86_64)" + }, + "identity": { + "hostname_alias": "live", + "mdns_enabled": true + }, + "paths": { + "state_dir": "/opt/live/state", + "logs_dir": "/opt/live/logs", + "generated_dir": "/opt/live/generated" + }, + "web_console": { + "api_port": 8001, + "public_host": "live.local", + "note": "生产环境由 Caddy :80 反代到静态资源与 API" + } +} diff --git a/live-platform/config/templates/Caddyfile.sample b/live-platform/config/templates/Caddyfile.sample new file mode 100644 index 0000000..756bbe5 --- /dev/null +++ b/live-platform/config/templates/Caddyfile.sample @@ -0,0 +1,13 @@ +# 将本文件复制到 /opt/live/templates/Caddyfile 并按实际路径修改 +# 目标:http://live.local → 静态控制台 + /api/* → FastAPI + +{$LIVE_DOMAIN:live.local} { + encode gzip + # 静态(Next export 由 web.py 同端口托管时可改为 reverse_proxy) + handle_path /api/* { + reverse_proxy 127.0.0.1:8001 + } + handle { + reverse_proxy 127.0.0.1:8001 + } +} diff --git a/live-platform/scripts/doctor.sh b/live-platform/scripts/doctor.sh new file mode 100644 index 0000000..ee1db72 --- /dev/null +++ b/live-platform/scripts/doctor.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# 一键体检:网络 / 端口 / adb / ffmpeg / docker / 服务状态(模块化输出) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REPO="$(cd "$ROOT/.." && pwd)" +# shellcheck source=/dev/null +source "$ROOT/scripts/lib/common.sh" + +echo "=== Live Platform Doctor ===" +echo "arch: $(detect_arch)" +echo "hostname: $(hostname -f 2>/dev/null || hostname)" + +section() { echo ""; echo "--- $1 ---"; } + +section "loopback / DNS" +ping -c1 -W2 127.0.0.1 >/dev/null 2>&1 && echo "ping 127.0.0.1: ok" || echo "ping 127.0.0.1: fail" +getent hosts live.local >/dev/null 2>&1 && echo "live.local resolves: yes" || echo "live.local resolves: (optional mdns)" + +section "config" +if [[ -f /opt/live/config/system.json ]]; then + echo "system.json: present" +else + echo "system.json: MISSING (run install-core)" +fi +if [[ -f /opt/live/generated/system-stack.env ]]; then + echo "generated env: present" +else + echo "generated env: missing" +fi + +section "ports (LISTEN)" +command -v ss >/dev/null 2>&1 && ss -tlnp 2>/dev/null | head -n 40 || netstat -tlnp 2>/dev/null | head -n 40 || true + +section "adb" +if command -v adb >/dev/null 2>&1; then + adb devices -l || true +else + echo "adb not installed" +fi + +section "ffmpeg" +command -v ffmpeg >/dev/null 2>&1 && ffmpeg -version | head -n1 || echo "ffmpeg missing" + +section "docker" +if command -v docker >/dev/null 2>&1; then + docker info >/dev/null 2>&1 && echo "docker: daemon ok" || echo "docker: daemon unreachable" +else + echo "docker not installed" +fi + +section "proxy hint (ShellCrash)" +if [[ -d /etc/ShellCrash ]]; then + echo "ShellCrash dir: /etc/ShellCrash" +else + echo "ShellCrash dir: not found" +fi + +if [[ -f "$REPO/web.py" ]] && command -v curl >/dev/null 2>&1; then + section "API" + PORT="${PORT:-8001}" + curl -sS -m 3 "http://127.0.0.1:${PORT}/health" && echo "" || echo "health check failed on :$PORT" + section "Port 80 (live-edge / Caddy)" + systemctl is-active live-edge.service 2>/dev/null || echo "live-edge.service: inactive" + curl -sS -m 5 -o /dev/null -w "GET http://127.0.0.1/ → http_code=%{http_code}\n" "http://127.0.0.1/" || echo "curl :80 failed" +fi + +echo "" +echo "=== Doctor finished ===" diff --git a/live-platform/scripts/install-core.sh b/live-platform/scripts/install-core.sh new file mode 100644 index 0000000..b0e7b04 --- /dev/null +++ b/live-platform/scripts/install-core.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# 幂等:同步配置中心默认 JSON → /opt/live/config,生成 system-stack.env,安装 Hub(无业务模块) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REPO="$(cd "$ROOT/.." && pwd)" +# shellcheck source=/dev/null +source "$ROOT/scripts/lib/common.sh" + +ARCH="$(detect_arch)" +echo "[live-platform] install-core arch=$ARCH" + +ensure_live_dirs + +if [[ ! -f /opt/live/config/system.json ]]; then + cp -a "$ROOT/config/defaults/system.json" /opt/live/config/system.json +fi +if [[ ! -f /opt/live/config/network.json ]]; then + cp -a "$ROOT/config/defaults/network.json" /opt/live/config/network.json +fi +if command -v rsync >/dev/null 2>&1; then + rsync -a --ignore-existing "$ROOT/config/defaults/services/" /opt/live/config/services/ + rsync -a --ignore-existing "$ROOT/config/defaults/business/" /opt/live/config/business/ +else + cp -n "$ROOT/config/defaults/services/"*.json /opt/live/config/services/ 2>/dev/null || true + cp -n "$ROOT/config/defaults/business/"*.json /opt/live/config/business/ 2>/dev/null || true +fi + +export LIVE_CONFIG_ROOT="${LIVE_CONFIG_ROOT:-/opt/live/config}" +python3 "$ROOT/tools/render_stack_env.py" /opt/live/generated/system-stack.env + +if [[ -n "${LINK_REPO_CONFIG:-}" ]]; then + mkdir -p "$REPO/config" + ln -sf /opt/live/generated/system-stack.env "$REPO/config/system-stack.env" +fi + +if [[ -x "$REPO/scripts/linux/install_hub.sh" ]]; then + bash "$REPO/scripts/linux/install_hub.sh" "$@" +else + echo "[live-platform] WARN: scripts/linux/install_hub.sh missing, skip hub packages" +fi + +echo "[live-platform] install-core done. Env: /opt/live/generated/system-stack.env" diff --git a/live-platform/scripts/install-live.sh b/live-platform/scripts/install-live.sh new file mode 100644 index 0000000..91a34d4 --- /dev/null +++ b/live-platform/scripts/install-live.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# 幂等:仅无人直播业务模块(与 install-core 解耦) +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REPO="$(cd "$ROOT/.." && pwd)" + +if [[ -x "$REPO/scripts/linux/install_business.sh" ]]; then + bash "$REPO/scripts/linux/install_business.sh" "$@" +else + echo "[live-platform] ERROR: scripts/linux/install_business.sh not found" + exit 1 +fi + +mkdir -p /opt/live/business/douyinyoutube +touch /opt/live/business/douyinyoutube/.installed +echo "[live-platform] install-live done" diff --git a/live-platform/scripts/lib/common.sh b/live-platform/scripts/lib/common.sh new file mode 100644 index 0000000..6b3c127 --- /dev/null +++ b/live-platform/scripts/lib/common.sh @@ -0,0 +1,29 @@ +# shellcheck shell=bash +# 被 install-core / install-live / doctor 引用;禁止写业务逻辑,仅路径与权限。 + +live_platform_root() { + local here + here="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + printf '%s\n' "$here" +} + +repo_root() { + local lp + lp="$(live_platform_root)" + cd "$lp/.." && pwd +} + +detect_arch() { + case "$(uname -m)" in + aarch64|arm64) echo "arm64" ;; + x86_64|amd64) echo "amd64" ;; + *) echo "generic" ;; + esac +} + +ensure_live_dirs() { + sudo mkdir -p /opt/live/config/services /opt/live/config/business \ + /opt/live/generated /opt/live/state /opt/live/logs /opt/live/templates 2>/dev/null || \ + mkdir -p /opt/live/config/services /opt/live/config/business \ + /opt/live/generated /opt/live/state /opt/live/logs /opt/live/templates +} diff --git a/live-platform/tools/render_caddyfile.py b/live-platform/tools/render_caddyfile.py new file mode 100644 index 0000000..44a538b --- /dev/null +++ b/live-platform/tools/render_caddyfile.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""生成 Caddy 配置:HTTP :80 → 本机 FastAPI+静态(PORT,默认 8001)。""" +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +def main() -> int: + alias = "live" + port = "8001" + out = Path("/opt/live/generated/Caddyfile") + + cfg = Path("/opt/live/config/system.json") + if cfg.is_file(): + data = json.loads(cfg.read_text(encoding="utf-8")) + ident = data.get("identity") or {} + web = data.get("web_console") or {} + alias = str(ident.get("hostname_alias") or alias) + if web.get("api_port") is not None: + port = str(web["api_port"]) + + if len(sys.argv) >= 2: + alias = sys.argv[1] + if len(sys.argv) >= 3: + port = sys.argv[2] + if len(sys.argv) >= 4: + out = Path(sys.argv[3]) + + host = f"{alias}.local" + body = f"""# Generated by live-platform/tools/render_caddyfile.py - do not hand-edit +# http://{host}/ and http:/// -> 127.0.0.1:{port} + +{host}:80, :80 {{ +\treverse_proxy 127.0.0.1:{port} +}} +""" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(body, encoding="utf-8") + print(str(out)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/live-platform/tools/render_stack_env.py b/live-platform/tools/render_stack_env.py new file mode 100644 index 0000000..29010f8 --- /dev/null +++ b/live-platform/tools/render_stack_env.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""从 /opt/live/config(或 LIVE_CONFIG_ROOT)JSON 配置生成 system-stack.env,供现有 service_ctl / install 脚本读取。""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + + +def config_root() -> Path: + env = os.environ.get("LIVE_CONFIG_ROOT", "").strip() + if env: + return Path(env) + if Path("/opt/live/config/system.json").is_file(): + return Path("/opt/live/config") + here = Path(__file__).resolve() + return here.parent.parent / "config" / "defaults" + + +def load_json(path: Path) -> dict: + if not path.is_file(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def collect_env(root: Path) -> dict[str, str]: + lines: dict[str, str] = {} + + sys_j = load_json(root / "system.json") + web = sys_j.get("web_console") or {} + ident = sys_j.get("identity") or {} + if ident.get("hostname_alias"): + lines["HOSTNAME_ALIAS"] = str(ident["hostname_alias"]) + if web.get("api_port") is not None: + lines["PORT"] = str(web["api_port"]) + + net = load_json(root / "network.json") + wifi = net.get("wifi") or {} + if wifi.get("connection_name"): + lines["WIFI_CONNECTION_NAME"] = str(wifi["connection_name"]) + if wifi.get("ssid"): + lines["WIFI_SSID"] = str(wifi["ssid"]) + if wifi.get("psk"): + lines["WIFI_PSK"] = str(wifi["psk"]) + if net.get("ipv6", {}).get("disable"): + lines["DISABLE_IPV6"] = "1" + + svc_dir = root / "services" + if svc_dir.is_dir(): + for f in sorted(svc_dir.glob("*.json")): + data = load_json(f) + env = data.get("env") or {} + if not isinstance(env, dict): + continue + for k, v in env.items(): + if v is not None and str(v) != "": + lines[str(k)] = str(v) + + ports = {} + if svc_dir.is_dir(): + for f in sorted(svc_dir.glob("*.json")): + data = load_json(f) + pid = data.get("id") or f.stem + pr = data.get("ports") or {} + if isinstance(pr, dict) and "http" in pr: + ports[pid] = pr["http"] + # 固定映射:homepage 使用 HOMEPAGE_PORT + if "homepage" in ports: + lines.setdefault("HOMEPAGE_PORT", str(ports["homepage"])) + + lines.setdefault("ENABLE_MDNS", "1") + lines.setdefault("ENABLE_WIFI_PROFILE", "1") + lines.setdefault("ENABLE_BROWSER", "1") + lines.setdefault("ENABLE_COCKPIT", "1") + return lines + + +def main() -> int: + root = config_root() + out = Path(os.environ.get("LIVE_ENV_OUT", "/opt/live/generated/system-stack.env")) + if len(sys.argv) > 1: + out = Path(sys.argv[1]) + lines = collect_env(root) + out.parent.mkdir(parents=True, exist_ok=True) + body = "\n".join(f"{k}={v}" for k, v in sorted(lines.items())) + "\n" + out.write_text( + "# Generated by live-platform/tools/render_stack_env.py — do not hand-edit\n" + body, + encoding="utf-8", + ) + # 开发树:可选同步到仓库 config(由 install 脚本 ln -sf) + print(str(out)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/linux/install_hub.sh b/scripts/linux/install_hub.sh index 60cfa15..0c05506 100644 --- a/scripts/linux/install_hub.sh +++ b/scripts/linux/install_hub.sh @@ -33,4 +33,5 @@ install_netdata prepare_homepage prepare_filebrowser run_hardware_probe +install_live_edge_proxy print_summary "hub" diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index 9239246..e973cb0 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -77,7 +77,7 @@ load_env() { HOSTNAME_ALIAS="${HOSTNAME_ALIAS:-live}" PORT="${PORT:-8001}" WEBTTY_PORT="${WEBTTY_PORT:-7681}" - HOMEPAGE_PORT="${HOMEPAGE_PORT:-80}" + HOMEPAGE_PORT="${HOMEPAGE_PORT:-3080}" FILEBROWSER_PORT="${FILEBROWSER_PORT:-8082}" NETDATA_PORT="${NETDATA_PORT:-19999}" SRS_HTTP_PORT="${SRS_HTTP_PORT:-8080}" @@ -98,6 +98,7 @@ load_env() { ANDROID_PANEL_PORT="${ANDROID_PANEL_PORT:-5000}" ANDROID_PANEL_DIR="${ANDROID_PANEL_DIR:-/opt/live-modules/web-scrcpy}" ANDROID_PANEL_VIDEO_BIT_RATE="${ANDROID_PANEL_VIDEO_BIT_RATE:-1024000}" + ENABLE_LIVE_EDGE_PROXY="${ENABLE_LIVE_EDGE_PROXY:-1}" } ensure_live_user() { @@ -548,12 +549,21 @@ PY render_homepage_services() { local target="$ROOT_DIR/services/homepage/config/services.yaml" + local primary_console + if [ "${ENABLE_LIVE_EDGE_PROXY:-1}" = "1" ]; then + primary_console="http://${HOSTNAME_ALIAS}.local" + else + primary_console="http://${HOSTNAME_ALIAS}.local:${PORT}" + fi log "Rendering Homepage service links" cat >"$target" <>"$target" </dev/null | grep -qx live-homepage; then + if docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -E 'live-homepage' | grep -qE '0\.0\.0\.0:80->|:::80->'; then + log "Rebinding Homepage from host :80 → :3080 (free port for Caddy)" + export HOMEPAGE_PORT=3080 + if [ -f "$ROOT_DIR/config/system-stack.env" ] && grep -q '^HOMEPAGE_PORT=80' "$ROOT_DIR/config/system-stack.env"; then + sed -i 's/^HOMEPAGE_PORT=80$/HOMEPAGE_PORT=3080/' "$ROOT_DIR/config/system-stack.env" || true + fi + compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" down >/dev/null 2>&1 || true + render_homepage_services + compose_run "$ROOT_DIR/services/homepage/docker-compose.yml" up -d || true + fi + fi + + if ! command -v caddy >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq caddy || true + fi + fi + if ! command -v caddy >/dev/null 2>&1; then + log "Installing Caddy from GitHub release (apt had no caddy)" + local arch raw + raw="$(uname -m)" + case "$raw" in + aarch64|arm64) arch="arm64" ;; + x86_64|amd64) arch="amd64" ;; + armv7l|armv6l) arch="armv7" ;; + *) + log "WARN: unknown arch $raw — install caddy manually" + return 0 + ;; + esac + local ver="2.8.4" + local tmp="/tmp/caddy-${ver}-linux-${arch}.tar.gz" + curl -fsSL "https://github.com/caddyserver/caddy/releases/download/v${ver}/caddy_${ver}_linux_${arch}.tar.gz" -o "$tmp" + tar -xzf "$tmp" -C /tmp caddy + install -m0755 /tmp/caddy /usr/local/bin/caddy + rm -f "$tmp" /tmp/caddy + fi + + systemctl stop caddy >/dev/null 2>&1 || true + systemctl disable caddy >/dev/null 2>&1 || true + + local caddy_bin + caddy_bin="$(command -v caddy || true)" + [ -n "$caddy_bin" ] || caddy_bin="/usr/local/bin/caddy" + + cat >/etc/systemd/system/live-edge.service < :${PORT}) +After=network-online.target live-console.service +Wants=network-online.target +Requires=live-console.service + +[Service] +Type=simple +ExecStart=$caddy_bin run --config /opt/live/generated/Caddyfile --adapter caddyfile +Restart=on-failure +RestartSec=4 + +[Install] +WantedBy=multi-user.target +UNIT + systemctl daemon-reload + systemctl enable --now live-edge.service + log "Edge proxy live-edge.service enabled (http://${HOSTNAME_ALIAS}.local)" +} + print_summary() { local profile="$1" cat < list[dict]: return nodes[: max(1, min(limit, 200))] +def _is_png(data: bytes) -> bool: + return bool(data and len(data) > 8 and data.startswith(b"\x89PNG\r\n\x1a\n")) + + async def android_screenshot(serial: str) -> bytes: if not serial: raise ValueError("Missing Android device serial") code, data, err = await _adb_binary(serial, "exec-out", "screencap", "-p", timeout=20.0) - if code != 0 or not data: - raise RuntimeError(err or "Failed to capture screenshot") - return data.replace(b"\r\n", b"\n") + if code == 0 and data: + normalized = data.replace(b"\r\n", b"\n") + if _is_png(data) or normalized.startswith(b"\x89PNG"): + return normalized + + last_err = err or "exec-out screencap failed" + for remote in ("/data/local/tmp/live-cap.png", "/sdcard/live-cap.png"): + tmp = Path(tempfile.mkstemp(suffix=".png")[1]) + try: + sh_code, _, sh_err = await _adb_text(serial, "shell", f"screencap -p {remote}", timeout=25.0) + if sh_code != 0: + last_err = sh_err or f"screencap to {remote} failed" + continue + pull_code, _, pull_err = await _run_cmd( + _adb_prefix(serial) + ["pull", remote, str(tmp)], + timeout=35.0, + ) + if pull_code != 0: + last_err = pull_err or "adb pull failed" + continue + pulled = tmp.read_bytes() + if _is_png(pulled) or pulled.startswith(b"\x89PNG"): + return pulled.replace(b"\r\n", b"\n") + last_err = "Pulled file is not a valid PNG" + finally: + tmp.unlink(missing_ok=True) + + raise RuntimeError(last_err) def _normalize_text(text: str) -> str: @@ -420,4 +451,12 @@ async def android_action(action: str, serial: str, payload: Optional[dict] = Non output = await _shell(serial, f"pm clear '{package}'", timeout=25.0) return {"message": f"Cleared app data: {package}", "output": output} + if action == "install_apk": + apk_path = str(payload.get("path", "")).strip() + if not apk_path or any(ch in apk_path for ch in ";$`&|()<>\n\r"): + raise ValueError("Invalid apk path on device (use adb push to /sdcard/ first)") + apk_path = apk_path.replace("'", "'\\''") + output = await _shell(serial, f"pm install -r -t '{apk_path}'", timeout=180.0) + return {"message": "APK install finished", "output": output} + raise ValueError(f"Unsupported Android action: {action}") diff --git a/src/hub_routes.py b/src/hub_routes.py new file mode 100644 index 0000000..5a7866a --- /dev/null +++ b/src/hub_routes.py @@ -0,0 +1,118 @@ +"""Live Hub 统一 API:仪表盘、配置中心;业务写操作经 API 落盘,前端不直接访问文件。""" +from __future__ import annotations + +import json +from typing import Any, Optional + +from fastapi import APIRouter +from pydantic import BaseModel + +from src.android_control import adb_available, list_android_devices +from src.control_plane import query_services, stack_summary, system_snapshot +from src.live_config import business_config, load_hub_config_snapshot, services_config_list +from src.web_process_backend import WebProcessBackend + +router = APIRouter(prefix="/hub", tags=["Live Hub"]) + +_process_backend: WebProcessBackend | None = None +_monitor_entries: list[dict[str, Any]] = [] + + +def configure_hub(process_backend: WebProcessBackend, monitor_entries: list[dict[str, Any]]) -> None: + global _process_backend, _monitor_entries + _process_backend = process_backend + _monitor_entries = list(monitor_entries) + + +class BusinessDouyinPatch(BaseModel): + streams: Optional[dict[str, str]] = None + ffmpeg: Optional[dict[str, Any]] = None + + +@router.get("/dashboard") +async def hub_dashboard() -> dict[str, Any]: + snap = system_snapshot() + services = await query_services() + if adb_available(): + devices = await list_android_devices() + else: + devices = [] + stack = stack_summary() + + live_status: dict[str, Any] = { + "processes": [], + "backend_mode": "unknown", + "hint": "PM2 or local registry", + } + pb = _process_backend + if pb is not None: + await pb.ensure_mode() + live_status["backend_mode"] = pb.mode + for entry in _monitor_entries: + name = str(entry.get("pm2", "")).strip() + if not name: + continue + try: + info = await pb.parse_pm2_describe(name) + except Exception as exc: # noqa: BLE001 + info = {"process_status": "error", "message": str(exc)} + live_status["processes"].append( + { + "pm2": name, + "label": entry.get("label", name), + "script": entry.get("script", ""), + **info, + } + ) + + return { + "snapshot": snap, + "stack": stack, + "services": services, + "android": { + "adb_available": adb_available(), + "devices": devices, + }, + "live": live_status, + "netdata": snap.get("netdata"), + } + + +@router.get("/config") +async def hub_config_read() -> dict[str, Any]: + return load_hub_config_snapshot() + + +@router.get("/services/declared") +async def hub_services_declared() -> dict[str, Any]: + return {"services": services_config_list()} + + +@router.get("/business/douyinyoutube") +async def hub_business_douyin_read() -> dict[str, Any]: + return business_config("douyinyoutube") + + +@router.post("/business/douyinyoutube") +async def hub_business_douyin_patch(body: BusinessDouyinPatch) -> dict[str, str]: + from src.live_config.loader import get_config_root + + root = get_config_root() + path = root / "business" / "douyinyoutube.json" + path.parent.mkdir(parents=True, exist_ok=True) + current: dict = {} + if path.is_file(): + try: + current = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + pass + if body.streams is not None: + cur_streams = current.get("streams") if isinstance(current.get("streams"), dict) else {} + cur_streams.update({k: v for k, v in body.streams.items() if v is not None}) + current["streams"] = cur_streams + if body.ffmpeg is not None: + cur_ff = current.get("ffmpeg") if isinstance(current.get("ffmpeg"), dict) else {} + cur_ff.update(body.ffmpeg) + current["ffmpeg"] = cur_ff + path.write_text(json.dumps(current, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return {"message": "business/douyinyoutube.json updated"} diff --git a/src/live_config/__init__.py b/src/live_config/__init__.py new file mode 100644 index 0000000..df7d4b3 --- /dev/null +++ b/src/live_config/__init__.py @@ -0,0 +1,8 @@ +from .loader import business_config, get_config_root, load_hub_config_snapshot, services_config_list + +__all__ = [ + "get_config_root", + "load_hub_config_snapshot", + "services_config_list", + "business_config", +] diff --git a/src/live_config/loader.py b/src/live_config/loader.py new file mode 100644 index 0000000..73160f8 --- /dev/null +++ b/src/live_config/loader.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + + +def get_config_root() -> Path: + env = os.environ.get("LIVE_CONFIG_ROOT", "").strip() + if env: + return Path(env) + if Path("/opt/live/config/system.json").is_file(): + return Path("/opt/live/config") + # 仓库内开发默认值 + base = Path(__file__).resolve().parent.parent.parent + return base / "live-platform" / "config" / "defaults" + + +def _read_json(path: Path) -> dict[str, Any]: + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + +def services_config_list() -> list[dict[str, Any]]: + root = get_config_root() / "services" + if not root.is_dir(): + return [] + out: list[dict[str, Any]] = [] + for f in sorted(root.glob("*.json")): + data = _read_json(f) + if data: + data.setdefault("id", f.stem) + out.append(data) + return out + + +def business_config(module_id: str) -> dict[str, Any]: + return _read_json(get_config_root() / "business" / f"{module_id}.json") + + +def load_hub_config_snapshot() -> dict[str, Any]: + root = get_config_root() + return { + "config_root": str(root), + "system": _read_json(root / "system.json"), + "network": _read_json(root / "network.json"), + "services": services_config_list(), + "business": { + "douyinyoutube": business_config("douyinyoutube"), + }, + } diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 4b2818a..c12712d 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -26,10 +26,18 @@ REPO_ROOT = Path(__file__).resolve().parent.parent FILES = [ "src/control_plane.py", "src/web_process_backend.py", + "src/hub_routes.py", + "src/live_config/__init__.py", + "src/live_config/loader.py", "web.py", "main.py", "scripts/hardware_probe.py", "douyin_youtube_ffplay.py", + "scripts/linux/install_hub.sh", + "scripts/linux/reinstall_live_edge.sh", + "scripts/linux/lib/common.sh", + "live-platform/tools/render_caddyfile.py", + "live-platform/tools/render_stack_env.py", "web-console/package.json", "web-console/package-lock.json", "web-console/app/globals.css", @@ -37,6 +45,9 @@ FILES = [ "web-console/app/page.tsx", "web-console/middleware.ts", "web-console/components/OverviewCharts.tsx", + "web-console/components/live-product/LiveControlApp.tsx", + "web-console/lib/panelUrls.ts", + "web-console/components/console/LiveConsoleWorkspace.tsx", ] LOG_DIR = REPO_ROOT / ".deploy_logs" @@ -189,7 +200,8 @@ def main() -> int: py_compile = ( f"cd {REMOTE_BASE} && python3 -m py_compile web.py " - "src/control_plane.py src/web_process_backend.py " + "src/control_plane.py src/web_process_backend.py src/hub_routes.py " + "src/live_config/__init__.py src/live_config/loader.py " "scripts/hardware_probe.py douyin_youtube_ffplay.py main.py" ) code_py, _ = run_logged(client, log_path, "python_py_compile", py_compile, timeout=90) @@ -215,6 +227,7 @@ def main() -> int: restart_cmd = ( f"echo '{PASS}' | sudo -S bash -c '" "systemctl try-restart live-console.service 2>/dev/null || true; " + "systemctl try-restart live-edge.service 2>/dev/null || true; " "systemctl try-restart douyinyoutube.service 2>/dev/null || true; " "systemctl try-restart douyin-live.service 2>/dev/null || true; " "systemctl try-restart live-web.service 2>/dev/null || true; " @@ -239,6 +252,34 @@ def main() -> int: "echo 'curl_health_failed'", timeout=25, ) + run_logged( + client, + log_path, + "curl_port80_root", + "curl -sS -m 12 -o /dev/null -w 'http_code=%{http_code}\\n' http://127.0.0.1/ 2>&1 || echo curl_80_fail", + timeout=20, + ) + run_logged( + client, + log_path, + "chmod_reinstall_edge", + f"chmod +x {REMOTE_BASE}/scripts/linux/reinstall_live_edge.sh 2>/dev/null || true", + timeout=15, + ) + run_logged( + client, + log_path, + "reinstall_live_edge", + f"echo '{PASS}' | sudo -S bash {REMOTE_BASE}/scripts/linux/reinstall_live_edge.sh", + timeout=180, + ) + run_logged( + client, + log_path, + "curl_port80_after_edge", + "curl -sS -m 12 -o /dev/null -w 'http_code=%{http_code}\\n' http://127.0.0.1/ 2>&1 || echo curl_80_fail", + timeout=20, + ) run_logged( client, diff --git a/web-console/app/android/page.tsx b/web-console/app/android/page.tsx index bee8ff3..8675d78 100644 --- a/web-console/app/android/page.tsx +++ b/web-console/app/android/page.tsx @@ -1 +1,7 @@ -export { default } from "../page"; +"use client"; + +import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace"; + +export default function AndroidConsolePage() { + return ; +} diff --git a/web-console/app/console/page.tsx b/web-console/app/console/page.tsx new file mode 100644 index 0000000..55412db --- /dev/null +++ b/web-console/app/console/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { LiveConsoleWorkspace } from "@/components/console/LiveConsoleWorkspace"; + +export default function ConsolePage() { + return ; +} diff --git a/web-console/app/globals.css b/web-console/app/globals.css index 58aebcd..cf14acd 100644 --- a/web-console/app/globals.css +++ b/web-console/app/globals.css @@ -284,3 +284,16 @@ html[data-theme="light"] .chart-surface { background: rgba(148, 163, 184, 0.35); border-radius: 6px; } + +/* 门户首页(/)轻量网格背景,与控制台 glass 风格区分 */ +.portal-grid-bg { + background-image: + linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-size: 28px 28px; +} + +/* Live Hub 产品壳:噪点与光晕 */ +.lp-noise { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E"); +} diff --git a/web-console/app/layout.tsx b/web-console/app/layout.tsx index 09ff9b2..88a72d1 100644 --- a/web-console/app/layout.tsx +++ b/web-console/app/layout.tsx @@ -15,8 +15,8 @@ const geistMono = localFont({ }); export const metadata: Metadata = { - title: "直播与系统控制台", - description: "业务进程、系统服务与配置文件的统一控制平面", + title: "Live Hub · 超级业务中心", + description: "ARM/x86 Linux 统一控制中心:服务、无人直播、安卓、网络与配置", }; export default function RootLayout({ diff --git a/web-console/app/page.tsx b/web-console/app/page.tsx index d6f3572..78e3acb 100644 --- a/web-console/app/page.tsx +++ b/web-console/app/page.tsx @@ -1,1418 +1,3 @@ "use client"; -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 }; -type Capacity = { total?: number; free?: number; used?: number; available?: number }; -type ProcessEntry = { pm2: string; script: string; label: string }; -type ProcessStatusResponse = { - process_status?: string; - recent_log?: string; - recent_error?: string; - output?: string; - pm2_output?: string; - message?: string; -}; -type ManagedRoot = { - root_id: string; - label: string; - description: string; - path: string; - exists: boolean; - allow_create: boolean; - allow_delete: boolean; - recursive: boolean; - default_file?: string | null; - file_count: number; -}; -type ManagedFile = { path: string; size: number; mtime: number }; -type StackSummary = { - hostname?: string; - mdns_host?: string; - machine?: string; - kernel?: string; - ips?: string[]; - dashboard_url?: string; - control_url?: string; - quick_links?: Record; -}; -type ServiceItem = { - service_id: string; - label: string; - description: string; - available: boolean; - running: boolean; - status: string; - detail?: string; - url?: string; - host?: string; - port?: string; - message?: string; -}; -type SystemSnapshot = { - cpu_load?: LoadAvg; - disk?: Capacity; - memory?: Capacity; - netdata?: { available?: boolean; version?: string }; -}; -type HardwareProfile = { - arch?: string; - ffmpeg_hwaccels?: string[]; - gpu?: { present?: boolean; driver?: string }; - npu?: { present?: boolean; devices?: string[] }; - recommendation?: { - video_decoder?: string; - hdmi_player?: string; - stability_mode?: string; - }; -}; -type AndroidDevice = { - serial: string; - state: string; - usb?: string; - product?: string; - model?: string; - device?: string; -}; -type AndroidOverview = { - serial?: string; - model?: string; - brand?: string; - android_version?: string; - sdk?: string; - battery?: string; - focus?: string; - resolution?: string; - rotation?: string; - screen_state?: string; -}; -type AndroidNode = { - label: string; - text?: string; - content_desc?: string; - resource_id?: string; - class_name?: string; - package?: string; - bounds?: string; - center_x: number; - center_y: number; - clickable?: boolean; - focusable?: boolean; - scrollable?: boolean; - enabled?: boolean; -}; -type AndroidPackage = { - package: string; - label?: string; -}; -type PreviewMode = "tap" | "double_tap" | "long_press"; - -const TABS: TabKey[] = ["overview", "business", "modules", "shellcrash", "android", "config"]; -const SHELLCRASH_ROOT_IDS = new Set(["shellcrash-configs", "shellcrash-yamls", "shellcrash-jsons"]); - -const apiBase = () => - (typeof process !== "undefined" && process.env.NEXT_PUBLIC_API_URL) || ""; - -function formatBytes(value?: number) { - if (!value) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let amount = value; - let index = 0; - while (amount >= 1024 && index < units.length - 1) { - amount /= 1024; - index += 1; - } - return `${amount.toFixed(amount >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; -} - -function statusTone(status: string) { - if (status === "online") return "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"; - if (status === "stopped") return "border-slate-500/30 bg-slate-500/10 text-slate-300"; - if (status === "configured") return "border-violet-500/30 bg-violet-500/10 text-violet-200"; - if (status === "unavailable") return "border-slate-700/30 bg-slate-800/50 text-slate-500"; - if (status === "error") return "border-rose-500/30 bg-rose-500/10 text-rose-200"; - return "border-cyan-500/30 bg-cyan-500/10 text-cyan-200"; -} - -function isTabKey(value: string | null): value is TabKey { - return value !== null && (TABS as string[]).includes(value); -} - -function pathnameToTab(pathname: string): TabKey | null { - const normalized = pathname.replace(/\/+$/, "") || "/"; - if (normalized === "/shellcrash") return "shellcrash"; - if (normalized === "/android") return "android"; - return null; -} - -function tabPath(tab: TabKey) { - if (tab === "shellcrash") return "/shellcrash"; - if (tab === "android") return "/android"; - return "/"; -} - -function parseResolution(resolution?: string) { - const match = resolution?.match(/(\d+)x(\d+)/); - if (!match) return null; - return { width: Number(match[1]), height: Number(match[2]) }; -} - -function Button({ - children, - className, - disabled, - onClick, - title, -}: { - children: React.ReactNode; - className?: string; - disabled?: boolean; - onClick?: () => void; - title?: string; -}) { - return ( - - ); -} - -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]); - - const [booting, setBooting] = useState(true); - const [error, setError] = useState(null); - const [toast, setToast] = useState(null); - const [busy, setBusy] = useState(null); - - const [entries, setEntries] = useState([]); - const [currentProcess, setCurrentProcess] = useState(""); - const [processStatus, setProcessStatus] = useState("unknown"); - const [recentLog, setRecentLog] = useState(""); - const [recentError, setRecentError] = useState(""); - - const [stack, setStack] = useState(null); - const [services, setServices] = useState([]); - const [snapshot, setSnapshot] = useState(null); - const [hardware, setHardware] = useState(null); - - const [roots, setRoots] = useState([]); - const [rootId, setRootId] = useState(""); - const [files, setFiles] = useState([]); - const [filePath, setFilePath] = useState(""); - const [fileContent, setFileContent] = useState(""); - - const [androidAvailable, setAndroidAvailable] = useState(false); - const [androidDevices, setAndroidDevices] = useState([]); - const [androidSerial, setAndroidSerial] = useState(""); - const [androidOverview, setAndroidOverview] = useState(null); - const [androidNodes, setAndroidNodes] = useState([]); - const [androidPackages, setAndroidPackages] = useState([]); - const [androidPackageQuery, setAndroidPackageQuery] = useState(""); - const [androidText, setAndroidText] = useState(""); - const [androidPackage, setAndroidPackage] = useState("com.zhiliaoapp.musically"); - const [androidUrl, setAndroidUrl] = useState("https://www.tiktok.com"); - const [androidShell, setAndroidShell] = useState(""); - const [androidOutput, setAndroidOutput] = useState(""); - const [shotNonce, setShotNonce] = useState(Date.now()); - const [previewMode, setPreviewMode] = useState("tap"); - const [previewPoint, setPreviewPoint] = useState<{ x: number; y: number } | null>(null); - const [fileImportKey, setFileImportKey] = useState(0); - - const currentRoot = roots.find((item) => item.root_id === rootId) ?? null; - const shellRoots = 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 androidService = services.find((item) => item.service_id === "android_panel") ?? null; - const ql = stack?.quick_links ?? {}; - const rawAndroidPanelUrl = - 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) => { - setToast(message); - window.setTimeout(() => setToast(null), 2500); - }, []); - - const fetchJson = useCallback( - async (path: string, init?: RequestInit): Promise => { - const response = await fetch(`${base}${path}`, init); - const data = (await response.json()) as T & { error?: string }; - if (!response.ok) { - throw new Error(data.error || `HTTP ${response.status}`); - } - return data; - }, - [base], - ); - - const syncUrl = useCallback((nextTab: TabKey, nextLang: Lang) => { - const url = new URL(window.location.href); - url.pathname = tabPath(nextTab); - if (url.pathname === "/") { - url.searchParams.set("tab", nextTab); - } else { - url.searchParams.delete("tab"); - } - url.searchParams.set("lang", nextLang); - window.history.replaceState(null, "", url.toString()); - window.localStorage.setItem("live-console-lang", nextLang); - }, []); - - const refreshProcess = useCallback(async () => { - if (!currentProcess) return; - const data = await fetchJson( - `/status?process=${encodeURIComponent(currentProcess)}`, - ); - setProcessStatus(data.process_status || "unknown"); - setRecentLog(data.recent_log || ""); - setRecentError(data.recent_error || ""); - }, [currentProcess, fetchJson]); - - const refreshServices = useCallback(async () => { - const [serviceData, snapshotData, hardwareData] = await Promise.all([ - fetchJson<{ services?: ServiceItem[] }>("/stack/services"), - fetchJson("/system_snapshot"), - fetchJson("/hardware_profile"), - ]); - setServices(serviceData.services || []); - setSnapshot(snapshotData); - setHardware(hardwareData); - }, [fetchJson]); - - const refreshFiles = useCallback( - async (selectedRoot: string, preferredPath?: string) => { - if (!selectedRoot) return; - const data = await fetchJson<{ files?: ManagedFile[] }>( - `/managed_files?root=${encodeURIComponent(selectedRoot)}`, - ); - const nextFiles = data.files || []; - setFiles(nextFiles); - const expected = preferredPath || filePath; - if (expected && nextFiles.some((item) => item.path === expected)) { - setFilePath(expected); - } else { - setFilePath(nextFiles[0]?.path || ""); - } - }, - [fetchJson, filePath], - ); - - const refreshAndroidDevices = useCallback(async () => { - const data = await fetchJson<{ available?: boolean; devices?: AndroidDevice[] }>("/android/devices"); - const nextDevices = data.devices || []; - const nextSerial = - androidSerial && nextDevices.some((item) => item.serial === androidSerial) - ? androidSerial - : nextDevices[0]?.serial || ""; - setAndroidAvailable(Boolean(data.available)); - setAndroidDevices(nextDevices); - setAndroidSerial(nextSerial); - return nextSerial; - }, [androidSerial, fetchJson]); - - const refreshAndroidOverview = useCallback( - async (serial: string) => { - if (!serial) { - setAndroidOverview(null); - return; - } - const data = await fetchJson( - `/android/overview?serial=${encodeURIComponent(serial)}`, - ); - setAndroidOverview(data); - }, - [fetchJson], - ); - - const refreshAndroidPackages = useCallback( - async (serial: string, query = androidPackageQuery) => { - if (!serial) { - setAndroidPackages([]); - return; - } - const data = await fetchJson<{ packages?: AndroidPackage[] }>( - `/android/packages?serial=${encodeURIComponent(serial)}&query=${encodeURIComponent(query)}&limit=60`, - ); - setAndroidPackages(data.packages || []); - }, - [androidPackageQuery, fetchJson], - ); - - const refreshAndroidUi = useCallback( - async (serial: string) => { - if (!serial) { - setAndroidNodes([]); - return; - } - const data = await fetchJson<{ nodes?: AndroidNode[] }>( - `/android/ui?serial=${encodeURIComponent(serial)}&limit=80`, - ); - setAndroidNodes(data.nodes || []); - }, - [fetchJson], - ); - - const openManagedFile = useCallback( - async (nextRoot: string, nextPath: string) => { - setRootId(nextRoot); - setFilePath(nextPath); - await refreshFiles(nextRoot, nextPath); - }, - [refreshFiles], - ); - - const jumpToConfig = useCallback( - (root: string, path: string) => { - setTab("config"); - window.setTimeout(() => { - void openManagedFile(root, path); - }, 0); - }, - [openManagedFile], - ); - - const probeHardware = useCallback(async () => { - setBusy("hardware:probe"); - try { - const data = await fetchJson("/hardware_profile?refresh=true"); - setHardware(data); - notify(tr("硬件探测已更新", "Hardware profile refreshed")); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }, [fetchJson, notify, tr]); - - const boot = useCallback(async () => { - setBooting(true); - setError(null); - try { - const [monitor, info, managed] = await Promise.all([ - fetchJson<{ entries?: ProcessEntry[] }>("/process_monitor"), - fetchJson<{ stack?: StackSummary }>("/server_info"), - fetchJson<{ roots?: ManagedRoot[] }>("/managed_roots"), - ]); - setEntries(monitor.entries || []); - setCurrentProcess(monitor.entries?.[0]?.pm2 || ""); - setStack(info.stack || null); - setRoots(managed.roots || []); - setRootId(managed.roots?.[0]?.root_id || ""); - await Promise.all([refreshServices(), refreshAndroidDevices()]); - } catch (reason) { - setError((reason as Error).message); - } finally { - setBooting(false); - } - }, [fetchJson, refreshAndroidDevices, refreshServices]); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const pathTab = pathnameToTab(window.location.pathname); - const nextTab = params.get("tab"); - const nextLang = params.get("lang"); - const storedLang = window.localStorage.getItem("live-console-lang"); - if (pathTab) setTab(pathTab); - 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]); - - useEffect(() => { - void boot(); - }, [boot]); - - useEffect(() => { - if (!currentProcess) return; - void refreshProcess(); - const timer = window.setInterval(() => void refreshProcess(), 1500); - return () => window.clearInterval(timer); - }, [currentProcess, refreshProcess]); - - useEffect(() => { - if (booting || error) return; - if (tab !== "overview" && tab !== "modules") return; - void refreshServices(); - const timer = window.setInterval(() => void refreshServices(), 10000); - return () => clearInterval(timer); - }, [booting, error, refreshServices, tab]); - - useEffect(() => { - const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots; - if (!visibleRoots.length) return; - if (!visibleRoots.some((item) => item.root_id === rootId)) { - setRootId(visibleRoots[0].root_id); - setFilePath(""); - } - }, [otherRoots, rootId, roots, shellRoots, tab]); - - useEffect(() => { - if (!rootId) return; - void refreshFiles(rootId); - }, [refreshFiles, rootId]); - - useEffect(() => { - if (!rootId || !filePath) { - setFileContent(""); - return; - } - (async () => { - try { - const data = await fetchJson<{ content?: string }>( - `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, - ); - setFileContent(data.content || ""); - } catch (reason) { - notify((reason as Error).message); - } - })(); - }, [fetchJson, filePath, notify, rootId]); - - useEffect(() => { - if (tab !== "android") return; - void (async () => { - const serial = await refreshAndroidDevices(); - if (serial) { - await Promise.all([ - refreshAndroidOverview(serial), - refreshAndroidUi(serial), - refreshAndroidPackages(serial), - ]); - setShotNonce(Date.now()); - } - })(); - }, [refreshAndroidDevices, refreshAndroidOverview, refreshAndroidPackages, refreshAndroidUi, tab]); - - useEffect(() => { - if (tab !== "android" || !androidSerial) return; - const timer = window.setInterval(() => { - void refreshAndroidOverview(androidSerial); - setShotNonce(Date.now()); - }, 5000); - return () => window.clearInterval(timer); - }, [androidSerial, refreshAndroidOverview, tab]); - - const processAction = async (action: "start" | "restart" | "stop") => { - if (!currentProcess) return; - setBusy(`process:${action}`); - try { - const data = await fetchJson(`/${action}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ process: currentProcess }), - }); - notify(String(data.output || data.pm2_output || data.message || "OK")); - await refreshProcess(); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const serviceAction = async (serviceId: string, action: "start" | "restart" | "stop") => { - setBusy(`${serviceId}:${action}`); - try { - const data = await fetchJson("/service_action", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ service: serviceId, action }), - }); - notify(data.message || "OK"); - await refreshServices(); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const saveFile = async () => { - if (!rootId || !filePath) return; - setBusy("file:save"); - try { - await fetchJson("/managed_file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ root: rootId, path: filePath, content: fileContent }), - }); - notify(tr("文件已保存", "File saved")); - await refreshFiles(rootId, filePath); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const createFile = async () => { - if (!currentRoot?.allow_create) { - notify(tr("当前目录不允许新建文件", "The current root does not allow new files")); - return; - } - const nextPath = window.prompt(tr("请输入新的相对路径", "Enter a new relative path"), currentRoot.default_file || ""); - if (!nextPath) return; - setBusy("file:create"); - try { - await fetchJson("/managed_file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ root: rootId, path: nextPath, content: "" }), - }); - notify(tr("文件已创建", "File created")); - await refreshFiles(rootId, nextPath); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const deleteFile = async () => { - if (!rootId || !filePath) return; - if (!currentRoot?.allow_delete) { - notify(tr("当前目录不允许删除文件", "The current root does not allow deletion")); - return; - } - if (!window.confirm(tr("确定删除该文件?", "Delete this file?"))) return; - setBusy("file:delete"); - try { - await fetchJson( - `/managed_file?root=${encodeURIComponent(rootId)}&path=${encodeURIComponent(filePath)}`, - { method: "DELETE" }, - ); - notify(tr("文件已删除", "File deleted")); - await refreshFiles(rootId); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const importManagedFile = async (event: ChangeEvent) => { - const upload = event.target.files?.[0]; - event.target.value = ""; - setFileImportKey((value) => value + 1); - if (!upload || !rootId) return; - - setBusy("file:import"); - try { - const content = await upload.text(); - await fetchJson("/managed_file", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ root: rootId, path: upload.name, content }), - }); - notify(tr("导入完成,已替换同名文件", "Import finished and replaced matching file")); - setFilePath(upload.name); - await refreshFiles(rootId, upload.name); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const sendAndroidAction = async (action: string, payload?: Record) => { - if (!androidSerial) { - notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); - return; - } - setBusy(`android:${action}`); - try { - const data = await fetchJson<{ message?: string; output?: string }>("/android/action", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, serial: androidSerial, payload: payload || {} }), - }); - notify(data.message || "OK"); - if (data.output) setAndroidOutput(data.output); - setShotNonce(Date.now()); - await refreshAndroidOverview(androidSerial); - } catch (reason) { - notify((reason as Error).message); - } finally { - setBusy(null); - } - }; - - const previewActionLabel = - previewMode === "tap" - ? tr("点击", "Tap") - : previewMode === "double_tap" - ? tr("双击", "Double Tap") - : tr("长按", "Long Press"); - - const handlePreviewClick = async (event: MouseEvent) => { - if (!androidSerial) { - notify(tr("当前没有可用的 ADB 设备", "No active ADB device")); - return; - } - const rect = event.currentTarget.getBoundingClientRect(); - const sourceWidth = androidResolution?.width || event.currentTarget.naturalWidth || rect.width; - const sourceHeight = androidResolution?.height || event.currentTarget.naturalHeight || rect.height; - const x = Math.max( - 0, - Math.min(sourceWidth, Math.round((event.clientX - rect.left) * (sourceWidth / rect.width))), - ); - const y = Math.max( - 0, - Math.min(sourceHeight, Math.round((event.clientY - rect.top) * (sourceHeight / rect.height))), - ); - setPreviewPoint({ x, y }); - if (previewMode === "long_press") { - await sendAndroidAction("long_press", { x, y, duration: 750 }); - return; - } - await sendAndroidAction(previewMode, { x, y }); - }; - - const serviceHref = (item: ServiceItem) => { - if (item.service_id === "shellcrash" && stack?.control_url) { - return `${stack.control_url}/shellcrash?lang=${lang}`; - } - if (item.service_id === "android_panel" && stack?.control_url) { - return `${stack.control_url}/android?lang=${lang}`; - } - return item.url || ""; - }; - - const statusText = (status: string) => - ({ - online: tr("在线", "Online"), - stopped: tr("已停止", "Stopped"), - configured: tr("已配置", "Configured"), - unavailable: tr("未安装", "Unavailable"), - }[status] || status); - - const activeRoots = tab === "shellcrash" ? shellRoots : otherRoots; - - const tabEmoji: Record = { - overview: "📊", - business: "🎬", - modules: "🧩", - shellcrash: "🛡️", - android: "🤖", - config: "⚙️", - }; - - return ( - <> -
- {toast ? ( -
- {toast} -
- ) : null} - -
-
-
-

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

-

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

-

- {tr( - "抖音 → YouTube / HDMI 转播、ws-scrcpy、Chromium 与可选 Neko 浏览器、ShellCrash 等模块解耦,单点故障不拖垮整机。", - "Douyin→YouTube/HDMI relay, ws-scrcpy, Chromium & optional Neko, ShellCrash — decoupled modules.", - )} -

-
-
-
- {(stack?.mdns_host || "live.local") + - " · " + - (stack?.machine || "unknown") + - " · " + - (stack?.kernel || "unknown")} -
- - - - - -
-
- - -
- - {booting ? ( -
- {tr("正在连接控制台...", "Connecting to the control plane...")} -
- ) : null} - - {!booting && error ? ( -
- {error} -
- ) : null} - - {!booting && !error ? ( -
- {tab === "overview" ? ( - <> -
-
-

{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 - - ) : null} - {stack?.control_url ? ( - <> - - 🛡️ ShellCrash - - - 🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")} - - - ⚙️ {tr("配置中心", "Config")} - - - ) : null} - {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} -
-
- -
-

{tr("节点状态", "Node Status")}

-
-

{stack?.mdns_host || "live.local"}

-

IP: {(stack?.ips || []).join(" / ") || "-"}

-

{stack?.machine || "-"}

-

{stack?.kernel || "-"}

-
-
- -
-

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

-

{tr("总览页每 10 秒自动刷新服务与监控数据。", "Overview auto-refreshes every 10s.")}

-
-

- CPU:{" "} - {[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]] - .map((item) => (typeof item === "number" ? item.toFixed(2) : "0.00")) - .join(" / ")} -

-

RAM: {formatBytes(snapshot?.memory?.available)} / {formatBytes(snapshot?.memory?.total)}

-

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

-

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

-
- -
-
- -
-

{tr("模块概况", "Module Summary")}

-
- {services.map((item) => ( -
-
-
-

{item.label}

-

{item.description}

-
- {statusText(item.status)} -
-

{item.detail || "-"}

- {serviceHref(item) ? ( - {tr("打开", "Open")} - ) : null} -
- ))} -
-
- - ) : null} - - {tab === "business" ? ( -
-
-

{tr("⚡ 快捷配置", "Quick config")}

-

- {tr("跳转配置中心编辑推流与录制相关文件。", "Jump to the config editor for stream keys and URLs.")} -

-
- - - - -
-
-
-

{tr("业务运行时", "Business Runtime")}

-

{tr("这里只放业务脚本本身,Hub 基座可单独运行。", "Only business processes live here.")}

- {entries.length > 0 ? ( - <> - -
- {statusText(processStatus)} - -
-
- - - -
- - ) : ( -
- {tr("未发现业务进程。若只部署了 Hub 基座,这是正常状态。", "No business process discovered.")} -
- )} -
-
-

{tr("业务日志", "Process Logs")}

-
-
{recentLog || tr("暂无输出", "No stdout yet.")}
-
{recentError || tr("暂无报错", "No stderr yet.")}
-
-
-
- ) : null} - - {tab === "modules" ? ( -
-
-
-
-

{tr("系统模块", "System Modules")}

-

{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}

-
-
- - -
-
-
- {services.map((item) => ( -
-
-
-

{item.label}

-

{item.description}

-
- {statusText(item.status)} -
-

{item.detail || "-"}

-
- - - - {serviceHref(item) ? {tr("打开", "Open")} : null} -
-
- ))} -
-
-
-

{tr("硬件与系统", "Hardware and System")}

-
-

Arch: {hardware?.arch || "-"}

-

FFmpeg: {(hardware?.ffmpeg_hwaccels || []).join(", ") || "-"}

-

GPU: {hardware?.gpu?.present ? hardware.gpu.driver || "present" : "none"}

-

NPU: {hardware?.npu?.present ? (hardware.npu.devices || []).join(", ") : "none"}

-

Decoder: {hardware?.recommendation?.video_decoder || "cpu"}

-

HDMI: {hardware?.recommendation?.hdmi_player || "cpu"}

-

Mode: {hardware?.recommendation?.stability_mode || "safe"}

-
-
-
- ) : null} - - {tab === "shellcrash" ? ( -
-
-

ShellCrash

-

{tr("这里不再依赖命令行菜单,直接做 YAML / JSON / CFG 编辑与服务控制。", "Manage ShellCrash from the web instead of the shell menu.")}

- {shellcrashService ? ( -
-
-
-

{shellcrashService.label}

-

{shellcrashService.description}

-
- {statusText(shellcrashService.status)} -
-

{shellcrashService.detail || "-"}

-
- - - -
-
- ) : null} -
-

{tr("快捷文件", "Quick Files")}

-
- - - - -
-
-
-
-
-
-

{tr("ShellCrash 文件编辑器", "ShellCrash Editor")}

-

{tr("支持查看、修改、新建、删除,路径被限制在 ShellCrash 托管目录内。", "View, edit, create and delete files inside managed ShellCrash roots.")}

-
-
- - -
-
-
-
- -
- - -
-
- {files.map((item) => ( - - ))} -
-
-
-
{filePath ? `${rootId} / ${filePath}` : tr("请选择文件", "Pick a file")}
-