Files
BiliNote/BillNote_frontend/src/components/BackendHealth/useBackendEvents.ts
huangjianwu 1329390f98 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>
2026-05-09 14:27:02 +08:00

120 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react'
// 桌面端 Sidecar 健康度。监听 Tauri 侧的 backend-message / backend-error /
// backend-terminated / backend-restarted 事件,把 stdout/stderr 缓冲成 ring buffer
// 同时维护进程运行状态。
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
export type LogLevel = 'info' | 'error'
export interface LogEntry {
level: LogLevel
text: string
ts: number
}
export type BackendStatus = 'running' | 'terminated'
const MAX_LOG_LINES = 200
interface BackendEvents {
status: BackendStatus
exitCode: number | null
logs: LogEntry[]
/** 调 Tauri 命令重启 sidecar */
restart: () => Promise<void>
/** 复制全部日志到剪贴板 */
copyLogs: () => Promise<boolean>
isTauri: boolean
}
export function useBackendEvents(): BackendEvents {
const [status, setStatus] = useState<BackendStatus>('running')
const [exitCode, setExitCode] = useState<number | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
// 用 ref 持有最新 logs 数组append 时不被闭包陷阱卡到旧值
const logsRef = useRef<LogEntry[]>([])
function append(entry: LogEntry) {
const next = logsRef.current.concat(entry)
if (next.length > MAX_LOG_LINES)
next.splice(0, next.length - MAX_LOG_LINES)
logsRef.current = next
setLogs(next)
}
useEffect(() => {
if (!isTauri) return
let unlisteners: Array<() => void> = []
;(async () => {
const { listen } = await import('@tauri-apps/api/event')
const offMsg = await listen<string>('backend-message', event => {
append({ level: 'info', text: stripQuotes(event.payload), ts: Date.now() })
})
const offErr = await listen<string>('backend-error', event => {
append({ level: 'error', text: stripQuotes(event.payload), ts: Date.now() })
})
const offTerm = await listen<number | null>('backend-terminated', event => {
setStatus('terminated')
setExitCode(event.payload ?? null)
append({
level: 'error',
text: `[Backend terminated] code=${event.payload ?? 'unknown'}`,
ts: Date.now(),
})
})
const offRestart = await listen('backend-restarted', () => {
setStatus('running')
setExitCode(null)
append({ level: 'info', text: '[Backend restarted]', ts: Date.now() })
})
unlisteners = [offMsg, offErr, offTerm, offRestart]
})()
return () => {
unlisteners.forEach(fn => fn())
}
}, [])
async function restart() {
if (!isTauri) return
const { invoke } = await import('@tauri-apps/api/core')
try {
await invoke('restart_backend_sidecar')
}
catch (e) {
append({ level: 'error', text: `[Restart failed] ${(e as Error).message ?? e}`, ts: Date.now() })
throw e
}
}
async function copyLogs() {
const text = logsRef.current
.map(l => `${new Date(l.ts).toISOString().slice(11, 19)} ${l.level === 'error' ? 'E' : 'I'} ${l.text}`)
.join('\n')
try {
await navigator.clipboard.writeText(text)
return true
}
catch {
return false
}
}
return { status, exitCode, logs, restart, copyLogs, isTauri }
}
// Rust 早期版本 emit 时把 stdout 包了一层 '...',新版本已经直接 emit 原文。
// 这里做兼容:去掉外层单引号(如果有的话)。
function stripQuotes(s: string): string {
if (typeof s !== 'string') return String(s)
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'"))
return s.slice(1, -1)
return s
}