'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { LogEntry, LogsApiResponse, ConfigApiResponse } from '../../types';
function parseLogLine(line: string): LogEntry | null {
const match = line.match(/\[([^\]]+)\] \[([^\]]+)\] (.+)/);
if (!match) return null;
const [, timestamp, source, message] = match;
const screenshot = message.match(/\[SCREENSHOT\] (.+)/)?.[1];
return {
timestamp,
source,
message,
screenshot,
original: line
};
}
function LogEntryComponent({ entry }: { entry: LogEntry }) {
return (
{new Date(entry.timestamp).toLocaleTimeString()}
{entry.source}
{entry.message}
{entry.screenshot && (
)}
);
}
interface LogsClientProps {
version: string;
}
export default function LogsClient({ version }: LogsClientProps) {
const [logs, setLogs] = useState([]);
const [mode, setMode] = useState<'head' | 'tail'>('tail');
const [isAtBottom, setIsAtBottom] = useState(true);
const [isLoadingNew, setIsLoadingNew] = useState(false);
const [lastLogCount, setLastLogCount] = useState(0);
const [lastFetched, setLastFetched] = useState(null);
const bottomRef = useRef(null);
const containerRef = useRef(null);
const pollIntervalRef = useRef(null);
const pollForNewLogs = async () => {
if (mode !== 'tail' || !isAtBottom) return;
try {
const response = await fetch('/api/logs/tail?lines=1000');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: LogsApiResponse = await response.json();
if (!data.logs) {
console.warn('No logs data in response');
return;
}
const entries = data.logs
.split('\n')
.filter((line: string) => line.trim())
.map(parseLogLine)
.filter((entry: LogEntry | null): entry is LogEntry => entry !== null);
if (entries.length > lastLogCount) {
setIsLoadingNew(true);
setLastFetched(new Date());
setTimeout(() => {
setLogs(entries);
setLastLogCount(entries.length);
setIsLoadingNew(false);
// Auto-scroll to bottom for new content
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, 50);
}, 250);
}
} catch (error) {
console.error('Error polling logs:', error);
// Don't spam console on network errors during polling
}
};
// Start/stop polling based on mode and scroll position
useEffect(() => {
if (mode === 'tail' && isAtBottom) {
pollIntervalRef.current = setInterval(pollForNewLogs, 2000); // Poll every 2 seconds
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
} else {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
}
}, [mode, isAtBottom, lastLogCount]);
const loadInitialLogs = async () => {
try {
const response = await fetch(`/api/logs/${mode}?lines=1000`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: LogsApiResponse = await response.json();
if (!data.logs) {
console.warn('No logs data in response');
setLogs([]);
return;
}
const entries = data.logs
.split('\n')
.filter((line: string) => line.trim())
.map(parseLogLine)
.filter((entry: LogEntry | null): entry is LogEntry => entry !== null);
setLogs(entries);
setLastLogCount(entries.length);
setLastFetched(new Date());
// Auto-scroll to bottom for tail mode
if (mode === 'tail') {
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
setIsAtBottom(true);
}, 100);
}
} catch (error) {
console.error('Error loading logs:', error);
setLogs([]);
}
};
useEffect(() => {
loadInitialLogs();
}, [mode]);
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
const handleScroll = () => {
if (containerRef.current) {
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const atBottom = scrollTop + clientHeight >= scrollHeight - 10;
setIsAtBottom(atBottom);
}
};
const filteredLogs = useMemo(() => {
return logs;
}, [logs]);
return (
🎭 dev-playwright
(v{version})
{logs.length} entries
{/* Mode Toggle */}
{/* Live indicator */}
{filteredLogs.length === 0 ? (
📝 No logs yet
Logs will appear here as your development server runs
) : (
{filteredLogs.map((entry, index) => (
))}
)}
{/* Footer with status and scroll indicator - full width like header */}
{isLoadingNew && (
)}
{!isLoadingNew && isAtBottom && lastFetched && (
Last updated {lastFetched.toLocaleTimeString()}
)}
{/* Scroll to bottom button - positioned on the right */}
{mode === 'tail' && !isAtBottom && !isLoadingNew && (
)}
);
}