/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ import { useEffect, useRef } 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'; const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013'; 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(''); } function isIdKey(key: string): boolean { return key === 'id' || key.endsWith('Id'); } interface TraceTimelineProps { events: TraceEvent[]; anchorTimestamp?: string; } export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) { const { t } = useTranslation(); const schema = useSchemaStore((s) => s.schema); const containerRef = useRef(null); const wasAtBottomRef = useRef(true); useEffect(() => { const el = containerRef.current; if (!el) return; if (wasAtBottomRef.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 (
{t('tracing.noEvents', 'No trace events to display.')}
); } 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 ; } if (eventType.endsWith('-start') || eventType.endsWith('-end')) { return ; } return ; } 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)`; } return (
{events.map((event, idx) => { const { label, explanation } = resolveEventLabel(event.event); const isFallback = !eventTypeEnum.find((e) => e.name === event.event); return (
{eventIcon(event.event)}
{isFallback ? ( {label} ) : ( {label} )} {explanation &&

{explanation}

}
{formatAbsoluteTime(event.timestamp)}
{formatRelativeTime(event.timestamp)}
{event.keyValues.length > 0 && (
{event.keyValues.map((kv, ki) => ( ))}
)}
); })}
); } function TlsBoolean({ on }: { on: boolean }) { const { t } = useTranslation(); return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}; } 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); return (
{label}
); } 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; depth: number; }) { switch (value['@type']) { case 'String': return <>{value.value}; case 'UnsignedInt': case 'Integer': { const raw = value.value; const numericInput = typeof raw === 'object' && raw !== null && 'source' in (raw as Record) ? ((raw as Record).source as string) : raw; if (isIdKey(keyName)) { const encoded = intToBase32(numericInput as number | string); if (keyName === 'queueId') { const section = viewToSection['x:QueuedMessage'] ?? 'Management'; return ( {encoded} ); } return {encoded}; } const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput); return <>{displayNum.toLocaleString()}; } case 'Boolean': { if (keyName === 'tls') { return ; } return <>{String(value.value)}; } case 'Float': return <>{value.value.toFixed(2)}; case 'UTCDateTime': 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 'List': { if (value.value.length <= 3) { return ( <> {value.value.map((v, i) => ( {i > 0 && ', '} ))} ); } return (
{value.value.map((v, i) => (
))}
); } case 'Event': { if (depth >= 2) { return (
            {JSON.stringify({ event: value.event, values: value.value }, null, 2)}
          
); } const { label, explanation } = resolveEventLabel(value.event); return (
{label}
{explanation &&
{explanation}
} {value.value.map((kv, i) => ( ))}
); } case 'Null': return ; default: return <>{JSON.stringify(value)}; } }