import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useAppTranslation } from '@features/localization/renderer'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Check, Clipboard, Loader2, RefreshCw } from 'lucide-react'; import { createEmptyMemberRuntimeLogTailResponse, type MemberRuntimeLogKind, type MemberRuntimeLogTailResponse, normalizeMemberRuntimeLogTailResponse, } from '../../contracts'; const PROCESS_LOG_KINDS: MemberRuntimeLogKind[] = ['stdout', 'stderr', 'events']; const PROCESS_LOG_AUTO_REFRESH_MS = 4000; const PROCESS_LOG_TAIL_BYTES = 128 * 1024; export interface MemberRuntimeProcessLogsPanelProps { readonly enabled: boolean; readonly loadRuntimeLogTail: (input: { readonly kind: MemberRuntimeLogKind; readonly maxBytes: number; readonly forceRefresh?: boolean; }) => Promise; } function formatBytes(bytes: number | undefined): string { if (!Number.isFinite(bytes ?? NaN)) return '--'; const safeBytes = Math.max(0, bytes ?? 0); if (safeBytes < 1024) return `${safeBytes} B`; const kb = safeBytes / 1024; if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`; const mb = kb / 1024; return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`; } function buildStatusText(log: MemberRuntimeLogTailResponse | null): string | null { if (!log) return null; if (log.missing) return 'No process log file captured for this member yet.'; if (!log.content) return 'Process log file is empty.'; if (log.truncated) return `Showing last ${formatBytes(log.bytesRead)}.`; return `Showing ${formatBytes(log.bytesRead)}.`; } function ProcessLogKindTabs({ selected, onSelect, }: Readonly<{ readonly selected: MemberRuntimeLogKind; readonly onSelect: (kind: MemberRuntimeLogKind) => void; }>): React.JSX.Element { return (
{PROCESS_LOG_KINDS.map((kind) => ( ))}
); } function ProcessLogVirtualList({ content, wrapLines, }: Readonly<{ readonly content: string; readonly wrapLines: boolean; }>): React.JSX.Element { const parentRef = useRef(null); const lines = useMemo(() => content.split(/\r?\n/), [content]); const rowVirtualizer = useVirtualizer({ count: lines.length, getScrollElement: () => parentRef.current, estimateSize: () => (wrapLines ? 36 : 20), overscan: 20, }); return (
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
{virtualRow.index + 1} {lines[virtualRow.index] || ' '}
))}
); } export function MemberRuntimeProcessLogsPanel({ enabled, loadRuntimeLogTail, }: Readonly): React.JSX.Element { const { t } = useAppTranslation('team'); const { t: tCommon } = useAppTranslation('common'); const [kind, setKind] = useState('stdout'); const [log, setLog] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [autoRefresh, setAutoRefresh] = useState(false); const [wrapLines, setWrapLines] = useState(false); const [copied, setCopied] = useState(false); const requestSeqRef = useRef(0); const copiedTimerRef = useRef | null>(null); const loadLog = useCallback( async (options?: { background?: boolean; forceRefresh?: boolean }) => { if (!enabled) return; const requestSeq = requestSeqRef.current + 1; requestSeqRef.current = requestSeq; if (!options?.background) { setLoading(true); setError(null); } try { const response = normalizeMemberRuntimeLogTailResponse( await loadRuntimeLogTail({ kind, maxBytes: PROCESS_LOG_TAIL_BYTES, ...(options?.forceRefresh ? { forceRefresh: true } : {}), }) ); if (requestSeqRef.current !== requestSeq) return; setLog(response); setError(null); } catch (loadError) { if (requestSeqRef.current !== requestSeq) return; if (!options?.background) { setLog(createEmptyMemberRuntimeLogTailResponse(kind)); } setError(loadError instanceof Error ? loadError.message : 'Failed to load process logs'); } finally { if (requestSeqRef.current === requestSeq) { setLoading(false); } } }, [enabled, kind, loadRuntimeLogTail] ); useEffect(() => { requestSeqRef.current += 1; setLog(null); setError(null); if (enabled) { void loadLog({ forceRefresh: true }); } }, [enabled, kind, loadLog]); useEffect(() => { if (!enabled || !autoRefresh) return undefined; const interval = setInterval(() => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; void loadLog({ background: true, forceRefresh: true }); }, PROCESS_LOG_AUTO_REFRESH_MS); return () => clearInterval(interval); }, [autoRefresh, enabled, loadLog]); useEffect(() => { return () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }; }, []); const copyCurrentLog = useCallback(async () => { const content = log?.content ?? ''; if (!content) return; try { await navigator.clipboard.writeText(content); setCopied(true); if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); copiedTimerRef.current = setTimeout(() => setCopied(false), 1600); } catch (copyError) { setError(copyError instanceof Error ? copyError.message : 'Failed to copy process logs'); } }, [log?.content]); const statusText = buildStatusText(log); const hasContent = Boolean(log?.content); return (
{kind}
{error ? (
{error}
) : null} {statusText ? (
{statusText}
) : null} {loading && !log ? (
{t('members.runtimeLogs.loadingTail')}
) : hasContent ? ( ) : (
{statusText ?? t('members.runtimeLogs.empty')}
)}
); }