343 lines
11 KiB
JavaScript
343 lines
11 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react'
|
||
import { api } from '../services/api'
|
||
import './LogMonitor.css'
|
||
|
||
const GROUPS = [
|
||
{ key: 'error', label: '错误' },
|
||
{ key: 'warning', label: '警告' },
|
||
{ key: 'info', label: '信息' },
|
||
]
|
||
|
||
const LEVELS = ['', 'ERROR', 'CRITICAL', 'WARNING', 'INFO']
|
||
const SERVICES = ['', 'backend', 'trading_system']
|
||
|
||
function formatCount(item) {
|
||
const c = Number(item?.count || 1)
|
||
return c > 1 ? `×${c}` : ''
|
||
}
|
||
|
||
export default function LogMonitor() {
|
||
const [items, setItems] = useState([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
const [group, setGroup] = useState('error')
|
||
const [overview, setOverview] = useState(null)
|
||
const [saving, setSaving] = useState(false)
|
||
const [pageStart, setPageStart] = useState(0)
|
||
const [pageMeta, setPageMeta] = useState({ key: '', llen_total: 0, has_more: false, next_start: 0 })
|
||
|
||
const [limit, setLimit] = useState(200)
|
||
const [service, setService] = useState('')
|
||
const [level, setLevel] = useState('')
|
||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||
const [refreshSec, setRefreshSec] = useState(5)
|
||
|
||
const params = useMemo(() => {
|
||
const p = { limit: String(limit), group, start: String(pageStart) }
|
||
if (service) p.service = service
|
||
if (level) p.level = level
|
||
return p
|
||
}, [limit, service, level, group, pageStart])
|
||
|
||
const loadOverview = async () => {
|
||
try {
|
||
const res = await api.getLogsOverview()
|
||
setOverview(res)
|
||
} catch (e) {
|
||
// 概览失败不阻塞日志列表
|
||
}
|
||
}
|
||
|
||
const load = async () => {
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const res = await api.getSystemLogs(params)
|
||
setItems(res?.items || [])
|
||
setPageMeta({
|
||
key: res?.key || '',
|
||
llen_total: Number(res?.llen_total || 0),
|
||
has_more: !!res?.has_more,
|
||
next_start: Number(res?.next_start || 0),
|
||
})
|
||
await loadOverview()
|
||
} catch (e) {
|
||
setError(e?.message || '获取日志失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
load()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [params])
|
||
|
||
useEffect(() => {
|
||
if (!autoRefresh) return
|
||
const sec = Number(refreshSec)
|
||
if (!sec || sec <= 0) return
|
||
const t = setInterval(() => load(), sec * 1000)
|
||
return () => clearInterval(t)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [autoRefresh, refreshSec, params])
|
||
|
||
const maxLen = overview?.config?.max_len || {}
|
||
const enabled = overview?.config?.enabled || {}
|
||
const llen = overview?.stats?.llen || {}
|
||
const addedToday = overview?.stats?.added_today || {}
|
||
const day = overview?.stats?.day || ''
|
||
|
||
const [maxLenDraft, setMaxLenDraft] = useState({ error: 2000, warning: 2000, info: 2000 })
|
||
useEffect(() => {
|
||
if (maxLen?.error || maxLen?.warning || maxLen?.info) {
|
||
setMaxLenDraft({
|
||
error: Number(maxLen.error || 2000),
|
||
warning: Number(maxLen.warning || 2000),
|
||
info: Number(maxLen.info || 2000),
|
||
})
|
||
}
|
||
}, [maxLen?.error, maxLen?.warning, maxLen?.info])
|
||
|
||
const saveConfig = async () => {
|
||
setSaving(true)
|
||
setError('')
|
||
try {
|
||
await api.updateLogsConfig({ max_len: maxLenDraft })
|
||
await loadOverview()
|
||
} catch (e) {
|
||
setError(e?.message || '更新日志配置失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const writeTest = async () => {
|
||
setError('')
|
||
try {
|
||
await api.writeLogsTest()
|
||
await load()
|
||
} catch (e) {
|
||
setError(e?.message || '写入测试日志失败')
|
||
}
|
||
}
|
||
|
||
const goFirstPage = () => setPageStart(0)
|
||
const goNextPage = () => setPageStart(pageMeta?.next_start || 0)
|
||
const goPrevPage = () => setPageStart(Math.max(0, pageStart - Number(limit || 200)))
|
||
|
||
return (
|
||
<div className="log-monitor">
|
||
<div className="log-header">
|
||
<div>
|
||
<h2>日志监控</h2>
|
||
<div className="log-subtitle">
|
||
来源:Redis List(分组存储 + 只保留最近 N 条 + 连续同类合并计数)
|
||
</div>
|
||
</div>
|
||
<div className="log-actions">
|
||
<button className="btn" onClick={load} disabled={loading}>
|
||
刷新
|
||
</button>
|
||
<button className="btn" onClick={writeTest} disabled={loading}>
|
||
写入测试日志
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="log-overview">
|
||
<div className="overview-row">
|
||
<div className="overview-title">今日统计 {day ? `(${day})` : ''}</div>
|
||
<div className="overview-items">
|
||
<span>error: {addedToday.error || 0} / {llen.error || 0}{enabled.error === false ? '(已停用)' : ''}</span>
|
||
<span>warning: {addedToday.warning || 0} / {llen.warning || 0}{enabled.warning === false ? '(已停用)' : ''}</span>
|
||
<span>info: {addedToday.info || 0} / {llen.info || 0}{enabled.info === false ? '(已停用)' : ''}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overview-row">
|
||
<div className="overview-title">最大条数(每类)</div>
|
||
<div className="overview-config">
|
||
<div className="mini">
|
||
<label>error</label>
|
||
<input
|
||
type="number"
|
||
min="100"
|
||
max="20000"
|
||
value={maxLenDraft.error}
|
||
onChange={(e) => setMaxLenDraft((s) => ({ ...s, error: Number(e.target.value || 2000) }))}
|
||
/>
|
||
</div>
|
||
<div className="mini">
|
||
<label>warning</label>
|
||
<input
|
||
type="number"
|
||
min="100"
|
||
max="20000"
|
||
value={maxLenDraft.warning}
|
||
onChange={(e) => setMaxLenDraft((s) => ({ ...s, warning: Number(e.target.value || 2000) }))}
|
||
/>
|
||
</div>
|
||
<div className="mini">
|
||
<label>info</label>
|
||
<input
|
||
type="number"
|
||
min="100"
|
||
max="20000"
|
||
value={maxLenDraft.info}
|
||
onChange={(e) => setMaxLenDraft((s) => ({ ...s, info: Number(e.target.value || 2000) }))}
|
||
/>
|
||
</div>
|
||
<button className="btn" onClick={saveConfig} disabled={saving}>
|
||
{saving ? '保存中...' : '保存配置'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="log-controls">
|
||
<div className="control">
|
||
<label>分组</label>
|
||
<select
|
||
value={group}
|
||
onChange={(e) => {
|
||
setGroup(e.target.value)
|
||
setPageStart(0)
|
||
}}
|
||
>
|
||
{GROUPS.map((g) => (
|
||
<option key={g.key} value={g.key}>
|
||
{g.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="control">
|
||
<label>条数</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="20000"
|
||
value={limit}
|
||
onChange={(e) => {
|
||
setLimit(Number(e.target.value || 200))
|
||
setPageStart(0)
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="control">
|
||
<label>服务</label>
|
||
<select value={service} onChange={(e) => setService(e.target.value)}>
|
||
{SERVICES.map((s) => (
|
||
<option key={s} value={s}>
|
||
{s || '全部'}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="control">
|
||
<label>级别</label>
|
||
<select value={level} onChange={(e) => setLevel(e.target.value)}>
|
||
{LEVELS.map((l) => (
|
||
<option key={l} value={l}>
|
||
{l || '全部'}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="control inline">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={autoRefresh}
|
||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||
/>
|
||
自动刷新
|
||
</label>
|
||
</div>
|
||
|
||
<div className="control">
|
||
<label>间隔(秒)</label>
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
max="60"
|
||
value={refreshSec}
|
||
onChange={(e) => setRefreshSec(Number(e.target.value || 5))}
|
||
disabled={!autoRefresh}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? <div className="log-error">{error}</div> : null}
|
||
|
||
<div className="log-paging">
|
||
<div className="paging-left">
|
||
<div className="paging-meta">
|
||
当前 key:<code>{pageMeta.key || '-'}</code>
|
||
</div>
|
||
<div className="paging-meta">
|
||
显示范围:
|
||
{pageMeta.llen_total
|
||
? `${pageStart + 1}-${pageStart + (items?.length || 0)} / ${pageMeta.llen_total}`
|
||
: '0 / 0'}
|
||
</div>
|
||
</div>
|
||
<div className="paging-actions">
|
||
<button className="btn" onClick={goFirstPage} disabled={loading || pageStart === 0}>
|
||
首页
|
||
</button>
|
||
<button className="btn" onClick={goPrevPage} disabled={loading || pageStart === 0}>
|
||
上一页
|
||
</button>
|
||
<button className="btn" onClick={goNextPage} disabled={loading || !pageMeta.has_more}>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="log-table">
|
||
<div className="log-row log-head">
|
||
<div className="c-time">时间</div>
|
||
<div className="c-svc">服务</div>
|
||
<div className="c-level">级别</div>
|
||
<div className="c-msg">内容</div>
|
||
</div>
|
||
|
||
{items.length === 0 ? (
|
||
<div className="log-empty">{loading ? '加载中...' : '暂无日志'}</div>
|
||
) : (
|
||
items.map((it, idx) => (
|
||
<div className="log-row" key={`${it.ts || ''}-${idx}`}>
|
||
<div className="c-time">{it.time || ''}</div>
|
||
<div className="c-svc">{it.service || ''}</div>
|
||
<div className="c-level">
|
||
<span className={`pill pill-${String(it.level || '').toLowerCase()}`}>{it.level}</span>
|
||
</div>
|
||
<div className="c-msg">
|
||
<div className="msg-line">
|
||
<span className="msg-text">{it.message || ''}</span>
|
||
<span className="msg-count">{formatCount(it)}</span>
|
||
</div>
|
||
{it.logger ? <div className="msg-meta">{it.logger}</div> : null}
|
||
{it.exc_text ? (
|
||
<details className="msg-details">
|
||
<summary>堆栈</summary>
|
||
<pre className="stack">{it.exc_text}</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|