284 lines
11 KiB
TypeScript
284 lines
11 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { X, FolderOpen, RefreshCw, ChevronDown, ChevronUp, Download, Search } from 'lucide-react'
|
|
import { Button } from './ui/button'
|
|
import { logger } from '../lib/logger'
|
|
import { getBackendCredentials } from '../lib/backend'
|
|
|
|
interface LogViewerProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
embedded?: boolean
|
|
}
|
|
|
|
// Wraps every case-insensitive occurrence of `query` in the line with a <mark>,
|
|
// keeping all other text as-is -- marks matches instead of hiding non-matching
|
|
// lines, so surrounding context stays visible while searching.
|
|
function highlightMatches(line: string, query: string): React.ReactNode {
|
|
if (!query) return line
|
|
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const parts = line.split(new RegExp(`(${escaped})`, 'gi'))
|
|
if (parts.length === 1) return line
|
|
// String.split with a capturing group alternates [text, match, text, match, ...] --
|
|
// odd indices are always the captured matches.
|
|
return parts.map((part, i) =>
|
|
i % 2 === 1
|
|
? <mark key={i} className="bg-yellow-500/70 text-black rounded-sm px-0.5">{part}</mark>
|
|
: <span key={i}>{part}</span>,
|
|
)
|
|
}
|
|
|
|
export function LogViewer({ isOpen, onClose, embedded = false }: LogViewerProps) {
|
|
const [logs, setLogs] = useState<string[]>([])
|
|
const [logPath, setLogPath] = useState('')
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [autoScroll, setAutoScroll] = useState(true)
|
|
const [searchInput, setSearchInput] = useState('')
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [tokenCopied, setTokenCopied] = useState(false)
|
|
const logContainerRef = useRef<HTMLDivElement>(null)
|
|
// Autoscroll doesn't make sense while browsing search matches -- remember
|
|
// the user's prior preference so clearing the search restores it exactly.
|
|
const savedAutoScrollRef = useRef<boolean | null>(null)
|
|
|
|
// Debounce the raw input so typing doesn't fire an IPC round-trip (a full
|
|
// untruncated log read while searching) on every keystroke.
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setSearchQuery(searchInput.trim()), 300)
|
|
return () => clearTimeout(timer)
|
|
}, [searchInput])
|
|
|
|
const fetchLogs = async () => {
|
|
if (!window.electronAPI?.getLogs) return
|
|
|
|
setIsLoading(true)
|
|
try {
|
|
const result = await window.electronAPI.getLogs({ query: searchQuery || undefined })
|
|
setLogs(result.lines || [])
|
|
setLogPath(result.logPath || '')
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch logs: ${error}`)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
fetchLogs()
|
|
// Live-tail only when NOT searching. A search reads the whole session file and
|
|
// ships every line over IPC (then re-highlights each), so polling that every 2s is a
|
|
// large CPU/IPC cost for no gain -- results are a point-in-time view. Fetch once per
|
|
// query change instead (this effect re-runs on searchQuery), and skip the interval.
|
|
if (searchQuery) return
|
|
const interval = setInterval(fetchLogs, 2000)
|
|
return () => clearInterval(interval)
|
|
}, [isOpen, searchQuery])
|
|
|
|
const matchCount = searchQuery
|
|
? logs.filter(l => l.toLowerCase().includes(searchQuery.toLowerCase())).length
|
|
: 0
|
|
|
|
useEffect(() => {
|
|
const isSearching = Boolean(searchQuery)
|
|
if (isSearching && savedAutoScrollRef.current === null) {
|
|
savedAutoScrollRef.current = autoScroll
|
|
setAutoScroll(false)
|
|
} else if (!isSearching && savedAutoScrollRef.current !== null) {
|
|
setAutoScroll(savedAutoScrollRef.current)
|
|
savedAutoScrollRef.current = null
|
|
}
|
|
// autoScroll intentionally excluded -- this only reacts to searchQuery
|
|
// transitions, not to the manual toggle (which is disabled while searching anyway).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchQuery])
|
|
|
|
useEffect(() => {
|
|
if (autoScroll && logContainerRef.current) {
|
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
|
}
|
|
}, [logs, autoScroll])
|
|
|
|
const handleOpenFolder = async () => {
|
|
if (window.electronAPI?.openLogFolder) {
|
|
await window.electronAPI.openLogFolder()
|
|
}
|
|
}
|
|
|
|
// Copy the backend Bearer token so local QA/perf scripts can authenticate
|
|
// against the Electron-spawned backend (its token is random per launch).
|
|
// Surfaced via a semi-hidden control in the footer (see below).
|
|
const handleCopyToken = async () => {
|
|
try {
|
|
const { token } = await getBackendCredentials()
|
|
if (!token) {
|
|
logger.error('No backend token available to copy')
|
|
return
|
|
}
|
|
await navigator.clipboard.writeText(token)
|
|
setTokenCopied(true)
|
|
setTimeout(() => setTokenCopied(false), 1200)
|
|
} catch (error) {
|
|
logger.error(`Failed to copy backend token: ${error}`)
|
|
}
|
|
}
|
|
|
|
const handleDownload = () => {
|
|
const content = logs.join('\n')
|
|
const blob = new Blob([content], { type: 'text/plain' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = logPath ? logPath.split(/[/\\]/).pop() || 'session.log' : 'session.log'
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
const panel = (
|
|
<div className={`bg-zinc-900 rounded-lg border border-zinc-700 w-full ${embedded ? 'h-full' : 'max-w-5xl h-[80vh]'} flex flex-col`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-zinc-700">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-semibold text-white">Logs</h2>
|
|
<span className="text-xs text-zinc-500 font-mono truncate max-w-[300px]" title={logPath}>
|
|
{logPath}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleDownload}
|
|
disabled={logs.length === 0}
|
|
className="text-zinc-400 hover:text-white"
|
|
title="Download logs"
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleOpenFolder}
|
|
className="text-zinc-400 hover:text-white"
|
|
title="Open log folder"
|
|
>
|
|
<FolderOpen className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={fetchLogs}
|
|
disabled={isLoading}
|
|
className="text-zinc-400 hover:text-white"
|
|
title="Refresh logs"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setAutoScroll(!autoScroll)}
|
|
disabled={Boolean(searchQuery)}
|
|
className={`${autoScroll ? 'text-blue-400' : 'text-zinc-400'} hover:text-white disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
title={searchQuery ? 'Auto-scroll disabled while searching' : autoScroll ? 'Auto-scroll enabled' : 'Auto-scroll disabled'}
|
|
>
|
|
{autoScroll ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
|
</Button>
|
|
{!embedded && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onClose}
|
|
className="text-zinc-400 hover:text-white"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800">
|
|
<Search className="h-3.5 w-3.5 text-zinc-500 flex-shrink-0" />
|
|
<input
|
|
type="text"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
placeholder="Search logs (case-insensitive)..."
|
|
className="flex-1 bg-transparent text-xs text-zinc-200 placeholder:text-zinc-600 focus:outline-none"
|
|
/>
|
|
{searchQuery && (
|
|
<span className="text-xs text-zinc-500 flex-shrink-0">
|
|
{matchCount} matching line{matchCount === 1 ? '' : 's'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Log content */}
|
|
<div
|
|
ref={logContainerRef}
|
|
className="flex-1 overflow-auto p-4 font-mono text-xs bg-black"
|
|
>
|
|
{logs.length === 0 ? (
|
|
<div className="text-zinc-500 text-center py-8">
|
|
No logs yet...
|
|
</div>
|
|
) : (
|
|
<div className="space-y-0.5">
|
|
{logs.map((line, index) => {
|
|
// Color code log levels
|
|
let lineClass = 'text-zinc-300'
|
|
if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
|
|
lineClass = 'text-red-400'
|
|
} else if (line.includes(' - WARNING - ')) {
|
|
lineClass = 'text-yellow-400'
|
|
} else if (line.includes(' - INFO - ')) {
|
|
lineClass = 'text-blue-300'
|
|
} else if (line.includes(' - DEBUG - ')) {
|
|
lineClass = 'text-zinc-500'
|
|
}
|
|
|
|
return (
|
|
<div key={index} className={`${lineClass} whitespace-pre-wrap break-all`}>
|
|
{highlightMatches(line, searchQuery)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between p-3 border-t border-zinc-700 text-xs text-zinc-500">
|
|
<span>{logs.length} lines {searchQuery ? '(full session)' : '(last 200)'}</span>
|
|
<div className="flex items-center gap-3">
|
|
{/* Semi-hidden: copies the backend Bearer token for local QA/perf scripts
|
|
(the Electron-spawned backend's token is random per launch). Muted until
|
|
hover so it stays out of the way. */}
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCopyToken()}
|
|
className="font-mono tracking-widest text-zinc-700 hover:text-zinc-300 transition-colors"
|
|
title="Copy backend auth token"
|
|
aria-label="Copy backend auth token"
|
|
>
|
|
{tokenCopied ? 'copied!' : '•••••'}
|
|
</button>
|
|
<span>Auto-refreshing every 2s</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (embedded) {
|
|
return panel
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4">
|
|
{panel}
|
|
</div>
|
|
)
|
|
}
|