mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 06:16:37 +08:00
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
"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>
|
||
)
|
||
}
|