import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Download, RefreshCw } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode'; import { hashLabel } from '@/lib/label-colors'; interface StructuredLogViewerProps { stackName: string; } type LogLevel = 'info' | 'warn' | 'err'; interface LogRow { id: number; ts: string | null; level: LogLevel; message: string; /** Normalized service name extracted from the log prefix, or null for synthetic / old-format rows. */ containerName: string | null; /** True when this row was synthesized by the client (e.g. reconnect sentinel). */ synthetic?: boolean; } 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 PREFIX_REGEX = /^([a-zA-Z0-9_.-]+)(?:\s+\|\s+)/; const ERROR_REGEX = /\b(ERROR|ERR|FATAL|Exception)\b/i; const WARN_REGEX = /\b(WARN|WARNING|WRN)\b/i; // Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s cap. After each successful // open the schedule resets. docker logs -f has no resumable offset, so on // reconnect we drop a synthetic sentinel row to mark the gap rather than // pretend nothing was missed. const RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; function parseLine(raw: string): Omit { let stripped = raw.replace(ANSI_REGEX, '').replace(/[\r\n]+$/, ''); let containerName: string | null = null; const prefixMatch = stripped.match(PREFIX_REGEX); if (prefixMatch) { containerName = prefixMatch[1]; stripped = stripped.slice(prefixMatch[0].length); } 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, containerName }; } 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 [connectionState, setConnectionState] = useState<'connecting' | 'open' | 'reconnecting'>('connecting'); const [chipColorMode] = useLogChipColorMode(); 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(() => { // Reset state accumulated from the previous stack before connecting. // eslint-disable-next-line react-hooks/set-state-in-effect setRows([]); rowIdRef.current = 0; setFollowing(true); followingRef.current = true; 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 closedByCleanup = false; let reconnectTimer: ReturnType | null = null; let attempt = 0; 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); }; const pushSyntheticRow = (level: LogLevel, message: string) => { rowIdRef.current += 1; pendingRef.current.push({ id: rowIdRef.current, ts: new Date().toISOString(), level, message, containerName: null, synthetic: true, }); scheduleFlush(); }; const connect = () => { const ws = new WebSocket(wsUrl); wsRef.current = ws; if (attempt === 0) setConnectionState('connecting'); ws.onopen = () => { if (closedByCleanup) { try { ws.close(); } catch { /* ignore */ } return; } if (attempt > 0) { // Recovered from a disconnect. Mark the gap so the operator knows // older lines may be missing - docker logs -f has no offset we // could resume from. pushSyntheticRow('warn', '--- reconnected; older lines may be missing ---'); } attempt = 0; setConnectionState('open'); }; ws.onmessage = (event) => { if (closedByCleanup) 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 = () => { /* fall through to onclose, which schedules reconnect */ }; ws.onclose = () => { if (closedByCleanup) return; const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]; attempt += 1; setConnectionState('reconnecting'); reconnectTimer = setTimeout(() => { reconnectTimer = null; if (!closedByCleanup) connect(); }, delay); }; }; connect(); return () => { closedByCleanup = true; if (rafId !== 0) cancelAnimationFrame(rafId); if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } const ws = wsRef.current; if (ws) { 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.containerName ? `[${r.containerName}] ${r.ts ?? ''} ${r.level.toUpperCase()} ${r.message}`.trim() : `${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} {connectionState === 'reconnecting' ? ( reconnecting… ) : 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.containerName && ( {row.containerName} )} {row.message}
)) )}
); }