Files
HeXi/hexi/web/src/pages/logs/index.tsx
T

203 lines
7.7 KiB
TypeScript
Raw Normal View History

import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
import { Button, Card } from '@heroui/react'
import { hubLogs, hubLogStream } from '../../api/client'
const MAX_LINES = 2000
function fmtBytes(n: number): string {
if (!Number.isFinite(n)) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = n
let i = 0
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++ }
return v.toFixed(1) + ' ' + units[i]
}
// ANSI SGR 颜色码 → 内联样式,用于还原 loguru 彩色日志
const ANSI_FG: Record<number, string> = {
30: '#9ca3af', 31: '#f87171', 32: '#4ade80', 33: '#fbbf24', 34: '#60a5fa', 35: '#c084fc', 36: '#22d3ee', 37: '#e5e7eb',
90: '#6b7280', 91: '#f87171', 92: '#4ade80', 93: '#fbbf24', 94: '#60a5fa', 95: '#c084fc', 96: '#22d3ee', 97: '#f9fafb',
}
const ANSI_BG: Record<number, string> = {
40: '#1f2937', 41: '#7f1d1d', 42: '#14532d', 43: '#713f12', 44: '#1e3a8a', 45: '#581c87', 46: '#155e75', 47: '#111827',
100: '#374151', 101: '#7f1d1d', 102: '#14532d', 103: '#713f12', 104: '#1e3a8a', 105: '#581c87', 106: '#155e75', 107: '#111827',
}
// 当日志行不含 ANSI 颜色码(例如历史日志/旧进程写入的非彩色文本)时,
// 按 loguru 等级标记降级上色,确保 Web 日志查看器始终有颜色。
const LEVEL_COLORS: Record<string, string> = {
TRACE: '#c084fc',
DEBUG: '#60a5fa',
INFO: '#22d3ee',
SUCCESS: '#4ade80',
WARNING: '#fbbf24',
ERROR: '#f87171',
CRITICAL: '#f87171',
}
function parseAnsi(text: string): { text: string; style: CSSProperties }[] {
const out: { text: string; style: CSSProperties }[] = []
let fg: string | undefined
let bg: string | undefined
let bold = false
let underline = false
let run = ''
const flush = () => {
if (!run) return
const style: CSSProperties = {}
if (fg) style.color = fg
if (bg) style.backgroundColor = bg
if (bold) style.fontWeight = 'bold'
if (underline) style.textDecoration = 'underline'
out.push({ text: run, style })
run = ''
}
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (ch === '\u001b' && text[i + 1] === '[') {
const end = text.indexOf('m', i)
if (end !== -1) {
const codes = text.slice(i + 2, end).split(';').map(s => parseInt(s, 10))
flush()
for (const c of codes) {
if (c === 0) { fg = undefined; bg = undefined; bold = false; underline = false }
else if (c === 1) bold = true
else if (c === 4) underline = true
else if (c >= 30 && c <= 37 || c >= 90 && c <= 97) fg = ANSI_FG[c]
else if (c >= 40 && c <= 47 || c >= 100 && c <= 107) bg = ANSI_BG[c]
else if (c === 39) fg = undefined
else if (c === 49) bg = undefined
}
i = end
continue
}
}
run += ch
}
flush()
return out
}
function AnsiLine({ text }: { text: string }) {
const parts = parseAnsi(text)
const hasAnsi = parts.some((p) => Boolean(p.style.color || p.style.backgroundColor || p.style.fontWeight || p.style.textDecoration))
if (hasAnsi) {
return <>{parts.map((p, i) => <span key={i} style={p.style}>{p.text}</span>)}</>
}
// 无 ANSI 时按等级标记整体上色,保证非彩色日志文件也能在 Web 里看到颜色。
const m = text.match(/\[(TRACE|DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\]/)
const color = m && LEVEL_COLORS[m[1]]
if (color) return <span style={{ color }}>{text}</span>
return <>{text}</>
}
export default function LogsPage() {
const [lines, setLines] = useState<string[]>([])
const [info, setInfo] = useState<{ file: string; size: number; total: number }>({ file: '', size: 0, total: 0 })
const [error, setError] = useState('')
const [streamStatus, setStreamStatus] = useState<'连接中' | '实时' | '重连中' | '已断开'>('连接中')
const [follow, setFollow] = useState(true)
const [paused, setPaused] = useState(false)
const [loading, setLoading] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sinceRef = useRef(0)
const pausedRef = useRef(false)
const pendingRef = useRef<string[]>([])
const disposeRef = useRef<(() => void) | null>(null)
const append = useCallback((line: string) => {
setLines(prev => [...prev, line].slice(-MAX_LINES))
}, [])
const connect = useCallback(() => {
if (disposeRef.current) disposeRef.current()
disposeRef.current = hubLogStream({
since: sinceRef.current,
onLine: (line) => {
if (pausedRef.current) { pendingRef.current.push(line); return }
append(line)
},
onStatus: (s) => setStreamStatus(s === 'open' ? '实时' : s === 'reconnecting' ? '重连中' : '已断开'),
})
}, [append])
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const d = await hubLogs(500)
if (!d) throw new Error('读取日志失败')
if (d.ok === false) throw new Error(d.msg || '读取日志失败')
setLines(d.lines || [])
setInfo({ file: d.file || '', size: d.size || 0, total: d.total || 0 })
sinceRef.current = d.offset || 0
connect()
} catch (e: any) {
setError(e.message || '读取日志失败')
} finally { setLoading(false) }
}, [connect])
useEffect(() => {
pausedRef.current = paused
}, [paused])
useEffect(() => {
// 恢复接收时,把暂停期间暂存的行补进来
if (!paused && pendingRef.current.length) {
const flush = pendingRef.current
pendingRef.current = []
setLines(prev => [...prev, ...flush].slice(-MAX_LINES))
}
}, [paused])
useEffect(() => {
refresh()
return () => { if (disposeRef.current) disposeRef.current() }
}, [refresh])
useEffect(() => {
if (follow && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [lines, follow])
const statusClass = streamStatus === '实时' ? 'text-emerald-600' : streamStatus === '重连中' ? 'text-amber-600' : 'text-slate-500'
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-800">查看日志</h2>
<p className="text-sm text-slate-400">实时读取 bot 运行日志(SSE 推送)</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="tertiary" onPress={() => setPaused(v => !v)}>{paused ? '恢复接收' : '暂停接收'}</Button>
<Button size="sm" variant="tertiary" onPress={() => setFollow(v => !v)}>{follow ? '停止跟随' : '跟随最新'}</Button>
</div>
</div>
{error && <Card className="border border-red-200 bg-red-50"><Card.Content><p className="text-sm text-red-600">{error}</p></Card.Content></Card>}
<Card className="border border-slate-200 bg-white">
<Card.Header>
<div className="flex w-full items-center justify-between">
<Card.Title>日志流</Card.Title>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className={statusClass}>● {streamStatus}</span>
{info.file && <span>{info.file} · {fmtBytes(info.size)} · 共 {info.total} 行</span>}
</div>
</div>
</Card.Header>
<Card.Content>
<div ref={scrollRef} className="h-[calc(100vh-320px)] min-h-[320px] overflow-auto rounded-lg bg-slate-900 p-3 font-mono text-xs leading-relaxed text-slate-100">
{lines.length ? lines.map((ln, i) => (
<div key={i} className="whitespace-pre-wrap break-all"><AnsiLine text={ln} /></div>
)) : (
<div className="text-slate-500">暂无日志…</div>
)}
</div>
</Card.Content>
</Card>
</div>
)
}