352 строки
12 KiB
TypeScript
352 строки
12 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* 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<LiveTracingState>({ kind: 'idle' });
|
|
const [keywords, setKeywords] = useState('');
|
|
const [filters, setFilters] = useState<KeyValueFilter[]>([]);
|
|
const [addingFilter, setAddingFilter] = useState(false);
|
|
const [newFilterKey, setNewFilterKey] = useState('');
|
|
const [newFilterValue, setNewFilterValue] = useState('');
|
|
const eventSourceRef = useRef<EventSource | null>(null);
|
|
const eventsRef = useRef<TraceEvent[]>([]);
|
|
const anchorRef = useRef<string>('');
|
|
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 (
|
|
<div className="mx-auto max-w-2xl space-y-6 pt-8">
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
{t('tracing.liveSubtitle', 'Stream server events in real time.')}
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardContent className="p-4 space-y-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
|
|
<Input
|
|
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
|
|
value={keywords}
|
|
onChange={(e) => setKeywords(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{filters.length > 0 && (
|
|
<div className="flex flex-wrap gap-2">
|
|
{filters.map((f, i) => (
|
|
<Badge key={i} variant="secondary" className="gap-1">
|
|
{f.label} = {f.value}
|
|
<button onClick={() => removeFilter(i)} className="ml-1 hover:text-destructive">
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{addingFilter ? (
|
|
<div className="flex items-center gap-2">
|
|
<Select value={newFilterKey} onValueChange={setNewFilterKey}>
|
|
<SelectTrigger className="w-48">
|
|
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
|
|
</SelectTrigger>
|
|
<SelectContent className="max-h-60">
|
|
{keyEnum.map((entry) => (
|
|
<SelectItem key={entry.name} value={entry.name}>
|
|
{entry.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Input
|
|
placeholder={t('tracing.valuePlaceholder', 'Value...')}
|
|
value={newFilterValue}
|
|
onChange={(e) => setNewFilterValue(e.target.value)}
|
|
className="flex-1"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') addFilter();
|
|
}}
|
|
/>
|
|
<Button size="icon" variant="ghost" onClick={addFilter}>
|
|
<Check className="h-4 w-4" />
|
|
</Button>
|
|
<Button size="icon" variant="ghost" onClick={() => setAddingFilter(false)}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="outline" size="sm" onClick={() => setAddingFilter(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{t('tracing.addFilter', 'Add filter')}
|
|
</Button>
|
|
)}
|
|
|
|
<Button className="w-full" onClick={openStream} disabled={isStarting}>
|
|
{isStarting ? (
|
|
<>
|
|
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
{t('tracing.connecting', 'Connecting...')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="mr-2 h-4 w-4" />
|
|
{t('tracing.startTracing', 'Start tracing')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : [];
|
|
const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<h2 className="text-lg font-semibold">{t('tracing.liveTitle', 'Live Tracing')}</h2>
|
|
{isStreaming && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
|
</span>
|
|
{t('tracing.streamingEvents', 'Streaming events...')}
|
|
</div>
|
|
)}
|
|
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{isStreaming && (
|
|
<Button variant="outline" onClick={stopStream}>
|
|
<Square className="mr-2 h-4 w-4" />
|
|
{t('tracing.stopTracing', 'Stop tracing')}
|
|
</Button>
|
|
)}
|
|
{(isStopped || isError) && (
|
|
<Button variant="outline" onClick={resetToIdle}>
|
|
<RotateCcw className="mr-2 h-4 w-4" />
|
|
{t('tracing.restart', 'Restart')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isError && (
|
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
|
{state.error}
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardContent className="p-4">
|
|
<TraceTimeline events={events} anchorTimestamp={anchor} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
{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)')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|