Spaces:
Paused
Paused
| /** | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β LOG STREAM VIEWER β | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * β Real-time log streaming with filtering and syntax highlighting β | |
| * β Part of the Liquid UI Arsenal β | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| */ | |
| import { useEffect, useRef, useState } from 'react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { ScrollArea } from '@/components/ui/scroll-area'; | |
| import { cn } from '@/lib/utils'; | |
| import { | |
| Play, Pause, Trash2, Search, Filter, | |
| ChevronDown, AlertTriangle, Info, Bug, Zap | |
| } from 'lucide-react'; | |
| export interface LogEntry { | |
| id: string; | |
| timestamp: string; | |
| level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; | |
| source: string; | |
| message: string; | |
| metadata?: Record<string, unknown>; | |
| } | |
| export interface LogStreamViewerProps { | |
| logs: LogEntry[]; | |
| maxLines?: number; | |
| autoScroll?: boolean; | |
| showTimestamps?: boolean; | |
| showSource?: boolean; | |
| title?: string; | |
| onClear?: () => void; | |
| } | |
| const levelConfig = { | |
| debug: { icon: Bug, color: 'text-gray-400', bg: 'bg-gray-500/10', badge: 'bg-gray-500/20' }, | |
| info: { icon: Info, color: 'text-blue-400', bg: 'bg-blue-500/10', badge: 'bg-blue-500/20' }, | |
| warn: { icon: AlertTriangle, color: 'text-yellow-400', bg: 'bg-yellow-500/10', badge: 'bg-yellow-500/20' }, | |
| error: { icon: Zap, color: 'text-red-400', bg: 'bg-red-500/10', badge: 'bg-red-500/20' }, | |
| fatal: { icon: Zap, color: 'text-red-500', bg: 'bg-red-600/20', badge: 'bg-red-600/30' }, | |
| }; | |
| export function LogStreamViewer({ | |
| logs, | |
| maxLines = 500, | |
| autoScroll: initialAutoScroll = true, | |
| showTimestamps = true, | |
| showSource = true, | |
| title = 'System Logs', | |
| onClear, | |
| }: LogStreamViewerProps) { | |
| const scrollRef = useRef<HTMLDivElement>(null); | |
| const [autoScroll, setAutoScroll] = useState(initialAutoScroll); | |
| const [isPaused, setIsPaused] = useState(false); | |
| const [filter, setFilter] = useState(''); | |
| const [levelFilter, setLevelFilter] = useState<Set<string>>(new Set(['debug', 'info', 'warn', 'error', 'fatal'])); | |
| const [showFilters, setShowFilters] = useState(false); | |
| // Auto-scroll to bottom | |
| useEffect(() => { | |
| if (autoScroll && !isPaused && scrollRef.current) { | |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; | |
| } | |
| }, [logs, autoScroll, isPaused]); | |
| // Filter logs | |
| const filteredLogs = logs | |
| .filter(log => levelFilter.has(log.level)) | |
| .filter(log => | |
| !filter || | |
| log.message.toLowerCase().includes(filter.toLowerCase()) || | |
| log.source.toLowerCase().includes(filter.toLowerCase()) | |
| ) | |
| .slice(-maxLines); | |
| // Level counts | |
| const levelCounts = logs.reduce((acc, log) => { | |
| acc[log.level] = (acc[log.level] || 0) + 1; | |
| return acc; | |
| }, {} as Record<string, number>); | |
| const toggleLevel = (level: string) => { | |
| const newFilter = new Set(levelFilter); | |
| if (newFilter.has(level)) { | |
| newFilter.delete(level); | |
| } else { | |
| newFilter.add(level); | |
| } | |
| setLevelFilter(newFilter); | |
| }; | |
| const formatTimestamp = (ts: string) => { | |
| try { | |
| return new Date(ts).toLocaleTimeString('da-DK', { | |
| hour: '2-digit', | |
| minute: '2-digit', | |
| second: '2-digit', | |
| fractionalSecondDigits: 3 | |
| }); | |
| } catch { | |
| return ts; | |
| } | |
| }; | |
| return ( | |
| <div className="rounded-lg border border-border/30 bg-background/50 overflow-hidden flex flex-col"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border/30"> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-sm font-medium">{title}</span> | |
| <Badge variant="outline" className="text-[10px] font-mono"> | |
| {filteredLogs.length}/{logs.length} | |
| </Badge> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setIsPaused(!isPaused)} | |
| className={cn("h-7 px-2", isPaused && "text-yellow-500")} | |
| > | |
| {isPaused ? <Play className="w-3 h-3" /> : <Pause className="w-3 h-3" />} | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => setShowFilters(!showFilters)} | |
| className={cn("h-7 px-2", showFilters && "bg-muted")} | |
| > | |
| <Filter className="w-3 h-3" /> | |
| </Button> | |
| {onClear && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={onClear} | |
| className="h-7 px-2 text-red-400 hover:text-red-300" | |
| > | |
| <Trash2 className="w-3 h-3" /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Filter bar */} | |
| {showFilters && ( | |
| <div className="px-4 py-2 bg-muted/20 border-b border-border/30 space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <Search className="w-3 h-3 text-muted-foreground" /> | |
| <Input | |
| value={filter} | |
| onChange={(e) => setFilter(e.target.value)} | |
| placeholder="Filter logs..." | |
| className="h-7 text-xs bg-background/50" | |
| /> | |
| </div> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| {Object.entries(levelConfig).map(([level, config]) => ( | |
| <button | |
| key={level} | |
| onClick={() => toggleLevel(level)} | |
| className={cn( | |
| 'px-2 py-0.5 rounded text-[10px] font-mono uppercase transition-all', | |
| levelFilter.has(level) ? config.badge : 'bg-muted/30 text-muted-foreground opacity-50' | |
| )} | |
| > | |
| {level} ({levelCounts[level] || 0}) | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Log entries */} | |
| <ScrollArea ref={scrollRef} className="flex-1 h-[300px]"> | |
| <div className="font-mono text-xs"> | |
| {filteredLogs.map((log) => { | |
| const config = levelConfig[log.level]; | |
| const Icon = config.icon; | |
| return ( | |
| <div | |
| key={log.id} | |
| className={cn( | |
| 'flex items-start gap-2 px-3 py-1.5 border-b border-border/10 hover:bg-muted/20 transition-colors', | |
| config.bg | |
| )} | |
| > | |
| <Icon className={cn('w-3 h-3 mt-0.5 flex-shrink-0', config.color)} /> | |
| {showTimestamps && ( | |
| <span className="text-muted-foreground flex-shrink-0 w-24"> | |
| {formatTimestamp(log.timestamp)} | |
| </span> | |
| )} | |
| {showSource && ( | |
| <span className="text-primary/70 flex-shrink-0 w-20 truncate"> | |
| [{log.source}] | |
| </span> | |
| )} | |
| <span className={cn('flex-1', config.color)}> | |
| {log.message} | |
| </span> | |
| </div> | |
| ); | |
| })} | |
| {filteredLogs.length === 0 && ( | |
| <div className="flex items-center justify-center py-8 text-muted-foreground"> | |
| No logs to display | |
| </div> | |
| )} | |
| </div> | |
| </ScrollArea> | |
| {/* Footer */} | |
| <div className="px-4 py-2 bg-muted/20 border-t border-border/30 flex items-center justify-between text-[10px] text-muted-foreground"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setAutoScroll(!autoScroll)} | |
| className={cn( | |
| 'flex items-center gap-1 px-2 py-0.5 rounded', | |
| autoScroll ? 'bg-green-500/20 text-green-400' : 'bg-muted/30' | |
| )} | |
| > | |
| <ChevronDown className="w-3 h-3" /> | |
| Auto-scroll | |
| </button> | |
| {isPaused && ( | |
| <span className="text-yellow-500 animate-pulse">PAUSED</span> | |
| )} | |
| </div> | |
| <span>Max: {maxLines} lines</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default LogStreamViewer; | |