import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Download } from 'lucide-react'; import { cn } from '@/lib/utils'; interface StructuredLogViewerProps { stackName: string; } type LogLevel = 'info' | 'warn' | 'err'; interface LogRow { id: number; ts: string | null; level: LogLevel; message: string; } type Filter = 'all' | LogLevel; const BUFFER_CAP = 10_000; const TIMESTAMP_REGEX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)\s+(.*)$/; // eslint-disable-next-line no-control-regex const ANSI_REGEX = /\x1b\[[0-9;]*[A-Za-z]/g; const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i; const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i; function parseLine(raw: string): Omit { const stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, ''); const match = stripped.match(TIMESTAMP_REGEX); const ts = match ? match[1] : null; const body = match ? match[2] : stripped; let level: LogLevel = 'info'; if (ERROR_REGEX.test(body)) level = 'err'; else if (WARN_REGEX.test(body)) level = 'warn'; return { ts, level, message: body }; } function formatTs(iso: string | null): string { if (!iso) return ''; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); const ss = String(d.getSeconds()).padStart(2, '0'); return `${hh}:${mm}:${ss}`; } export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) { const [rows, setRows] = useState([]); const [filter, setFilter] = useState('all'); const [following, setFollowing] = useState(true); const scrollRef = useRef(null); const followingRef = useRef(true); const rowIdRef = useRef(0); const wsRef = useRef(null); const pendingRef = useRef([]); useEffect(() => { followingRef.current = following; }, [following]); useEffect(() => { const cleanStackName = stackName.replace(/\.(yml|yaml)$/, ''); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const activeNodeId = localStorage.getItem('sencho-active-node') || ''; const wsUrl = `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`; let closed = false; const ws = new WebSocket(wsUrl); wsRef.current = ws; let rafId = 0; const flushPending = () => { rafId = 0; if (pendingRef.current.length === 0) return; const incoming = pendingRef.current; pendingRef.current = []; setRows((prev) => { const merged = prev.concat(incoming); return merged.length > BUFFER_CAP ? merged.slice(merged.length - BUFFER_CAP) : merged; }); }; const scheduleFlush = () => { if (rafId !== 0) return; rafId = requestAnimationFrame(flushPending); }; ws.onmessage = (event) => { if (closed) return; const text = typeof event.data === 'string' ? event.data : ''; if (!text) return; for (const line of text.split(/\r?\n/)) { if (!line) continue; const parsed = parseLine(line); if (!parsed.message) continue; rowIdRef.current += 1; pendingRef.current.push({ id: rowIdRef.current, ...parsed }); } scheduleFlush(); }; ws.onerror = () => { /* surface nothing; reconnection is backend's job */ }; return () => { closed = true; if (rafId !== 0) cancelAnimationFrame(rafId); try { ws.close(); } catch { /* ignore */ } wsRef.current = null; pendingRef.current = []; }; }, [stackName]); const filtered = useMemo(() => { if (filter === 'all') return rows; return rows.filter(r => r.level === filter); }, [rows, filter]); const errCount = useMemo(() => rows.reduce((n, r) => r.level === 'err' ? n + 1 : n, 0), [rows]); // Auto-scroll to bottom when following. useEffect(() => { if (!followingRef.current) return; const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [filtered]); const handleScroll = () => { const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; const atBottom = distanceFromBottom < 24; if (atBottom !== followingRef.current) { followingRef.current = atBottom; setFollowing(atBottom); } }; const resumeFollow = () => { setFollowing(true); followingRef.current = true; const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight; }; const downloadLogs = () => { const text = rows.map(r => `${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim()).join('\n'); const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${stackName.replace(/\.(yml|yaml)$/, '')}-logs.txt`; a.click(); URL.revokeObjectURL(url); }; const label = `logs · ${stackName.replace(/\.(yml|yaml)$/, '')}`; return (
{label} {following ? ( following ) : ( )}
{(['all', 'info', 'warn', 'err'] as const).map(f => ( ))}
{filtered.length === 0 ? (
Waiting for log output…
) : ( filtered.map(row => (
{formatTs(row.ts)} {row.level} {row.message}
)) )}
); }