262 lines
11 KiB
TypeScript
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;
|
|
|