diff --git a/backend/api/routes/system.py b/backend/api/routes/system.py index 2f22849..658e52c 100644 --- a/backend/api/routes/system.py +++ b/backend/api/routes/system.py @@ -187,6 +187,7 @@ def _get_redis_client_for_logs(): async def get_logs( limit: int = 200, group: str = "error", + start: int = 0, service: Optional[str] = None, level: Optional[str] = None, x_admin_token: Optional[str] = Header(default=None, alias="X-Admin-Token"), @@ -204,8 +205,11 @@ async def get_logs( if limit <= 0: limit = 200 - if limit > 2000: - limit = 2000 + if limit > 20000: + limit = 20000 + + if start < 0: + start = 0 group = (group or "error").strip().lower() if group not in LOG_GROUPS: @@ -218,33 +222,71 @@ async def get_logs( raise HTTPException(status_code=503, detail="Redis 不可用,无法读取日志") try: - raw_items = client.lrange(list_key, 0, limit - 1) + llen_total = int(client.llen(list_key) or 0) except Exception as 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]] = [] - for raw in raw_items or []: - try: - obj = raw - if isinstance(raw, bytes): - obj = raw.decode("utf-8", errors="ignore") - if isinstance(obj, str): - parsed = json.loads(obj) - else: - continue - if not isinstance(parsed, dict): - continue - if service and str(parsed.get("service")) != service: - continue - if level and str(parsed.get("level")) != level: - continue - items.append(parsed) - except Exception: - continue + + try: + while len(items) < limit and pos < llen_total and pos < max_scan: + chunk_size = min(500, limit, max_scan - pos) + end = pos + chunk_size - 1 + raw_batch = client.lrange(list_key, pos, end) + scanned += len(raw_batch or []) + + for raw in raw_batch or []: + try: + obj = raw + if isinstance(raw, bytes): + obj = raw.decode("utf-8", errors="ignore") + if not isinstance(obj, str): + continue + parsed = json.loads(obj) + if not isinstance(parsed, dict): + 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 { "group": group, "key": list_key, + "start": start, + "limit": limit, + "llen_total": llen_total, + "scanned": scanned, + "next_start": pos, + "has_more": pos < llen_total, "count": len(items), "items": items, } diff --git a/frontend/src/components/LogMonitor.css b/frontend/src/components/LogMonitor.css index 4452903..0bd00dd 100644 --- a/frontend/src/components/LogMonitor.css +++ b/frontend/src/components/LogMonitor.css @@ -141,6 +141,44 @@ 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 { display: grid; grid-template-columns: 170px 140px 110px 1fr; diff --git a/frontend/src/components/LogMonitor.jsx b/frontend/src/components/LogMonitor.jsx index f9779d9..6bb42a1 100644 --- a/frontend/src/components/LogMonitor.jsx +++ b/frontend/src/components/LogMonitor.jsx @@ -24,6 +24,8 @@ export default function LogMonitor() { 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('') @@ -32,11 +34,11 @@ export default function LogMonitor() { const [refreshSec, setRefreshSec] = useState(5) const params = useMemo(() => { - const p = { limit: String(limit), group } + 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]) + }, [limit, service, level, group, pageStart]) const loadOverview = async () => { try { @@ -53,6 +55,12 @@ export default function LogMonitor() { 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 || '获取日志失败') @@ -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 (
{pageMeta.key || '-'}
+