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 posixpath
import sys
import time
from datetime import datetime
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
FILES = [
"src/control_plane.py",
"src/web_process_backend.py",
"web.py",
"main.py",
"scripts/hardware_probe.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/middleware.ts",
"web-console/components/OverviewCharts.tsx",
]
LOG_DIR = REPO_ROOT / ".deploy_logs"
@@ -182,7 +189,8 @@ def main() -> int:
py_compile = (
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)
if code_py != 0:
@@ -191,6 +199,7 @@ def main() -> int:
build_cmd = (
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)"
)
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'"
)
run_logged(client, log_path, "sudo_try_restart_units", restart_cmd, timeout=60)
run_logged(
client,
log_path,
"curl_health",
"curl -sS -m 10 http://127.0.0.1:8001/health 2>&1 || "
"curl -sS -m 10 http://127.0.0.1:8101/health 2>&1 || "
"sudo_restart_live_console",
f"echo '{PASS}' | sudo -S systemctl restart live-console.service",
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'",
timeout=20,
timeout=25,
)
run_logged(

View File

@@ -64,6 +64,161 @@ html[data-theme="light"] .chart-surface {
background: var(--bg-card);
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 {

View File

@@ -383,6 +383,29 @@ export default function Home() {
[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 () => {
setBooting(true);
setError(null);
@@ -439,6 +462,14 @@ export default function Home() {
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;
@@ -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">
{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}
</div>
) : null}
@@ -725,11 +756,11 @@ export default function Home() {
</p>
</div>
<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?.machine || "unknown") +
" | " +
" · " +
(stack?.kernel || "unknown")}
</div>
<Button
@@ -764,15 +795,12 @@ export default function Home() {
</div>
</div>
<nav className="flex flex-wrap gap-2">
<nav className="tab-strip" aria-label={tr("主导航", "Primary navigation")}>
{TABS.map((item) => (
<button
key={item}
className={`btn-focus rounded-2xl px-4 py-2 text-sm ${
tab === item
? "border border-cyan-400/50 bg-cyan-500/10 text-cyan-100"
: "border border-white/10 bg-black/20 text-slate-300"
}`}
type="button"
className={`tab-btn ${tab === item ? "tab-btn-active" : ""}`}
onClick={() => setTab(item)}
>
{{
@@ -806,10 +834,8 @@ export default function Home() {
<>
<section className="grid gap-6 xl:grid-cols-12">
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-[color:var(--text)]">
{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}
</h2>
<p className="mt-1 text-xs text-slate-400 [html[data-theme=light]_&]:text-slate-600">
<h2 className="section-title">{tr("🛰️ 流媒体与桌面入口", "Streaming & desktop")}</h2>
<p className="section-lead">
{tr(
"ws-scrcpy浏览器里控安卓Chromium本机+油猴+RemoteDebugNeko可选 Docker 远程桌面开 YouTube。",
"ws-scrcpy: browser ADB; Chromium: host+Tampermonkey+9222; Neko: optional Docker desktop.",
@@ -817,110 +843,55 @@ export default function Home() {
</p>
<div className="mt-4 flex max-h-[28rem] flex-col gap-2 overflow-y-auto pr-1 log-scroll">
{stack?.dashboard_url ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-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"
>
<a className="link-tile" href={stack.dashboard_url} target="_blank" rel="noreferrer">
🏠 Homepage
</a>
) : null}
{stack?.control_url ? (
<>
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-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"
>
<a className="link-tile" href={`${stack.control_url}?tab=shellcrash&lang=${lang}`} target="_blank" rel="noreferrer">
🛡 ShellCrash
</a>
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={`${stack.control_url}?tab=android&lang=${lang}`}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={`${stack.control_url}?tab=android&lang=${lang}`} target="_blank" rel="noreferrer">
🤖 {tr("安卓中心 (含 ws-scrcpy)", "Android hub + ws-scrcpy")}
</a>
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-cyan-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={`${stack.control_url}?tab=config&lang=${lang}`}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={`${stack.control_url}?tab=config&lang=${lang}`} target="_blank" rel="noreferrer">
{tr("配置中心", "Config")}
</a>
</>
) : null}
{ql.ws_scrcpy ? (
<a
className="btn-focus rounded-2xl border border-cyan-500/25 bg-cyan-500/10 px-4 py-2.5 text-sm text-cyan-50 transition hover:border-cyan-400/50 [html[data-theme=light]_&]:border-cyan-300 [html[data-theme=light]_&]:bg-cyan-50 [html[data-theme=light]_&]:text-cyan-950"
href={ql.ws_scrcpy}
target="_blank"
rel="noreferrer"
>
<a className="link-tile link-tile-accent" href={ql.ws_scrcpy} target="_blank" rel="noreferrer">
📱 ws-scrcpy ({tr("原始流控制页", "raw stream UI")})
</a>
) : null}
{ql.chromium_remote_debug ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 transition hover:border-violet-400/30 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.chromium_remote_debug}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={ql.chromium_remote_debug} target="_blank" rel="noreferrer">
🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")}
</a>
) : null}
{ql.neko_browser ? (
<a
className="btn-focus rounded-2xl border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-sm text-amber-50 transition hover:border-amber-400/40 [html[data-theme=light]_&]:border-amber-200 [html[data-theme=light]_&]:bg-amber-50 [html[data-theme=light]_&]:text-amber-950"
href={ql.neko_browser}
target="_blank"
rel="noreferrer"
>
<a className="link-tile link-tile-warn" href={ql.neko_browser} target="_blank" rel="noreferrer">
🐱 Neko / {tr("Docker 浏览器桌面", "Docker browser desktop")}
</a>
) : null}
{ql.webtty ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.webtty}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={ql.webtty} target="_blank" rel="noreferrer">
WebTTY
</a>
) : null}
{ql.filebrowser ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.filebrowser}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={ql.filebrowser} target="_blank" rel="noreferrer">
📂 File Browser
</a>
) : null}
{ql.netdata ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.netdata}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={ql.netdata} target="_blank" rel="noreferrer">
📈 Netdata
</a>
) : null}
{ql.srs_http ? (
<a
className="btn-focus rounded-2xl border border-white/10 bg-black/20 px-4 py-2.5 text-sm text-slate-100 [html[data-theme=light]_&]:border-slate-200 [html[data-theme=light]_&]:bg-white/60 [html[data-theme=light]_&]:text-slate-800"
href={ql.srs_http}
target="_blank"
rel="noreferrer"
>
<a className="link-tile" href={ql.srs_http} target="_blank" rel="noreferrer">
🎞 SRS HTTP
</a>
) : null}
@@ -928,8 +899,8 @@ export default function Home() {
</article>
<article className="glass rounded-3xl p-6 xl:col-span-4">
<h2 className="text-lg font-semibold text-white">{tr("节点状态", "Node Status")}</h2>
<div className="mt-4 rounded-2xl border border-white/8 bg-black/20 p-4 text-sm text-slate-300">
<h2 className="section-title">{tr("节点状态", "Node Status")}</h2>
<div className="panel-inset mt-4">
<p>{stack?.mdns_host || "live.local"}</p>
<p className="mt-2">IP: {(stack?.ips || []).join(" / ") || "-"}</p>
<p className="mt-2">{stack?.machine || "-"}</p>
@@ -938,8 +909,9 @@ export default function Home() {
</article>
<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>
<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">
<h2 className="section-title">{tr("系统快照", "System Snapshot")}</h2>
<p className="section-lead">{tr("总览页每 10 秒自动刷新服务与监控数据。", "Overview auto-refreshes every 10s.")}</p>
<div className="panel-inset mt-4">
<p>
CPU:{" "}
{[snapshot?.cpu_load?.["1m"], snapshot?.cpu_load?.["5m"], snapshot?.cpu_load?.["15m"]]
@@ -955,10 +927,10 @@ export default function Home() {
</section>
<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">
{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>
<h3 className="text-base font-semibold text-white">{item.label}</h3>
@@ -979,12 +951,32 @@ export default function Home() {
{tab === "business" ? (
<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">
<h2 className="text-lg font-semibold text-white">{tr("业务运行时", "Business Runtime")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("这里只放业务脚本本身Hub 基座可单独运行。", "Only business processes live here.")}</p>
<h2 className="section-title">{tr("业务运行时", "Business Runtime")}</h2>
<p className="section-lead">{tr("这里只放业务脚本本身Hub 基座可单独运行。", "Only business processes live here.")}</p>
{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) => (
<option key={item.pm2} value={item.pm2}>{item.label} | {item.script}</option>
))}
@@ -1006,7 +998,7 @@ export default function Home() {
)}
</article>
<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">
<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>
@@ -1018,16 +1010,23 @@ export default function Home() {
{tab === "modules" ? (
<section className="grid gap-6 xl:grid-cols-12">
<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>
<h2 className="text-lg font-semibold text-white">{tr("系统模块", "System Modules")}</h2>
<p className="mt-2 text-xs text-slate-400">{tr("每个模块都独立启停和降级,避免一个服务异常拖垮整套业务。", "Each module starts and degrades independently.")}</p>
<h2 className="section-title">{tr("系统模块", "System Modules")}</h2>
<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>
<Button className="border border-white/12 bg-black/20" onClick={() => void refreshServices()}>{tr("刷新服务", "Refresh Services")}</Button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{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>
<h3 className="text-base font-semibold text-white">{item.label}</h3>