/** * Component: LogDetailPanel * Documentation: documentation/admin-dashboard.md * * Three collapsible sub-sections (Event Log / Result / Error) with count badges. * Per-event level filter. Copy-to-clipboard on each event, full event log, * result JSON, error block, and Bull Job ID. Toast confirmations. * Default open on desktop (`defaultOpen` prop), collapsed on mobile. * * NO "View related request" link — no admin request detail page exists (Zach #4). */ 'use client'; import { useMemo, useState } from 'react'; import { useToast } from '@/components/ui/Toast'; import { JobEvent, Log } from '../types'; type Level = 'all' | 'info' | 'warn' | 'error'; // =========================================================================== // CopyButton — extracted because used 5+ times // =========================================================================== interface CopyButtonProps { text: string; label: string; className?: string; /** When true, render as a compact icon-only button. */ iconOnly?: boolean; } function CopyButton({ text, label, className, iconOnly = false }: CopyButtonProps) { const toast = useToast(); const handleClick = async () => { const ok = await copyToClipboard(text); if (ok) toast.success(`Copied ${label}`); else toast.error('Copy unavailable on insecure connection'); }; return ( ); } async function copyToClipboard(text: string): Promise { if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(text); return true; } catch { // fall through to textarea fallback } } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '0'; ta.style.left = '0'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch { return false; } } // =========================================================================== // EventLine — single row in the event log // =========================================================================== function levelColorClass(level: string): string { if (level === 'error') return 'text-red-400'; if (level === 'warn') return 'text-amber-400'; return 'text-emerald-400'; } function formatEventLine(e: JobEvent): string { const ts = (() => { try { return new Date(e.createdAt).toISOString().split('T')[1].split('.')[0]; } catch { return e.createdAt; } })(); const meta = e.metadata && Object.keys(e.metadata).length > 0 ? '\n' + JSON.stringify(e.metadata, null, 2) : ''; return `${ts} [${e.level.toUpperCase()}] [${e.context}] ${e.message}${meta}`; } function EventLine({ event }: { event: JobEvent }) { const ts = (() => { try { return new Date(event.createdAt).toISOString().split('T')[1].split('.')[0]; } catch { return event.createdAt; } })(); return (
[{event.context}]{' '} {event.message} {ts} {event.metadata && Object.keys(event.metadata).length > 0 && (
          {JSON.stringify(event.metadata, null, 2)}
        
)}
); } // =========================================================================== // Collapsible — a sub-section with title, count badge, chevron toggle // =========================================================================== interface CollapsibleProps { title: string; count?: number; defaultOpen: boolean; children: React.ReactNode; headerRight?: React.ReactNode; } function Collapsible({ title, count, defaultOpen, children, headerRight }: CollapsibleProps) { const [open, setOpen] = useState(defaultOpen); return (
{open && headerRight}
{open && children}
); } // =========================================================================== // LogDetailPanel // =========================================================================== interface LogDetailPanelProps { log: Log; /** Default-open state for the three sub-sections. Desktop: true; Mobile: false. */ defaultOpen: boolean; } export function LogDetailPanel({ log, defaultOpen }: LogDetailPanelProps) { const [level, setLevel] = useState('all'); const filteredEvents = useMemo(() => { if (level === 'all') return log.events; return log.events.filter((e) => e.level === level); }, [log.events, level]); const fullEventLog = useMemo( () => log.events.map(formatEventLine).join('\n'), [log.events] ); const resultText = useMemo( () => (log.result ? JSON.stringify(log.result, null, 2) : ''), [log.result] ); const hasResult = !!(log.result && Object.keys(log.result).length > 0); return (
{log.bullJobId && (
Bull Job ID: {log.bullJobId}
)} {log.events.length > 0 && (
} > {filteredEvents.length === 0 ? (
No events at level "{level}".
) : (
{filteredEvents.map((event) => ( ))}
)} )} {hasResult && ( } >
            {resultText}
          
)} {log.errorMessage && ( } >
{log.errorMessage}
)} ); } // =========================================================================== // LevelFilterPills — small group toggle // =========================================================================== function LevelFilterPills({ value, onChange, }: { value: Level; onChange: (next: Level) => void; }) { const options: { key: Level; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'info', label: 'Info' }, { key: 'warn', label: 'Warn' }, { key: 'error', label: 'Error' }, ]; return (
{options.map((opt) => ( ))}
); }