This commit is contained in:
ryan
2026-06-08 16:28:18 +08:00
parent 5c7c995a95
commit c6cc8c3305
14 changed files with 1459 additions and 2 deletions
@@ -0,0 +1,334 @@
"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<Error | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
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<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(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<typeof setTimeout>
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 <LoadingStateWithBorder />
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/50">
<div className="flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">系统日志</h1>
</div>
<div className="flex items-center gap-2">
<Badge variant={connected ? "secondary" : "destructive"}>
{connected ? "已连接" : "未连接"}
</Badge>
{connected && (
<Button variant="outline" size="sm" onClick={togglePause}>
{paused
? <><Play className="size-3.5 mr-1.5" />恢复</>
: <><Pause className="size-3.5 mr-1.5" />暂停</>
}
</Button>
)}
{!connected && (
<Button variant="outline" size="sm" onClick={reconnect}>
重连
</Button>
)}
</div>
</div>
{/* Log viewer — fixed height, scrollable */}
<div
ref={containerRef}
onScroll={handleScroll}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
className="mt-3 h-[calc(100vh-220px)] overflow-y-auto overflow-x-hidden rounded-md border border-border/50 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
>
{/* Load older logs */}
{hasMore && (
<div className="sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm">
<Button
variant="ghost"
size="sm"
onClick={handleLoadMore}
disabled={loadingMore}
className="text-muted-foreground hover:text-foreground h-7 text-xs"
>
{loadingMore
? <><Loader2 className="size-3 mr-1.5 animate-spin" />加载中...</>
: <><ChevronUp className="size-3 mr-1.5" />加载更早日志</>
}
</Button>
</div>
)}
{/* Log lines */}
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="text-center text-gray-500 py-8">暂无日志</div>
) : (
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 (
<div
key={entry.index}
className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}
>
{entry.data}
</div>
)
})
)}
</div>
</div>
{/* Floating "back to latest" button */}
{!autoScroll && (
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20">
<Button
variant="outline"
size="sm"
onClick={scrollToBottom}
className="shadow-lg bg-background/80 backdrop-blur-sm"
>
<ArrowDown className="size-3.5 mr-1.5" />
回到最新
</Button>
</div>
)}
</div>
)
}