This commit is contained in:
薇薇安 2026-01-18 21:20:32 +08:00
parent 596b2ec788
commit 79526151f3
3 changed files with 152 additions and 26 deletions

View File

@ -187,6 +187,7 @@ def _get_redis_client_for_logs():
async def get_logs( async def get_logs(
limit: int = 200, limit: int = 200,
group: str = "error", group: str = "error",
start: int = 0,
service: Optional[str] = None, service: Optional[str] = None,
level: Optional[str] = None, level: Optional[str] = None,
x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"), x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"),
@ -204,8 +205,11 @@ async def get_logs(
if limit <= 0: if limit <= 0:
limit = 200 limit = 200
if limit > 2000: if limit > 20000:
limit = 2000 limit = 20000
if start < 0:
start = 0
group = (group or "error").strip().lower() group = (group or "error").strip().lower()
if group not in LOG_GROUPS: if group not in LOG_GROUPS:
@ -218,33 +222,71 @@ async def get_logs(
raise HTTPException(status_code=503, detail="Redis 不可用,无法读取日志") raise HTTPException(status_code=503, detail="Redis 不可用,无法读取日志")
try: try:
raw_items = client.lrange(list_key, 0, limit - 1) llen_total = int(client.llen(list_key) or 0)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"读取 Redis 日志失败: {e}") raise HTTPException(status_code=500, detail=f"读取 Redis 日志失败: {e}")
if llen_total <= 0:
return {
"group": group,
"key": list_key,
"start": start,
"limit": limit,
"llen_total": 0,
"next_start": start,
"has_more": False,
"count": 0,
"items": [],
}
# 分页扫描:为了支持 service/level 过滤,这里会向后多取一些直到凑够 limit 或到末尾
# 保护:最多扫描 limit*10 条,避免过滤太严格导致无限扫描
max_scan = min(llen_total, start + limit * 10)
pos = start
scanned = 0
items: list[Dict[str, Any]] = [] items: list[Dict[str, Any]] = []
for raw in raw_items or []:
try: try:
obj = raw while len(items) < limit and pos < llen_total and pos < max_scan:
if isinstance(raw, bytes): chunk_size = min(500, limit, max_scan - pos)
obj = raw.decode("utf-8", errors="ignore") end = pos + chunk_size - 1
if isinstance(obj, str): raw_batch = client.lrange(list_key, pos, end)
parsed = json.loads(obj) scanned += len(raw_batch or [])
else:
continue for raw in raw_batch or []:
if not isinstance(parsed, dict): try:
continue obj = raw
if service and str(parsed.get("service")) != service: if isinstance(raw, bytes):
continue obj = raw.decode("utf-8", errors="ignore")
if level and str(parsed.get("level")) != level: if not isinstance(obj, str):
continue continue
items.append(parsed) parsed = json.loads(obj)
except Exception: if not isinstance(parsed, dict):
continue continue
if service and str(parsed.get("service")) != service:
continue
if level and str(parsed.get("level")) != level:
continue
items.append(parsed)
if len(items) >= limit:
break
except Exception:
continue
pos = end + 1
except Exception as e:
raise HTTPException(status_code=500, detail=f"读取 Redis 日志失败: {e}")
return { return {
"group": group, "group": group,
"key": list_key, "key": list_key,
"start": start,
"limit": limit,
"llen_total": llen_total,
"scanned": scanned,
"next_start": pos,
"has_more": pos < llen_total,
"count": len(items), "count": len(items),
"items": items, "items": items,
} }

View File

@ -141,6 +141,44 @@
background: #fff; background: #fff;
} }
.log-paging {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 12px;
border: 1px solid #eee;
border-radius: 10px;
background: #fff;
}
.paging-left {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 260px;
}
.paging-meta {
font-size: 12px;
color: #333;
}
.paging-meta code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
padding: 2px 6px;
border-radius: 6px;
background: #f5f5f5;
border: 1px solid #eee;
}
.paging-actions {
display: flex;
gap: 8px;
align-items: center;
}
.log-row { .log-row {
display: grid; display: grid;
grid-template-columns: 170px 140px 110px 1fr; grid-template-columns: 170px 140px 110px 1fr;

View File

@ -24,6 +24,8 @@ export default function LogMonitor() {
const [group, setGroup] = useState('error') const [group, setGroup] = useState('error')
const [overview, setOverview] = useState(null) const [overview, setOverview] = useState(null)
const [saving, setSaving] = useState(false) 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 [limit, setLimit] = useState(200)
const [service, setService] = useState('') const [service, setService] = useState('')
@ -32,11 +34,11 @@ export default function LogMonitor() {
const [refreshSec, setRefreshSec] = useState(5) const [refreshSec, setRefreshSec] = useState(5)
const params = useMemo(() => { const params = useMemo(() => {
const p = { limit: String(limit), group } const p = { limit: String(limit), group, start: String(pageStart) }
if (service) p.service = service if (service) p.service = service
if (level) p.level = level if (level) p.level = level
return p return p
}, [limit, service, level, group]) }, [limit, service, level, group, pageStart])
const loadOverview = async () => { const loadOverview = async () => {
try { try {
@ -53,6 +55,12 @@ export default function LogMonitor() {
try { try {
const res = await api.getSystemLogs(params) const res = await api.getSystemLogs(params)
setItems(res?.items || []) 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() await loadOverview()
} catch (e) { } catch (e) {
setError(e?.message || '获取日志失败') setError(e?.message || '获取日志失败')
@ -105,6 +113,10 @@ export default function LogMonitor() {
} }
} }
const goFirstPage = () => setPageStart(0)
const goNextPage = () => setPageStart(pageMeta?.next_start || 0)
const goPrevPage = () => setPageStart(Math.max(0, pageStart - Number(limit || 200)))
return ( return (
<div className="log-monitor"> <div className="log-monitor">
<div className="log-header"> <div className="log-header">
@ -174,7 +186,13 @@ export default function LogMonitor() {
<div className="log-controls"> <div className="log-controls">
<div className="control"> <div className="control">
<label>分组</label> <label>分组</label>
<select value={group} onChange={(e) => setGroup(e.target.value)}> <select
value={group}
onChange={(e) => {
setGroup(e.target.value)
setPageStart(0)
}}
>
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<option key={g.key} value={g.key}> <option key={g.key} value={g.key}>
{g.label} {g.label}
@ -188,9 +206,12 @@ export default function LogMonitor() {
<input <input
type="number" type="number"
min="1" min="1"
max="2000" max="20000"
value={limit} value={limit}
onChange={(e) => setLimit(Number(e.target.value || 200))} onChange={(e) => {
setLimit(Number(e.target.value || 200))
setPageStart(0)
}}
/> />
</div> </div>
@ -242,6 +263,31 @@ export default function LogMonitor() {
{error ? <div className="log-error">{error}</div> : null} {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-table">
<div className="log-row log-head"> <div className="log-row log-head">
<div className="c-time">时间</div> <div className="c-time">时间</div>