Files
Stalwart-webui/src/features/tracing/components/TraceTimeline.tsx
T

349 lines
11 KiB
TypeScript

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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<HTMLDivElement>(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 (
<div className="flex items-center justify-center p-8 text-muted-foreground">
{t('tracing.noEvents', 'No trace events to display.')}
</div>
);
}
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)`;
}
return (
<div className="relative">
<div ref={containerRef} onScroll={handleScroll} 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);
return (
<div key={idx} className="flex gap-3">
<div className="mt-1 shrink-0">{eventIcon(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>
) : (
<span className="text-sm font-semibold">{label}</span>
)}
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
</div>
<div className="shrink-0 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>
</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}
/>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-card to-transparent" />
</div>
);
}
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 (
<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="text-xs pt-0.5">
<TraceValueDisplay
keyName={kv.key}
value={kv.value}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
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;
}) {
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<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)}</>;
}
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) => (
<span key={i}>
{i > 0 && ', '}
<TraceValueDisplay
keyName={keyName}
value={v}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</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>
))}
</div>
);
}
case 'Event': {
if (depth >= 2) {
return (
<pre className="text-xs bg-muted/30 p-2 rounded whitespace-pre-wrap">
{JSON.stringify({ event: value.event, values: value.value }, null, 2)}
</pre>
);
}
const { label, explanation } = resolveEventLabel(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>}
{value.value.map((kv, i) => (
<TraceKeyValueRow
key={i}
kv={kv}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
depth={depth + 1}
/>
))}
</div>
);
}
case 'Null':
return <span className="text-muted-foreground">&mdash;</span>;
default:
return <>{JSON.stringify(value)}</>;
}
}