feat(desktop): Sidecar 健康度面板 + 重启后端能力

P1 已经把 backend-warning / backend-terminated 横幅做出来了;P2 把
lib.rs 那条 stdout/stderr/terminated 信息流真正落到一个常驻 UI 上:
- 右下角浮动状态点(绿/黄/红),轮询 /api/sys_health 决定颜色
- 点开抽屉看最近 200 行日志(ring buffer),含「重启后端」「复制日志」按钮

Rust:
- src-tauri/src/lib.rs:把 sidecar 启动抽出 spawn_backend_sidecar(),
  CommandChild 存进 SidecarHandle(Mutex<Option<CommandChild>>) 这个 state
- 新增 #[tauri::command] restart_backend_sidecar:kill 旧 child + 重新 spawn +
  emit 'backend-restarted' 给前端
- 监听任务 stdout/stderr emit 时不再用 format!("'{}'", ...) 包引号,原文直传;
  前端 hook 同时兼容旧形式(兜底剥引号)

前端:
- components/BackendHealth/useBackendEvents.ts:listen 四个事件 +
  ring buffer (MAX 200 行) + invoke restart + clipboard 复制日志
- BackendHealthIndicator.tsx:右下角浮动状态点,5s 轮询 /api/sys_health;
  连续 3 次失败或 backend-terminated 触发 → 红
- BackendLogPanel.tsx:右侧抽屉,深色 monospace 日志区 + 操作按钮
- 纯 web 环境(无 __TAURI_INTERNALS__)下静默不挂载

P3 / P4 还在路上。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-09 14:27:02 +08:00
parent 9a64a2da8e
commit 1329390f98
5 changed files with 450 additions and 70 deletions

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react'
import { useBackendEvents } from './useBackendEvents'
import BackendLogPanel from './BackendLogPanel'
// 健康度判定:
// - 绿sidecar running 且 /sys_health 通
// - 黄sidecar running 但 /sys_health 失败 (ffmpeg 缺等)
// - 红sidecar terminated 或 /sys_health 连续 3 次失败
type Health = 'green' | 'yellow' | 'red' | 'unknown'
const HEALTH_POLL_MS = 5000
const SYS_HEALTH_PATH = '/api/sys_health'
function backendBase(): string {
// 与 services/request.ts 用的一致
const fromEnv = (import.meta as any).env?.VITE_API_BASE_URL as string | undefined
return (fromEnv ?? '').replace(/\/$/, '')
}
const BackendHealthIndicator = () => {
const { status, isTauri, exitCode, logs, restart, copyLogs } = useBackendEvents()
const [open, setOpen] = useState(false)
const [healthCheckFailures, setHealthCheckFailures] = useState(0)
const [lastHealthOk, setLastHealthOk] = useState<boolean | null>(null)
// 仅在 Tauri 环境挂指示器;纯 web 用户由 useCheckBackend 接管
useEffect(() => {
if (!isTauri) return
let mounted = true
async function ping() {
try {
const res = await fetch(`${backendBase()}${SYS_HEALTH_PATH}`)
const ok = res.ok
if (!mounted) return
if (ok) {
setHealthCheckFailures(0)
setLastHealthOk(true)
}
else {
setHealthCheckFailures(c => c + 1)
setLastHealthOk(false)
}
}
catch {
if (!mounted) return
setHealthCheckFailures(c => c + 1)
setLastHealthOk(false)
}
}
ping()
const t = setInterval(ping, HEALTH_POLL_MS)
return () => {
mounted = false
clearInterval(t)
}
}, [isTauri])
if (!isTauri) return null
const health: Health = (() => {
if (status === 'terminated') return 'red'
if (healthCheckFailures >= 3) return 'red'
if (lastHealthOk === false) return 'yellow'
if (lastHealthOk === true) return 'green'
return 'unknown'
})()
const colorMap: Record<Health, string> = {
green: 'bg-green-500',
yellow: 'bg-amber-500',
red: 'bg-red-500',
unknown: 'bg-gray-400',
}
const labelMap: Record<Health, string> = {
green: '后端运行正常',
yellow: '后端运行中(部分检查未通过)',
red: status === 'terminated' ? `后端已退出 (code=${exitCode ?? 'unknown'})` : '后端无响应',
unknown: '后端状态未知',
}
return (
<>
<button
className="fixed right-3 bottom-3 z-[9998] flex items-center gap-2 rounded-full border bg-white px-3 py-1.5 text-xs shadow hover:shadow-md"
title={labelMap[health]}
onClick={() => setOpen(true)}
>
<span className={`inline-block h-2 w-2 rounded-full ${colorMap[health]}${health === 'red' || health === 'yellow' ? ' animate-pulse' : ''}`} />
<span className="text-gray-700"></span>
</button>
{open && (
<BackendLogPanel
status={status}
exitCode={exitCode}
logs={logs}
health={health}
onRestart={restart}
onCopyLogs={copyLogs}
onClose={() => setOpen(false)}
/>
)}
</>
)
}
export default BackendHealthIndicator