P0(4): 假数据清除(avg_latency_ms:2400→null,无端点字段改null)、假进度条改诚实的不确定态、 公共页不再反向依赖 admin(api/public.ts)、PredictProgress 重写 P1(6): 23处 any 归零(对齐后端 Pydantic 契约新增 PredictionMatchRef/HealthProbe 等)、 a11y(aria-live 0→4,htmlFor 1→17,aria-describedby/invalid 补齐)、App.tsx 抽 SiteLayout、 路由级 lazy+代码分割(首屏 315KB→238KB)、index.html 补 SEO/favicon/OG、死代码清理(AdminIcon 抽出) P2(7): Login/index.css 裸色值令牌化、groupByDate useMemo、滚动监听统一、原生控件基元化、 useLeagues 静默失败补告警、路由级 ErrorBoundary 另修复审计未列问题: - Monitoring todos 过滤器 t!==false 放行 null 导致整页崩溃 → Boolean(t) 真值过滤 - Collection 渲染期 Date.now()(react-hooks/purity 捕获)→ 计时器 effect - bg-press-wash/60 透明度修饰符静默失效 → RGB 三元组 + 构建期令牌守卫(下个提交接入) 工程化:数据层 dal/api/types(1047行)git mv 至 src/api/,admin 留 @deprecated 兼容壳, 21 个引用方直指新路径;tsconfig 开启 noUnusedLocals/noUnusedParameters(清理9处存量)
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
/**
|
|
* Admin 后台 - 系统日志页面(报刊风)
|
|
*
|
|
* 查看应用运行日志(内存缓冲,最新在前):
|
|
* - 级别筛选 + 关键字搜索
|
|
* - 自动刷新(10s)可开关
|
|
* - 缓冲上限 2000 条,进程重启后清零
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
import { fetchLogs } from '../../api/dal'
|
|
import type { LogEntry } from '../../api/types'
|
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
|
import { Button, Input } from '../../components/ui'
|
|
|
|
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
|
|
|
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
|
|
DEBUG: { status: 'info', text: 'DEBUG' },
|
|
INFO: { status: 'info', text: 'INFO' },
|
|
WARNING: { status: 'warning', text: 'WARN' },
|
|
ERROR: { status: 'error', text: 'ERROR' },
|
|
CRITICAL: { status: 'error', text: 'FATAL' },
|
|
}
|
|
|
|
function fmtTs(ts: number): string {
|
|
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
|
|
export default function LogsPage() {
|
|
const [entries, setEntries] = useState<LogEntry[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [level, setLevel] = useState<string>('')
|
|
const [keyword, setKeyword] = useState('')
|
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
|
|
setEntries(d.entries)
|
|
setError('')
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [level, keyword])
|
|
|
|
// 筛选条件变化 → 立即拉取
|
|
useEffect(() => {
|
|
load()
|
|
}, [load])
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const userScrolledDown = useRef(false)
|
|
|
|
// 检测用户是否向下滚动回看历史(旧日志在下方)
|
|
const handleScroll = () => {
|
|
const el = scrollRef.current
|
|
if (!el) return
|
|
const atTop = el.scrollTop < 50
|
|
userScrolledDown.current = !atTop
|
|
}
|
|
|
|
// 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
|
|
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
|
|
useEffect(() => {
|
|
if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
|
|
scrollRef.current.scrollTop = 0
|
|
}
|
|
}, [entries, autoRefresh])
|
|
|
|
// 自动刷新
|
|
useEffect(() => {
|
|
if (timerRef.current) clearInterval(timerRef.current)
|
|
if (autoRefresh) {
|
|
timerRef.current = setInterval(load, 10_000)
|
|
}
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current)
|
|
}
|
|
}, [autoRefresh, load])
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="系统日志"
|
|
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
|
/>
|
|
|
|
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
|
|
|
<Card>
|
|
<CardHeader
|
|
title="日志查看"
|
|
description={entries.length > 0 ? `显示最新 ${entries.length} 条` : undefined}
|
|
action={
|
|
<div className="flex items-center gap-2">
|
|
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoRefresh}
|
|
onChange={e => setAutoRefresh(e.target.checked)}
|
|
className="accent-current"
|
|
/>
|
|
10s 自动刷新
|
|
</label>
|
|
<Button size="sm" onClick={load} disabled={loading}>
|
|
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
<CardBody className="px-0 sm:px-0">
|
|
{/* 筛选栏 */}
|
|
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{LEVELS.map(lv => (
|
|
<Button
|
|
key={lv || 'all'}
|
|
onClick={() => setLevel(lv)}
|
|
variant={level === lv ? 'solid' : 'default'}
|
|
size="sm"
|
|
>
|
|
{lv || '全部'}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
<Input
|
|
value={keyword}
|
|
onChange={e => setKeyword(e.target.value)}
|
|
placeholder="搜索关键字(消息 / logger)…"
|
|
aria-label="搜索日志关键字"
|
|
className="w-full sm:w-64"
|
|
/>
|
|
</div>
|
|
|
|
{/* 日志列表 */}
|
|
{loading && entries.length === 0 ? (
|
|
<div className="space-y-2 px-4 py-3 sm:px-5">
|
|
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
</div>
|
|
) : entries.length === 0 ? (
|
|
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
|
) : (
|
|
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
|
|
{entries.map((e, i) => {
|
|
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
|
return (
|
|
<div
|
|
key={`${e.ts}-${i}`}
|
|
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
|
|
>
|
|
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
|
|
<span className="w-14 flex-shrink-0">
|
|
<Badge status={badge.status}>{badge.text}</Badge>
|
|
</span>
|
|
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
|
|
{e.logger}
|
|
</span>
|
|
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
|
|
{e.message}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|