Rewrite live tracing, the trace timeline and the trace detail view #129
No files matched your search
@@ -1,311 +1,105 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } 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 { Check, Play, Plus, RotateCcw, Square, X } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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 { normalizeTraceEvents } from '../normalize';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
import { liveEventSubject } from '@/features/ai/explain/explain';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { appendEvents, liveTracingQuery, MAX_EVENTS, streamLiveTrace, type KeyFilter, type LiveBuffer } from '../live';
|
||||
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 =
|
||||
type Live =
|
||||
| { 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 };
|
||||
| { kind: 'starting' }
|
||||
| { kind: 'streaming'; buffer: LiveBuffer }
|
||||
| { kind: 'stopped'; buffer: LiveBuffer }
|
||||
/** Failed; the events so far stay on screen, timed from the first of them. */
|
||||
| { kind: 'failed'; buffer: LiveBuffer; error: string };
|
||||
|
||||
const EMPTY: LiveBuffer = { events: [], anchor: '' };
|
||||
|
||||
/** One live stream at a time; it stops when the page goes away. */
|
||||
function useLiveTrace() {
|
||||
const { t } = useTranslation();
|
||||
const [live, setLive] = useState<Live>({ kind: 'idle' });
|
||||
const stopStream = useRef<(() => void) | null>(null);
|
||||
|
||||
const halt = useCallback(() => {
|
||||
stopStream.current?.();
|
||||
stopStream.current = null;
|
||||
}, []);
|
||||
useEffect(() => halt, [halt]);
|
||||
|
||||
const start = useCallback(
|
||||
(keywords: string, filters: KeyFilter[]) => {
|
||||
halt();
|
||||
setLive({ kind: 'starting' });
|
||||
const bufferOf = (l: Live) => (l.kind === 'idle' || l.kind === 'starting' ? EMPTY : l.buffer);
|
||||
stopStream.current = streamLiveTrace(liveTracingQuery(keywords, filters), {
|
||||
opened: () => setLive((l) => ({ kind: 'streaming', buffer: bufferOf(l) })),
|
||||
events: (batch) => setLive((l) => ({ kind: 'streaming', buffer: appendEvents(bufferOf(l), batch) })),
|
||||
failed: (f) =>
|
||||
setLive((l) => ({
|
||||
kind: 'failed',
|
||||
buffer: { events: bufferOf(l).events, anchor: '' },
|
||||
error:
|
||||
f.reason === 'disconnected'
|
||||
? t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.')
|
||||
: (f.message ?? t('tracing.liveFailedStart', 'Failed to start live tracing.')),
|
||||
})),
|
||||
});
|
||||
},
|
||||
[halt, t],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
halt();
|
||||
setLive((l) => (l.kind === 'streaming' ? { kind: 'stopped', buffer: l.buffer } : l));
|
||||
}, [halt]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
halt();
|
||||
setLive({ kind: 'idle' });
|
||||
}, [halt]);
|
||||
|
||||
return { live, start, stop, reset };
|
||||
}
|
||||
|
||||
export function LiveTracingPage() {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [state, setState] = useState<LiveTracingState>({ kind: 'idle' });
|
||||
const { live, start, stop, reset } = useLiveTrace();
|
||||
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 [filters, setFilters] = useState<KeyFilter[]>([]);
|
||||
|
||||
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 = normalizeTraceEvents(JSON.parse(event.data));
|
||||
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) {
|
||||
if (live.kind === 'idle' || live.kind === 'starting') {
|
||||
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>
|
||||
<TracingForm
|
||||
keywords={keywords}
|
||||
onKeywords={setKeywords}
|
||||
filters={filters}
|
||||
onFilters={setFilters}
|
||||
starting={live.kind === 'starting'}
|
||||
onStart={() => start(keywords, filters)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : [];
|
||||
const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined;
|
||||
|
||||
const { events, anchor } = live.buffer;
|
||||
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 && (
|
||||
{live.kind === 'streaming' && (
|
||||
<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" />
|
||||
@@ -314,17 +108,18 @@ export function LiveTracingPage() {
|
||||
{t('tracing.streamingEvents', 'Streaming events...')}
|
||||
</div>
|
||||
)}
|
||||
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
|
||||
{live.kind === 'stopped' && (
|
||||
<span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isStreaming && (
|
||||
<Button variant="outline" onClick={stopStream}>
|
||||
{live.kind === 'streaming' ? (
|
||||
<Button variant="outline" onClick={stop}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
{t('tracing.stopTracing', 'Stop tracing')}
|
||||
</Button>
|
||||
)}
|
||||
{(isStopped || isError) && (
|
||||
<Button variant="outline" onClick={resetToIdle}>
|
||||
) : (
|
||||
<Button variant="outline" onClick={reset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t('tracing.restart', 'Restart')}
|
||||
</Button>
|
||||
@@ -332,15 +127,19 @@ export function LiveTracingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
{live.kind === 'failed' && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{state.error}
|
||||
{live.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline events={events} anchorTimestamp={anchor} explainSubject={(event) => liveEventSubject(event)} />
|
||||
<TraceTimeline
|
||||
events={events}
|
||||
anchorTimestamp={anchor || undefined}
|
||||
explainSubject={(event) => liveEventSubject(event)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -353,3 +152,117 @@ export function LiveTracingPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TracingFormProps {
|
||||
keywords: string;
|
||||
onKeywords: (keywords: string) => void;
|
||||
filters: KeyFilter[];
|
||||
onFilters: (filters: KeyFilter[]) => void;
|
||||
starting: boolean;
|
||||
onStart: () => void;
|
||||
}
|
||||
|
||||
/** Keywords and key = value filters for the stream, then Start. */
|
||||
function TracingForm({ keywords, onKeywords, filters, onFilters, starting, onStart }: TracingFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const keys = useMemo(() => schema?.enums?.Key ?? [], [schema]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [key, setKey] = useState('');
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const add = () => {
|
||||
if (!key || !value.trim()) return;
|
||||
onFilters([...filters, { key, label: keys.find((k) => k.name === key)?.label ?? key, value: value.trim() }]);
|
||||
setKey('');
|
||||
setValue('');
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
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) => onKeywords(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={() => onFilters(filters.filter((_, j) => j !== i))}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{adding ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={key} onValueChange={setKey}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{keys.map((k) => (
|
||||
<SelectItem key={k.name} value={k.name}>
|
||||
{k.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('tracing.valuePlaceholder', 'Value...')}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
className="flex-1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" onClick={add}>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setAdding(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setAdding(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('tracing.addFilter', 'Add filter')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button className="w-full" onClick={onStart} disabled={starting}>
|
||||
{starting ? (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +1,61 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, FileText, Clock, Timer, Activity, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Activity, ArrowLeft, Clock, FileText, Loader2, Timer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { jmapGet, getAccountId } from '@/services/jmap/client';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import type { TraceEvent } from '../types';
|
||||
import { getAccountId, jmapGet } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { longDuration, traceSummary } from '../format';
|
||||
import { normalizeTraceEvents } from '../normalize';
|
||||
import type { TraceEvent } from '../types';
|
||||
import { TraceTimeline } from './TraceTimeline';
|
||||
|
||||
type Loaded = { kind: 'loading' } | { kind: 'failed'; error: string } | { kind: 'loaded'; events: TraceEvent[] };
|
||||
|
||||
interface TraceDetailViewProps {
|
||||
viewName: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
/** A stored trace (`x:Trace`): a summary of the span, then its events. */
|
||||
export function TraceDetailView({ viewName, objectId }: { viewName: string; objectId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const [events, setEvents] = useState<TraceEvent[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState<Loaded>({ kind: 'loading' });
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema) return;
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
let current = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = resolveObject(schema!, viewName);
|
||||
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
|
||||
|
||||
const accountId = getAccountId(resolved.objectName);
|
||||
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const getResult = responses.find(([name]) => name.endsWith('/get'));
|
||||
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
|
||||
|
||||
const data = getResult[1] as { list?: Array<{ id: string; events?: unknown }> };
|
||||
const trace = data.list?.[0];
|
||||
const object = resolveObject(schema, viewName);
|
||||
if (!object) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
|
||||
const responses = await jmapGet(object.objectName, getAccountId(object.objectName), [objectId]);
|
||||
if (!current) return;
|
||||
const got = responses.find(([name]) => name.endsWith('/get'));
|
||||
if (!got) throw new Error(t('view.noGetResponse', 'No get response'));
|
||||
const trace = (got[1].list as { events?: unknown }[] | undefined)?.[0];
|
||||
if (!trace) throw new Error(t('tracing.traceNotFound', 'Trace not found'));
|
||||
|
||||
setEvents(normalizeTraceEvents(trace.events));
|
||||
setLoaded({ kind: 'loaded', events: normalizeTraceEvents(trace.events) });
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setError(e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'));
|
||||
if (current) {
|
||||
setLoaded({
|
||||
kind: 'failed',
|
||||
error: e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
current = false;
|
||||
};
|
||||
}, [schema, viewName, objectId, t]);
|
||||
|
||||
if (loading) {
|
||||
if (loaded.kind === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -78,47 +63,21 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (loaded.kind === 'failed') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t('common.back', 'Back')}
|
||||
</Button>
|
||||
<div className="text-destructive p-4">{error}</div>
|
||||
<div className="text-destructive p-4">{loaded.error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events) return null;
|
||||
|
||||
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||
const firstEvent = events[0];
|
||||
const lastEvent = events[events.length - 1];
|
||||
|
||||
const spanLabel = firstEvent
|
||||
? (eventTypeEnum.find((e) => e.name === firstEvent.event)?.label ?? firstEvent.event)
|
||||
: '-';
|
||||
|
||||
const dateStr = firstEvent
|
||||
? new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(firstEvent.timestamp))
|
||||
: '-';
|
||||
|
||||
const durationMs =
|
||||
firstEvent && lastEvent && events.length >= 2
|
||||
? new Date(lastEvent.timestamp).getTime() - new Date(firstEvent.timestamp).getTime()
|
||||
: null;
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
const { events } = loaded;
|
||||
const eventNames = new Map((schema?.enums?.EventType ?? []).map((e) => [e.name, e.label]));
|
||||
const summary = traceSummary(events, (event) => eventNames.get(event));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -128,52 +87,29 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
</Button>
|
||||
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('tracing.spanType', 'Span Type')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold truncate">{spanLabel}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{t('tracing.date', 'Date')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{dateStr}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="h-4 w-4" />
|
||||
{t('tracing.duration', 'Duration')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{durationMs !== null ? formatDuration(durationMs) : '-'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Activity className="h-4 w-4" />
|
||||
{t('tracing.events', 'Events')}
|
||||
</div>
|
||||
<p className="mt-1 text-lg font-semibold">{events.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Fact icon={<FileText className="h-4 w-4" />} name={t('tracing.spanType', 'Span Type')} truncate>
|
||||
{summary.span ?? '-'}
|
||||
</Fact>
|
||||
<Fact icon={<Clock className="h-4 w-4" />} name={t('tracing.date', 'Date')}>
|
||||
{summary.startedAt
|
||||
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(
|
||||
new Date(summary.startedAt),
|
||||
)
|
||||
: '-'}
|
||||
</Fact>
|
||||
<Fact icon={<Timer className="h-4 w-4" />} name={t('tracing.duration', 'Duration')}>
|
||||
{summary.durationMs !== null ? longDuration(summary.durationMs) : '-'}
|
||||
</Fact>
|
||||
<Fact icon={<Activity className="h-4 w-4" />} name={t('tracing.events', 'Events')}>
|
||||
{events.length}
|
||||
</Fact>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TraceTimeline
|
||||
events={events}
|
||||
anchorTimestamp={firstEvent?.timestamp}
|
||||
anchorTimestamp={summary.startedAt ?? undefined}
|
||||
explainSubject={(_, index) => ({ '@type': 'TraceEvent', traceId: objectId, index })}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -181,3 +117,27 @@ export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({
|
||||
icon,
|
||||
name,
|
||||
truncate,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
truncate?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{icon}
|
||||
{name}
|
||||
</div>
|
||||
<p className={`mt-1 text-lg font-semibold${truncate ? ' truncate' : ''}`}>{children}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,91 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CircleDot, CircleX, Clock } from 'lucide-react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { isRawEvent, type ExplainSubject } from '@/features/ai/explain/explain';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { eventTone, idToText, isIdKey, offsetFrom, shortDuration } from '../format';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
/** How close to the bottom counts as "at the bottom", so new events keep it scrolled there. */
|
||||
const STICK_PX = 50;
|
||||
|
||||
function intToBase32(input: number | string): string {
|
||||
let n: bigint;
|
||||
try {
|
||||
n = BigInt(typeof input === 'number' ? Math.floor(input) : input);
|
||||
} catch {
|
||||
return String(input);
|
||||
}
|
||||
if (n === 0n) return 'a';
|
||||
const chars: string[] = [];
|
||||
while (n > 0n) {
|
||||
chars.push(BASE32_ALPHABET[Number(n % 32n)]);
|
||||
n = n / 32n;
|
||||
}
|
||||
return chars.reverse().join('');
|
||||
interface EventName {
|
||||
label: string;
|
||||
explanation?: string;
|
||||
/** False for an event type the schema doesn't name; it shows as its raw name. */
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
function isIdKey(key: string): boolean {
|
||||
return key === 'id' || key.endsWith('Id');
|
||||
/** People's names for event types and keys, from the schema's EventType and Key enums. */
|
||||
interface Names {
|
||||
event: (type: string) => EventName;
|
||||
key: (key: string) => string;
|
||||
/** The section the queued-message pages live in, for queue id links. */
|
||||
queueSection: string;
|
||||
}
|
||||
|
||||
function useNames(): Names {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
return useMemo(() => {
|
||||
const events = new Map((schema?.enums?.EventType ?? []).map((e) => [e.name, e]));
|
||||
const keys = new Map((schema?.enums?.Key ?? []).map((e) => [e.name, e.label]));
|
||||
return {
|
||||
event: (type) => {
|
||||
const entry = events.get(type);
|
||||
return entry
|
||||
? { label: entry.label, explanation: entry.explanation, known: true }
|
||||
: { label: type, known: false };
|
||||
},
|
||||
key: (key) => keys.get(key) ?? key,
|
||||
queueSection: viewToSection['x:QueuedMessage'] ?? 'Management',
|
||||
};
|
||||
}, [schema, viewToSection]);
|
||||
}
|
||||
|
||||
const TONE_ICON = {
|
||||
error: <CircleX className="h-4 w-4 text-red-500" />,
|
||||
span: <Clock className="h-4 w-4 text-muted-foreground" />,
|
||||
plain: <CircleDot className="h-4 w-4 text-muted-foreground" />,
|
||||
};
|
||||
|
||||
const clockTime = (timestamp: string) =>
|
||||
new Date(timestamp).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
|
||||
interface TraceTimelineProps {
|
||||
events: TraceEvent[];
|
||||
/** What the offsets count from; the first event's time when absent. */
|
||||
anchorTimestamp?: string;
|
||||
/** inbuxa: what "Explain this" asks about an event (ai-explain EX-17). */
|
||||
/** What "Explain this" asks about an event (ai-explain EX-17); no button without it. */
|
||||
explainSubject?: (event: TraceEvent, index: number) => ExplainSubject;
|
||||
}
|
||||
|
||||
/** A trace's events in order, each with its time, its offset from the anchor and its values. */
|
||||
export function TraceTimeline({ events, anchorTimestamp, explainSubject }: TraceTimelineProps) {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
const names = useNames();
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const atBottom = useRef(true);
|
||||
|
||||
// Follow new events, unless the reader has scrolled up to look at older ones.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (wasAtBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
const el = scroller.current;
|
||||
if (el && atBottom.current) el.scrollTop = el.scrollHeight;
|
||||
}, [events.length]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||
};
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
@@ -72,106 +94,54 @@ export function TraceTimeline({ events, anchorTimestamp, explainSubject }: Trace
|
||||
);
|
||||
}
|
||||
|
||||
const anchorMs = anchorTimestamp ? new Date(anchorTimestamp).getTime() : new Date(events[0].timestamp).getTime();
|
||||
|
||||
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
|
||||
const keyEnum = schema?.enums?.['Key'] ?? [];
|
||||
|
||||
function resolveEventLabel(eventType: string): { label: string; explanation?: string } {
|
||||
const entry = eventTypeEnum.find((e) => e.name === eventType);
|
||||
if (entry) return { label: entry.label, explanation: entry.explanation };
|
||||
return { label: eventType };
|
||||
}
|
||||
|
||||
function resolveKeyLabel(key: string): string {
|
||||
const entry = keyEnum.find((e) => e.name === key);
|
||||
return entry?.label ?? key;
|
||||
}
|
||||
|
||||
function eventIcon(eventType: string) {
|
||||
if (
|
||||
eventType.includes('error') ||
|
||||
eventType.includes('failed') ||
|
||||
eventType.includes('invalid') ||
|
||||
eventType.includes('reject')
|
||||
) {
|
||||
return <CircleX className="h-4 w-4 text-red-500" />;
|
||||
}
|
||||
if (eventType.endsWith('-start') || eventType.endsWith('-end')) {
|
||||
return <Clock className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
} as Intl.DateTimeFormatOptions);
|
||||
}
|
||||
|
||||
function formatRelativeTime(ts: string): string {
|
||||
const ms = new Date(ts).getTime() - anchorMs;
|
||||
if (ms <= 0) return '(+0 ms)';
|
||||
if (ms < 1000) return `(+${Math.round(ms)} ms)`;
|
||||
if (ms < 60_000) return `(+${(ms / 1000).toFixed(1)} s)`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return `(+${m} m ${s} s)`;
|
||||
}
|
||||
const anchorMs = Date.parse(anchorTimestamp || events[0].timestamp);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={containerRef} onScroll={handleScroll} className="max-h-[70vh] overflow-y-auto scroll-smooth">
|
||||
<div
|
||||
ref={scroller}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX;
|
||||
}}
|
||||
className="max-h-[70vh] overflow-y-auto scroll-smooth"
|
||||
>
|
||||
<div className="space-y-4 p-2">
|
||||
{events.map((event, idx) => {
|
||||
const { label, explanation } = resolveEventLabel(event.event);
|
||||
const isFallback = !eventTypeEnum.find((e) => e.name === event.event);
|
||||
|
||||
{events.map((event, index) => {
|
||||
const name = names.event(event.event);
|
||||
return (
|
||||
<div key={idx} className="flex gap-3">
|
||||
<div className="mt-1 shrink-0">{eventIcon(event.event)}</div>
|
||||
|
||||
<div key={index} className="flex gap-3">
|
||||
<div className="mt-1 shrink-0">{TONE_ICON[eventTone(event.event)]}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
{isFallback ? (
|
||||
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{label}</span>
|
||||
{name.known ? (
|
||||
<span className="text-sm font-semibold">{name.label}</span>
|
||||
) : (
|
||||
<span className="text-sm font-semibold">{label}</span>
|
||||
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{name.label}</span>
|
||||
)}
|
||||
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
|
||||
{name.explanation && <p className="text-xs text-muted-foreground mt-0.5">{name.explanation}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
{/* inbuxa: raw protocol traffic isn't sent to the model (EX-9) */}
|
||||
{/* Raw protocol traffic isn't sent to the model (EX-9). */}
|
||||
{explainSubject && !isRawEvent(event.event) && (
|
||||
<ExplainButton
|
||||
icon
|
||||
className="mt-0.5"
|
||||
title={label}
|
||||
subject={() => explainSubject(event, idx)}
|
||||
title={name.label}
|
||||
subject={() => explainSubject(event, index)}
|
||||
/>
|
||||
)}
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground">{clockTime(event.timestamp)}</div>
|
||||
<div className="text-xs text-muted-foreground/60">{offsetFrom(anchorMs, event.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.keyValues.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{event.keyValues.map((kv, ki) => (
|
||||
<TraceKeyValueRow
|
||||
key={ki}
|
||||
kv={kv}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
depth={0}
|
||||
/>
|
||||
{event.keyValues.map((kv, i) => (
|
||||
<ValueRow key={i} kv={kv} names={names} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -186,148 +156,73 @@ export function TraceTimeline({ events, anchorTimestamp, explainSubject }: Trace
|
||||
);
|
||||
}
|
||||
|
||||
function TlsBoolean({ on }: { on: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}</>;
|
||||
interface ValueProps {
|
||||
names: Names;
|
||||
/** How deep in nested events this is; past two levels, an event shows as JSON. */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
function TraceKeyValueRow({
|
||||
kv,
|
||||
resolveKeyLabel,
|
||||
resolveEventLabel,
|
||||
depth,
|
||||
}: {
|
||||
kv: TraceKeyValue;
|
||||
resolveKeyLabel: (key: string) => string;
|
||||
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||
depth: number;
|
||||
}) {
|
||||
const label = resolveKeyLabel(kv.key);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
|
||||
function ValueRow({ kv, names, depth }: ValueProps & { kv: TraceKeyValue }) {
|
||||
return (
|
||||
<div className="flex gap-2 text-sm" style={{ paddingLeft: depth * 16 }}>
|
||||
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{label}</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{names.key(kv.key)}</span>
|
||||
<span className="text-xs pt-0.5">
|
||||
<TraceValueDisplay
|
||||
keyName={kv.key}
|
||||
value={kv.value}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
<Value keyName={kv.key} value={kv.value} names={names} depth={depth} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceValueDisplay({
|
||||
keyName,
|
||||
value,
|
||||
resolveKeyLabel,
|
||||
resolveEventLabel,
|
||||
viewToSection,
|
||||
depth,
|
||||
}: {
|
||||
keyName: string;
|
||||
value: TraceValue;
|
||||
resolveKeyLabel: (key: string) => string;
|
||||
resolveEventLabel: (event: string) => { label: string; explanation?: string };
|
||||
viewToSection: Record<string, string>;
|
||||
depth: number;
|
||||
}) {
|
||||
/** Very large integers can arrive wrapped as `{ source: "<digits>" }`. */
|
||||
function integerOf(raw: unknown): number | string {
|
||||
return raw !== null && typeof raw === 'object' && 'source' in raw ? String(raw.source) : (raw as number);
|
||||
}
|
||||
|
||||
function Value({ keyName, value, names, depth }: ValueProps & { keyName: string; value: TraceValue }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
switch (value['@type']) {
|
||||
case 'String':
|
||||
return <>{value.value}</>;
|
||||
case 'IpAddr':
|
||||
return value.value;
|
||||
case 'UnsignedInt':
|
||||
case 'Integer': {
|
||||
const raw = value.value;
|
||||
const numericInput =
|
||||
typeof raw === 'object' && raw !== null && 'source' in (raw as Record<string, unknown>)
|
||||
? ((raw as Record<string, unknown>).source as string)
|
||||
: raw;
|
||||
|
||||
if (isIdKey(keyName)) {
|
||||
const encoded = intToBase32(numericInput as number | string);
|
||||
if (keyName === 'queueId') {
|
||||
const section = viewToSection['x:QueuedMessage'] ?? 'Management';
|
||||
return (
|
||||
<Link to={`/${section}/x:QueuedMessage/${encoded}`} className="text-primary underline hover:no-underline">
|
||||
{encoded}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono">{encoded}</span>;
|
||||
}
|
||||
|
||||
const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput);
|
||||
return <>{displayNum.toLocaleString()}</>;
|
||||
}
|
||||
case 'Boolean': {
|
||||
if (keyName === 'tls') {
|
||||
return <TlsBoolean on={value.value} />;
|
||||
}
|
||||
return <>{String(value.value)}</>;
|
||||
const n = integerOf(value.value);
|
||||
if (!isIdKey(keyName)) return Number(n).toLocaleString();
|
||||
const id = idToText(n);
|
||||
if (keyName !== 'queueId') return <span className="font-mono">{id}</span>;
|
||||
return (
|
||||
<Link to={`/${names.queueSection}/x:QueuedMessage/${id}`} className="text-primary underline hover:no-underline">
|
||||
{id}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
case 'Boolean':
|
||||
if (keyName !== 'tls') return String(value.value);
|
||||
return value.value ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled');
|
||||
case 'Float':
|
||||
return <>{value.value.toFixed(2)}</>;
|
||||
return value.value.toFixed(2);
|
||||
case 'UTCDateTime':
|
||||
return (
|
||||
<>
|
||||
{new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value.value))}
|
||||
</>
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'medium' }).format(
|
||||
new Date(value.value),
|
||||
);
|
||||
case 'Duration': {
|
||||
const ms = value.value;
|
||||
if (ms < 1000) return <>{Math.round(ms)} ms</>;
|
||||
if (ms < 60_000) return <>{(ms / 1000).toFixed(1)} s</>;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = Math.floor((ms % 60_000) / 1000);
|
||||
return (
|
||||
<>
|
||||
{m} m {s} s
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'IpAddr':
|
||||
return <>{value.value}</>;
|
||||
case 'Duration':
|
||||
return shortDuration(value.value);
|
||||
case 'List': {
|
||||
if (value.value.length <= 3) {
|
||||
return (
|
||||
<>
|
||||
{value.value.map((v, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
<TraceValueDisplay
|
||||
keyName={keyName}
|
||||
value={v}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
const items = value.value.map((item, i) => (
|
||||
<Value key={i} keyName={keyName} value={item} names={names} depth={depth} />
|
||||
));
|
||||
// Up to three on one line, more one per line.
|
||||
if (items.length <= 3)
|
||||
return items.map((item, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{item}
|
||||
</span>
|
||||
));
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{value.value.map((v, i) => (
|
||||
<div key={i}>
|
||||
<TraceValueDisplay
|
||||
keyName={keyName}
|
||||
value={v}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
viewToSection={viewToSection}
|
||||
depth={depth}
|
||||
/>
|
||||
</div>
|
||||
{items.map((item, i) => (
|
||||
<div key={i}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -340,19 +235,13 @@ function TraceValueDisplay({
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
const { label, explanation } = resolveEventLabel(value.event);
|
||||
const name = names.event(value.event);
|
||||
return (
|
||||
<div className="mt-1 border-l-2 border-muted pl-3 space-y-1">
|
||||
<div className="text-xs font-semibold">{label}</div>
|
||||
{explanation && <div className="text-xs text-muted-foreground">{explanation}</div>}
|
||||
<div className="text-xs font-semibold">{name.label}</div>
|
||||
{name.explanation && <div className="text-xs text-muted-foreground">{name.explanation}</div>}
|
||||
{value.value.map((kv, i) => (
|
||||
<TraceKeyValueRow
|
||||
key={i}
|
||||
kv={kv}
|
||||
resolveKeyLabel={resolveKeyLabel}
|
||||
resolveEventLabel={resolveEventLabel}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
<ValueRow key={i} kv={kv} names={names} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -360,6 +249,6 @@ function TraceValueDisplay({
|
||||
case 'Null':
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
default:
|
||||
return <>{JSON.stringify(value)}</>;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { eventTone, idToText, isIdKey, longDuration, offsetFrom, shortDuration, traceSummary } from './format';
|
||||
import type { TraceEvent } from './types';
|
||||
|
||||
describe('idToText', () => {
|
||||
it("spells ids in the server's base-32 alphabet", () => {
|
||||
expect(idToText(0)).toBe('a');
|
||||
expect(idToText(31)).toBe('3');
|
||||
expect(idToText(32)).toBe('ba');
|
||||
expect(idToText(1234567)).toBe('bfvuh');
|
||||
expect(idToText(42.9)).toBe('bk');
|
||||
});
|
||||
|
||||
it('takes ids too big for a number as decimal text', () => {
|
||||
expect(idToText('18446744073709551615')).toBe('p333333333333');
|
||||
});
|
||||
|
||||
it('shows anything that is not a whole number as it came', () => {
|
||||
expect(idToText('not a number')).toBe('not a number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isIdKey', () => {
|
||||
it('knows ids by name', () => {
|
||||
expect(isIdKey('id')).toBe(true);
|
||||
expect(isIdKey('queueId')).toBe(true);
|
||||
expect(isIdKey('identity')).toBe(false);
|
||||
expect(isIdKey('Id')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventTone', () => {
|
||||
it.each([
|
||||
['smtp.error', 'error'],
|
||||
['auth.failed', 'error'],
|
||||
['message.invalid-header', 'error'],
|
||||
['spam.rejected', 'error'],
|
||||
['smtp.connection-start', 'span'],
|
||||
['delivery.attempt-end', 'span'],
|
||||
['smtp.ehlo', 'plain'],
|
||||
] as const)('%s is %s', (event, tone) => {
|
||||
expect(eventTone(event)).toBe(tone);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durations', () => {
|
||||
it('reads an offset from the first event', () => {
|
||||
const at = '2026-10-18T10:00:00.000Z';
|
||||
const plus = (ms: number) => new Date(Date.parse(at) + ms).toISOString();
|
||||
expect(offsetFrom(Date.parse(at), at)).toBe('(+0 ms)');
|
||||
expect(offsetFrom(Date.parse(at), plus(-5))).toBe('(+0 ms)');
|
||||
expect(offsetFrom(Date.parse(at), plus(250))).toBe('(+250 ms)');
|
||||
expect(offsetFrom(Date.parse(at), plus(12_340))).toBe('(+12.3 s)');
|
||||
expect(offsetFrom(Date.parse(at), plus(125_000))).toBe('(+2 m 5 s)');
|
||||
});
|
||||
|
||||
it('spells durations two ways: spaced in a value, joined in a summary', () => {
|
||||
expect(shortDuration(999.6)).toBe('1000 ms');
|
||||
expect(shortDuration(1500)).toBe('1.5 s');
|
||||
expect(shortDuration(61_000)).toBe('1 m 1 s');
|
||||
expect(longDuration(61_000)).toBe('1m 1s');
|
||||
expect(longDuration(800)).toBe('800 ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('traceSummary', () => {
|
||||
const ev = (event: string, timestamp: string): TraceEvent => ({ event, timestamp, keyValues: [] });
|
||||
const label = (name: string) => (name === 'smtp.connection-start' ? 'SMTP connection started' : undefined);
|
||||
|
||||
it('names the span by its first event and times it from first to last', () => {
|
||||
expect(
|
||||
traceSummary(
|
||||
[
|
||||
ev('smtp.connection-start', '2026-10-18T10:00:00.000Z'),
|
||||
ev('smtp.connection-end', '2026-10-18T10:00:02.500Z'),
|
||||
],
|
||||
label,
|
||||
),
|
||||
).toEqual({ span: 'SMTP connection started', startedAt: '2026-10-18T10:00:00.000Z', durationMs: 2500 });
|
||||
});
|
||||
|
||||
it('has no duration for a single event, and nothing for none', () => {
|
||||
expect(traceSummary([ev('odd.event', '2026-10-18T10:00:00.000Z')], label)).toEqual({
|
||||
span: 'odd.event',
|
||||
startedAt: '2026-10-18T10:00:00.000Z',
|
||||
durationMs: null,
|
||||
});
|
||||
expect(traceSummary([], label)).toEqual({ span: null, startedAt: null, durationMs: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** How trace events and their values read on screen. */
|
||||
import type { TraceEvent } from './types';
|
||||
|
||||
/** The server writes ids in base 32 with this alphabet, so a queue id here matches the one in its logs and URLs. */
|
||||
const ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
|
||||
/** An id as the server spells it. Very large ids arrive as decimal text; anything else shows as it came. */
|
||||
export function idToText(id: number | string): string {
|
||||
let n: bigint;
|
||||
try {
|
||||
n = BigInt(typeof id === 'number' ? Math.floor(id) : id);
|
||||
} catch {
|
||||
return String(id);
|
||||
}
|
||||
if (n < 0n) return String(id);
|
||||
let text = '';
|
||||
do {
|
||||
text = ID_ALPHABET[Number(n % 32n)] + text;
|
||||
n /= 32n;
|
||||
} while (n > 0n);
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Keys named `id` or ending in `Id` hold ids. */
|
||||
export const isIdKey = (key: string) => key === 'id' || key.endsWith('Id');
|
||||
|
||||
/** Failures get the error icon, span boundaries the clock, the rest a dot. */
|
||||
export function eventTone(event: string): 'error' | 'span' | 'plain' {
|
||||
if (['error', 'failed', 'invalid', 'reject'].some((word) => event.includes(word))) return 'error';
|
||||
if (event.endsWith('-start') || event.endsWith('-end')) return 'span';
|
||||
return 'plain';
|
||||
}
|
||||
|
||||
/** A duration in a trace value: "250 ms", "1.5 s", "2 m 5 s". */
|
||||
export function shortDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
return `${Math.floor(ms / 60_000)} m ${Math.floor((ms % 60_000) / 1000)} s`;
|
||||
}
|
||||
|
||||
/** A trace's whole duration in its summary: minutes and seconds run together, "2m 5s". */
|
||||
export function longDuration(ms: number): string {
|
||||
return ms < 60_000 ? shortDuration(ms) : `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`;
|
||||
}
|
||||
|
||||
/** How long after the anchor an event happened, "(+250 ms)"; never negative. */
|
||||
export function offsetFrom(anchorMs: number, timestamp: string): string {
|
||||
return `(+${shortDuration(Math.max(0, Date.parse(timestamp) - anchorMs))})`;
|
||||
}
|
||||
|
||||
/** A stored trace in brief: what its first event was, when it started, and how long it ran. */
|
||||
export function traceSummary(events: TraceEvent[], label: (event: string) => string | undefined) {
|
||||
const first = events.at(0);
|
||||
const last = events.at(-1);
|
||||
return {
|
||||
span: first ? (label(first.event) ?? first.event) : null,
|
||||
startedAt: first?.timestamp ?? null,
|
||||
durationMs: first && last && events.length >= 2 ? Date.parse(last.timestamp) - Date.parse(first.timestamp) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiFetch = vi.fn();
|
||||
vi.mock('@/services/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
getApiBaseUrl: () => 'https://mail.example',
|
||||
}));
|
||||
|
||||
class FakeSource {
|
||||
static all: FakeSource[] = [];
|
||||
closed = false;
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private listeners: ((e: { data: string }) => void)[] = [];
|
||||
url: string;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
FakeSource.all.push(this);
|
||||
}
|
||||
addEventListener(name: string, fn: (e: { data: string }) => void) {
|
||||
if (name === 'trace') this.listeners.push(fn);
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
send(data: unknown) {
|
||||
for (const fn of this.listeners) fn({ data: typeof data === 'string' ? data : JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('EventSource', FakeSource);
|
||||
|
||||
const { appendEvents, liveTracingQuery, streamLiveTrace, MAX_EVENTS } = await import('./live');
|
||||
|
||||
const ev = (n: number) => ({ event: `e${n}`, timestamp: `t${n}`, keyValues: [] });
|
||||
const last = () => FakeSource.all.at(-1)!;
|
||||
const handlers = () => ({ opened: vi.fn(), events: vi.fn(), failed: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
FakeSource.all = [];
|
||||
apiFetch.mockReset().mockResolvedValue({ text: async () => 'tok' });
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('appendEvents', () => {
|
||||
it('keeps the newest events up to the cap, and the first timestamp ever seen', () => {
|
||||
let buffer = appendEvents({ events: [], anchor: '' }, [ev(1), ev(2)]);
|
||||
expect(buffer).toEqual({ events: [ev(1), ev(2)], anchor: 't1' });
|
||||
buffer = appendEvents(
|
||||
buffer,
|
||||
Array.from({ length: MAX_EVENTS }, (_, i) => ev(i + 3)),
|
||||
);
|
||||
expect(buffer.events).toHaveLength(MAX_EVENTS);
|
||||
expect(buffer.events[0]).toEqual(ev(3));
|
||||
expect(buffer.anchor).toBe('t1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveTracingQuery', () => {
|
||||
it('sends the keywords and one parameter per filter, the last value winning', () => {
|
||||
expect(
|
||||
liveTracingQuery(' smtp ', [
|
||||
{ key: 'remoteIp', label: 'Remote IP', value: '1' },
|
||||
{ key: 'remoteIp', label: 'Remote IP', value: '2' },
|
||||
{ key: 'domain', label: 'Domain', value: 'a.org' },
|
||||
]),
|
||||
).toBe('filter=smtp&remoteIp=2&domain=a.org');
|
||||
expect(liveTracingQuery(' ', [])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamLiveTrace', () => {
|
||||
it('opens the stream with a fresh token and passes on each batch', async () => {
|
||||
const on = handlers();
|
||||
streamLiveTrace('filter=x', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/token/tracing');
|
||||
expect(last().url).toBe('https://mail.example/api/live/tracing?token=tok&filter=x');
|
||||
last().onopen!();
|
||||
expect(on.opened).toHaveBeenCalled();
|
||||
last().send({
|
||||
0: {
|
||||
event: 'smtp.ehlo',
|
||||
timestamp: 't',
|
||||
keyValues: { 0: { key: 'domain', value: { '@type': 'String', value: 'a' } } },
|
||||
},
|
||||
});
|
||||
expect(on.events).toHaveBeenCalledWith([
|
||||
{ event: 'smtp.ehlo', timestamp: 't', keyValues: [{ key: 'domain', value: { '@type': 'String', value: 'a' } }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a batch it cannot read', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const on = handlers();
|
||||
streamLiveTrace('', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
last().send('{oops');
|
||||
expect(on.events).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconnects up to five times, two seconds apart, then gives up', async () => {
|
||||
const on = handlers();
|
||||
streamLiveTrace('', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
last().onerror!();
|
||||
expect(last().closed).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1999);
|
||||
expect(FakeSource.all).toHaveLength(i);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(FakeSource.all).toHaveLength(i + 1);
|
||||
}
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
last().onerror!();
|
||||
expect(on.failed).toHaveBeenCalledWith({ reason: 'disconnected' });
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(FakeSource.all).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('starts counting again after a reconnect opens', async () => {
|
||||
const on = handlers();
|
||||
streamLiveTrace('', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
last().onerror!();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
}
|
||||
last().onopen!();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
last().onerror!();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
}
|
||||
expect(on.failed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails to start when there is no token', async () => {
|
||||
apiFetch.mockRejectedValueOnce(new Error('HTTP 403'));
|
||||
const on = handlers();
|
||||
streamLiveTrace('', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(on.failed).toHaveBeenCalledWith({ reason: 'start', message: 'HTTP 403' });
|
||||
});
|
||||
|
||||
it('stays stopped: no reconnect, no events, nothing opened late', async () => {
|
||||
const on = handlers();
|
||||
const stop = streamLiveTrace('', on);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
last().onerror!();
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(FakeSource.all).toHaveLength(1);
|
||||
|
||||
const early = streamLiveTrace('', on);
|
||||
early();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(FakeSource.all).toHaveLength(1);
|
||||
expect(on.failed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** Live tracing: the server's event stream, kept open through short drops, and the buffer the page shows. */
|
||||
import { apiFetch, getApiBaseUrl } from '@/services/api';
|
||||
import { normalizeTraceEvents } from './normalize';
|
||||
import type { TraceEvent } from './types';
|
||||
|
||||
/** The page keeps the newest events up to this many. */
|
||||
export const MAX_EVENTS = 1000;
|
||||
/** A dropped stream is reopened this many times in a row, this far apart, before the page gives up. */
|
||||
const RETRIES = 5;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
export interface LiveBuffer {
|
||||
events: TraceEvent[];
|
||||
/** The first event's timestamp, which the offsets count from; it stays when old events are dropped. */
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
export function appendEvents(buffer: LiveBuffer, batch: TraceEvent[]): LiveBuffer {
|
||||
const events = [...buffer.events, ...batch];
|
||||
return {
|
||||
events: events.length > MAX_EVENTS ? events.slice(-MAX_EVENTS) : events,
|
||||
anchor: buffer.anchor || (batch[0]?.timestamp ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** A key = value filter on the stream; `label` is the key's name for people. */
|
||||
export interface KeyFilter {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** The stream's query, less the token: any keywords, and each filter as its own parameter. */
|
||||
export function liveTracingQuery(keywords: string, filters: KeyFilter[]): string {
|
||||
const query = new URLSearchParams();
|
||||
if (keywords.trim()) query.set('filter', keywords.trim());
|
||||
for (const { key, value } of filters) query.set(key, value);
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
export type LiveFailure = { reason: 'disconnected' } | { reason: 'start'; message: string | null };
|
||||
|
||||
export interface LiveEvents {
|
||||
opened: () => void;
|
||||
events: (batch: TraceEvent[]) => void;
|
||||
failed: (failure: LiveFailure) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the live trace stream. `query` is everything but the token, which
|
||||
* is fetched first, as EventSource can't send headers. Returns a function
|
||||
* that stops it for good: no reconnects and no more callbacks after it.
|
||||
*/
|
||||
export function streamLiveTrace(query: string, on: LiveEvents): () => void {
|
||||
let source: EventSource | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
let retries = 0;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
clearTimeout(retry);
|
||||
source?.close();
|
||||
source = null;
|
||||
};
|
||||
|
||||
const connect = (url: string) => {
|
||||
const es = new EventSource(url);
|
||||
source = es;
|
||||
es.onopen = () => {
|
||||
if (stopped) return;
|
||||
retries = 0;
|
||||
on.opened();
|
||||
};
|
||||
es.addEventListener('trace', (message) => {
|
||||
if (stopped) return;
|
||||
let batch: TraceEvent[];
|
||||
try {
|
||||
batch = normalizeTraceEvents(JSON.parse((message as MessageEvent<string>).data));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse trace event:', e);
|
||||
return;
|
||||
}
|
||||
on.events(batch);
|
||||
});
|
||||
es.onerror = () => {
|
||||
if (stopped || es !== source) return;
|
||||
es.close();
|
||||
source = null;
|
||||
if (retries < RETRIES) {
|
||||
retries++;
|
||||
retry = setTimeout(() => connect(url), RETRY_DELAY_MS);
|
||||
} else {
|
||||
stopped = true;
|
||||
on.failed({ reason: 'disconnected' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
let token: string;
|
||||
try {
|
||||
token = await (await apiFetch('/api/token/tracing')).text();
|
||||
} catch (e) {
|
||||
if (!stopped) on.failed({ reason: 'start', message: e instanceof Error ? e.message : null });
|
||||
return;
|
||||
}
|
||||
if (stopped) return;
|
||||
const rest = query ? `&${query}` : '';
|
||||
connect(`${getApiBaseUrl()}/api/live/tracing?token=${encodeURIComponent(token)}${rest}`);
|
||||
})();
|
||||
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeTraceEvents } from './normalize';
|
||||
|
||||
describe('normalizeTraceEvents', () => {
|
||||
it('turns JMAP maps into arrays, in index order, all the way down', () => {
|
||||
expect(
|
||||
normalizeTraceEvents({
|
||||
1: { event: 'b', timestamp: 't2', keyValues: [] },
|
||||
0: {
|
||||
event: 'a',
|
||||
timestamp: 't1',
|
||||
keyValues: {
|
||||
1: {
|
||||
key: 'list',
|
||||
value: {
|
||||
'@type': 'List',
|
||||
value: { 1: { '@type': 'Integer', value: 2 }, 0: { '@type': 'Integer', value: 1 } },
|
||||
},
|
||||
},
|
||||
0: {
|
||||
key: 'cause',
|
||||
value: {
|
||||
'@type': 'Event',
|
||||
event: 'inner',
|
||||
value: { 0: { key: 'n', value: { '@type': 'Boolean', value: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
event: 'a',
|
||||
timestamp: 't1',
|
||||
keyValues: [
|
||||
{
|
||||
key: 'cause',
|
||||
value: {
|
||||
'@type': 'Event',
|
||||
event: 'inner',
|
||||
value: [{ key: 'n', value: { '@type': 'Boolean', value: true } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'list',
|
||||
value: {
|
||||
'@type': 'List',
|
||||
value: [
|
||||
{ '@type': 'Integer', value: 1 },
|
||||
{ '@type': 'Integer', value: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ event: 'b', timestamp: 't2', keyValues: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills in what is missing', () => {
|
||||
expect(normalizeTraceEvents([{ keyValues: [{ value: 'not an object' }, {}] }])).toEqual([
|
||||
{
|
||||
event: '',
|
||||
timestamp: '',
|
||||
keyValues: [
|
||||
{ key: '', value: { '@type': 'Null' } },
|
||||
{ key: '', value: { '@type': 'Null' } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(normalizeTraceEvents(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from './types';
|
||||
|
||||
type Raw = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Trace events as the server sends them, stored or live: key-values arrive as
|
||||
* a JMAP map ({"0": …, "1": …}), not an array.
|
||||
* Trace events as the server sends them, stored or live. Lists arrive as
|
||||
* JMAP maps ({"0": …, "1": …}), at every level, and become arrays here.
|
||||
*/
|
||||
export function normalizeTraceEvents(raw: unknown): TraceEvent[] {
|
||||
const events = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return events.map((evt) => ({
|
||||
event: String(evt.event ?? ''),
|
||||
timestamp: String(evt.timestamp ?? ''),
|
||||
keyValues: normalizeKeyValues(evt.keyValues),
|
||||
return jmapMapToArray<Raw>(raw).map((event) => ({
|
||||
event: String(event.event ?? ''),
|
||||
timestamp: String(event.timestamp ?? ''),
|
||||
keyValues: keyValues(event.keyValues),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
|
||||
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
|
||||
return kvs.map((kv) => ({
|
||||
key: String(kv.key ?? ''),
|
||||
value: normalizeTraceValue(kv.value),
|
||||
}));
|
||||
function keyValues(raw: unknown): TraceKeyValue[] {
|
||||
return jmapMapToArray<Raw>(raw).map((kv) => ({ key: String(kv.key ?? ''), value: value(kv.value) }));
|
||||
}
|
||||
|
||||
function normalizeTraceValue(raw: unknown): TraceValue {
|
||||
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const type = obj['@type'] as string;
|
||||
|
||||
if (type === 'List') {
|
||||
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
|
||||
function value(raw: unknown): TraceValue {
|
||||
if (raw === null || typeof raw !== 'object') return { '@type': 'Null' };
|
||||
const v = raw as Raw;
|
||||
switch (v['@type']) {
|
||||
case 'List':
|
||||
return { '@type': 'List', value: jmapMapToArray<unknown>(v.value).map(value) };
|
||||
case 'Event':
|
||||
return { '@type': 'Event', event: String(v.event ?? ''), value: keyValues(v.value) };
|
||||
default:
|
||||
return raw as TraceValue;
|
||||
}
|
||||
if (type === 'Event') {
|
||||
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
|
||||
}
|
||||
return raw as TraceValue;
|
||||
}
|
||||
@@ -1,24 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** A trace: the events of one span, such as an SMTP session, in order. */
|
||||
export interface Trace {
|
||||
events: TraceEvent[];
|
||||
}
|
||||
|
||||
export interface TraceEvent {
|
||||
/** The event type, e.g. `smtp.ehlo`; the schema's EventType enum names it for people. */
|
||||
event: string;
|
||||
keyValues: TraceKeyValue[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TraceKeyValue {
|
||||
/** The key; the schema's Key enum names it for people. */
|
||||
key: string;
|
||||
value: TraceValue;
|
||||
}
|
||||
|
||||
/** A value, tagged with its kind as the server sends it. Durations are in milliseconds. */
|
||||
export type TraceValue =
|
||||
| { '@type': 'String'; value: string }
|
||||
| { '@type': 'UnsignedInt'; value: number }
|
||||
@@ -29,5 +33,6 @@ export type TraceValue =
|
||||
| { '@type': 'Duration'; value: number }
|
||||
| { '@type': 'IpAddr'; value: string }
|
||||
| { '@type': 'List'; value: TraceValue[] }
|
||||
/** Another event nested inside this one, such as the cause of an error. */
|
||||
| { '@type': 'Event'; event: string; value: TraceKeyValue[] }
|
||||
| { '@type': 'Null' };
|
||||
Reference in new issue
Block a user