/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Play, Square, RotateCcw, Plus, Check, X } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useSchemaStore } from '@/stores/schemaStore'; import { apiFetch, getApiBaseUrl } from '@/services/api'; import type { TraceEvent } from '../types'; import { TraceTimeline } from './TraceTimeline'; const MAX_EVENTS = 1000; const MAX_RECONNECT = 5; const RECONNECT_DELAY = 2000; interface KeyValueFilter { key: string; label: string; value: string; } type LiveTracingState = | { kind: 'idle' } | { kind: 'starting'; keywords: string; filters: KeyValueFilter[] } | { kind: 'streaming'; events: TraceEvent[]; anchorTimestamp: string; eventSource: EventSource } | { kind: 'stopped'; events: TraceEvent[]; anchorTimestamp: string } | { kind: 'error'; events: TraceEvent[]; error: string }; export function LiveTracingPage() { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const [state, setState] = useState({ kind: 'idle' }); const [keywords, setKeywords] = useState(''); const [filters, setFilters] = useState([]); const [addingFilter, setAddingFilter] = useState(false); const [newFilterKey, setNewFilterKey] = useState(''); const [newFilterValue, setNewFilterValue] = useState(''); const eventSourceRef = useRef(null); const eventsRef = useRef([]); const anchorRef = useRef(''); const reconnectAttempts = useRef(0); const keyEnum = useMemo(() => schema?.enums?.['Key'] ?? [], [schema]); useEffect(() => { return () => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } }; }, []); const openStream = useCallback(async () => { setState({ kind: 'starting', keywords, filters }); eventsRef.current = []; anchorRef.current = ''; reconnectAttempts.current = 0; try { const tokenResponse = await apiFetch('/api/token/tracing'); const token = await tokenResponse.text(); const params = new URLSearchParams(); params.set('token', token); if (keywords.trim()) { params.set('filter', keywords.trim()); } for (const f of filters) { params.set(f.key, f.value); } const baseUrl = getApiBaseUrl(); const url = `${baseUrl}/api/live/tracing?${params.toString()}`; const connectEventSource = () => { const es = new EventSource(url); eventSourceRef.current = es; es.addEventListener('trace', (event) => { try { const batch = JSON.parse(event.data) as TraceEvent[]; const currentEvents = eventsRef.current; for (const evt of batch) { currentEvents.push(evt); if (!anchorRef.current) { anchorRef.current = evt.timestamp; } } if (currentEvents.length > MAX_EVENTS) { eventsRef.current = currentEvents.slice(currentEvents.length - MAX_EVENTS); } setState({ kind: 'streaming', events: [...eventsRef.current], anchorTimestamp: anchorRef.current, eventSource: es, }); } catch (e) { console.error('Failed to parse trace event:', e); } }); es.onopen = () => { reconnectAttempts.current = 0; setState({ kind: 'streaming', events: [...eventsRef.current], anchorTimestamp: anchorRef.current || '', eventSource: es, }); }; es.onerror = () => { if (es !== eventSourceRef.current) return; es.close(); eventSourceRef.current = null; if (reconnectAttempts.current < MAX_RECONNECT) { reconnectAttempts.current++; setTimeout(connectEventSource, RECONNECT_DELAY); } else { setState({ kind: 'error', events: [...eventsRef.current], error: t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.'), }); } }; }; connectEventSource(); } catch (e) { setState({ kind: 'error', events: [], error: e instanceof Error ? e.message : t('tracing.liveFailedStart', 'Failed to start live tracing.'), }); } }, [keywords, filters, t]); const stopStream = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } setState((prev) => { if (prev.kind === 'streaming') { return { kind: 'stopped', events: prev.events, anchorTimestamp: prev.anchorTimestamp, }; } return prev; }); }, []); const resetToIdle = useCallback(() => { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } eventsRef.current = []; anchorRef.current = ''; setState({ kind: 'idle' }); }, []); const addFilter = useCallback(() => { if (!newFilterKey || !newFilterValue.trim()) return; const keyEntry = keyEnum.find((e) => e.name === newFilterKey); setFilters((prev) => [ ...prev, { key: newFilterKey, label: keyEntry?.label ?? newFilterKey, value: newFilterValue.trim(), }, ]); setNewFilterKey(''); setNewFilterValue(''); setAddingFilter(false); }, [newFilterKey, newFilterValue, keyEnum]); const removeFilter = useCallback((idx: number) => { setFilters((prev) => prev.filter((_, i) => i !== idx)); }, []); const isIdle = state.kind === 'idle'; const isStarting = state.kind === 'starting'; const isStreaming = state.kind === 'streaming'; const isStopped = state.kind === 'stopped'; const isError = state.kind === 'error'; if (isIdle || isStarting) { return (

{t('tracing.liveTitle', 'Live Tracing')}

{t('tracing.liveSubtitle', 'Stream server events in real time.')}

setKeywords(e.target.value)} />
{filters.length > 0 && (
{filters.map((f, i) => ( {f.label} = {f.value} ))}
)} {addingFilter ? (
setNewFilterValue(e.target.value)} className="flex-1" onKeyDown={(e) => { if (e.key === 'Enter') addFilter(); }} />
) : ( )}
); } const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : []; const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined; return (

{t('tracing.liveTitle', 'Live Tracing')}

{isStreaming && (
{t('tracing.streamingEvents', 'Streaming events...')}
)} {isStopped && {t('tracing.stopped', 'Stopped')}}
{isStreaming && ( )} {(isStopped || isError) && ( )}
{isError && (
{state.error}
)}

{events.length === 1 ? t('tracing.eventCount_one', '{{count}} event', { count: events.length }) : t('tracing.eventCount_other', '{{count}} events', { count: events.length })} {events.length >= MAX_EVENTS && ' ' + t('tracing.bufferFull', '(buffer full)')}

); }