mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 00:26:37 +08:00
logs
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user