feat(ui): auto-refresh overview/modules, quick config, HW re-probe, link-tile layout; deploy: npm ci+build, restart live-console

Made-with: Cursor
This commit is contained in:
eric
2026-03-28 12:32:20 -05:00
parent d9eceb2169
commit ec5dc7f31e
3 changed files with 272 additions and 101 deletions

View File

@@ -6,6 +6,7 @@ from __future__ import annotations
import os import os
import posixpath import posixpath
import sys import sys
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -23,13 +24,19 @@ REMOTE_BASE = os.environ.get("LIVE_DEPLOY_PATH", "/home/live/douyinyoutube").rst
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent
FILES = [ FILES = [
"src/control_plane.py",
"src/web_process_backend.py", "src/web_process_backend.py",
"web.py", "web.py",
"main.py", "main.py",
"scripts/hardware_probe.py", "scripts/hardware_probe.py",
"douyin_youtube_ffplay.py", "douyin_youtube_ffplay.py",
"web-console/package.json",
"web-console/package-lock.json",
"web-console/app/globals.css",
"web-console/app/layout.tsx",
"web-console/app/page.tsx", "web-console/app/page.tsx",
"web-console/middleware.ts", "web-console/middleware.ts",
"web-console/components/OverviewCharts.tsx",
] ]
LOG_DIR = REPO_ROOT / ".deploy_logs" LOG_DIR = REPO_ROOT / ".deploy_logs"
@@ -182,7 +189,8 @@ def main() -> int:
py_compile = ( py_compile = (
f"cd {REMOTE_BASE} && python3 -m py_compile web.py " f"cd {REMOTE_BASE} && python3 -m py_compile web.py "
"src/web_process_backend.py scripts/hardware_probe.py douyin_youtube_ffplay.py main.py" "src/control_plane.py src/web_process_backend.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) code_py, _ = run_logged(client, log_path, "python_py_compile", py_compile, timeout=90)
if code_py != 0: if code_py != 0:
@@ -191,6 +199,7 @@ def main() -> int:
build_cmd = ( build_cmd = (
f"cd {REMOTE_BASE}/web-console && " f"cd {REMOTE_BASE}/web-console && "
"(npm ci 2>/dev/null || npm install) && "
"(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)" "(command -v pnpm >/dev/null 2>&1 && pnpm run build || npm run build)"
) )
code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900) code_b, _ = run_logged(client, log_path, "web_console_build", build_cmd, timeout=900)
@@ -212,15 +221,23 @@ def main() -> int:
"echo try_restart_done'" "echo try_restart_done'"
) )
run_logged(client, log_path, "sudo_try_restart_units", restart_cmd, timeout=60) run_logged(client, log_path, "sudo_try_restart_units", restart_cmd, timeout=60)
run_logged( run_logged(
client, client,
log_path, log_path,
"curl_health", "sudo_restart_live_console",
"curl -sS -m 10 http://127.0.0.1:8001/health 2>&1 || " f"echo '{PASS}' | sudo -S systemctl restart live-console.service",
"curl -sS -m 10 http://127.0.0.1:8101/health 2>&1 || " timeout=120,
)
_safe_print("等待服务监听…")
time.sleep(6)
run_logged(
client,
log_path,
"curl_health_after_restart",
"curl -sS -m 15 http://127.0.0.1:8001/health 2>&1 || "
"curl -sS -m 15 http://127.0.0.1:8101/health 2>&1 || "
"echo 'curl_health_failed'", "echo 'curl_health_failed'",
timeout=20, timeout=25,
) )
run_logged( run_logged(

View File

@@ -64,6 +64,161 @@ html[data-theme="light"] .chart-surface {
background: var(--bg-card); background: var(--bg-card);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
html[data-theme="light"] .glass {
@apply border-slate-200/90 shadow-slate-400/15;
}
.section-title {
@apply text-lg font-semibold tracking-tight;
color: var(--text);
}
.section-lead {
@apply mt-2 text-xs leading-relaxed;
color: var(--muted);
}
.meta-chip {
@apply rounded-2xl border px-4 py-3 text-xs font-mono leading-relaxed;
border-color: rgb(255 255 255 / 0.1);
background: rgb(0 0 0 / 0.22);
color: var(--muted);
}
html[data-theme="light"] .meta-chip {
border-color: rgb(226 232 240 / 0.95);
background: rgb(255 255 255 / 0.72);
color: var(--muted);
}
.tab-strip {
@apply -mx-1 flex gap-2 overflow-x-auto pb-1.5 pt-0.5 md:flex-wrap md:overflow-visible;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
}
.tab-btn {
@apply btn-focus shrink-0 snap-start rounded-2xl border px-4 py-2.5 text-sm font-medium transition-colors duration-200;
border-color: rgb(255 255 255 / 0.12);
background: rgb(0 0 0 / 0.22);
color: rgb(203 213 225);
}
.tab-btn:hover {
border-color: rgb(34 211 238 / 0.25);
color: rgb(226 232 240);
}
.tab-btn-active {
border-color: rgb(34 211 238 / 0.45) !important;
background: rgb(34 211 238 / 0.12) !important;
color: rgb(207 250 254) !important;
}
html[data-theme="light"] .tab-btn {
border-color: rgb(226 232 240);
background: rgb(255 255 255 / 0.75);
color: rgb(71 85 105);
}
html[data-theme="light"] .tab-btn:hover {
border-color: rgb(6 182 212 / 0.45);
color: rgb(15 118 110);
}
html[data-theme="light"] .tab-btn-active {
border-color: rgb(6 182 212 / 0.55) !important;
background: rgb(207 250 254 / 0.55) !important;
color: rgb(14 116 144) !important;
}
.link-tile {
@apply btn-focus block rounded-2xl border px-4 py-2.5 text-sm font-medium transition-colors duration-200;
border-color: rgb(255 255 255 / 0.1);
background: rgb(0 0 0 / 0.2);
color: rgb(241 245 249);
}
.link-tile:hover {
border-color: rgb(34 211 238 / 0.35);
}
html[data-theme="light"] .link-tile {
border-color: rgb(226 232 240);
background: rgb(255 255 255 / 0.65);
color: rgb(30 41 59);
}
.link-tile-accent {
border-color: rgb(34 211 238 / 0.28);
background: rgb(34 211 238 / 0.1);
color: rgb(236 254 255);
}
.link-tile-accent:hover {
border-color: rgb(34 211 238 / 0.55);
}
html[data-theme="light"] .link-tile-accent {
border-color: rgb(6 182 212 / 0.35);
background: rgb(207 250 254 / 0.65);
color: rgb(14 116 144);
}
.link-tile-warn {
border-color: rgb(251 191 36 / 0.25);
background: rgb(251 191 36 / 0.1);
color: rgb(254 252 232);
}
html[data-theme="light"] .link-tile-warn {
border-color: rgb(251 191 36 / 0.45);
background: rgb(254 252 232 / 0.9);
color: rgb(120 53 15);
}
.service-card {
@apply rounded-3xl border p-5 transition-shadow duration-200;
border-color: rgb(255 255 255 / 0.08);
background: rgb(0 0 0 / 0.2);
}
.service-card:hover {
box-shadow: 0 12px 40px -24px rgb(34 211 238 / 0.35);
}
html[data-theme="light"] .service-card {
border-color: rgb(226 232 240 / 0.95);
background: rgb(255 255 255 / 0.55);
}
.panel-inset {
@apply rounded-2xl border p-4 text-sm;
border-color: rgb(255 255 255 / 0.08);
background: rgb(0 0 0 / 0.2);
color: rgb(203 213 225);
}
html[data-theme="light"] .panel-inset {
border-color: rgb(226 232 240 / 0.85);
background: rgb(255 255 255 / 0.5);
color: rgb(51 65 85);
}
.field-select {
@apply w-full rounded-2xl border px-4 py-3 text-sm outline-none transition;
border-color: rgb(255 255 255 / 0.12);
background: rgb(2 6 23 / 0.55);
color: rgb(248 250 252);
}
html[data-theme="light"] .field-select {
border-color: rgb(226 232 240);
background: rgb(255 255 255 / 0.9);
color: rgb(15 23 42);
} }
.glow-text { .glow-text {

View File

@@ -383,6 +383,29 @@ export default function Home() {
[refreshFiles], [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<HardwareProfile>("/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 () => { const boot = useCallback(async () => {
setBooting(true); setBooting(true);
setError(null); setError(null);
@@ -439,6 +462,14 @@ export default function Home() {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [currentProcess, refreshProcess]); }, [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(() => { useEffect(() => {
const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots; const visibleRoots = tab === "shellcrash" ? shellRoots : tab === "config" ? otherRoots : roots;
if (!visibleRoots.length) return; if (!visibleRoots.length) return;
@@ -703,7 +734,7 @@ export default function Home() {
<> <>
<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"> <main className="safe-pb-nav mx-auto min-h-screen max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-10">
{toast ? ( {toast ? (
<div className="animate-toast-in fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-sm text-cyan-50"> <div className="animate-toast-in fixed bottom-24 left-1/2 z-50 max-w-[min(92vw,28rem)] -translate-x-1/2 rounded-2xl border border-cyan-500/25 bg-slate-950/95 px-5 py-3 text-center text-sm text-cyan-50 shadow-lg md:bottom-6 [html[data-theme=light]_&]:border-cyan-600/25 [html[data-theme=light]_&]:bg-white/95 [html[data-theme=light]_&]:text-cyan-950">
{toast} {toast}
</div> </div>
) : null} ) : null}
@@ -725,11 +756,11 @@ export default function Home() {
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-xs text-slate-300"> <div className="meta-chip max-w-full">
{(stack?.mdns_host || "live.local") + {(stack?.mdns_host || "live.local") +
" | " + " · " +
(stack?.machine || "unknown") + (stack?.machine || "unknown") +
" | " + " · " +
(stack?.kernel || "unknown")} (stack?.kernel || "unknown")}
</div> </div>
<Button <Button
@@ -764,15 +795,12 @@ export default function Home() {
</div> </div>
</div> </div>
<nav className="flex flex-wrap gap-2"> <nav className="tab-strip" aria-label={tr("主导航", "Primary navigation")}>
{TABS.map((item) => ( {TABS.map((item) => (
<button <button
key={item} key={item}
className={`btn-focus rounded-2xl px-4 py-2 text-sm ${ type="button"
tab === item className={`tab-btn ${tab === item ? "tab-btn-active" : ""}`}
? "border border-cyan-400/50 bg-cyan-500/10 text-cyan-100"
: "border border-white/10 bg-black/20 text-slate-300"
}`}
onClick={() => setTab(item)} onClick={() => setTab(item)}
> >
{{ {{
@@ -806,10 +834,8 @@ export default function Home() {
<> <>
<section className="grid gap-6 xl:grid-cols-12"> <section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-[color:var(--text)]"> <h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2>
{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")} <p className="section-lead">
</h2>
<p className="mt-1 text-xs text-slate-400 [html[data-theme=light]_&]:text-slate-600">
{tr( {tr(
"ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug;Neko:可选 Docker 远程桌面开 YouTube。", "ws-scrcpy:浏览器里控安卓;Chromium:本机+油猴+RemoteDebug;Neko:可选 Docker 远程桌面开 YouTube。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.", "ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.",
@@ -817,110 +843,55 @@ export default function Home() {
</p> </p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll"> <div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll">
{stack?.dashboard_url ? ( {stack?.dashboard_url ? (
<a <a className="link-tile" href={stack.dashboard_url} target="_blank" rel="noreferrer">
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 🏠 Homepage
</a> </a>
) : null} ) : null}
{stack?.control_url ? ( {stack?.control_url ? (
<> <>
<a <a className="link-tile" href={`${stack.control_url}?tab=shellcrash&lang=${lang}`} target="_blank" rel="noreferrer">
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 🛡️ ShellCrash
</a> </a>
<a <a className="link-tile" href={`${stack.control_url}?tab=android&lang=${lang}`} target="_blank" rel="noreferrer">
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")} 🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")}
</a> </a>
<a <a className="link-tile" href={`${stack.control_url}?tab=config&lang=${lang}`} target="_blank" rel="noreferrer">
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")} ⚙️ {tr("配置中心", "Config")}
</a> </a>
</> </>
) : null} ) : null}
{ql.ws_scrcpy ? ( {ql.ws_scrcpy ? (
<a <a className="link-tile link-tile-accent" href={ql.ws_scrcpy} target="_blank" rel="noreferrer">
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")}) 📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")})
</a> </a>
) : null} ) : null}
{ql.chromium_remote_debug ? ( {ql.chromium_remote_debug ? (
<a <a className="link-tile" href={ql.chromium_remote_debug} target="_blank" rel="noreferrer">
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")} 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a> </a>
) : null} ) : null}
{ql.neko_browser ? ( {ql.neko_browser ? (
<a <a className="link-tile link-tile-warn" href={ql.neko_browser} target="_blank" rel="noreferrer">
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")} 🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")}
</a> </a>
) : null} ) : null}
{ql.webtty ? ( {ql.webtty ? (
<a <a className="link-tile" href={ql.webtty} target="_blank" rel="noreferrer">
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 ⌨️ WebTTY
</a> </a>
) : null} ) : null}
{ql.filebrowser ? ( {ql.filebrowser ? (
<a <a className="link-tile" href={ql.filebrowser} target="_blank" rel="noreferrer">
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 📂 File Browser
</a> </a>
) : null} ) : null}
{ql.netdata ? ( {ql.netdata ? (
<a <a className="link-tile" href={ql.netdata} target="_blank" rel="noreferrer">
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 📈 Netdata
</a> </a>
) : null} ) : null}
{ql.srs_http ? ( {ql.srs_http ? (
<a <a className="link-tile" href={ql.srs_http} target="_blank" rel="noreferrer">
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 🎞️ SRS HTTP
</a> </a>
) : null} ) : null}
@@ -928,8 +899,8 @@ export default function Home() {
</article> </article>
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("节点状态", "Node Status")}</h2> <h2 className="section-title">{tr("节点状态", "Node Status")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300"> <div className="panel-inset mt-4">
<p>{stack?.mdns_host || "live.local"}</p> <p>{stack?.mdns_host || "live.local"}</p>
<p className="mt-2">IP: {(stack?.ips || []).join(" / ") || "-"}</p> <p className="mt-2">IP: {(stack?.ips || []).join(" / ") || "-"}</p>
<p className="mt-2">{stack?.machine || "-"}</p> <p className="mt-2">{stack?.machine || "-"}</p>
@@ -938,8 +909,9 @@ export default function Home() {
</article> </article>
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-[color:var(--text)]">{tr("系统快照", "System Snapshot")}</h2> <h2 className="section-title">{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 className="section-lead">{tr("总览页每 10 秒自动刷新服务与监控数据。", "Overview auto-refreshes every 10s.")}</p>
<div className="panel-inset mt-4">
<p> <p>
CPU:{" "} CPU:{" "}
{[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]] {[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]]
@@ -955,10 +927,10 @@ export default function Home() {
</section> </section>
<section className="glass rounded-3xl p-6"> <section className="glass rounded-3xl p-6">
<h2 className="text-lg font-semibold text-white">{tr("模块概况", "Module Summary")}</h2> <h2 className="section-title">{tr("模块概况", "Module Summary")}</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{services.map((item) => ( {services.map((item) => (
<div key={item.service_id} className="rounded-3xl border border-white/8 bg-black/20 p-5"> <div key={item.service_id} className="service-card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-white">{item.label}</h3> <h3 className="text-base font-semibold text-white">{item.label}</h3>
@@ -979,12 +951,32 @@ export default function Home() {
{tab === "business" ? ( {tab === "business" ? (
<section className="grid gap-6 xl:grid-cols-12"> <section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-12">
<h2 className="section-title">{tr("⚡ 快捷配置", "Quick config")}</h2>
<p className="section-lead">
{tr("跳转配置中心编辑推流与录制相关文件。", "Jump to the config editor for stream keys and URLs.")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button className="border border-cyan-500/30 bg-cyan-600/80" onClick={() => jumpToConfig("app-config", "youtube.ini")}>
youtube.ini
</Button>
<Button className="border border-cyan-500/30 bg-cyan-600/80" onClick={() => jumpToConfig("app-config", "URL_config.ini")}>
URL_config.ini
</Button>
<Button className="border border-white/15 bg-black/30" onClick={() => jumpToConfig("app-config", "config.ini")}>
config.ini
</Button>
<Button className="border border-white/15 bg-black/30" onClick={() => jumpToConfig("stack-config", "system-stack.env")}>
system-stack.env
</Button>
</div>
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4"> <article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("业务运行时", "Business Runtime")}</h2> <h2 className="section-title">{tr("业务运行时", "Business Runtime")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("这里只放业务脚本本身,Hub 基座可单独运行。", "Only business processes live here.")}</p> <p className="section-lead">{tr("这里只放业务脚本本身,Hub 基座可单独运行。", "Only business processes live here.")}</p>
{entries.length > 0 ? ( {entries.length > 0 ? (
<> <>
<select className="mt-4 w-full rounded-2xl border border-white/12 bg-slate-950/60 px-4 py-3 text-sm text-white" value={currentProcess} onChange={(event) => setCurrentProcess(event.target.value)}> <select className="field-select mt-4" value={currentProcess} onChange={(event) => setCurrentProcess(event.target.value)}>
{entries.map((item) => ( {entries.map((item) => (
<option key={item.pm2} value={item.pm2}>{item.label} | {item.script}</option> <option key={item.pm2} value={item.pm2}>{item.label} | {item.script}</option>
))} ))}
@@ -1006,7 +998,7 @@ export default function Home() {
)} )}
</article> </article>
<article className="glass rounded-3xl p-6 xl:col-span-8"> <article className="glass rounded-3xl p-6 xl:col-span-8">
<h2 className="text-lg font-semibold text-white">{tr("业务日志", "Process Logs")}</h2> <h2 className="section-title">{tr("业务日志", "Process Logs")}</h2>
<div className="mt-4 grid gap-4 lg:grid-cols-2"> <div className="mt-4 grid gap-4 lg:grid-cols-2">
<pre className="log-scroll min-h-[22rem] rounded-2xl border border-emerald-500/15 bg-black/55 p-4 font-mono text-[11px] text-emerald-100/95">{recentLog || tr("暂无输出", "No stdout yet.")}</pre> <pre className="log-scroll min-h-[22rem] rounded-2xl border border-emerald-500/15 bg-black/55 p-4 font-mono text-[11px] text-emerald-100/95">{recentLog || tr("暂无输出", "No stdout yet.")}</pre>
<pre className="log-scroll min-h-[22rem] rounded-2xl border border-rose-500/15 bg-black/55 p-4 font-mono text-[11px] text-rose-100/95">{recentError || tr("暂无报错", "No stderr yet.")}</pre> <pre className="log-scroll min-h-[22rem] rounded-2xl border border-rose-500/15 bg-black/55 p-4 font-mono text-[11px] text-rose-100/95">{recentError || tr("暂无报错", "No stderr yet.")}</pre>
@@ -1018,16 +1010,23 @@ export default function Home() {
{tab === "modules" ? ( {tab === "modules" ? (
<section className="grid gap-6 xl:grid-cols-12"> <section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-8"> <article className="glass rounded-3xl p-6 xl:col-span-8">
<div className="flex items-center justify-between gap-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-white">{tr("系统模块", "System Modules")}</h2> <h2 className="section-title">{tr("系统模块", "System Modules")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}</p> <p className="section-lead">{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshServices()}>
{tr("刷新服务", "Refresh Services")}
</Button>
<Button className="border border-violet-500/35 bg-violet-600/70" disabled={busy === "hardware:probe"} onClick={() => void probeHardware()}>
{busy === "hardware:probe" ? tr("探测中…", "Probing…") : tr("重探测硬件", "Re-probe HW")}
</Button>
</div> </div>
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshServices()}>{tr("刷新服务", "Refresh Services")}</Button>
</div> </div>
<div className="mt-4 grid gap-4 md:grid-cols-2"> <div className="mt-4 grid gap-4 md:grid-cols-2">
{services.map((item) => ( {services.map((item) => (
<div key={item.service_id} className="rounded-3xl border border-white/8 bg-black/20 p-5"> <div key={item.service_id} className="service-card">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<h3 className="text-base font-semibold text-white">{item.label}</h3> <h3 className="text-base font-semibold text-white">{item.label}</h3>