mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-12 19:20:00 +08:00
Merge pull request #362 from JefferyHcool/feat/desktop-backend-health
feat(desktop): Sidecar 健康度面板 + 重启后端能力
This commit is contained in:
@@ -6,6 +6,7 @@ import { useCheckBackend } from '@/hooks/useCheckBackend.ts'
|
||||
import { systemCheck } from '@/services/system.ts'
|
||||
import BackendInitDialog from '@/components/BackendInitDialog'
|
||||
import StartupBanner from '@/components/SystemDiagnostic/StartupBanner'
|
||||
import BackendHealthIndicator from '@/components/BackendHealth/BackendHealthIndicator'
|
||||
import Index from '@/pages/Index.tsx'
|
||||
import { HomePage } from './pages/HomePage/Home.tsx'
|
||||
|
||||
@@ -45,6 +46,7 @@ function App() {
|
||||
return (
|
||||
<>
|
||||
<StartupBanner />
|
||||
<BackendHealthIndicator />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div className="flex h-screen items-center justify-center">加载中…</div>}>
|
||||
<Routes>
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { LogEntry, BackendStatus } from './useBackendEvents'
|
||||
|
||||
interface Props {
|
||||
status: BackendStatus
|
||||
exitCode: number | null
|
||||
logs: LogEntry[]
|
||||
health: 'green' | 'yellow' | 'red' | 'unknown'
|
||||
onRestart: () => Promise<void>
|
||||
onCopyLogs: () => Promise<boolean>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const BackendLogPanel = ({ status, exitCode, logs, health, onRestart, onCopyLogs, onClose }: Props) => {
|
||||
const [restarting, setRestarting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 新日志进来自动滚到底
|
||||
useEffect(() => {
|
||||
if (scrollRef.current)
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}, [logs])
|
||||
|
||||
async function handleRestart() {
|
||||
setRestarting(true)
|
||||
try { await onRestart() }
|
||||
catch { /* errors already in log via useBackendEvents */ }
|
||||
finally { setRestarting(false) }
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const ok = await onCopyLogs()
|
||||
setCopied(ok)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 半透明遮罩 */}
|
||||
<div className="fixed inset-0 z-[9998] bg-black/20" onClick={onClose} />
|
||||
|
||||
<aside className="fixed right-0 bottom-0 top-0 z-[9999] flex w-[480px] max-w-[90vw] flex-col border-l bg-white shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">后端运行状态</h2>
|
||||
<div className="mt-0.5 text-xs text-gray-500">
|
||||
{status === 'terminated'
|
||||
? `已退出(退出码 ${exitCode ?? 'unknown'})`
|
||||
: health === 'red'
|
||||
? '运行中但无响应'
|
||||
: health === 'yellow'
|
||||
? '运行中,部分系统检查未通过'
|
||||
: '运行正常'}
|
||||
</div>
|
||||
</div>
|
||||
<button className="rounded p-1 text-gray-500 hover:bg-gray-100" onClick={onClose}>✕</button>
|
||||
</header>
|
||||
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<button
|
||||
className="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
disabled={restarting}
|
||||
onClick={handleRestart}
|
||||
>
|
||||
{restarting ? '重启中…' : '重启后端'}
|
||||
</button>
|
||||
<button
|
||||
className="rounded bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? '已复制 ✓' : '复制日志'}
|
||||
</button>
|
||||
<span className="ml-auto text-xs text-gray-400">
|
||||
最近 {logs.length} 行
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-auto bg-gray-900 p-3 font-mono text-xs text-gray-100"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-gray-500 italic">暂无日志输出</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div
|
||||
key={`${l.ts}-${i}`}
|
||||
className={`whitespace-pre-wrap break-all leading-snug ${l.level === 'error' ? 'text-red-300' : 'text-gray-100'}`}
|
||||
>
|
||||
<span className="mr-2 text-gray-500">
|
||||
{new Date(l.ts).toISOString().slice(11, 19)}
|
||||
</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-t px-4 py-2 text-xs text-gray-500">
|
||||
后端进程退出 / 无响应时,先点「重启后端」;仍不行复制日志去 issue 反馈。
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BackendLogPanel
|
||||
@@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user