ui: maximize button on the log viewer
The LogViewer header now has a maximize toggle (and a clear button) — maximize expands it to a full-screen overlay (Esc to restore), so you can read service/ job logs without the 24rem cap. Consolidated the per-page 'Logs' Clear button into the viewer header (both service and job detail pages).
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
|
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
|
||||||
|
import { Maximize2, Minimize2, Trash2 } from "lucide-react"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
|
|
||||||
interface LogViewerProps {
|
interface LogViewerProps {
|
||||||
@@ -17,53 +18,83 @@ export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function Lo
|
|||||||
) {
|
) {
|
||||||
const [logs, setLogs] = useState<string[]>([])
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [maximized, setMaximized] = useState(false)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!follow) {
|
if (!follow) {
|
||||||
// Static fetch
|
|
||||||
apiClient
|
apiClient
|
||||||
.get<{ lines: string[] }>(`/logs/${name}?n=${lines}`)
|
.get<{ lines: string[] }>(`/logs/${name}?n=${lines}`)
|
||||||
.then((data) => setLogs(data.lines))
|
.then((data) => setLogs(data.lines))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE follow
|
|
||||||
const url = apiClient.streamUrl(`/logs/${name}?n=${lines}&follow=true`)
|
const url = apiClient.streamUrl(`/logs/${name}?n=${lines}&follow=true`)
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(url)
|
||||||
|
|
||||||
es.onopen = () => setConnected(true)
|
es.onopen = () => setConnected(true)
|
||||||
|
|
||||||
es.onmessage = (e) => {
|
es.onmessage = (e) => {
|
||||||
setLogs((prev) => {
|
setLogs((prev) => {
|
||||||
const next = [...prev, e.data]
|
const next = [...prev, e.data]
|
||||||
// Keep last 500 lines in memory
|
|
||||||
return next.length > 500 ? next.slice(-500) : next
|
return next.length > 500 ? next.slice(-500) : next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
es.onerror = () => setConnected(false)
|
es.onerror = () => setConnected(false)
|
||||||
|
|
||||||
return () => es.close()
|
return () => es.close()
|
||||||
}, [name, lines, follow])
|
}, [name, lines, follow])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({ clear: () => setLogs([]) }), [])
|
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])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [logs])
|
}, [logs])
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
"p-1 rounded text-[var(--muted)] hover:text-[var(--foreground)] hover:bg-white/10 transition-colors"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-black/40 border border-[var(--border)] rounded-lg overflow-hidden">
|
<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)]">
|
<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>
|
<span>logs: {name}</span>
|
||||||
{follow && (
|
{follow && (
|
||||||
<span className={connected ? "text-green-400" : "text-red-400"}>
|
<span className={connected ? "text-green-400" : "text-red-400"}>
|
||||||
{connected ? "streaming" : "disconnected"}
|
{connected ? "streaming" : "disconnected"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-0.5">
|
||||||
|
<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>
|
</div>
|
||||||
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-96 overflow-y-auto">
|
<pre
|
||||||
|
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 ? (
|
{logs.length === 0 ? (
|
||||||
<span className="text-[var(--muted)]">No logs yet</span>
|
<span className="text-[var(--muted)]">No logs yet</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useRef } from "react"
|
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { Clock, Trash2 } from "lucide-react"
|
import { Clock } from "lucide-react"
|
||||||
import { useJob, useStatus, useEventStream } from "@/services/api/hooks"
|
import { useJob, useStatus, useEventStream } from "@/services/api/hooks"
|
||||||
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
|
import { LogViewer } from "@/components/LogViewer"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ServiceControls } from "@/components/detail/ServiceControls"
|
import { ServiceControls } from "@/components/detail/ServiceControls"
|
||||||
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
||||||
@@ -10,7 +9,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
|||||||
|
|
||||||
export function ScheduledDetailPage() {
|
export function ScheduledDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
const logRef = useRef<LogViewerHandle>(null)
|
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data: deployment, isLoading, error, refetch } = useJob(name ?? "")
|
const { data: deployment, isLoading, error, refetch } = useJob(name ?? "")
|
||||||
const { data: statusResp } = useStatus()
|
const { data: statusResp } = useStatus()
|
||||||
@@ -74,16 +72,8 @@ export function ScheduledDetailPage() {
|
|||||||
|
|
||||||
{deployment.managed && (
|
{deployment.managed && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h2 className="text-lg font-semibold mb-3">Logs</h2>
|
||||||
<h2 className="text-lg font-semibold">Logs</h2>
|
<LogViewer name={deployment.id} />
|
||||||
<button
|
|
||||||
onClick={() => logRef.current?.clear()}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} /> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<LogViewer ref={logRef} name={deployment.id} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useRef } from "react"
|
|
||||||
import { useParams, Link } from "react-router-dom"
|
import { useParams, Link } from "react-router-dom"
|
||||||
import { Server, ExternalLink, Terminal, Trash2 } from "lucide-react"
|
import { Server, ExternalLink, Terminal } from "lucide-react"
|
||||||
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
|
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { HealthBadge } from "@/components/HealthBadge"
|
import { HealthBadge } from "@/components/HealthBadge"
|
||||||
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
|
import { LogViewer } from "@/components/LogViewer"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ServiceControls } from "@/components/detail/ServiceControls"
|
import { ServiceControls } from "@/components/detail/ServiceControls"
|
||||||
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
||||||
@@ -12,7 +11,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
|||||||
|
|
||||||
export function ServiceDetailPage() {
|
export function ServiceDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
const logRef = useRef<LogViewerHandle>(null)
|
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data: deployment, isLoading, error, refetch } = useService(name ?? "")
|
const { data: deployment, isLoading, error, refetch } = useService(name ?? "")
|
||||||
const { data: statusResp } = useStatus()
|
const { data: statusResp } = useStatus()
|
||||||
@@ -141,16 +139,8 @@ export function ServiceDetailPage() {
|
|||||||
|
|
||||||
{deployment.managed && (
|
{deployment.managed && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<h2 className="text-lg font-semibold mb-3">Logs</h2>
|
||||||
<h2 className="text-lg font-semibold">Logs</h2>
|
<LogViewer name={deployment.id} />
|
||||||
<button
|
|
||||||
onClick={() => logRef.current?.clear()}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} /> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<LogViewer ref={logRef} name={deployment.id} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user