"use client" import {useCallback, useEffect, useRef, useState} from "react" import {toast} from "sonner" import {ArrowDown, ChevronUp, Loader2, Pause, Play, Terminal} from "lucide-react" import {AdminService} from "@/lib/services" import {ErrorInline} from "@/components/layout/error" import {LoadingStateWithBorder} from "@/components/layout/loading" import {Badge} from "@/components/ui/badge" import {Button} from "@/components/ui/button" interface LogEntry { index: number data: string } function getApiBaseUrl(): string { return process.env.NEXT_PUBLIC_LINUX_DO_CREDIT_BACKEND_URL || "" } function buildWsUrl(): string { const base = getApiBaseUrl() const wsBase = base.replace(/^http/, "ws") return `${wsBase}/api/v1/admin/logs/ws` } function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" { const lower = line.toLowerCase() if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error" if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn" if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug" if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info" return "unknown" } // Distance (px) from bottom to treat as "at bottom" — keeps auto-scroll // active even when the user's last row is a few pixels shy of the edge. const BOTTOM_THRESHOLD = 40 export function SystemLogs() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [logs, setLogs] = useState([]) const [hasMore, setHasMore] = useState(false) const [nextCursor, setNextCursor] = useState(0) const [loadingMore, setLoadingMore] = useState(false) const [connected, setConnected] = useState(false) const [paused, setPaused] = useState(false) // autoScroll = true → new logs auto-scroll to bottom // autoScroll = false → user is browsing history, lock scroll position const [autoScroll, setAutoScroll] = useState(true) const containerRef = useRef(null) const wsRef = useRef(null) const pausedRef = useRef(paused) const autoScrollRef = useRef(autoScroll) const isUserScrolling = useRef(false) // Keep refs in sync for use inside callbacks without stale closures useEffect(() => { pausedRef.current = paused }, [paused]) useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll]) // ---- Scroll detection ------------------------------------------------ // We distinguish programmatic scrolls (triggered by our own auto-scroll or // load-history offset fixup) from user-initiated scrolls by gating with // the `isUserScrolling` flag. Only a user scroll can toggle `autoScroll`. const handleScroll = useCallback(() => { const el = containerRef.current if (!el) return // Ignore programmatic scrolls if (!isUserScrolling.current) return const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD if (atBottom && !autoScrollRef.current) { setAutoScroll(true) } else if (!atBottom && autoScrollRef.current) { setAutoScroll(false) } }, []) // Mark user-initiated scrolls const handleWheel = useCallback(() => { isUserScrolling.current = true }, []) // Touch devices const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, []) // After the user stops scrolling, reset the flag so our programmatic // scrolls won't accidentally toggle autoScroll. useEffect(() => { const el = containerRef.current if (!el) return let timer: ReturnType const onScrollEnd = () => { clearTimeout(timer) timer = setTimeout(() => { isUserScrolling.current = false }, 150) } el.addEventListener("scroll", onScrollEnd, { passive: true }) return () => { el.removeEventListener("scroll", onScrollEnd) clearTimeout(timer) } }, []) // ---- Auto-scroll to bottom when new logs arrive ---------------------- useEffect(() => { if (!autoScroll || !containerRef.current) return isUserScrolling.current = false const el = containerRef.current requestAnimationFrame(() => { el.scrollTop = el.scrollHeight }) }, [logs, autoScroll]) // ---- Data fetching --------------------------------------------------- const fetchLogs = useCallback(async (cursor: number = 0) => { try { return await AdminService.getLogs(cursor) } catch (err) { throw err instanceof Error ? err : new Error("获取日志失败") } }, []) const loadHistory = useCallback(async (cursor: number = 0) => { const isInitial = cursor === 0 if (isInitial) { setLoading(true) setError(null) } else { setLoadingMore(true) } try { const data = await fetchLogs(cursor) if (isInitial) { setLogs(data.lines || []) } else { // Prepend older logs; keep the viewport showing the same content // by restoring the scroll offset after the DOM update. setLogs(prev => [...(data.lines || []), ...prev]) // Wait for React to render, then compensate scroll position requestAnimationFrame(() => { const el = containerRef.current if (!el) return // The number of new rows prepended × approximate line height const newCount = (data.lines || []).length const lineH = 20 // matches leading-5 ≈ 20px isUserScrolling.current = false el.scrollTop = el.scrollTop + newCount * lineH }) } setHasMore(data.has_more) setNextCursor(data.next_cursor) } catch (err) { if (isInitial) { setError(err instanceof Error ? err : new Error("获取日志失败")) } else { toast.error("加载更早日志失败") } } finally { if (isInitial) setLoading(false) else setLoadingMore(false) } }, [fetchLogs]) // ---- WebSocket ------------------------------------------------------- const connectWs = useCallback(() => { if (wsRef.current) wsRef.current.close() const ws = new WebSocket(buildWsUrl()) wsRef.current = ws ws.onopen = () => { setConnected(true) } ws.onmessage = (event) => { if (pausedRef.current) return try { const msg = JSON.parse(event.data) if (msg.type === "log" && msg.data) { const entry: LogEntry = msg.data setLogs(prev => { const next = [...prev, entry] return next.length > 2000 ? next.slice(-2000) : next }) } } catch { /* ignore */ } } ws.onclose = () => { setConnected(false); wsRef.current = null } ws.onerror = () => { setConnected(false) } }, []) // ---- Initialize ------------------------------------------------------ useEffect(() => { loadHistory(0).then(() => connectWs()) return () => { wsRef.current?.close() wsRef.current = null } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // ---- Actions --------------------------------------------------------- const scrollToBottom = useCallback(() => { setAutoScroll(true) requestAnimationFrame(() => { if (containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight } }) }, []) const togglePause = useCallback(() => setPaused(p => !p), []) const reconnect = useCallback(() => connectWs(), [connectWs]) const handleLoadMore = useCallback(() => { if (nextCursor > 0) loadHistory(nextCursor) }, [nextCursor, loadHistory]) // ---- Render ---------------------------------------------------------- if (loading) return if (error) return loadHistory(0)} /> return (
{/* Header */}

系统日志

{connected ? "已连接" : "未连接"} {connected && ( )} {!connected && ( )}
{/* Log viewer — fixed height, scrollable */}
{/* Load older logs */} {hasMore && (
)} {/* Log lines */}
{logs.length === 0 ? (
暂无日志
) : ( logs.map((entry) => { const level = parseLogLevel(entry.data) const color = level === "error" ? "text-red-400" : level === "warn" ? "text-yellow-400" : level === "debug" ? "text-gray-500" : "text-gray-300" return (
{entry.data}
) }) )}
{/* Floating "back to latest" button */} {!autoScroll && (
)}
) }