mono/packages/ui/src/components/LogViewer.tsx
2026-01-20 10:34:09 +01:00

262 lines
11 KiB
TypeScript

import { useMemo, useState, useEffect, useRef } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Button } from './ui/button';
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { ListFilter, ArrowDownCircle, Trash2, Download, X } from 'lucide-react';
import { Input } from './ui/input';
import { toast } from 'sonner';
import { useLog, LogEntry } from '@/contexts/LogContext';
interface LogViewerProps {
onClose?: () => void;
logs?: LogEntry[];
clearLogs?: () => void;
title?: string;
sourceInfo?: React.ReactNode;
}
const LogViewer: React.FC<LogViewerProps> = ({ onClose, logs: propLogs, clearLogs: propClearLogs, title = "Activity Logs", sourceInfo }) => {
const context = useLog();
const logs = propLogs || context.logs;
const clearLogs = propClearLogs || context.clearLogs;
const [autoScrollEnabled, setAutoScrollEnabled] = useState(true);
const [activeTab, setActiveTab] = useState<string>('all');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const safeToString = (value: any): string => {
if (typeof value === 'string') return value;
if (value === null || typeof value === 'undefined') return '';
try {
return JSON.stringify(value);
} catch (e) {
return '[Unserializable object]';
}
};
const filteredLogs = useMemo(() => {
let filtered = logs;
if (searchTerm) {
const searchTerms = searchTerm.toLowerCase().split(' ').filter(term => term.trim() !== '');
if (searchTerms.length > 0) {
filtered = filtered.filter(log =>
searchTerms.every(term =>
(log.message && safeToString(log.message).toLowerCase().includes(term)) ||
(log.level && log.level.toLowerCase().includes(term)) ||
(log.category && safeToString(log.category).toLowerCase().includes(term))
)
);
}
}
return {
all: filtered,
info: filtered.filter(log => log.level === 'info'),
debug: filtered.filter(log => log.level === 'debug'),
warning: filtered.filter(log => log.level === 'warning'),
error: filtered.filter(log => log.level === 'error'),
success: filtered.filter(log => log.level === 'success'),
};
}, [logs, searchTerm]);
useEffect(() => {
if (autoScrollEnabled && messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'auto', block: 'end' });
}
}, [filteredLogs[activeTab as keyof typeof filteredLogs], activeTab, autoScrollEnabled]);
const handleDownloadJson = () => {
if (filteredLogs.all.length === 0) {
toast.info("No logs to download based on the current filter.");
return;
}
const jsonString = JSON.stringify(filteredLogs.all, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
a.download = `wizard-logs-${timestamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Filtered logs have been downloaded.");
};
const renderLogList = (level: keyof typeof filteredLogs) => (
<ScrollArea
ref={activeTab === level ? scrollAreaRef : undefined}
className="h-full w-full rounded-md border border-border bg-muted/30 p-2 md:p-3 font-mono text-[10px] sm:text-xs overflow-hidden"
>
{filteredLogs[level].length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No {level !== 'all' ? `${level} ` : ''}logs available.
</div>
) : (
<>
{filteredLogs[level].map((log) => (
<div key={log.id} className="whitespace-pre-wrap mb-1 break-words">
<span className="text-muted-foreground mr-1 sm:mr-2">
{log.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className={`mr-1 sm:mr-2 font-semibold ${getLogLevelColor(log.level)}`}>
[{log.level.toUpperCase()}]
</span>
{log.category && (
<span className="text-orange-400 mr-1 sm:mr-2">
[{safeToString(log.category)}]
</span>
)}
<span className="break-all">{safeToString(log.message)}</span>
</div>
))}
<div ref={messagesEndRef} />
</>
)}
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
);
const getLogLevelColor = (level: string) => {
switch (level) {
case 'error':
return 'text-red-500';
case 'warning':
return 'text-yellow-500';
case 'info':
return 'text-blue-500';
case 'debug':
return 'text-purple-400';
case 'success':
return 'text-green-500';
default:
return 'text-muted-foreground';
}
};
return (
<div className="border border-border rounded-lg p-2 md:p-4 bg-card flex flex-col h-full">
{/* Header - Mobile Optimized */}
<div className="flex flex-col gap-2 mb-3 md:mb-4">
{/* Title Row */}
<div className="flex items-center justify-between">
<h3 className="text-base md:text-lg font-bold flex items-center gap-2">
<ListFilter className="h-4 w-4 md:h-5 md:w-5" />
<span className="hidden sm:inline">{title}</span>
<span className="sm:hidden">Logs</span>
{sourceInfo}
</h3>
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 md:px-2 md:w-auto"
title="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Search Row */}
<Input
placeholder="Search logs..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full h-9 text-sm"
/>
{/* Controls Row */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center space-x-2">
<Switch
id="auto-scroll-switch"
checked={autoScrollEnabled}
onCheckedChange={setAutoScrollEnabled}
/>
<Label htmlFor="auto-scroll-switch" className="text-xs font-mono flex items-center gap-1 whitespace-nowrap">
<ArrowDownCircle className="h-3 w-3" />
<span className="hidden sm:inline">Auto Scroll</span>
<span className="sm:hidden">Auto</span>
</Label>
</div>
<div className="flex items-center gap-1.5">
<Button
variant="destructive"
size="sm"
onClick={() => {
clearLogs();
toast.success("Logs cleared");
}}
disabled={logs.length === 0}
className="h-8 w-8 p-0 md:w-auto md:px-3"
title="Clear logs"
>
<Trash2 className="h-3.5 w-3.5" />
<span className="hidden md:inline md:ml-1.5">Clear</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDownloadJson}
disabled={filteredLogs.all.length === 0}
className="h-8 w-8 p-0 md:w-auto md:px-3"
title="Download logs"
>
<Download className="h-3.5 w-3.5" />
<span className="hidden md:inline md:ml-1.5">Download</span>
</Button>
</div>
</div>
</div>
<Tabs
defaultValue="all"
className="w-full flex flex-col flex-grow min-h-0"
value={activeTab}
onValueChange={setActiveTab}
>
<TabsList className="grid w-full grid-cols-3 sm:grid-cols-6 mb-2 h-auto bg-muted/50 p-0.5">
<TabsTrigger value="all" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
All<span className="hidden sm:inline ml-0.5">({filteredLogs.all.length})</span>
</TabsTrigger>
<TabsTrigger value="info" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Info<span className="hidden sm:inline ml-0.5">({filteredLogs.info.length})</span>
</TabsTrigger>
<TabsTrigger value="success" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
OK<span className="hidden sm:inline ml-0.5">({filteredLogs.success.length})</span>
</TabsTrigger>
<TabsTrigger value="debug" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Debug<span className="hidden sm:inline ml-0.5">({filteredLogs.debug.length})</span>
</TabsTrigger>
<TabsTrigger value="warning" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Warn<span className="hidden sm:inline ml-0.5">({filteredLogs.warning.length})</span>
</TabsTrigger>
<TabsTrigger value="error" className="text-[11px] sm:text-xs px-1 sm:px-2 py-1.5">
Err<span className="hidden sm:inline ml-0.5">or ({filteredLogs.error.length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex-grow min-h-0"><div className="h-full">{renderLogList('all')}</div></TabsContent>
<TabsContent value="info" className="flex-grow min-h-0"><div className="h-full">{renderLogList('info')}</div></TabsContent>
<TabsContent value="success" className="flex-grow min-h-0"><div className="h-full">{renderLogList('success')}</div></TabsContent>
<TabsContent value="debug" className="flex-grow min-h-0"><div className="h-full">{renderLogList('debug')}</div></TabsContent>
<TabsContent value="warning" className="flex-grow min-h-0"><div className="h-full">{renderLogList('warning')}</div></TabsContent>
<TabsContent value="error" className="flex-grow min-h-0"><div className="h-full">{renderLogList('error')}</div></TabsContent>
</Tabs>
</div>
);
};
export default LogViewer;