The auto-scroll effect used bottomRef.scrollIntoView(), which walks the entire scroll-ancestor chain up to the window and scrolls the page to the log's bottom on every new line. Scroll the <pre> container directly via scrollTop instead, so only the log pane moves. Drop the now-unused bottom marker div and ref.
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
|
|
import { Maximize2, Minimize2, Pause, Play, Trash2 } from "lucide-react"
|
|
import { apiClient } from "@/services/api/client"
|
|
|
|
interface LogViewerProps {
|
|
name: string
|
|
lines?: number
|
|
follow?: boolean
|
|
}
|
|
|
|
export interface LogViewerHandle {
|
|
clear: () => void
|
|
}
|
|
|
|
export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function LogViewer(
|
|
{ name, lines = 50, follow = true },
|
|
ref,
|
|
) {
|
|
const [logs, setLogs] = useState<string[]>([])
|
|
const [connected, setConnected] = useState(false)
|
|
const [maximized, setMaximized] = useState(false)
|
|
// When true, new log lines scroll the view to the bottom. Scrolling up to read
|
|
// pauses it; scrolling back to the bottom (or the play button) resumes.
|
|
const [autoScroll, setAutoScroll] = useState(true)
|
|
const preRef = useRef<HTMLPreElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!follow) {
|
|
apiClient
|
|
.get<{ lines: string[] }>(`/logs/${name}?n=${lines}`)
|
|
.then((data) => setLogs(data.lines))
|
|
return
|
|
}
|
|
|
|
const url = apiClient.streamUrl(`/logs/${name}?n=${lines}&follow=true`)
|
|
const es = new EventSource(url)
|
|
es.onopen = () => setConnected(true)
|
|
es.onmessage = (e) => {
|
|
setLogs((prev) => {
|
|
const next = [...prev, e.data]
|
|
return next.length > 500 ? next.slice(-500) : next
|
|
})
|
|
}
|
|
es.onerror = () => setConnected(false)
|
|
return () => es.close()
|
|
}, [name, lines, follow])
|
|
|
|
const clear = () => setLogs([])
|
|
useImperativeHandle(ref, () => ({ clear }), [])
|
|
|
|
// Esc exits fullscreen.
|
|
useEffect(() => {
|
|
if (!maximized) return
|
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMaximized(false)
|
|
window.addEventListener("keydown", onKey)
|
|
return () => window.removeEventListener("keydown", onKey)
|
|
}, [maximized])
|
|
|
|
// Jump to the newest line only while auto-scroll is on. Scroll the <pre>
|
|
// container directly (not scrollIntoView, which would walk up to the window
|
|
// and jump the whole page). Instant, so the scroll-position check below never
|
|
// sees a mid-animation false "scrolled up".
|
|
useEffect(() => {
|
|
const el = preRef.current
|
|
if (autoScroll && el) el.scrollTop = el.scrollHeight
|
|
}, [logs, autoScroll])
|
|
|
|
// Keep auto-scroll in sync with where the user is: away from the bottom pauses,
|
|
// back at the bottom resumes — so the button and the scroll position never disagree.
|
|
const onScroll = () => {
|
|
const el = preRef.current
|
|
if (!el) return
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
|
setAutoScroll(atBottom)
|
|
}
|
|
|
|
const btn =
|
|
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
maximized
|
|
? "fixed inset-0 z-50 bg-[var(--background)] flex flex-col"
|
|
: "bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden"
|
|
}
|
|
>
|
|
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--border)] text-xs text-[var(--muted)]">
|
|
<span className="flex items-center gap-2">
|
|
<span>logs: {name}</span>
|
|
{follow && (
|
|
<span className={connected ? "text-green-400" : "text-red-400"}>
|
|
{connected ? "streaming" : "disconnected"}
|
|
</span>
|
|
)}
|
|
{follow && !autoScroll && <span className="text-amber-400">paused</span>}
|
|
</span>
|
|
<span className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={() => setAutoScroll((s) => !s)}
|
|
className={btn}
|
|
title={autoScroll ? "Pause auto-scroll" : "Resume auto-scroll (jump to latest)"}
|
|
>
|
|
{autoScroll ? <Pause size={13} /> : <Play size={13} />}
|
|
</button>
|
|
<button onClick={clear} className={btn} title="Clear">
|
|
<Trash2 size={13} />
|
|
</button>
|
|
<button
|
|
onClick={() => setMaximized((m) => !m)}
|
|
className={btn}
|
|
title={maximized ? "Restore (Esc)" : "Maximize"}
|
|
>
|
|
{maximized ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<pre
|
|
ref={preRef}
|
|
onScroll={onScroll}
|
|
className={`p-3 text-xs font-mono text-gray-300 ${
|
|
maximized ? "flex-1 overflow-auto" : "overflow-x-auto max-h-96 overflow-y-auto"
|
|
}`}
|
|
>
|
|
{logs.length === 0 ? (
|
|
<span className="text-[var(--muted)]">No logs yet</span>
|
|
) : (
|
|
logs.map((line, i) => (
|
|
<div key={i} className="leading-5 hover:bg-white/5">
|
|
{line}
|
|
</div>
|
|
))
|
|
)}
|
|
</pre>
|
|
</div>
|
|
)
|
|
})
|