mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: first draft of tracing ui primitives
This commit is contained in:
1 parent
1168fd8a21
commit
798bca9761
32 files changed
+4228
No files matched your search
@@ -0,0 +1,9 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -70,6 +70,11 @@ export const docsConfig: DocsConfig = {
|
||||
href: '/docs/ui-patterns/charts',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Tracing',
|
||||
href: '/docs/ui-patterns/tracing',
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: 'Connect Interstitials',
|
||||
href: '/docs/ui-patterns/connect-interstitials',
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: Tracing
|
||||
description: Composable primitives for distributed-trace views built on the shared design system
|
||||
---
|
||||
|
||||
```tsx
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
```
|
||||
|
||||
## Ruler
|
||||
|
||||
<ComponentPreview name="trace-ruler-demo" peekCode wide />
|
||||
|
||||
## Bar and Markers
|
||||
|
||||
<ComponentPreview name="trace-span-bar-demo" peekCode wide />
|
||||
|
||||
## Cell
|
||||
|
||||
<ComponentPreview name="trace-tree-cell-demo" peekCode wide />
|
||||
|
||||
## Minimap
|
||||
|
||||
<ComponentPreview name="trace-minimap-demo" peekCode wide />
|
||||
|
||||
## Inspector
|
||||
|
||||
<ComponentPreview name="trace-inspector-demo" peekCode wide />
|
||||
|
||||
## Composition
|
||||
|
||||
<ComponentPreview name="trace-waterfall-demo" peekCode wide />
|
||||
|
||||
### Compact, read-only embed
|
||||
|
||||
<ComponentPreview name="trace-waterfall-compact-demo" peekCode wide />
|
||||
|
||||
### Selection synced to the URL
|
||||
|
||||
<ComponentPreview name="trace-waterfall-url-sync-demo" peekCode wide />
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import { ToggleGroup, ToggleGroupItem } from 'ui'
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
const sample = createSampleTrace()
|
||||
|
||||
const OPTIONS = [
|
||||
{ id: 'none', label: 'None' },
|
||||
{ id: 'span-0001', label: 'Root' },
|
||||
{ id: 'span-0008', label: 'Error' },
|
||||
{ id: 'span-0016', label: 'Running' },
|
||||
]
|
||||
|
||||
export default function TraceInspectorDemo() {
|
||||
const [selectedId, setSelectedId] = useState('span-0008')
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="segmented"
|
||||
tone="text"
|
||||
value={selectedId}
|
||||
onValueChange={(value) => setSelectedId(value)}
|
||||
allowDeselect={false}
|
||||
aria-label="Inspected span"
|
||||
>
|
||||
{OPTIONS.map((option) => (
|
||||
<ToggleGroupItem key={option.id} value={option.id}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Trace.Root
|
||||
trace={sample}
|
||||
selectedId={selectedId === 'none' ? null : selectedId}
|
||||
onSelectedIdChange={(id) => setSelectedId(id ?? 'none')}
|
||||
>
|
||||
<Trace.Inspector className="h-[420px] w-full max-w-md rounded-md border" />
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Slider, Toggle } from 'ui'
|
||||
import { Trace, type TimeWindow } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
export default function TraceMinimapDemo() {
|
||||
const [isLarge, setIsLarge] = useState(false)
|
||||
const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 5000 : 0 }), [isLarge])
|
||||
const [window, setWindow] = useState<TimeWindow | undefined>(undefined)
|
||||
|
||||
const start = sample.spans[0].startMs
|
||||
const bounds: TimeWindow = [start, sample.nowMs]
|
||||
const current = window ?? bounds
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Toggle variant="outline" size="sm" pressed={isLarge} onPressedChange={setIsLarge}>
|
||||
5,000 spans (density mode)
|
||||
</Toggle>
|
||||
<label className="flex flex-1 items-center gap-2 text-xs text-foreground-light">
|
||||
Window
|
||||
<Slider
|
||||
className="min-w-40 flex-1"
|
||||
min={bounds[0]}
|
||||
max={bounds[1]}
|
||||
step={1}
|
||||
minStepsBetweenThumbs={5}
|
||||
value={[current[0], current[1]]}
|
||||
onValueChange={([a, b]) => setWindow([a, b])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Trace.Root trace={sample} window={window} onWindowChange={setWindow}>
|
||||
<Trace.Minimap />
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { Slider } from 'ui'
|
||||
import { Trace, type Span, type TimeWindow } from 'ui-patterns/Trace'
|
||||
|
||||
function traceOf(durationMs: number): { spans: Span[] } {
|
||||
return {
|
||||
spans: [
|
||||
{
|
||||
id: 'root',
|
||||
parentId: null,
|
||||
traceId: 'ruler',
|
||||
name: 'root',
|
||||
serviceName: 'svc',
|
||||
kind: 'server',
|
||||
startMs: 0,
|
||||
endMs: durationMs,
|
||||
status: 'ok',
|
||||
attributes: {},
|
||||
events: [],
|
||||
links: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const trace = traceOf(1000)
|
||||
|
||||
export default function TraceRulerDemo() {
|
||||
const [window, setWindow] = useState<TimeWindow>([0, 1000])
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<label className="flex flex-col gap-2 text-xs text-foreground-light">
|
||||
Window
|
||||
<Slider
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
minStepsBetweenThumbs={10}
|
||||
value={[window[0], window[1]]}
|
||||
onValueChange={([start, end]) => setWindow([start, end])}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Trace.Root trace={trace} window={window} onWindowChange={setWindow}>
|
||||
<Trace.Waterfall treeWidth={80}>
|
||||
<Trace.Ruler />
|
||||
</Trace.Waterfall>
|
||||
</Trace.Root>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{[
|
||||
{ durationMs: 2, label: 'Sub-millisecond' },
|
||||
{ durationMs: 412, label: 'Request-sized' },
|
||||
{ durationMs: 95 * 60_000, label: 'Minutes' },
|
||||
].map(({ durationMs, label }) => (
|
||||
<Trace.Root key={label} trace={traceOf(durationMs)}>
|
||||
<Trace.Waterfall treeWidth={120}>
|
||||
<Trace.Ruler>{label}</Trace.Ruler>
|
||||
</Trace.Waterfall>
|
||||
</Trace.Root>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import type { Span, SpanKind, SpanStatus } from 'ui-patterns/Trace'
|
||||
|
||||
export interface SampleTraceOptions {
|
||||
spanCount?: number
|
||||
seed?: number
|
||||
startMs?: number
|
||||
includeOrphan?: boolean
|
||||
includeRunning?: boolean
|
||||
includeZeroDuration?: boolean
|
||||
includeClockSkew?: boolean
|
||||
}
|
||||
|
||||
export interface SampleTrace {
|
||||
traceId: string
|
||||
spans: Span[]
|
||||
nowMs: number
|
||||
}
|
||||
|
||||
export const SAMPLE_TRACE_ID = 'trace-4f2a9c1e7b3d5a60'
|
||||
export const SAMPLE_TRACE_START_MS = Date.UTC(2026, 0, 15, 9, 30, 0)
|
||||
|
||||
function mulberry32(seed: number) {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0
|
||||
let t = state
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function span(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
name: string,
|
||||
serviceName: string,
|
||||
kind: SpanKind,
|
||||
startMs: number,
|
||||
endMs: number | null,
|
||||
status: SpanStatus,
|
||||
base: number,
|
||||
extra: Partial<Pick<Span, 'attributes' | 'events' | 'links'>> = {}
|
||||
): Span {
|
||||
return {
|
||||
id,
|
||||
parentId,
|
||||
traceId: SAMPLE_TRACE_ID,
|
||||
name,
|
||||
serviceName,
|
||||
kind,
|
||||
startMs: base + startMs,
|
||||
endMs: endMs === null ? null : base + endMs,
|
||||
status,
|
||||
attributes: extra.attributes ?? {},
|
||||
events: (extra.events ?? []).map((event) => ({ ...event, timeMs: base + event.timeMs })),
|
||||
links: extra.links ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function baseSpans(base: number, options: Required<SampleTraceOptions>): Span[] {
|
||||
const spans: Span[] = [
|
||||
span('span-0001', null, 'GET /api/orders', 'api-gateway', 'server', 0, 412, 'ok', base, {
|
||||
attributes: {
|
||||
'http.method': 'GET',
|
||||
'http.route': '/api/orders',
|
||||
'http.status_code': 200,
|
||||
'http.url': 'https://api.example.com/api/orders?limit=50',
|
||||
'user.id': 'usr_8f3k2',
|
||||
'net.peer.ip': '10.0.3.14',
|
||||
'request.id': 'req_01HZX4M2',
|
||||
},
|
||||
events: [
|
||||
{ timeMs: 0.3, name: 'request.received' },
|
||||
{ timeMs: 12, name: 'log', attributes: { 'log.severity': 'INFO', message: 'cache miss' } },
|
||||
],
|
||||
}),
|
||||
span('span-0002', 'span-0001', 'authenticate', 'auth-service', 'client', 2, 38, 'ok', base, {
|
||||
attributes: { 'auth.method': 'jwt', 'auth.cached': false, 'auth.token_expiry': 1768556400 },
|
||||
}),
|
||||
span('span-0003', 'span-0002', 'jwt.verify', 'auth-service', 'internal', 4, 31, 'ok', base, {
|
||||
attributes: { 'jwt.alg': 'RS256', 'jwt.kid': 'k-2026-01' },
|
||||
}),
|
||||
span(
|
||||
'span-0004',
|
||||
'span-0001',
|
||||
'cache.get orders:usr_8f3k2',
|
||||
'redis-cache',
|
||||
'client',
|
||||
40,
|
||||
43,
|
||||
'ok',
|
||||
base,
|
||||
{ attributes: { 'db.system': 'redis', 'cache.hit': false } }
|
||||
),
|
||||
span('span-0005', 'span-0001', 'SELECT orders', 'postgres', 'client', 45, 210, 'ok', base, {
|
||||
attributes: {
|
||||
'db.system': 'postgresql',
|
||||
'db.name': 'app',
|
||||
'db.statement':
|
||||
'SELECT id, total, status FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 50',
|
||||
'db.rows': 50,
|
||||
},
|
||||
}),
|
||||
span('span-0006', 'span-0005', 'pg.connect', 'postgres', 'internal', 45, 52, 'ok', base, {
|
||||
attributes: { 'pool.waiting': 3, 'pool.size': 20 },
|
||||
}),
|
||||
span('span-0007', 'span-0005', 'pg.execute', 'postgres', 'internal', 52, 206, 'ok', base, {
|
||||
events: [{ timeMs: 180, name: 'first_row' }],
|
||||
}),
|
||||
span(
|
||||
'span-0008',
|
||||
'span-0001',
|
||||
'POST /payments/authorize',
|
||||
'payment-service',
|
||||
'client',
|
||||
215,
|
||||
398,
|
||||
'error',
|
||||
base,
|
||||
{
|
||||
attributes: {
|
||||
'http.method': 'POST',
|
||||
'http.status_code': 502,
|
||||
'error.message': 'upstream timeout after 150ms',
|
||||
'retry.count': 1,
|
||||
'payment.provider_response': {
|
||||
code: 'TIMEOUT',
|
||||
attempt: 2,
|
||||
details: { region: 'eu-west-1', latency_ms: 150 },
|
||||
},
|
||||
},
|
||||
events: [
|
||||
{
|
||||
timeMs: 395,
|
||||
name: 'exception',
|
||||
attributes: {
|
||||
'exception.type': 'UpstreamTimeoutError',
|
||||
'exception.message': 'upstream timeout after 150ms',
|
||||
'exception.stacktrace':
|
||||
'UpstreamTimeoutError: upstream timeout after 150ms\n at charge (payments/provider.ts:88:11)\n at authorize (payments/authorize.ts:42:5)',
|
||||
},
|
||||
},
|
||||
],
|
||||
links: [{ traceId: 'trace-91cc0e4d2a7f1b38', spanId: 'span-77aa' }],
|
||||
}
|
||||
),
|
||||
span(
|
||||
'span-0009',
|
||||
'span-0008',
|
||||
'authorize',
|
||||
'payment-service',
|
||||
'server',
|
||||
218,
|
||||
392,
|
||||
'error',
|
||||
base,
|
||||
{
|
||||
attributes: { 'payment.amount_cents': 12900, 'payment.currency': 'EUR' },
|
||||
}
|
||||
),
|
||||
span('span-0010', 'span-0009', 'fraud.check', 'fraud-service', 'client', 220, 268, 'ok', base, {
|
||||
attributes: { 'fraud.model': 'v7', 'fraud.score': 0.12 },
|
||||
events: [{ timeMs: 265, name: 'score.computed', attributes: { 'fraud.score': 0.12 } }],
|
||||
}),
|
||||
span(
|
||||
'span-0011',
|
||||
'span-0009',
|
||||
'provider.charge',
|
||||
'payment-service',
|
||||
'client',
|
||||
270,
|
||||
390,
|
||||
'error',
|
||||
base,
|
||||
{ attributes: { 'provider.name': 'cards-eu', 'provider.timeout_ms': 150 } }
|
||||
),
|
||||
span(
|
||||
'span-0012',
|
||||
'span-0011',
|
||||
'http.request',
|
||||
'provider-client',
|
||||
'client',
|
||||
options.includeClockSkew ? 268 : 271,
|
||||
389,
|
||||
'error',
|
||||
base,
|
||||
{
|
||||
attributes: {
|
||||
'http.method': 'POST',
|
||||
'http.url': 'https://cards-eu.example.net/v1/charges',
|
||||
},
|
||||
}
|
||||
),
|
||||
span(
|
||||
'span-0014',
|
||||
'span-0001',
|
||||
'render.response',
|
||||
'api-gateway',
|
||||
'internal',
|
||||
402,
|
||||
411,
|
||||
'ok',
|
||||
base,
|
||||
{
|
||||
attributes: { 'response.bytes': 18422, 'response.format': 'json' },
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
if (options.includeZeroDuration) {
|
||||
spans.push(
|
||||
span('span-0013', 'span-0001', 'cache.set', 'redis-cache', 'client', 400, 400, 'ok', base, {
|
||||
attributes: { 'db.system': 'redis', 'cache.ttl_seconds': 60 },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (options.includeOrphan) {
|
||||
spans.push(
|
||||
span(
|
||||
'span-0015',
|
||||
'span-missing-parent',
|
||||
'background.job',
|
||||
'worker-service',
|
||||
'consumer',
|
||||
300,
|
||||
360,
|
||||
'ok',
|
||||
base,
|
||||
{ attributes: { 'messaging.system': 'pgmq', 'messaging.destination': 'order_events' } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (options.includeRunning) {
|
||||
spans.push(
|
||||
span(
|
||||
'span-0016',
|
||||
'span-0001',
|
||||
'webhook.dispatch',
|
||||
'webhook-service',
|
||||
'producer',
|
||||
405,
|
||||
null,
|
||||
'unset',
|
||||
base,
|
||||
{
|
||||
attributes: { 'webhook.url': 'https://hooks.example.com/orders', 'webhook.attempt': 1 },
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return spans
|
||||
}
|
||||
|
||||
const FILLER_NAMES = [
|
||||
'db.query',
|
||||
'cache.get',
|
||||
'http.request',
|
||||
'serialize',
|
||||
'validate',
|
||||
'deserialize',
|
||||
'queue.publish',
|
||||
'fn.invoke',
|
||||
'template.render',
|
||||
'policy.evaluate',
|
||||
]
|
||||
|
||||
const FILLER_SERVICES = [
|
||||
'api-gateway',
|
||||
'auth-service',
|
||||
'postgres',
|
||||
'redis-cache',
|
||||
'payment-service',
|
||||
'fraud-service',
|
||||
'worker-service',
|
||||
'search-service',
|
||||
]
|
||||
|
||||
export function createSampleTrace(options: SampleTraceOptions = {}): SampleTrace {
|
||||
const resolved: Required<SampleTraceOptions> = {
|
||||
spanCount: options.spanCount ?? 0,
|
||||
seed: options.seed ?? 42,
|
||||
startMs: options.startMs ?? SAMPLE_TRACE_START_MS,
|
||||
includeOrphan: options.includeOrphan ?? true,
|
||||
includeRunning: options.includeRunning ?? true,
|
||||
includeZeroDuration: options.includeZeroDuration ?? true,
|
||||
includeClockSkew: options.includeClockSkew ?? true,
|
||||
}
|
||||
|
||||
const base = resolved.startMs
|
||||
const spans = baseSpans(base, resolved)
|
||||
const nowMs = base + 430
|
||||
|
||||
if (resolved.spanCount > spans.length) {
|
||||
const random = mulberry32(resolved.seed)
|
||||
const depthOf = new Map<string, number>()
|
||||
const endOf = (candidate: Span) => candidate.endMs ?? nowMs
|
||||
for (const existing of spans) depthOf.set(existing.id, 0)
|
||||
|
||||
const parentPool = spans.filter((candidate) => endOf(candidate) - candidate.startMs > 2)
|
||||
let counter = spans.length + 1
|
||||
|
||||
while (spans.length < resolved.spanCount) {
|
||||
const parent = parentPool[Math.floor(random() * parentPool.length)]
|
||||
const parentDepth = depthOf.get(parent.id) ?? 0
|
||||
const parentStart = parent.startMs
|
||||
const parentDuration = Math.max(1, endOf(parent) - parentStart)
|
||||
const start = parentStart + random() * parentDuration * 0.85
|
||||
const maxDuration = Math.max(0.05, endOf(parent) - start)
|
||||
const duration = Math.max(0.05, random() * maxDuration * 0.6)
|
||||
const isError = random() < 0.03
|
||||
const id = `span-${counter.toString(16).padStart(6, '0')}`
|
||||
counter++
|
||||
|
||||
const child: Span = {
|
||||
id,
|
||||
parentId: parent.id,
|
||||
traceId: SAMPLE_TRACE_ID,
|
||||
name: FILLER_NAMES[Math.floor(random() * FILLER_NAMES.length)],
|
||||
serviceName: FILLER_SERVICES[Math.floor(random() * FILLER_SERVICES.length)],
|
||||
kind: 'internal',
|
||||
startMs: start,
|
||||
endMs: start + duration,
|
||||
status: isError ? 'error' : 'ok',
|
||||
attributes: { 'generated.index': counter, 'generated.depth': parentDepth + 1 },
|
||||
events: isError
|
||||
? [
|
||||
{
|
||||
timeMs: start + duration * 0.9,
|
||||
name: 'exception',
|
||||
attributes: { 'exception.type': 'Error' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
links: [],
|
||||
}
|
||||
spans.push(child)
|
||||
depthOf.set(id, parentDepth + 1)
|
||||
if (parentDepth + 1 < 12 && duration > 2) parentPool.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
return { traceId: SAMPLE_TRACE_ID, spans, nowMs }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { Slider, Toggle } from 'ui'
|
||||
import { Trace, useSpan, type Span, type TimeWindow } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
const sample = createSampleTrace()
|
||||
const root = sample.spans[0]
|
||||
const base = root.startMs
|
||||
|
||||
function make(id: string, name: string, overrides: Partial<Span> = {}): Span {
|
||||
return { ...root, id, parentId: null, name, status: 'ok', events: [], ...overrides }
|
||||
}
|
||||
|
||||
const spans: Span[] = [
|
||||
make('default', 'default'),
|
||||
make('selected', 'selected'),
|
||||
make('error', 'error status', { status: 'error' }),
|
||||
make('unset', 'unset status', { status: 'unset' }),
|
||||
make('running', 'running', { startMs: base + 300, endMs: null, status: 'unset' }),
|
||||
make('zero', 'zero duration', { startMs: base + 200, endMs: base + 200 }),
|
||||
make('markers', 'markers', {
|
||||
startMs: base + 20,
|
||||
endMs: base + 380,
|
||||
events: [
|
||||
{ timeMs: base + 60, name: 'log', attributes: { 'log.severity': 'INFO' } },
|
||||
{ timeMs: base + 200, name: 'cache.warmed' },
|
||||
{ timeMs: base + 340, name: 'exception', attributes: { 'exception.type': 'TimeoutError' } },
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
const filteredSpans: Span[] = [
|
||||
make('dim', 'dim (ancestor of a match)'),
|
||||
make('match', 'match', { parentId: 'dim', startMs: base + 100, endMs: base + 250 }),
|
||||
]
|
||||
|
||||
function Label() {
|
||||
const { span } = useSpan()
|
||||
return (
|
||||
<span className="w-44 shrink-0 self-center px-2 text-xs text-foreground-light">
|
||||
{span.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Gallery() {
|
||||
return (
|
||||
<Trace.Waterfall className="h-auto">
|
||||
<Trace.Rows rowHeight={28} className="h-56" readOnly>
|
||||
<Label />
|
||||
<Trace.Lane>
|
||||
<Trace.Bar />
|
||||
<Trace.Markers />
|
||||
</Trace.Lane>
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TraceSpanBarDemo() {
|
||||
const [isZoomed, setIsZoomed] = useState(false)
|
||||
const [offset, setOffset] = useState(120)
|
||||
const window: TimeWindow | undefined = isZoomed ? [base + offset, base + offset + 180] : undefined
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Toggle variant="outline" size="sm" pressed={isZoomed} onPressedChange={setIsZoomed}>
|
||||
Zoom window (clips bars)
|
||||
</Toggle>
|
||||
<label className="flex flex-1 items-center gap-2 text-xs text-foreground-light">
|
||||
Window start
|
||||
<Slider
|
||||
className="min-w-40 flex-1"
|
||||
min={0}
|
||||
max={250}
|
||||
step={5}
|
||||
disabled={!isZoomed}
|
||||
value={[offset]}
|
||||
onValueChange={([value]) => setOffset(value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Trace.Root trace={{ spans, nowMs: sample.nowMs }} window={window} selectedId="selected">
|
||||
<Gallery />
|
||||
</Trace.Root>
|
||||
<Trace.Root
|
||||
trace={{ spans: filteredSpans, nowMs: sample.nowMs }}
|
||||
window={window}
|
||||
defaultQuery="name:match"
|
||||
>
|
||||
<Trace.Waterfall className="h-auto">
|
||||
<Trace.Rows rowHeight={28} className="h-16" readOnly>
|
||||
<Label />
|
||||
<Trace.Lane>
|
||||
<Trace.Bar />
|
||||
</Trace.Lane>
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import { Slider } from 'ui'
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
const sample = createSampleTrace()
|
||||
const defaultCollapsed = new Set(['span-0008'])
|
||||
|
||||
export default function TraceTreeCellDemo() {
|
||||
const [indent, setIndent] = useState(14)
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<label className="flex items-center gap-2 text-xs text-foreground-light">
|
||||
Indent {indent}px
|
||||
<Slider
|
||||
className="min-w-40 flex-1"
|
||||
min={8}
|
||||
max={32}
|
||||
step={2}
|
||||
value={[indent]}
|
||||
onValueChange={([value]) => setIndent(value)}
|
||||
/>
|
||||
</label>
|
||||
<Trace.Root trace={sample} defaultCollapsed={defaultCollapsed}>
|
||||
<Trace.Waterfall treeWidth={420} className="h-96 max-w-md">
|
||||
<Trace.Rows rowHeight={28}>
|
||||
<Trace.Cell indent={indent} className="border-r-0" />
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
const sample = createSampleTrace({ includeOrphan: false, includeRunning: false })
|
||||
|
||||
export default function TraceWaterfallCompactDemo() {
|
||||
return (
|
||||
<Trace.Root trace={sample}>
|
||||
<Trace.Waterfall treeWidth={200} className="h-56 w-full">
|
||||
<Trace.Ruler />
|
||||
<Trace.Rows rowHeight={22} readOnly>
|
||||
<Trace.Cell indent={10} />
|
||||
<Trace.Lane>
|
||||
<Trace.Bar />
|
||||
</Trace.Lane>
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
</Trace.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Toggle } from 'ui'
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
export default function TraceWaterfallDemo() {
|
||||
const [isLarge, setIsLarge] = useState(false)
|
||||
const sample = useMemo(() => createSampleTrace({ spanCount: isLarge ? 100_000 : 0 }), [isLarge])
|
||||
const [selectedId, setSelectedId] = useState<string | null>('span-0008')
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Trace.Root trace={sample} selectedId={selectedId} onSelectedIdChange={setSelectedId}>
|
||||
<Trace.Header>
|
||||
<Trace.Title />
|
||||
<Trace.Status />
|
||||
<Trace.Filter placeholder="Filter spans, e.g. status:error" />
|
||||
<Trace.CollapseAll />
|
||||
<Trace.ResetZoom />
|
||||
<Toggle
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
pressed={isLarge}
|
||||
onPressedChange={setIsLarge}
|
||||
>
|
||||
100k spans
|
||||
</Toggle>
|
||||
</Trace.Header>
|
||||
<Trace.Minimap />
|
||||
<div className="flex h-[480px] overflow-hidden rounded-md border border-default">
|
||||
<Trace.Waterfall treeWidth={320} className="min-w-0 flex-1 rounded-none border-0">
|
||||
<Trace.Ruler />
|
||||
<Trace.Rows rowHeight={28}>
|
||||
<Trace.Cell />
|
||||
<Trace.Lane>
|
||||
<Trace.Bar />
|
||||
<Trace.Markers />
|
||||
</Trace.Lane>
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
<Trace.Inspector className="w-[340px] shrink-0" />
|
||||
</div>
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import { Trace } from 'ui-patterns/Trace'
|
||||
|
||||
import { createSampleTrace } from './trace-sample'
|
||||
|
||||
const sample = createSampleTrace()
|
||||
const PARAM = 'span'
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener('popstate', callback)
|
||||
return () => window.removeEventListener('popstate', callback)
|
||||
}
|
||||
|
||||
function readSpanParam() {
|
||||
return new URLSearchParams(window.location.search).get(PARAM)
|
||||
}
|
||||
|
||||
function useUrlSelection() {
|
||||
const selectedId = useSyncExternalStore(subscribe, readSpanParam, () => null)
|
||||
const setSelectedId = useCallback((id: string | null) => {
|
||||
const url = new URL(window.location.href)
|
||||
if (id) url.searchParams.set(PARAM, id)
|
||||
else url.searchParams.delete(PARAM)
|
||||
window.history.replaceState(window.history.state, '', url)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}, [])
|
||||
return { selectedId, setSelectedId }
|
||||
}
|
||||
|
||||
export default function TraceWaterfallUrlSyncDemo() {
|
||||
const { selectedId, setSelectedId } = useUrlSelection()
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<p className="font-mono text-xs text-foreground-light">
|
||||
?{PARAM}={selectedId ?? '(none)'}
|
||||
</p>
|
||||
<Trace.Root trace={sample} selectedId={selectedId} onSelectedIdChange={setSelectedId}>
|
||||
<div className="flex h-72 overflow-hidden rounded-md border border-default">
|
||||
<Trace.Waterfall treeWidth={240} className="min-w-0 flex-1 rounded-none border-0">
|
||||
<Trace.Ruler />
|
||||
<Trace.Rows rowHeight={26}>
|
||||
<Trace.Cell />
|
||||
<Trace.Lane>
|
||||
<Trace.Bar />
|
||||
</Trace.Lane>
|
||||
</Trace.Rows>
|
||||
</Trace.Waterfall>
|
||||
<Trace.Inspector className="w-72 shrink-0" />
|
||||
</div>
|
||||
</Trace.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1924,4 +1924,50 @@ export const examples: Registry = [
|
||||
type: 'components:example',
|
||||
files: ['example/status-code-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-ruler-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['slider'],
|
||||
files: ['example/trace-ruler-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-span-bar-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['slider', 'toggle'],
|
||||
files: ['example/trace-span-bar-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-tree-cell-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['slider', 'toggle'],
|
||||
files: ['example/trace-tree-cell-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-minimap-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['slider', 'toggle'],
|
||||
files: ['example/trace-minimap-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-inspector-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['toggle-group'],
|
||||
files: ['example/trace-inspector-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-waterfall-demo',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['toggle'],
|
||||
files: ['example/trace-waterfall-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-waterfall-compact-demo',
|
||||
type: 'components:example',
|
||||
files: ['example/trace-waterfall-compact-demo.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'trace-waterfall-url-sync-demo',
|
||||
type: 'components:example',
|
||||
files: ['example/trace-waterfall-url-sync-demo.tsx'],
|
||||
},
|
||||
]
|
||||
@@ -710,6 +710,66 @@
|
||||
"import": "./src/Toc/types.ts",
|
||||
"types": "./src/Toc/types.ts"
|
||||
},
|
||||
"./Trace/Bar": {
|
||||
"import": "./src/Trace/Bar.tsx",
|
||||
"types": "./src/Trace/Bar.tsx"
|
||||
},
|
||||
"./Trace/Cell": {
|
||||
"import": "./src/Trace/Cell.tsx",
|
||||
"types": "./src/Trace/Cell.tsx"
|
||||
},
|
||||
"./Trace/Header": {
|
||||
"import": "./src/Trace/Header.tsx",
|
||||
"types": "./src/Trace/Header.tsx"
|
||||
},
|
||||
"./Trace/Inspector": {
|
||||
"import": "./src/Trace/Inspector.tsx",
|
||||
"types": "./src/Trace/Inspector.tsx"
|
||||
},
|
||||
"./Trace/Minimap": {
|
||||
"import": "./src/Trace/Minimap.tsx",
|
||||
"types": "./src/Trace/Minimap.tsx"
|
||||
},
|
||||
"./Trace/Trace": {
|
||||
"import": "./src/Trace/Trace.tsx",
|
||||
"types": "./src/Trace/Trace.tsx"
|
||||
},
|
||||
"./Trace/Waterfall": {
|
||||
"import": "./src/Trace/Waterfall.tsx",
|
||||
"types": "./src/Trace/Waterfall.tsx"
|
||||
},
|
||||
"./Trace/hooks/useBrush": {
|
||||
"import": "./src/Trace/hooks/useBrush.ts",
|
||||
"types": "./src/Trace/hooks/useBrush.ts"
|
||||
},
|
||||
"./Trace/hooks/useKeyboard": {
|
||||
"import": "./src/Trace/hooks/useKeyboard.ts",
|
||||
"types": "./src/Trace/hooks/useKeyboard.ts"
|
||||
},
|
||||
"./Trace/hooks/useSpan": {
|
||||
"import": "./src/Trace/hooks/useSpan.ts",
|
||||
"types": "./src/Trace/hooks/useSpan.ts"
|
||||
},
|
||||
"./Trace/hooks/useTimeScale": {
|
||||
"import": "./src/Trace/hooks/useTimeScale.ts",
|
||||
"types": "./src/Trace/hooks/useTimeScale.ts"
|
||||
},
|
||||
"./Trace/hooks/useVisibleRows": {
|
||||
"import": "./src/Trace/hooks/useVisibleRows.ts",
|
||||
"types": "./src/Trace/hooks/useVisibleRows.ts"
|
||||
},
|
||||
"./Trace": {
|
||||
"import": "./src/Trace/index.ts",
|
||||
"types": "./src/Trace/index.ts"
|
||||
},
|
||||
"./Trace/types": {
|
||||
"import": "./src/Trace/types.ts",
|
||||
"types": "./src/Trace/types.ts"
|
||||
},
|
||||
"./Trace/utils": {
|
||||
"import": "./src/Trace/utils.ts",
|
||||
"types": "./src/Trace/utils.ts"
|
||||
},
|
||||
"./TweetCard": {
|
||||
"import": "./src/TweetCard/index.tsx",
|
||||
"types": "./src/TweetCard/index.tsx"
|
||||
@@ -773,6 +833,7 @@
|
||||
"@supabase/sql-to-rest": "^0.1.6",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@tanstack/react-table": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"common": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { CircleAlert } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { useSpan } from './hooks/useSpan'
|
||||
import { useData, useView } from './Trace'
|
||||
import type { MarkerKind, SpanEvent } from './types'
|
||||
import {
|
||||
computeBarGeometry,
|
||||
durationOf,
|
||||
effectiveEndMs,
|
||||
formatMs,
|
||||
inferMarkerKind,
|
||||
MARKER_KIND_LABEL,
|
||||
} from './utils'
|
||||
|
||||
export function Bar({ className, style, children, ...props }: ComponentProps<'div'>) {
|
||||
const { span, state, isRunning } = useSpan()
|
||||
const { window } = useView()
|
||||
const { nowMs } = useData()
|
||||
|
||||
const endMs = effectiveEndMs(span, isRunning ? Math.max(nowMs, window[1]) : nowMs)
|
||||
const geometry = computeBarGeometry(span.startMs, endMs, window)
|
||||
if (!geometry.isVisible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-trace-bar
|
||||
data-state={state}
|
||||
data-status={span.status}
|
||||
data-service={span.serviceName}
|
||||
data-clip={geometry.clip}
|
||||
data-zero-duration={geometry.isZeroDuration ? '' : undefined}
|
||||
data-running={isRunning ? '' : undefined}
|
||||
className={cn(
|
||||
'absolute top-1/2 h-[55%] min-w-[2px] -translate-y-1/2 rounded-sm bg-current',
|
||||
'data-[status=ok]:text-brand data-[status=error]:text-destructive data-[status=unset]:text-foreground-muted',
|
||||
'transition-[opacity,box-shadow] motion-reduce:transition-none',
|
||||
'data-[state=hover]:brightness-110',
|
||||
'data-[state=selected]:ring-2 data-[state=selected]:ring-foreground data-[state=selected]:ring-offset-1 data-[state=selected]:ring-offset-background',
|
||||
'data-[state=dim]:opacity-40',
|
||||
'data-[clip=start]:rounded-l-none data-[clip=end]:rounded-r-none data-[clip=both]:rounded-none',
|
||||
'data-[zero-duration]:w-[2px]',
|
||||
'data-[running]:bg-[repeating-linear-gradient(135deg,currentColor_0_6px,transparent_6px_10px)] data-[running]:motion-safe:animate-pulse',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
left: `${geometry.leftFraction * 100}%`,
|
||||
width: geometry.isZeroDuration ? undefined : `${geometry.widthFraction * 100}%`,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkerProps {
|
||||
event: SpanEvent
|
||||
kind: MarkerKind
|
||||
offsetMs: number
|
||||
}
|
||||
|
||||
function Marker({ event, kind, offsetMs }: MarkerProps) {
|
||||
const { window, bounds } = useView()
|
||||
const duration = window[1] - window[0]
|
||||
if (!(duration > 0)) return null
|
||||
const fraction = (event.timeMs - window[0]) / duration
|
||||
if (fraction < 0 || fraction > 1) return null
|
||||
|
||||
const kindLabel = MARKER_KIND_LABEL[kind]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${kindLabel} at ${formatMs(event.timeMs - bounds[0])}`}
|
||||
data-trace-marker
|
||||
data-kind={kind}
|
||||
className="absolute top-1/2 flex size-3 -translate-x-1/2 -translate-y-1/2 items-center justify-center focus-ring"
|
||||
style={{ left: `${fraction * 100}%` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{kind === 'exception' && <CircleAlert className="size-3 text-destructive" />}
|
||||
{kind === 'log' && (
|
||||
<span aria-hidden className="size-1.5 rounded-full bg-foreground-lighter" />
|
||||
)}
|
||||
{kind === 'milestone' && (
|
||||
<span aria-hidden className="size-1.5 rotate-45 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{event.name}
|
||||
<span className="text-foreground-light"> · +{formatMs(offsetMs)}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function Markers() {
|
||||
const { span } = useSpan()
|
||||
if (span.events.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{span.events.map((event, index) => (
|
||||
<Marker
|
||||
key={`${event.timeMs}-${event.name}-${index}`}
|
||||
event={event}
|
||||
kind={inferMarkerKind(event)}
|
||||
offsetMs={event.timeMs - span.startMs}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Duration({ className, ...props }: ComponentProps<'span'>) {
|
||||
const { span, isRunning } = useSpan()
|
||||
const { nowMs } = useData()
|
||||
const duration = formatMs(durationOf(span, nowMs))
|
||||
|
||||
return (
|
||||
<span
|
||||
data-trace-duration
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-xs tabular-nums text-foreground-lighter',
|
||||
isRunning && 'text-foreground-light',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isRunning ? `${duration}…` : duration}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronRight, Unplug } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Badge, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { Duration } from './Bar'
|
||||
import { useSpan } from './hooks/useSpan'
|
||||
import { useView } from './Trace'
|
||||
|
||||
export interface CellProps extends ComponentProps<'div'> {
|
||||
indent?: number
|
||||
}
|
||||
|
||||
const TOGGLE_SIZE = 18
|
||||
|
||||
export function Cell({ indent = 14, className, style, ...props }: CellProps) {
|
||||
const { row, span, readOnly } = useSpan()
|
||||
const { dispatch } = useView()
|
||||
const guideOffset = TOGGLE_SIZE / 2 + 4
|
||||
|
||||
return (
|
||||
<div
|
||||
data-trace-cell
|
||||
className={cn(
|
||||
'relative flex w-(--trace-tree-width) shrink-0 items-center gap-1.5 overflow-hidden border-r border-muted pr-2',
|
||||
className
|
||||
)}
|
||||
style={{ paddingLeft: row.depth * indent + 4, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{row.guides.map((show, depth) =>
|
||||
show ? (
|
||||
<span
|
||||
key={depth}
|
||||
aria-hidden
|
||||
data-trace-guide
|
||||
className="absolute inset-y-0 w-px bg-border-muted"
|
||||
style={{ left: depth * indent + guideOffset }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
||||
<span className="flex shrink-0 items-center justify-center" style={{ width: TOGGLE_SIZE }}>
|
||||
{row.hasChildren && (
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
tabIndex={-1}
|
||||
aria-label={row.isExpanded ? 'Collapse' : 'Expand'}
|
||||
disabled={readOnly}
|
||||
className="size-[18px] p-0"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'transition-transform motion-reduce:transition-none',
|
||||
row.isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
dispatch({ type: 'toggle', id: row.id })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
data-trace-status-dot
|
||||
data-status={span.status}
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
span.status === 'error' && 'bg-destructive',
|
||||
span.status === 'ok' && 'bg-brand',
|
||||
span.status === 'unset' && 'border border-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-trace-name className="min-w-0 flex-1 truncate">
|
||||
{span.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start">
|
||||
{span.name}
|
||||
<span className="text-foreground-light"> · {span.serviceName}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<span
|
||||
data-trace-service
|
||||
data-service={span.serviceName}
|
||||
className="max-w-[30%] shrink truncate text-xs text-foreground-lighter"
|
||||
>
|
||||
{span.serviceName}
|
||||
</span>
|
||||
|
||||
{row.isOrphan && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span data-trace-orphan className="flex shrink-0 text-warning">
|
||||
<Unplug className="size-3" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Parent span is missing from this trace</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{row.hiddenDescendantCount > 0 && (
|
||||
<Badge data-trace-hidden-count>+{row.hiddenDescendantCount}</Badge>
|
||||
)}
|
||||
|
||||
<Duration className="ml-auto" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronsDownUp, Maximize2, Search } from 'lucide-react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { BadgeProps, ButtonProps } from 'ui'
|
||||
import { Badge, Button, cn, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
|
||||
import { useData, useView } from './Trace'
|
||||
import type { SpanStatus } from './types'
|
||||
|
||||
export function Header({ className, children, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Trace tools"
|
||||
data-trace-header
|
||||
className={cn('flex flex-wrap items-center gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Title({ className, children, ...props }: ComponentProps<'h2'>) {
|
||||
const { roots, byId } = useData()
|
||||
const root = roots.length > 0 ? byId.get(roots[0]) : undefined
|
||||
return (
|
||||
<h2
|
||||
data-trace-title
|
||||
className={cn('truncate text-sm font-medium text-foreground', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? root?.name ?? 'Trace'}
|
||||
</h2>
|
||||
)
|
||||
}
|
||||
|
||||
export const STATUS_BADGE_VARIANT: Record<SpanStatus, BadgeProps['variant']> = {
|
||||
ok: 'success',
|
||||
error: 'destructive',
|
||||
unset: 'default',
|
||||
}
|
||||
|
||||
export function Status({ className, children, ...props }: Omit<BadgeProps, 'variant'>) {
|
||||
const { roots, byId } = useData()
|
||||
const root = roots.length > 0 ? byId.get(roots[0]) : undefined
|
||||
const status: SpanStatus = root?.status ?? 'unset'
|
||||
return (
|
||||
<Badge
|
||||
variant={STATUS_BADGE_VARIANT[status]}
|
||||
data-trace-status
|
||||
data-status={status}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FilterProps extends Omit<ComponentProps<'input'>, 'value' | 'onChange' | 'size'> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Filter({
|
||||
className,
|
||||
placeholder = 'Filter spans',
|
||||
onKeyDown,
|
||||
...props
|
||||
}: FilterProps) {
|
||||
const { query, matchIds, dispatch } = useView()
|
||||
|
||||
return (
|
||||
<InputGroup data-trace-filter className={cn('w-64', className)}>
|
||||
<InputGroupAddon>
|
||||
<Search className="size-3.5" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
aria-label="Filter spans"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(event) => dispatch({ type: 'setQuery', query: event.target.value })}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.key === 'Escape' && query !== '' && !event.defaultPrevented) {
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'setQuery', query: '' })
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{matchIds && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span
|
||||
data-trace-filter-count
|
||||
className="font-mono text-xs tabular-nums text-foreground-lighter"
|
||||
>
|
||||
{matchIds.size}
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapseAll({ children, onClick, ...props }: ButtonProps) {
|
||||
const { dispatch } = useView()
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<ChevronsDownUp />}
|
||||
data-trace-collapse-all
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) dispatch({ type: 'collapseAll' })
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children ?? 'Collapse all'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ResetZoom({ children, onClick, ...props }: ButtonProps) {
|
||||
const { isReset, dispatch } = useView()
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<Maximize2 />}
|
||||
disabled={isReset}
|
||||
data-trace-reset-zoom
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented) dispatch({ type: 'setWindow', window: null })
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children ?? 'Reset zoom'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronRight, CircleAlert, Copy, CornerDownRight, ExternalLink } from 'lucide-react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
cn,
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
copyToClipboard,
|
||||
ScrollArea,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { STATUS_BADGE_VARIANT } from './Header'
|
||||
import { useData, useView } from './Trace'
|
||||
import type { Span, ValueKind } from './types'
|
||||
import {
|
||||
detectValueKind,
|
||||
durationOf,
|
||||
formatMs,
|
||||
inferMarkerKind,
|
||||
sortedEntries,
|
||||
stringifyValue,
|
||||
summarizeJson,
|
||||
} from './utils'
|
||||
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, () => toast.success('Copied to clipboard'))
|
||||
}
|
||||
|
||||
interface ValueProps {
|
||||
entryKey: string
|
||||
value: unknown
|
||||
kind: ValueKind
|
||||
}
|
||||
|
||||
function Value({ entryKey, value, kind }: ValueProps) {
|
||||
const { byId } = useData()
|
||||
const { dispatch } = useView()
|
||||
|
||||
switch (kind) {
|
||||
case 'null':
|
||||
return <span className="text-foreground-muted">null</span>
|
||||
case 'boolean':
|
||||
return (
|
||||
<span className={cn(value ? 'text-brand' : 'text-foreground-light')}>{String(value)}</span>
|
||||
)
|
||||
case 'number':
|
||||
return <span className="tabular-nums">{stringifyValue(value)}</span>
|
||||
case 'id': {
|
||||
const id = String(value)
|
||||
if (!byId.has(id) || /trace/i.test(entryKey)) return <span className="break-all">{id}</span>
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
size="tiny"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
iconRight={<CornerDownRight />}
|
||||
onClick={() => dispatch({ type: 'select', id })}
|
||||
>
|
||||
{id}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
case 'url': {
|
||||
const href = String(value)
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex max-w-full items-center gap-1 break-all text-brand underline-offset-2 hover:underline focus-ring"
|
||||
>
|
||||
<span className="min-w-0 break-all">{href}</span>
|
||||
<ExternalLink className="size-3 shrink-0" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
case 'error':
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-destructive">
|
||||
{String(value)}
|
||||
</pre>
|
||||
)
|
||||
case 'json':
|
||||
return (
|
||||
<Collapsible className="group/json">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
className="h-auto px-1 py-0.5 font-mono text-xs"
|
||||
iconLeft={
|
||||
<ChevronRight className="transition-transform group-data-[state=open]/json:rotate-90 motion-reduce:transition-none" />
|
||||
}
|
||||
>
|
||||
{summarizeJson(value)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded bg-surface-200 p-2 text-xs">
|
||||
{stringifyValue(value)}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
default:
|
||||
return <span className="whitespace-pre-wrap break-all">{String(value)}</span>
|
||||
}
|
||||
}
|
||||
|
||||
interface KeyValueProps {
|
||||
data: Record<string, unknown>
|
||||
emptyMessage?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function KeyValue({ data, emptyMessage = 'No attributes', className }: KeyValueProps) {
|
||||
const entries = sortedEntries(data)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div
|
||||
data-trace-key-value
|
||||
data-empty
|
||||
className={cn('px-3 py-2 text-xs text-foreground-light', className)}
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-trace-key-value className={cn('text-xs', className)}>
|
||||
<Table containerProps={{ className: 'overflow-visible' }}>
|
||||
<TableBody>
|
||||
{entries.map(([key, value]) => {
|
||||
const kind = detectValueKind(key, value)
|
||||
return (
|
||||
<TableRow key={key} data-trace-key-value-row data-kind={kind} className="group/kv">
|
||||
<TableCell className="w-[38%] min-w-0 py-1.5 pl-3 pr-2 align-top">
|
||||
<span className="block break-all font-mono text-foreground-light">{key}</span>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0 py-1.5 pl-2 pr-2 align-top font-mono">
|
||||
<div className="flex items-start gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Value entryKey={key} value={value} kind={kind} />
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
aria-label={`Copy ${key}`}
|
||||
className="size-5 shrink-0 p-0 opacity-0 group-hover/kv:opacity-100 focus-visible:opacity-100"
|
||||
icon={<Copy />}
|
||||
onClick={() => copy(stringifyValue(value))}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Copy value</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: ReactNode
|
||||
count?: number
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function Section({ title, count, children }: SectionProps) {
|
||||
return (
|
||||
<section data-trace-inspector-section className="border-b border-muted last:border-b-0">
|
||||
<h3 className="flex items-center gap-2 px-3 pb-1 pt-3 text-xs font-medium text-foreground-light">
|
||||
{title}
|
||||
{count !== undefined && (
|
||||
<span className="font-mono tabular-nums text-foreground-lighter">{count}</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="pb-2">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SpanHeader({ span }: { span: Span }) {
|
||||
const { nowMs, bounds } = useData()
|
||||
const isRunning = span.endMs === null
|
||||
const duration = formatMs(durationOf(span, nowMs))
|
||||
|
||||
return (
|
||||
<header
|
||||
data-trace-inspector-header
|
||||
className="flex flex-col gap-2 border-b border-default px-3 py-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge data-service={span.serviceName} className="normal-case tracking-normal">
|
||||
{span.serviceName}
|
||||
</Badge>
|
||||
<Badge variant={STATUS_BADGE_VARIANT[span.status]} data-status={span.status}>
|
||||
{span.status}
|
||||
</Badge>
|
||||
<Badge>{span.kind}</Badge>
|
||||
{isRunning && <Badge>running</Badge>}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h2 className="truncate text-sm font-medium text-foreground">{span.name}</h2>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
{span.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 font-mono text-xs">
|
||||
<dt className="text-foreground-light">Duration</dt>
|
||||
<dd className="tabular-nums text-foreground">{isRunning ? `${duration}…` : duration}</dd>
|
||||
<dt className="text-foreground-light">Started</dt>
|
||||
<dd className="tabular-nums text-foreground">+{formatMs(span.startMs - bounds[0])}</dd>
|
||||
<dt className="text-foreground-light">Span ID</dt>
|
||||
<dd className="flex min-w-0 items-center gap-1 text-foreground">
|
||||
<span className="truncate">{span.id}</span>
|
||||
<Button
|
||||
variant="text"
|
||||
size="tiny"
|
||||
aria-label="Copy span ID"
|
||||
className="size-5 shrink-0 p-0"
|
||||
icon={<Copy />}
|
||||
onClick={() => copy(span.id)}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function Events({ span }: { span: Span }) {
|
||||
return (
|
||||
<Section title="Events" count={span.events.length}>
|
||||
{span.events.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-foreground-light">No events</p>
|
||||
)}
|
||||
{span.events.length > 0 && (
|
||||
<ol className="flex flex-col">
|
||||
{span.events.map((event, index) => {
|
||||
const kind = inferMarkerKind(event)
|
||||
const hasAttributes = event.attributes && Object.keys(event.attributes).length > 0
|
||||
return (
|
||||
<li
|
||||
key={`${event.timeMs}-${event.name}-${index}`}
|
||||
data-trace-inspector-event
|
||||
data-kind={kind}
|
||||
className="flex flex-col gap-1 border-t border-muted px-3 py-2 first:border-t-0"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 shrink-0 font-mono tabular-nums text-foreground-lighter">
|
||||
+{formatMs(event.timeMs - span.startMs)}
|
||||
</span>
|
||||
{kind === 'exception' && (
|
||||
<CircleAlert className="size-3 shrink-0 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate font-mono',
|
||||
kind === 'exception' ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{event.name}
|
||||
</span>
|
||||
</div>
|
||||
{hasAttributes && <KeyValue data={event.attributes ?? {}} className="-mx-3" />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function Links({ span }: { span: Span }) {
|
||||
const { byId } = useData()
|
||||
const { dispatch } = useView()
|
||||
if (span.links.length === 0) return null
|
||||
|
||||
return (
|
||||
<Section title="Links" count={span.links.length}>
|
||||
<ul className="flex flex-col">
|
||||
{span.links.map((link) => (
|
||||
<li
|
||||
key={`${link.traceId}:${link.spanId}`}
|
||||
data-trace-inspector-link
|
||||
className="flex items-center gap-2 px-3 py-1 font-mono text-xs"
|
||||
>
|
||||
<span className="text-foreground-light">trace</span>
|
||||
<span className="truncate">{link.traceId}</span>
|
||||
<span className="text-foreground-light">span</span>
|
||||
{byId.has(link.spanId) ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="tiny"
|
||||
className="h-auto min-w-0 p-0 font-mono text-xs"
|
||||
iconRight={<CornerDownRight />}
|
||||
onClick={() => dispatch({ type: 'select', id: link.spanId })}
|
||||
>
|
||||
<span className="truncate">{link.spanId}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="truncate">{link.spanId}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export interface InspectorProps extends ComponentProps<'aside'> {
|
||||
spanId?: string
|
||||
emptyMessage?: ReactNode
|
||||
}
|
||||
|
||||
export function Inspector({ spanId, emptyMessage, className, ...props }: InspectorProps) {
|
||||
const data = useData()
|
||||
const { selectedId } = useView()
|
||||
const resolvedId = spanId ?? selectedId
|
||||
const span = resolvedId !== null ? (data.byId.get(resolvedId) ?? null) : null
|
||||
|
||||
return (
|
||||
<aside
|
||||
role="complementary"
|
||||
aria-label="Span details"
|
||||
data-trace-inspector
|
||||
data-empty={span ? undefined : ''}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col overflow-hidden border-l border-default bg-surface-100 text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{!span && (
|
||||
<div
|
||||
data-trace-inspector-empty
|
||||
className="flex flex-1 items-center justify-center p-6 text-center text-sm text-foreground-light"
|
||||
>
|
||||
{emptyMessage ?? 'Select a span to see its details'}
|
||||
</div>
|
||||
)}
|
||||
{span && (
|
||||
<>
|
||||
<SpanHeader span={span} />
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<Section title="Attributes" count={Object.keys(span.attributes).length}>
|
||||
<KeyValue data={span.attributes} />
|
||||
</Section>
|
||||
<Events span={span} />
|
||||
<Links span={span} />
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { useBrush } from './hooks/useBrush'
|
||||
import { useTimeScale } from './hooks/useTimeScale'
|
||||
import { useData, useView } from './Trace'
|
||||
import type { SpanStatus, TimeWindow } from './types'
|
||||
import { effectiveEndMs, formatMs, msToFraction } from './utils'
|
||||
|
||||
export interface MinimapProps extends ComponentProps<'div'> {
|
||||
height?: number
|
||||
maxDetailSpans?: number
|
||||
laneHeight?: number
|
||||
}
|
||||
|
||||
const VIEWBOX_WIDTH = 1000
|
||||
|
||||
interface DensityBucket {
|
||||
x: number
|
||||
total: number
|
||||
errors: number
|
||||
}
|
||||
|
||||
interface MinimapItem {
|
||||
id: string
|
||||
startMs: number
|
||||
endMs: number
|
||||
status: SpanStatus
|
||||
depth: number
|
||||
}
|
||||
|
||||
function computeDensity(
|
||||
items: readonly MinimapItem[],
|
||||
bounds: TimeWindow,
|
||||
bucketCount: number
|
||||
): DensityBucket[] {
|
||||
const duration = bounds[1] - bounds[0]
|
||||
if (!(duration > 0) || bucketCount <= 0) return []
|
||||
const total = new Array<number>(bucketCount + 1).fill(0)
|
||||
const errors = new Array<number>(bucketCount + 1).fill(0)
|
||||
for (const item of items) {
|
||||
const from = Math.max(0, Math.floor(((item.startMs - bounds[0]) / duration) * bucketCount))
|
||||
const to = Math.min(
|
||||
bucketCount - 1,
|
||||
Math.max(from, Math.ceil(((item.endMs - bounds[0]) / duration) * bucketCount) - 1)
|
||||
)
|
||||
total[from] += 1
|
||||
total[to + 1] -= 1
|
||||
if (item.status === 'error') {
|
||||
errors[from] += 1
|
||||
errors[to + 1] -= 1
|
||||
}
|
||||
}
|
||||
const buckets: DensityBucket[] = []
|
||||
let runningTotal = 0
|
||||
let runningErrors = 0
|
||||
for (let i = 0; i < bucketCount; i++) {
|
||||
runningTotal += total[i]
|
||||
runningErrors += errors[i]
|
||||
buckets.push({ x: i / bucketCount, total: runningTotal, errors: runningErrors })
|
||||
}
|
||||
return buckets
|
||||
}
|
||||
|
||||
const STATUS_FILL: Record<SpanStatus, string> = {
|
||||
ok: 'fill-brand',
|
||||
error: 'fill-destructive',
|
||||
unset: 'fill-foreground-muted',
|
||||
}
|
||||
|
||||
export function Minimap({
|
||||
height = 48,
|
||||
maxDetailSpans = 2000,
|
||||
laneHeight = 3,
|
||||
className,
|
||||
...props
|
||||
}: MinimapProps) {
|
||||
const index = useData()
|
||||
const { window, bounds, dispatch } = useView()
|
||||
const { ref, width } = useTimeScale<HTMLDivElement>()
|
||||
const brush = useBrush({
|
||||
bounds,
|
||||
window,
|
||||
width,
|
||||
onWindowChange: (next) => dispatch({ type: 'setWindow', window: next }),
|
||||
onReset: () => dispatch({ type: 'setWindow', window: null }),
|
||||
})
|
||||
|
||||
const isDetail = index.byId.size <= maxDetailSpans
|
||||
const maxLanes = Math.max(1, Math.floor(height / laneHeight))
|
||||
|
||||
const shapes = useMemo(() => {
|
||||
const items: MinimapItem[] = Array.from(index.byId.values()).map((span) => ({
|
||||
id: span.id,
|
||||
startMs: span.startMs,
|
||||
endMs: effectiveEndMs(span, index.nowMs),
|
||||
status: span.status,
|
||||
depth: index.depthOf.get(span.id) ?? 0,
|
||||
}))
|
||||
if (isDetail) return { kind: 'detail' as const, items }
|
||||
const bucketCount = Math.max(50, Math.min(400, Math.floor(width / 2) || 200))
|
||||
return { kind: 'density' as const, buckets: computeDensity(items, bounds, bucketCount) }
|
||||
}, [index, isDetail, bounds, width])
|
||||
|
||||
const maxDensity =
|
||||
shapes.kind === 'density' ? Math.max(1, ...shapes.buckets.map((bucket) => bucket.total)) : 1
|
||||
|
||||
const leftShade = msToFraction(window[0], bounds)
|
||||
const rightShade = 1 - msToFraction(window[1], bounds)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-trace-minimap
|
||||
data-mode={shapes.kind}
|
||||
className={cn(
|
||||
'relative w-full select-none overflow-hidden rounded-md border border-default bg-surface-100',
|
||||
className
|
||||
)}
|
||||
style={{ height }}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
aria-hidden
|
||||
className="absolute inset-0 size-full"
|
||||
viewBox={`0 0 ${VIEWBOX_WIDTH} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{shapes.kind === 'detail' &&
|
||||
shapes.items.map((item) => {
|
||||
const x = msToFraction(item.startMs, bounds) * VIEWBOX_WIDTH
|
||||
const w = Math.max(1, msToFraction(item.endMs, bounds) * VIEWBOX_WIDTH - x)
|
||||
const lane = Math.min(item.depth, maxLanes - 1)
|
||||
return (
|
||||
<rect
|
||||
key={item.id}
|
||||
x={x}
|
||||
y={lane * laneHeight}
|
||||
width={w}
|
||||
height={Math.max(1, laneHeight - 1)}
|
||||
className={cn('opacity-70', STATUS_FILL[item.status])}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{shapes.kind === 'density' &&
|
||||
shapes.buckets.map((bucket) => {
|
||||
const barHeight = (bucket.total / maxDensity) * height
|
||||
const errorHeight = (bucket.errors / maxDensity) * height
|
||||
const barWidth = VIEWBOX_WIDTH / shapes.buckets.length
|
||||
return (
|
||||
<g key={bucket.x}>
|
||||
<rect
|
||||
x={bucket.x * VIEWBOX_WIDTH}
|
||||
y={height - barHeight}
|
||||
width={barWidth}
|
||||
height={barHeight}
|
||||
className="fill-foreground-muted opacity-40"
|
||||
/>
|
||||
{errorHeight > 0 && (
|
||||
<rect
|
||||
x={bucket.x * VIEWBOX_WIDTH}
|
||||
y={height - errorHeight}
|
||||
width={barWidth}
|
||||
height={errorHeight}
|
||||
className="fill-destructive opacity-60"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
data-trace-brush-track
|
||||
className="absolute inset-0 cursor-crosshair"
|
||||
{...brush.getTrackProps()}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 left-0 bg-background/60"
|
||||
style={{ width: `${leftShade * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-y-0 right-0 bg-background/60"
|
||||
style={{ width: `${rightShade * 100}%` }}
|
||||
/>
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Visible time window"
|
||||
data-trace-brush
|
||||
data-dragging={brush.isDragging ? '' : undefined}
|
||||
data-full={brush.isFull ? '' : undefined}
|
||||
className={cn(
|
||||
'absolute inset-y-0 cursor-grab border-x border-foreground-muted bg-foreground/5 focus-inset',
|
||||
'data-[dragging]:cursor-grabbing data-[dragging]:bg-foreground/10'
|
||||
)}
|
||||
style={{ left: brush.rect.left, width: brush.rect.width }}
|
||||
{...brush.getBrushProps()}
|
||||
>
|
||||
<div
|
||||
aria-label="Window start"
|
||||
aria-valuetext={formatMs(window[0] - bounds[0])}
|
||||
data-trace-brush-handle="start"
|
||||
className="absolute inset-y-0 -left-1 w-2 cursor-ew-resize focus-ring"
|
||||
{...brush.getHandleProps('start')}
|
||||
>
|
||||
<span className="absolute inset-y-[25%] left-[3px] w-0.5 rounded bg-foreground-muted" />
|
||||
</div>
|
||||
<div
|
||||
aria-label="Window end"
|
||||
aria-valuetext={formatMs(window[1] - bounds[0])}
|
||||
data-trace-brush-handle="end"
|
||||
className="absolute inset-y-0 -right-1 w-2 cursor-ew-resize focus-ring"
|
||||
{...brush.getHandleProps('end')}
|
||||
>
|
||||
<span className="absolute inset-y-[25%] right-[3px] w-0.5 rounded bg-foreground-muted" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, use, useCallback, useMemo, useReducer, useRef } from 'react'
|
||||
|
||||
import type {
|
||||
DataContextValue,
|
||||
RootProps,
|
||||
TimeWindow,
|
||||
ViewAction,
|
||||
ViewContextValue,
|
||||
ViewState,
|
||||
} from './types'
|
||||
import { buildTraceIndex, clampWindow, computeMatchIds, isFullWindow } from './utils'
|
||||
|
||||
export const DataContext = createContext<DataContextValue | null>(null)
|
||||
export const ViewContext = createContext<ViewContextValue | null>(null)
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||
|
||||
function viewReducer(state: ViewState, action: ViewAction): ViewState {
|
||||
switch (action.type) {
|
||||
case 'setWindow':
|
||||
return state.window === action.window ? state : { ...state, window: action.window }
|
||||
case 'select':
|
||||
return state.selectedId === action.id ? state : { ...state, selectedId: action.id }
|
||||
case 'hover':
|
||||
return state.hoveredId === action.id ? state : { ...state, hoveredId: action.id }
|
||||
case 'toggle': {
|
||||
const collapsed = new Set(state.collapsed)
|
||||
if (collapsed.has(action.id)) collapsed.delete(action.id)
|
||||
else collapsed.add(action.id)
|
||||
return { ...state, collapsed }
|
||||
}
|
||||
case 'expand': {
|
||||
if (!state.collapsed.has(action.id)) return state
|
||||
const collapsed = new Set(state.collapsed)
|
||||
collapsed.delete(action.id)
|
||||
return { ...state, collapsed }
|
||||
}
|
||||
case 'collapse': {
|
||||
if (state.collapsed.has(action.id)) return state
|
||||
const collapsed = new Set(state.collapsed)
|
||||
collapsed.add(action.id)
|
||||
return { ...state, collapsed }
|
||||
}
|
||||
case 'expandAll':
|
||||
return state.collapsed.size === 0 ? state : { ...state, collapsed: EMPTY_SET }
|
||||
case 'setCollapsed':
|
||||
return { ...state, collapsed: action.collapsed }
|
||||
case 'setQuery':
|
||||
return state.query === action.query ? state : { ...state, query: action.query }
|
||||
case 'collapseAll':
|
||||
return state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function Root({
|
||||
trace,
|
||||
selectedId: selectedIdProp,
|
||||
onSelectedIdChange,
|
||||
window: windowProp,
|
||||
onWindowChange,
|
||||
defaultCollapsed = EMPTY_SET,
|
||||
defaultQuery = '',
|
||||
children,
|
||||
}: RootProps) {
|
||||
const index = useMemo(() => buildTraceIndex(trace.spans, trace.nowMs), [trace.spans, trace.nowMs])
|
||||
|
||||
const [state, rawDispatch] = useReducer(
|
||||
viewReducer,
|
||||
null,
|
||||
(): ViewState => ({
|
||||
window: null,
|
||||
selectedId: null,
|
||||
hoveredId: null,
|
||||
collapsed: defaultCollapsed,
|
||||
query: defaultQuery,
|
||||
})
|
||||
)
|
||||
|
||||
const bounds = index.bounds
|
||||
const isWindowControlled = windowProp !== undefined
|
||||
const isSelectionControlled = selectedIdProp !== undefined
|
||||
|
||||
const latest = useRef({
|
||||
index,
|
||||
onSelectedIdChange,
|
||||
onWindowChange,
|
||||
isWindowControlled,
|
||||
isSelectionControlled,
|
||||
})
|
||||
latest.current = {
|
||||
index,
|
||||
onSelectedIdChange,
|
||||
onWindowChange,
|
||||
isWindowControlled,
|
||||
isSelectionControlled,
|
||||
}
|
||||
|
||||
const dispatch = useCallback((action: ViewAction) => {
|
||||
const current = latest.current
|
||||
switch (action.type) {
|
||||
case 'select':
|
||||
current.onSelectedIdChange?.(action.id)
|
||||
if (!current.isSelectionControlled) rawDispatch(action)
|
||||
return
|
||||
case 'setWindow': {
|
||||
const b = current.index.bounds
|
||||
const next: TimeWindow = action.window === null ? b : clampWindow(action.window, b)
|
||||
current.onWindowChange?.(next)
|
||||
if (!current.isWindowControlled) {
|
||||
rawDispatch({ type: 'setWindow', window: isFullWindow(next, b) ? null : next })
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'collapseAll': {
|
||||
const parents = new Set<string>()
|
||||
for (const [id, children] of current.index.childrenOf) {
|
||||
if (children.length > 0) parents.add(id)
|
||||
}
|
||||
rawDispatch({ type: 'setCollapsed', collapsed: parents })
|
||||
return
|
||||
}
|
||||
default:
|
||||
rawDispatch(action)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const window = useMemo<TimeWindow>(() => {
|
||||
const stored = isWindowControlled ? windowProp : state.window
|
||||
if (!stored) return bounds
|
||||
return clampWindow(stored, bounds)
|
||||
}, [isWindowControlled, windowProp, state.window, bounds])
|
||||
|
||||
const selectedId = isSelectionControlled ? (selectedIdProp ?? null) : state.selectedId
|
||||
const matchIds = useMemo(() => computeMatchIds(index, state.query), [index, state.query])
|
||||
|
||||
const view = useMemo<ViewContextValue>(
|
||||
() => ({
|
||||
window,
|
||||
bounds,
|
||||
isReset: isFullWindow(window, bounds),
|
||||
selectedId,
|
||||
hoveredId: state.hoveredId,
|
||||
collapsed: state.collapsed,
|
||||
query: state.query,
|
||||
matchIds,
|
||||
dispatch,
|
||||
}),
|
||||
[window, bounds, selectedId, state.hoveredId, state.collapsed, state.query, matchIds, dispatch]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={index}>
|
||||
<ViewContext.Provider value={view}>{children}</ViewContext.Provider>
|
||||
</DataContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useData(): DataContextValue {
|
||||
const value = use(DataContext)
|
||||
if (!value) throw new Error('Trace parts must be rendered inside Trace.Root')
|
||||
return value
|
||||
}
|
||||
|
||||
export function useView(): ViewContextValue {
|
||||
const value = use(ViewContext)
|
||||
if (!value) throw new Error('Trace parts must be rendered inside Trace.Root')
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ScrollArea as ScrollAreaPrimitive } from 'radix-ui'
|
||||
import type { ComponentProps, CSSProperties, KeyboardEvent, ReactNode } from 'react'
|
||||
import { createContext, use, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { cn, ScrollBar, ScrollViewport } from 'ui'
|
||||
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { SpanContext } from './hooks/useSpan'
|
||||
import { useTimeScale } from './hooks/useTimeScale'
|
||||
import { useVisibleRows } from './hooks/useVisibleRows'
|
||||
import { useView } from './Trace'
|
||||
import type { BarState, SpanContextValue, VisibleRow } from './types'
|
||||
|
||||
export interface WaterfallProps extends ComponentProps<'div'> {
|
||||
treeWidth?: number
|
||||
}
|
||||
|
||||
export function Waterfall({
|
||||
treeWidth = 320,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: WaterfallProps) {
|
||||
const vars = { '--trace-tree-width': `${treeWidth}px` } as CSSProperties
|
||||
return (
|
||||
<div
|
||||
data-trace-waterfall
|
||||
className={cn(
|
||||
'flex min-h-0 flex-col overflow-hidden rounded-md border border-default bg-surface-100 text-sm text-foreground',
|
||||
className
|
||||
)}
|
||||
style={{ ...vars, ...style }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RulerProps extends ComponentProps<'div'> {
|
||||
targetTickSpacing?: number
|
||||
}
|
||||
|
||||
export function Ruler({ targetTickSpacing = 80, className, children, ...props }: RulerProps) {
|
||||
const { ref, scale } = useTimeScale<HTMLDivElement>({ targetTickSpacing })
|
||||
|
||||
return (
|
||||
<div
|
||||
data-trace-ruler
|
||||
className={cn(
|
||||
'flex h-7 shrink-0 items-stretch border-b border-default bg-surface-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex w-(--trace-tree-width) shrink-0 items-center gap-2 border-r border-default px-2 text-xs text-foreground-light">
|
||||
{children ?? 'Span'}
|
||||
</div>
|
||||
<div ref={ref} aria-hidden className="relative min-w-0 flex-1 select-none overflow-hidden">
|
||||
{scale.ticks.map((tick) => (
|
||||
<div
|
||||
key={tick.ms}
|
||||
data-trace-tick
|
||||
className="absolute inset-y-0 border-l border-strong"
|
||||
style={{ left: `${tick.fraction * 100}%` }}
|
||||
>
|
||||
<span className="absolute left-1 top-1/2 -translate-y-1/2 whitespace-nowrap font-mono text-[10px] tabular-nums text-foreground-lighter">
|
||||
{tick.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface RowsContextValue {
|
||||
readOnly: boolean
|
||||
focusableId: string | null
|
||||
}
|
||||
|
||||
const RowsContext = createContext<RowsContextValue>({ readOnly: false, focusableId: null })
|
||||
|
||||
interface RowProps {
|
||||
row: VisibleRow
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function Row({ row, children }: RowProps) {
|
||||
const { selectedId, hoveredId, dispatch } = useView()
|
||||
const { readOnly, focusableId } = use(RowsContext)
|
||||
|
||||
const { span } = row
|
||||
const isSelected = !readOnly && row.id === selectedId
|
||||
const isHovered = !readOnly && row.id === hoveredId
|
||||
const isRunning = span.endMs === null
|
||||
const isDim = row.filterMatch === 'ancestor'
|
||||
|
||||
let state: BarState = 'default'
|
||||
if (isSelected) state = 'selected'
|
||||
else if (isHovered) state = 'hover'
|
||||
else if (isDim) state = 'dim'
|
||||
else if (isRunning) state = 'running'
|
||||
|
||||
const value = useMemo<SpanContextValue>(
|
||||
() => ({ span, row, isSelected, isHovered, isRunning, state, readOnly }),
|
||||
[span, row, isSelected, isHovered, isRunning, state, readOnly]
|
||||
)
|
||||
|
||||
return (
|
||||
<SpanContext.Provider value={value}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-level={row.depth + 1}
|
||||
aria-setsize={row.siblingCount}
|
||||
aria-posinset={row.siblingIndex + 1}
|
||||
aria-expanded={row.hasChildren ? row.isExpanded : undefined}
|
||||
aria-selected={readOnly ? undefined : isSelected}
|
||||
tabIndex={readOnly ? -1 : row.id === focusableId ? 0 : -1}
|
||||
data-trace-row
|
||||
data-trace-row-id={row.id}
|
||||
data-state={state}
|
||||
data-status={span.status}
|
||||
data-service={span.serviceName}
|
||||
data-depth={row.depth}
|
||||
data-filter={row.filterMatch}
|
||||
data-running={isRunning ? '' : undefined}
|
||||
data-orphan={row.isOrphan ? '' : undefined}
|
||||
className={cn(
|
||||
'flex h-(--trace-row-height) w-full items-stretch border-b border-muted focus-inset',
|
||||
!readOnly && 'cursor-pointer hover:bg-surface-200',
|
||||
'data-[state=selected]:bg-selection',
|
||||
'data-[filter=ancestor]:opacity-60'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!readOnly) dispatch({ type: 'select', id: row.id })
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (!readOnly) dispatch({ type: 'hover', id: row.id })
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!readOnly) dispatch({ type: 'hover', id: null })
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SpanContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface RowsProps extends ComponentProps<typeof ScrollAreaPrimitive.Root> {
|
||||
rowHeight?: number
|
||||
overscan?: number
|
||||
readOnly?: boolean
|
||||
emptyMessage?: ReactNode
|
||||
}
|
||||
|
||||
export function Rows({
|
||||
rowHeight = 28,
|
||||
overscan = 8,
|
||||
readOnly = false,
|
||||
emptyMessage,
|
||||
children,
|
||||
className,
|
||||
'aria-label': ariaLabel = 'Spans',
|
||||
...props
|
||||
}: RowsProps) {
|
||||
const { rows, rowIndexById, totalCount, matchCount } = useVisibleRows()
|
||||
const { selectedId, dispatch } = useView()
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const treeRef = useRef<HTMLDivElement>(null)
|
||||
const pendingFocus = useRef(false)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => viewportRef.current,
|
||||
estimateSize: () => rowHeight,
|
||||
overscan,
|
||||
getItemKey: (index) => rows[index].id,
|
||||
})
|
||||
|
||||
const keyboard = useKeyboard({ rows, rowIndexById, selectedId, dispatch, disabled: readOnly })
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
pendingFocus.current = true
|
||||
keyboard.onKeyDown(event)
|
||||
},
|
||||
[keyboard]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly || selectedId === null) return
|
||||
const index = rowIndexById.get(selectedId)
|
||||
if (index === undefined) return
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
}, [selectedId, rowIndexById, virtualizer, readOnly])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFocus.current || selectedId === null) return
|
||||
pendingFocus.current = false
|
||||
let frame = 0
|
||||
const focusRow = (attempt: number) => {
|
||||
const tree = treeRef.current
|
||||
if (!tree) return
|
||||
const target = tree.querySelector<HTMLElement>(
|
||||
`[data-trace-row-id="${CSS.escape(selectedId)}"]`
|
||||
)
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true })
|
||||
return
|
||||
}
|
||||
if (attempt < 3) frame = requestAnimationFrame(() => focusRow(attempt + 1))
|
||||
}
|
||||
focusRow(0)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [selectedId])
|
||||
|
||||
const rowsContext = useMemo<RowsContextValue>(
|
||||
() => ({ readOnly, focusableId: readOnly ? null : (selectedId ?? rows[0]?.id ?? null) }),
|
||||
[readOnly, selectedId, rows]
|
||||
)
|
||||
|
||||
const vars = { '--trace-row-height': `${rowHeight}px` } as CSSProperties
|
||||
|
||||
let fallback = emptyMessage ?? 'No spans in this trace'
|
||||
if (emptyMessage === undefined && totalCount > 0 && matchCount === 0) {
|
||||
fallback = 'No spans match this filter'
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-trace-rows
|
||||
className={cn('relative min-h-0 flex-1 overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollViewport ref={viewportRef} className="h-full w-full">
|
||||
<RowsContext.Provider value={rowsContext}>
|
||||
{rows.length === 0 && (
|
||||
<div
|
||||
data-trace-empty
|
||||
className="flex items-center justify-center p-6 text-center text-sm text-foreground-light"
|
||||
>
|
||||
{fallback}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
aria-label={ariaLabel}
|
||||
aria-multiselectable={false}
|
||||
className="relative w-full"
|
||||
style={{ ...vars, height: virtualizer.getTotalSize() }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const row = rows[item.index]
|
||||
if (!row) return null
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
role="none"
|
||||
data-index={item.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ height: item.size, transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<Row row={row}>{children}</Row>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</RowsContext.Provider>
|
||||
</ScrollViewport>
|
||||
<ScrollBar orientation="vertical" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export function Lane({ className, children, ...props }: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-trace-lane
|
||||
className={cn('relative min-w-0 flex-1 overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { KeyboardEvent, PointerEvent } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import type { BrushEdge, BrushMode, BrushRect, TimeWindow } from '../types'
|
||||
import { brushRect, isFullWindow, panWindow, pxToMs, resizeWindow, windowFromDrag } from '../utils'
|
||||
|
||||
export interface UseBrushOptions {
|
||||
bounds: TimeWindow
|
||||
window: TimeWindow
|
||||
width: number
|
||||
onWindowChange: (window: TimeWindow) => void
|
||||
onReset?: () => void
|
||||
minDurationMs?: number
|
||||
clickThresholdPx?: number
|
||||
keyboardStepFraction?: number
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
mode: BrushMode
|
||||
pointerId: number
|
||||
originX: number
|
||||
trackLeft: number
|
||||
originWindow: TimeWindow
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export interface BrushTrackProps {
|
||||
onPointerDown: (event: PointerEvent<HTMLElement>) => void
|
||||
onPointerMove: (event: PointerEvent<HTMLElement>) => void
|
||||
onPointerUp: (event: PointerEvent<HTMLElement>) => void
|
||||
onPointerCancel: (event: PointerEvent<HTMLElement>) => void
|
||||
onDoubleClick: () => void
|
||||
}
|
||||
|
||||
export interface BrushBodyProps extends Omit<BrushTrackProps, 'onDoubleClick'> {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void
|
||||
tabIndex: number
|
||||
}
|
||||
|
||||
export interface BrushHandleProps extends Omit<BrushTrackProps, 'onDoubleClick'> {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void
|
||||
role: 'slider'
|
||||
tabIndex: number
|
||||
'aria-valuemin': number
|
||||
'aria-valuemax': number
|
||||
'aria-valuenow': number
|
||||
'aria-orientation': 'horizontal'
|
||||
}
|
||||
|
||||
export interface UseBrushResult {
|
||||
rect: BrushRect
|
||||
mode: BrushMode
|
||||
isDragging: boolean
|
||||
isFull: boolean
|
||||
getTrackProps: () => BrushTrackProps
|
||||
getBrushProps: () => BrushBodyProps
|
||||
getHandleProps: (edge: BrushEdge) => BrushHandleProps
|
||||
}
|
||||
|
||||
export function useBrush(options: UseBrushOptions): UseBrushResult {
|
||||
const {
|
||||
bounds,
|
||||
window,
|
||||
width,
|
||||
onWindowChange,
|
||||
onReset,
|
||||
minDurationMs,
|
||||
clickThresholdPx = 3,
|
||||
keyboardStepFraction = 0.05,
|
||||
} = options
|
||||
|
||||
const [mode, setMode] = useState<BrushMode>('idle')
|
||||
const drag = useRef<DragState | null>(null)
|
||||
|
||||
const latest = useRef({ bounds, window, width, onWindowChange, onReset, minDurationMs })
|
||||
latest.current = { bounds, window, width, onWindowChange, onReset, minDurationMs }
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const { onReset: handleReset, onWindowChange: change, bounds: b } = latest.current
|
||||
if (handleReset) handleReset()
|
||||
else change([b[0], b[1]])
|
||||
}, [])
|
||||
|
||||
const begin = useCallback((event: PointerEvent<HTMLElement>, nextMode: BrushMode) => {
|
||||
if (event.button !== 0) return
|
||||
const track = event.currentTarget.closest<HTMLElement>('[data-trace-brush-track]')
|
||||
const trackLeft = (track ?? event.currentTarget).getBoundingClientRect().left
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
drag.current = {
|
||||
mode: nextMode,
|
||||
pointerId: event.pointerId,
|
||||
originX: event.clientX - trackLeft,
|
||||
trackLeft,
|
||||
originWindow: latest.current.window,
|
||||
moved: false,
|
||||
}
|
||||
setMode(nextMode)
|
||||
}, [])
|
||||
|
||||
const move = useCallback((event: PointerEvent<HTMLElement>) => {
|
||||
const state = drag.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
const { bounds: b, width: w, onWindowChange: change, minDurationMs: min } = latest.current
|
||||
const x = event.clientX - state.trackLeft
|
||||
const dx = x - state.originX
|
||||
if (Math.abs(dx) >= 1) state.moved = true
|
||||
if (!state.moved) return
|
||||
|
||||
switch (state.mode) {
|
||||
case 'create':
|
||||
change(windowFromDrag(pxToMs(state.originX, b, w), pxToMs(x, b, w), b, min))
|
||||
break
|
||||
case 'move': {
|
||||
const deltaMs = pxToMs(dx, b, w) - b[0]
|
||||
change(panWindow(state.originWindow, deltaMs, b))
|
||||
break
|
||||
}
|
||||
case 'resize-start':
|
||||
change(resizeWindow(state.originWindow, 'start', pxToMs(x, b, w), b, min))
|
||||
break
|
||||
case 'resize-end':
|
||||
change(resizeWindow(state.originWindow, 'end', pxToMs(x, b, w), b, min))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [])
|
||||
|
||||
const end = useCallback(
|
||||
(event: PointerEvent<HTMLElement>) => {
|
||||
const state = drag.current
|
||||
if (!state || state.pointerId !== event.pointerId) return
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
const { bounds: b, width: w, window: current, onWindowChange: change } = latest.current
|
||||
const x = event.clientX - state.trackLeft
|
||||
const isClick = Math.abs(x - state.originX) < clickThresholdPx
|
||||
if (state.mode === 'create' && isClick && !isFullWindow(current, b)) {
|
||||
const size = current[1] - current[0]
|
||||
change(panWindow(current, pxToMs(x, b, w) - size / 2 - current[0], b))
|
||||
}
|
||||
drag.current = null
|
||||
setMode('idle')
|
||||
},
|
||||
[clickThresholdPx]
|
||||
)
|
||||
|
||||
const sharedPointerProps = useMemo(
|
||||
() => ({ onPointerMove: move, onPointerUp: end, onPointerCancel: end }),
|
||||
[move, end]
|
||||
)
|
||||
|
||||
const getTrackProps = useCallback(
|
||||
(): BrushTrackProps => ({
|
||||
onPointerDown: (event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
begin(event, 'create')
|
||||
},
|
||||
...sharedPointerProps,
|
||||
onDoubleClick: reset,
|
||||
}),
|
||||
[begin, sharedPointerProps, reset]
|
||||
)
|
||||
|
||||
const keyboardPan = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
const { bounds: b, window: current, onWindowChange: change } = latest.current
|
||||
const step = (current[1] - current[0]) * keyboardStepFraction * direction
|
||||
change(panWindow(current, step, b))
|
||||
},
|
||||
[keyboardStepFraction]
|
||||
)
|
||||
|
||||
const keyboardResize = useCallback(
|
||||
(edge: BrushEdge, direction: -1 | 1) => {
|
||||
const {
|
||||
bounds: b,
|
||||
window: current,
|
||||
onWindowChange: change,
|
||||
minDurationMs: min,
|
||||
} = latest.current
|
||||
const step = (b[1] - b[0]) * keyboardStepFraction * direction
|
||||
const ms = (edge === 'start' ? current[0] : current[1]) + step
|
||||
change(resizeWindow(current, edge, ms, b, min))
|
||||
},
|
||||
[keyboardStepFraction]
|
||||
)
|
||||
|
||||
const getBrushProps = useCallback(
|
||||
(): BrushBodyProps => ({
|
||||
onPointerDown: (event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
event.stopPropagation()
|
||||
begin(event, 'move')
|
||||
},
|
||||
...sharedPointerProps,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
keyboardPan(-1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
keyboardPan(1)
|
||||
} else if (event.key === 'Escape' || event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
reset()
|
||||
}
|
||||
},
|
||||
tabIndex: 0,
|
||||
}),
|
||||
[begin, sharedPointerProps, keyboardPan, reset]
|
||||
)
|
||||
|
||||
const getHandleProps = useCallback(
|
||||
(edge: BrushEdge): BrushHandleProps => ({
|
||||
onPointerDown: (event) => {
|
||||
event.stopPropagation()
|
||||
begin(event, edge === 'start' ? 'resize-start' : 'resize-end')
|
||||
},
|
||||
...sharedPointerProps,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
keyboardResize(edge, -1)
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
keyboardResize(edge, 1)
|
||||
}
|
||||
},
|
||||
role: 'slider',
|
||||
tabIndex: 0,
|
||||
'aria-valuemin': bounds[0],
|
||||
'aria-valuemax': bounds[1],
|
||||
'aria-valuenow': edge === 'start' ? window[0] : window[1],
|
||||
'aria-orientation': 'horizontal',
|
||||
}),
|
||||
[begin, sharedPointerProps, keyboardResize, bounds, window]
|
||||
)
|
||||
|
||||
return {
|
||||
rect: brushRect(window, bounds, width),
|
||||
mode,
|
||||
isDragging: mode !== 'idle',
|
||||
isFull: isFullWindow(window, bounds),
|
||||
getTrackProps,
|
||||
getBrushProps,
|
||||
getHandleProps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Dispatch, KeyboardEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type { KeyboardAction, ViewAction, VisibleRow } from '../types'
|
||||
import { NAVIGATION_KEYS, resolveKeyboardAction } from '../utils'
|
||||
|
||||
export interface UseKeyboardOptions {
|
||||
rows: readonly VisibleRow[]
|
||||
rowIndexById: ReadonlyMap<string, number>
|
||||
selectedId: string | null
|
||||
dispatch: Dispatch<ViewAction>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface UseKeyboardResult {
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => void
|
||||
press: (key: string) => KeyboardAction | null
|
||||
}
|
||||
|
||||
export function useKeyboard(options: UseKeyboardOptions): UseKeyboardResult {
|
||||
const { rows, rowIndexById, selectedId, dispatch, disabled = false } = options
|
||||
|
||||
const press = useCallback(
|
||||
(key: string): KeyboardAction | null => {
|
||||
if (disabled) return null
|
||||
const action = resolveKeyboardAction({ key, rows, selectedId, rowIndexById })
|
||||
if (action) dispatch(action)
|
||||
return action
|
||||
},
|
||||
[disabled, rows, selectedId, rowIndexById, dispatch]
|
||||
)
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLElement>) => {
|
||||
if (disabled || !NAVIGATION_KEYS.has(event.key)) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
event.preventDefault()
|
||||
press(event.key)
|
||||
},
|
||||
[disabled, press]
|
||||
)
|
||||
|
||||
return { onKeyDown, press }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
import type { SpanContextValue } from '../types'
|
||||
|
||||
export const SpanContext = createContext<SpanContextValue | null>(null)
|
||||
|
||||
export function useSpan(): SpanContextValue {
|
||||
const value = use(SpanContext)
|
||||
if (!value) throw new Error('This part must be rendered inside the Trace.Rows template')
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useView } from '../Trace'
|
||||
import type { TimeScale } from '../types'
|
||||
import { computeTimeScale } from '../utils'
|
||||
|
||||
export interface UseTimeScaleOptions {
|
||||
targetTickSpacing?: number
|
||||
}
|
||||
|
||||
export interface UseTimeScaleResult<T extends HTMLElement> {
|
||||
ref: (element: T | null) => void
|
||||
width: number
|
||||
scale: TimeScale
|
||||
}
|
||||
|
||||
export function useTimeScale<T extends HTMLElement = HTMLDivElement>(
|
||||
options: UseTimeScaleOptions = {}
|
||||
): UseTimeScaleResult<T> {
|
||||
const { targetTickSpacing = 80 } = options
|
||||
const { window, bounds } = useView()
|
||||
const [element, setElement] = useState<T | null>(null)
|
||||
const [width, setWidth] = useState(0)
|
||||
|
||||
const ref = useCallback((node: T | null) => setElement(node), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!element) return
|
||||
const measure = () => {
|
||||
const next = element.getBoundingClientRect().width
|
||||
setWidth((previous) => (Math.abs(previous - next) < 0.5 ? previous : next))
|
||||
}
|
||||
measure()
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [element])
|
||||
|
||||
const [start, end] = window
|
||||
const origin = bounds[0]
|
||||
const scale = useMemo(
|
||||
() => computeTimeScale({ window: [start, end], width, origin, targetTickSpacing }),
|
||||
[start, end, width, origin, targetTickSpacing]
|
||||
)
|
||||
|
||||
return { ref, width, scale }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useData, useView } from '../Trace'
|
||||
import type { VisibleRow } from '../types'
|
||||
import { flattenTree, indexRowsById } from '../utils'
|
||||
|
||||
export interface UseVisibleRowsResult {
|
||||
rows: readonly VisibleRow[]
|
||||
rowIndexById: ReadonlyMap<string, number>
|
||||
totalCount: number
|
||||
matchCount: number | null
|
||||
}
|
||||
|
||||
export function useVisibleRows(): UseVisibleRowsResult {
|
||||
const index = useData()
|
||||
const { collapsed, matchIds } = useView()
|
||||
|
||||
return useMemo(() => {
|
||||
const rows = flattenTree(index, collapsed, matchIds)
|
||||
return {
|
||||
rows,
|
||||
rowIndexById: indexRowsById(rows),
|
||||
totalCount: index.byId.size,
|
||||
matchCount: matchIds ? matchIds.size : null,
|
||||
}
|
||||
}, [index, collapsed, matchIds])
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Bar, Duration, Markers } from './Bar'
|
||||
import { Cell } from './Cell'
|
||||
import { CollapseAll, Filter, Header, ResetZoom, Status, Title } from './Header'
|
||||
import { Inspector } from './Inspector'
|
||||
import { Minimap } from './Minimap'
|
||||
import { Root } from './Trace'
|
||||
import { Lane, Rows, Ruler, Waterfall } from './Waterfall'
|
||||
|
||||
export const Trace = {
|
||||
Root,
|
||||
Header,
|
||||
Title,
|
||||
Status,
|
||||
Filter,
|
||||
CollapseAll,
|
||||
ResetZoom,
|
||||
Minimap,
|
||||
Waterfall,
|
||||
Ruler,
|
||||
Rows,
|
||||
Cell,
|
||||
Lane,
|
||||
Bar,
|
||||
Markers,
|
||||
Duration,
|
||||
Inspector,
|
||||
}
|
||||
|
||||
export { useSpan } from './hooks/useSpan'
|
||||
export { useTimeScale } from './hooks/useTimeScale'
|
||||
|
||||
export type {
|
||||
BarState,
|
||||
MarkerKind,
|
||||
RootProps,
|
||||
Span,
|
||||
SpanContextValue,
|
||||
SpanEvent,
|
||||
SpanKind,
|
||||
SpanLink,
|
||||
SpanStatus,
|
||||
TimeScale,
|
||||
TimeTick,
|
||||
TimeWindow,
|
||||
TraceData,
|
||||
ViewAction,
|
||||
ViewContextValue,
|
||||
VisibleRow,
|
||||
} from './types'
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { Dispatch, ReactNode } from 'react'
|
||||
|
||||
export type SpanStatus = 'ok' | 'error' | 'unset'
|
||||
|
||||
export type SpanKind = 'internal' | 'server' | 'client' | 'producer' | 'consumer' | (string & {})
|
||||
|
||||
export interface SpanEvent {
|
||||
timeMs: number
|
||||
name: string
|
||||
attributes?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface SpanLink {
|
||||
traceId: string
|
||||
spanId: string
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
id: string
|
||||
parentId: string | null
|
||||
traceId: string
|
||||
name: string
|
||||
serviceName: string
|
||||
kind: SpanKind
|
||||
startMs: number
|
||||
endMs: number | null
|
||||
status: SpanStatus
|
||||
attributes: Record<string, unknown>
|
||||
events: SpanEvent[]
|
||||
links: SpanLink[]
|
||||
}
|
||||
|
||||
export interface TraceData {
|
||||
traceId?: string
|
||||
spans: readonly Span[]
|
||||
nowMs?: number
|
||||
}
|
||||
|
||||
export type TimeWindow = readonly [start: number, end: number]
|
||||
|
||||
export interface TraceIndex {
|
||||
spans: readonly Span[]
|
||||
byId: ReadonlyMap<string, Span>
|
||||
childrenOf: ReadonlyMap<string, readonly string[]>
|
||||
roots: readonly string[]
|
||||
orphanIds: ReadonlySet<string>
|
||||
depthOf: ReadonlyMap<string, number>
|
||||
descendantCountOf: ReadonlyMap<string, number>
|
||||
bounds: TimeWindow
|
||||
nowMs: number
|
||||
serviceNames: readonly string[]
|
||||
}
|
||||
|
||||
export type FilterMatch = 'inactive' | 'match' | 'ancestor'
|
||||
|
||||
export interface VisibleRow {
|
||||
id: string
|
||||
span: Span
|
||||
index: number
|
||||
depth: number
|
||||
parentId: string | null
|
||||
hasChildren: boolean
|
||||
isExpanded: boolean
|
||||
hiddenDescendantCount: number
|
||||
isOrphan: boolean
|
||||
siblingIndex: number
|
||||
siblingCount: number
|
||||
guides: readonly boolean[]
|
||||
filterMatch: FilterMatch
|
||||
}
|
||||
|
||||
export interface TimeTick {
|
||||
ms: number
|
||||
fraction: number
|
||||
x: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TimeScale {
|
||||
window: TimeWindow
|
||||
origin: number
|
||||
width: number
|
||||
durationMs: number
|
||||
pxPerMs: number
|
||||
stepMs: number
|
||||
ticks: readonly TimeTick[]
|
||||
toFraction: (ms: number) => number
|
||||
toX: (ms: number) => number
|
||||
toMs: (x: number) => number
|
||||
format: (ms: number) => string
|
||||
}
|
||||
|
||||
export interface TimeScaleOptions {
|
||||
window: TimeWindow
|
||||
width: number
|
||||
origin?: number
|
||||
targetTickSpacing?: number
|
||||
}
|
||||
|
||||
export type BarState = 'default' | 'hover' | 'selected' | 'dim' | 'running'
|
||||
|
||||
export type BarClip = 'none' | 'start' | 'end' | 'both'
|
||||
|
||||
export interface BarGeometry {
|
||||
isVisible: boolean
|
||||
isZeroDuration: boolean
|
||||
clip: BarClip
|
||||
leftFraction: number
|
||||
widthFraction: number
|
||||
}
|
||||
|
||||
export type MarkerKind = 'log' | 'exception' | 'milestone'
|
||||
|
||||
export type ValueKind = 'string' | 'number' | 'boolean' | 'null' | 'id' | 'url' | 'error' | 'json'
|
||||
|
||||
export type KeyboardAction =
|
||||
| { type: 'select'; id: string }
|
||||
| { type: 'expand'; id: string }
|
||||
| { type: 'collapse'; id: string }
|
||||
|
||||
export type BrushEdge = 'start' | 'end'
|
||||
|
||||
export type BrushMode = 'idle' | 'create' | 'move' | 'resize-start' | 'resize-end'
|
||||
|
||||
export interface BrushRect {
|
||||
left: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export interface FilterTerm {
|
||||
key: string | null
|
||||
value: string
|
||||
negate: boolean
|
||||
}
|
||||
|
||||
export interface ViewState {
|
||||
window: TimeWindow | null
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
collapsed: ReadonlySet<string>
|
||||
query: string
|
||||
}
|
||||
|
||||
export type ViewAction =
|
||||
| { type: 'setWindow'; window: TimeWindow | null }
|
||||
| { type: 'select'; id: string | null }
|
||||
| { type: 'hover'; id: string | null }
|
||||
| { type: 'toggle'; id: string }
|
||||
| { type: 'expand'; id: string }
|
||||
| { type: 'collapse'; id: string }
|
||||
| { type: 'collapseAll' }
|
||||
| { type: 'expandAll' }
|
||||
| { type: 'setCollapsed'; collapsed: ReadonlySet<string> }
|
||||
| { type: 'setQuery'; query: string }
|
||||
|
||||
export type DataContextValue = TraceIndex
|
||||
|
||||
export interface ViewContextValue {
|
||||
window: TimeWindow
|
||||
bounds: TimeWindow
|
||||
isReset: boolean
|
||||
selectedId: string | null
|
||||
hoveredId: string | null
|
||||
collapsed: ReadonlySet<string>
|
||||
query: string
|
||||
matchIds: ReadonlySet<string> | null
|
||||
dispatch: Dispatch<ViewAction>
|
||||
}
|
||||
|
||||
export interface SpanContextValue {
|
||||
span: Span
|
||||
row: VisibleRow
|
||||
isSelected: boolean
|
||||
isHovered: boolean
|
||||
isRunning: boolean
|
||||
state: BarState
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
export interface RootProps {
|
||||
trace: TraceData
|
||||
selectedId?: string | null
|
||||
onSelectedIdChange?: (id: string | null) => void
|
||||
window?: TimeWindow
|
||||
onWindowChange?: (window: TimeWindow) => void
|
||||
defaultCollapsed?: ReadonlySet<string>
|
||||
defaultQuery?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Span, TimeWindow } from './types'
|
||||
import {
|
||||
brushRect,
|
||||
buildTraceIndex,
|
||||
clampWindow,
|
||||
collectAncestors,
|
||||
computeBarGeometry,
|
||||
computeMatchIds,
|
||||
computeTicks,
|
||||
computeTimeScale,
|
||||
flattenTree,
|
||||
formatMs,
|
||||
indexRowsById,
|
||||
isFullWindow,
|
||||
matches,
|
||||
matchTerms,
|
||||
msToPx,
|
||||
niceTickStep,
|
||||
panWindow,
|
||||
parseFilterQuery,
|
||||
precisionForStep,
|
||||
pxToMs,
|
||||
resizeWindow,
|
||||
resolveKeyboardAction,
|
||||
windowFromDrag,
|
||||
zoomWindow,
|
||||
} from './utils'
|
||||
|
||||
function makeSpan(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
startMs: number,
|
||||
endMs: number | null,
|
||||
overrides: Partial<Span> = {}
|
||||
): Span {
|
||||
return {
|
||||
id,
|
||||
parentId,
|
||||
traceId: 't',
|
||||
name: id,
|
||||
serviceName: 'svc',
|
||||
kind: 'internal',
|
||||
startMs,
|
||||
endMs,
|
||||
status: 'ok',
|
||||
attributes: {},
|
||||
events: [],
|
||||
links: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('niceTickStep', () => {
|
||||
it('picks the smallest 1-2-5 step that fits the tick budget', () => {
|
||||
expect(niceTickStep(1000, 10)).toBe(100)
|
||||
expect(niceTickStep(1000, 25)).toBe(50)
|
||||
expect(niceTickStep(412, 8)).toBe(100)
|
||||
expect(niceTickStep(3, 6)).toBe(0.5)
|
||||
})
|
||||
|
||||
it('switches to clock-friendly steps above one second', () => {
|
||||
expect(niceTickStep(60_000, 5)).toBe(15_000)
|
||||
expect(niceTickStep(10 * 60_000, 6)).toBe(2 * 60_000)
|
||||
expect(niceTickStep(3 * 3_600_000, 4)).toBe(3_600_000)
|
||||
})
|
||||
|
||||
it('extends beyond the table in whole-day multiples', () => {
|
||||
const step = niceTickStep(10 * 24 * 3_600_000, 2)
|
||||
expect(step % (24 * 3_600_000)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 0 for degenerate input', () => {
|
||||
expect(niceTickStep(0, 10)).toBe(0)
|
||||
expect(niceTickStep(100, 0)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMs', () => {
|
||||
it('formats sub-millisecond values in microseconds', () => {
|
||||
expect(formatMs(0)).toBe('0ms')
|
||||
expect(formatMs(0.25)).toBe('250µs')
|
||||
expect(formatMs(0.0042)).toBe('4.2µs')
|
||||
})
|
||||
|
||||
it('formats milliseconds with fewer decimals as the value grows', () => {
|
||||
expect(formatMs(1.234)).toBe('1.23ms')
|
||||
expect(formatMs(12.34)).toBe('12.3ms')
|
||||
expect(formatMs(123.4)).toBe('123ms')
|
||||
})
|
||||
|
||||
it('formats seconds, minutes, and hours', () => {
|
||||
expect(formatMs(1500)).toBe('1.5s')
|
||||
expect(formatMs(65_000)).toBe('1m 5s')
|
||||
expect(formatMs(120_000)).toBe('2m')
|
||||
expect(formatMs(3_600_000 + 30 * 60_000)).toBe('1h 30m')
|
||||
})
|
||||
|
||||
it('respects explicit precision and negative values', () => {
|
||||
expect(formatMs(50.04, { precision: 2 })).toBe('50.04ms')
|
||||
expect(formatMs(-250)).toBe('-250ms')
|
||||
})
|
||||
})
|
||||
|
||||
describe('precisionForStep', () => {
|
||||
it('derives label precision from the tick step', () => {
|
||||
expect(precisionForStep(100)).toBe(0)
|
||||
expect(precisionForStep(0.5)).toBe(1)
|
||||
expect(precisionForStep(0.05)).toBe(2)
|
||||
expect(precisionForStep(0.0005)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTicks and computeTimeScale', () => {
|
||||
it('aligns ticks to the origin, not to the window start', () => {
|
||||
const { ticks, stepMs } = computeTicks({
|
||||
window: [1030, 1430],
|
||||
width: 400,
|
||||
origin: 1000,
|
||||
targetTickSpacing: 80,
|
||||
})
|
||||
expect(stepMs).toBe(100)
|
||||
expect(ticks.map((tick) => tick.ms)).toEqual([1100, 1200, 1300, 1400])
|
||||
expect(ticks.map((tick) => tick.label)).toEqual(['100ms', '200ms', '300ms', '400ms'])
|
||||
})
|
||||
|
||||
it('returns nothing without width or duration', () => {
|
||||
expect(
|
||||
computeTicks({ window: [0, 100], width: 0, origin: 0, targetTickSpacing: 80 }).ticks
|
||||
).toEqual([])
|
||||
expect(
|
||||
computeTicks({ window: [5, 5], width: 100, origin: 0, targetTickSpacing: 80 }).ticks
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('maps ms to x and back', () => {
|
||||
const scale = computeTimeScale({ window: [200, 700], width: 1000 })
|
||||
expect(scale.toX(450)).toBe(500)
|
||||
expect(scale.toFraction(700)).toBe(1)
|
||||
expect(scale.toMs(250)).toBe(325)
|
||||
expect(scale.pxPerMs).toBe(2)
|
||||
})
|
||||
|
||||
it('handles an empty window without dividing by zero', () => {
|
||||
const scale = computeTimeScale({ window: [100, 100], width: 300 })
|
||||
expect(scale.toX(100)).toBe(0)
|
||||
expect(scale.toMs(50)).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
const treeSpans: Span[] = [
|
||||
makeSpan('root', null, 0, 100),
|
||||
makeSpan('b', 'root', 40, 60),
|
||||
makeSpan('a', 'root', 10, 30),
|
||||
makeSpan('a1', 'a', 12, 20),
|
||||
makeSpan('a2', 'a', 8, 25),
|
||||
makeSpan('b1', 'b', 45, 45),
|
||||
makeSpan('orphan', 'ghost', 70, 80),
|
||||
makeSpan('running', 'root', 90, null),
|
||||
]
|
||||
|
||||
describe('buildTraceIndex', () => {
|
||||
const index = buildTraceIndex(treeSpans)
|
||||
|
||||
it('sorts children by start time and treats orphans as roots', () => {
|
||||
expect(index.roots).toEqual(['root', 'orphan'])
|
||||
expect(index.childrenOf.get('root')).toEqual(['a', 'b', 'running'])
|
||||
expect(index.childrenOf.get('a')).toEqual(['a2', 'a1'])
|
||||
expect(index.orphanIds.has('orphan')).toBe(true)
|
||||
})
|
||||
|
||||
it('computes depth and descendant counts', () => {
|
||||
expect(index.depthOf.get('a1')).toBe(2)
|
||||
expect(index.descendantCountOf.get('root')).toBe(6)
|
||||
expect(index.descendantCountOf.get('a')).toBe(2)
|
||||
})
|
||||
|
||||
it('derives bounds and extends them for running spans when nowMs is provided', () => {
|
||||
expect(index.bounds).toEqual([0, 100])
|
||||
expect(buildTraceIndex(treeSpans, 150).bounds).toEqual([0, 150])
|
||||
expect(buildTraceIndex([]).bounds).toEqual([0, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTree', () => {
|
||||
const index = buildTraceIndex(treeSpans)
|
||||
|
||||
it('flattens depth-first in start order when nothing is collapsed', () => {
|
||||
const rows = flattenTree(index, new Set(), null)
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'root',
|
||||
'a',
|
||||
'a2',
|
||||
'a1',
|
||||
'b',
|
||||
'b1',
|
||||
'running',
|
||||
'orphan',
|
||||
])
|
||||
expect(rows.map((row) => row.depth)).toEqual([0, 1, 2, 2, 1, 2, 1, 0])
|
||||
expect(rows.every((row) => row.filterMatch === 'inactive')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides descendants of collapsed nodes and reports how many are hidden', () => {
|
||||
const rows = flattenTree(index, new Set(['a', 'root']), null)
|
||||
expect(rows.map((row) => row.id)).toEqual(['root', 'orphan'])
|
||||
expect(rows[0].hiddenDescendantCount).toBe(6)
|
||||
const partial = flattenTree(index, new Set(['a']), null)
|
||||
expect(partial.find((row) => row.id === 'a')?.hiddenDescendantCount).toBe(2)
|
||||
})
|
||||
|
||||
it('computes sibling positions and depth guides', () => {
|
||||
const rows = flattenTree(index, new Set(), null)
|
||||
const a2 = rows.find((row) => row.id === 'a2')!
|
||||
expect(a2.siblingIndex).toBe(0)
|
||||
expect(a2.siblingCount).toBe(2)
|
||||
expect(a2.guides).toEqual([true, true])
|
||||
expect(rows.find((row) => row.id === 'running')!.guides).toEqual([true])
|
||||
})
|
||||
|
||||
it('keeps matches and their ancestors when a filter is active', () => {
|
||||
const rows = flattenTree(index, new Set(), new Set(['a1']))
|
||||
expect(rows.map((row) => row.id)).toEqual(['root', 'a', 'a1'])
|
||||
expect(rows.map((row) => row.filterMatch)).toEqual(['ancestor', 'ancestor', 'match'])
|
||||
})
|
||||
|
||||
it('counts hidden descendants within the filtered set', () => {
|
||||
const rows = flattenTree(index, new Set(['root']), new Set(['a1', 'b1']))
|
||||
expect(rows.map((row) => row.id)).toEqual(['root'])
|
||||
expect(rows[0].hiddenDescendantCount).toBe(4)
|
||||
expect(flattenTree(index, new Set(), new Set())).toEqual([])
|
||||
})
|
||||
|
||||
it('collects ancestors up to the root and stops at missing parents', () => {
|
||||
const keep = collectAncestors(index, new Set(['a1', 'orphan']))
|
||||
expect(Array.from(keep).sort()).toEqual(['a', 'a1', 'orphan', 'root'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter', () => {
|
||||
const span = makeSpan('span-1', null, 0, 10, {
|
||||
name: 'SELECT orders',
|
||||
serviceName: 'postgres',
|
||||
kind: 'client',
|
||||
status: 'error',
|
||||
attributes: {
|
||||
'db.statement': 'SELECT * FROM orders',
|
||||
'http.status_code': 502,
|
||||
'cache.hit': false,
|
||||
'user.id': null,
|
||||
},
|
||||
events: [{ timeMs: 5, name: 'exception' }],
|
||||
})
|
||||
|
||||
it('parses free text, key:value terms, quotes, and negation', () => {
|
||||
expect(parseFilterQuery('orders status:error service=postgres')).toEqual([
|
||||
{ key: null, value: 'orders', negate: false },
|
||||
{ key: 'status', value: 'error', negate: false },
|
||||
{ key: 'service', value: 'postgres', negate: false },
|
||||
])
|
||||
expect(parseFilterQuery('db.statement:"SELECT * FROM" -status:ok -cache')).toEqual([
|
||||
{ key: 'db.statement', value: 'SELECT * FROM', negate: false },
|
||||
{ key: 'status', value: 'ok', negate: true },
|
||||
{ key: null, value: 'cache', negate: true },
|
||||
])
|
||||
expect(parseFilterQuery('-')).toEqual([{ key: null, value: '-', negate: false }])
|
||||
})
|
||||
|
||||
it('matches free text against name, service, id, attributes, and events', () => {
|
||||
expect(matches(span, 'select')).toBe(true)
|
||||
expect(matches(span, 'POSTGRES')).toBe(true)
|
||||
expect(matches(span, '502')).toBe(true)
|
||||
expect(matches(span, 'exception')).toBe(true)
|
||||
expect(matches(span, 'redis')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches keyed terms and honours negation', () => {
|
||||
expect(matches(span, 'status:error')).toBe(true)
|
||||
expect(matches(span, 'cache.hit:false')).toBe(true)
|
||||
expect(matches(span, 'user.id:null')).toBe(true)
|
||||
expect(matches(span, 'missing.key:anything')).toBe(false)
|
||||
expect(matches(span, 'select status:ok')).toBe(false)
|
||||
expect(matchTerms(span, parseFilterQuery('-select'))).toBe(false)
|
||||
})
|
||||
|
||||
it('computes match ids and treats an empty query as inactive', () => {
|
||||
const other = makeSpan('span-2', null, 0, 5, { name: 'cache.get', serviceName: 'redis' })
|
||||
const index = buildTraceIndex([span, other])
|
||||
expect(computeMatchIds(index, ' ')).toBeNull()
|
||||
expect(Array.from(computeMatchIds(index, 'status:error')!)).toEqual(['span-1'])
|
||||
expect(computeMatchIds(index, 'nothing-here')!.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('brush math', () => {
|
||||
const bounds: TimeWindow = [1000, 2000]
|
||||
|
||||
it('converts between px and ms', () => {
|
||||
expect(msToPx(1500, bounds, 500)).toBe(250)
|
||||
expect(pxToMs(125, bounds, 500)).toBe(1250)
|
||||
expect(pxToMs(100, bounds, 0)).toBe(1000)
|
||||
})
|
||||
|
||||
it('clamps windows inside bounds, preserving size and minimum duration', () => {
|
||||
expect(clampWindow([900, 1200], bounds)).toEqual([1000, 1300])
|
||||
expect(clampWindow([1800, 2100], bounds)).toEqual([1700, 2000])
|
||||
expect(clampWindow([500, 2500], bounds)).toEqual([1000, 2000])
|
||||
expect(clampWindow([1500, 1500], bounds, 100)).toEqual([1450, 1550])
|
||||
expect(clampWindow([1600, 1200], bounds)).toEqual([1200, 1600])
|
||||
})
|
||||
|
||||
it('pans without resizing and stops at the edges', () => {
|
||||
expect(panWindow([1200, 1400], 100, bounds)).toEqual([1300, 1500])
|
||||
expect(panWindow([1200, 1400], 5000, bounds)).toEqual([1800, 2000])
|
||||
})
|
||||
|
||||
it('resizes one edge and respects the minimum duration', () => {
|
||||
expect(resizeWindow([1200, 1600], 'start', 1300, bounds)).toEqual([1300, 1600])
|
||||
expect(resizeWindow([1200, 1600], 'start', 1700, bounds, 50)).toEqual([1550, 1600])
|
||||
expect(resizeWindow([1200, 1600], 'end', 9999, bounds)).toEqual([1200, 2000])
|
||||
})
|
||||
|
||||
it('builds windows from drags and zooms around an anchor', () => {
|
||||
expect(windowFromDrag(1600, 1200, bounds)).toEqual([1200, 1600])
|
||||
expect(zoomWindow([1200, 1600], 0.5, 1400, bounds)).toEqual([1300, 1500])
|
||||
expect(zoomWindow([1200, 1600], 10, undefined, bounds)).toEqual([1000, 2000])
|
||||
})
|
||||
|
||||
it('converts a window to a pixel rectangle and detects the full window', () => {
|
||||
expect(brushRect([1250, 1750], bounds, 400)).toEqual({ left: 100, width: 200 })
|
||||
expect(isFullWindow([1000, 2000], bounds)).toBe(true)
|
||||
expect(isFullWindow([1000, 1999], bounds)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveKeyboardAction', () => {
|
||||
const index = buildTraceIndex([
|
||||
makeSpan('root', null, 0, 10),
|
||||
makeSpan('a', 'root', 1, 11),
|
||||
makeSpan('a1', 'a', 2, 12),
|
||||
makeSpan('b', 'root', 5, 15),
|
||||
])
|
||||
const setup = (collapsed: string[] = []) => {
|
||||
const rows = flattenTree(index, new Set(collapsed), null)
|
||||
return { rows, rowIndexById: indexRowsById(rows) }
|
||||
}
|
||||
|
||||
it('moves up and down and stops at the edges', () => {
|
||||
const tree = setup()
|
||||
expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: null })).toEqual({
|
||||
type: 'select',
|
||||
id: 'root',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'a' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'a1',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowUp', ...tree, selectedId: 'root' })).toBeNull()
|
||||
expect(resolveKeyboardAction({ key: 'ArrowDown', ...tree, selectedId: 'b' })).toBeNull()
|
||||
})
|
||||
|
||||
it('expands on ArrowRight, then moves into the node', () => {
|
||||
expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(['a']), selectedId: 'a' })).toEqual({
|
||||
type: 'expand',
|
||||
id: 'a',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'a' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'a1',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowRight', ...setup(), selectedId: 'b' })).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses on ArrowLeft, otherwise jumps to the parent', () => {
|
||||
const tree = setup()
|
||||
expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a' })).toEqual({
|
||||
type: 'collapse',
|
||||
id: 'a',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'a1' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'a',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'ArrowLeft', ...tree, selectedId: 'b' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'root',
|
||||
})
|
||||
})
|
||||
|
||||
it('jumps with Home and End and ignores other keys', () => {
|
||||
const tree = setup()
|
||||
expect(resolveKeyboardAction({ key: 'Home', ...tree, selectedId: 'b' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'root',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'End', ...tree, selectedId: 'root' })).toEqual({
|
||||
type: 'select',
|
||||
id: 'b',
|
||||
})
|
||||
expect(resolveKeyboardAction({ key: 'Enter', ...tree, selectedId: 'a' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeBarGeometry', () => {
|
||||
const window: TimeWindow = [100, 200]
|
||||
|
||||
it('positions and clips bars against the window', () => {
|
||||
expect(computeBarGeometry(125, 175, window)).toMatchObject({
|
||||
clip: 'none',
|
||||
leftFraction: 0.25,
|
||||
widthFraction: 0.5,
|
||||
})
|
||||
expect(computeBarGeometry(50, 150, window)).toMatchObject({ clip: 'start', leftFraction: 0 })
|
||||
expect(computeBarGeometry(150, 250, window)).toMatchObject({ clip: 'end', widthFraction: 0.5 })
|
||||
expect(computeBarGeometry(0, 300, window)).toMatchObject({ clip: 'both', widthFraction: 1 })
|
||||
})
|
||||
|
||||
it('hides bars outside the window and flags zero duration', () => {
|
||||
expect(computeBarGeometry(0, 50, window).isVisible).toBe(false)
|
||||
expect(computeBarGeometry(150, 150, window)).toMatchObject({
|
||||
isVisible: true,
|
||||
isZeroDuration: true,
|
||||
})
|
||||
expect(computeBarGeometry(150, 140, window).isZeroDuration).toBe(true)
|
||||
expect(computeBarGeometry(100, 150, [100, 100]).isVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,780 @@
|
||||
import type {
|
||||
BarGeometry,
|
||||
BrushEdge,
|
||||
BrushRect,
|
||||
FilterMatch,
|
||||
FilterTerm,
|
||||
KeyboardAction,
|
||||
MarkerKind,
|
||||
Span,
|
||||
SpanEvent,
|
||||
TimeScale,
|
||||
TimeScaleOptions,
|
||||
TimeTick,
|
||||
TimeWindow,
|
||||
TraceIndex,
|
||||
ValueKind,
|
||||
VisibleRow,
|
||||
} from './types'
|
||||
|
||||
// Trace index
|
||||
|
||||
export function effectiveEndMs(span: Pick<Span, 'startMs' | 'endMs'>, nowMs: number): number {
|
||||
if (span.endMs === null) return Math.max(span.startMs, nowMs)
|
||||
return span.endMs
|
||||
}
|
||||
|
||||
export function durationOf(span: Pick<Span, 'startMs' | 'endMs'>, nowMs: number): number {
|
||||
return Math.max(0, effectiveEndMs(span, nowMs) - span.startMs)
|
||||
}
|
||||
|
||||
function compareByStart(byId: ReadonlyMap<string, Span>) {
|
||||
return (a: string, b: string) => {
|
||||
const sa = byId.get(a)!
|
||||
const sb = byId.get(b)!
|
||||
if (sa.startMs !== sb.startMs) return sa.startMs - sb.startMs
|
||||
return sa.id < sb.id ? -1 : sa.id > sb.id ? 1 : 0
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTraceIndex(spans: readonly Span[], nowMs?: number): TraceIndex {
|
||||
const byId = new Map<string, Span>()
|
||||
for (const span of spans) byId.set(span.id, span)
|
||||
|
||||
const childrenOf = new Map<string, string[]>()
|
||||
const roots: string[] = []
|
||||
const orphanIds = new Set<string>()
|
||||
const services = new Set<string>()
|
||||
|
||||
let minStart = Number.POSITIVE_INFINITY
|
||||
let maxKnown = Number.NEGATIVE_INFINITY
|
||||
|
||||
for (const span of byId.values()) {
|
||||
services.add(span.serviceName)
|
||||
if (span.startMs < minStart) minStart = span.startMs
|
||||
if (span.startMs > maxKnown) maxKnown = span.startMs
|
||||
if (span.endMs !== null && span.endMs > maxKnown) maxKnown = span.endMs
|
||||
|
||||
const hasParent = span.parentId !== null && byId.has(span.parentId)
|
||||
if (span.parentId !== null && !hasParent) orphanIds.add(span.id)
|
||||
if (!hasParent) {
|
||||
roots.push(span.id)
|
||||
continue
|
||||
}
|
||||
const siblings = childrenOf.get(span.parentId!)
|
||||
if (siblings) siblings.push(span.id)
|
||||
else childrenOf.set(span.parentId!, [span.id])
|
||||
}
|
||||
|
||||
const compare = compareByStart(byId)
|
||||
roots.sort(compare)
|
||||
for (const ids of childrenOf.values()) ids.sort(compare)
|
||||
|
||||
const resolvedNow = nowMs ?? (Number.isFinite(maxKnown) ? maxKnown : 0)
|
||||
|
||||
let maxEnd = maxKnown
|
||||
for (const span of byId.values()) {
|
||||
const end = effectiveEndMs(span, resolvedNow)
|
||||
if (end > maxEnd) maxEnd = end
|
||||
}
|
||||
|
||||
const depthOf = new Map<string, number>()
|
||||
const descendantCountOf = new Map<string, number>()
|
||||
const postOrder: string[] = []
|
||||
const stack: Array<{ id: string; depth: number }> = []
|
||||
for (let i = roots.length - 1; i >= 0; i--) stack.push({ id: roots[i], depth: 0 })
|
||||
|
||||
while (stack.length > 0) {
|
||||
const { id, depth } = stack.pop()!
|
||||
if (depthOf.has(id)) continue
|
||||
depthOf.set(id, depth)
|
||||
postOrder.push(id)
|
||||
const children = childrenOf.get(id)
|
||||
if (!children) continue
|
||||
for (let i = children.length - 1; i >= 0; i--) stack.push({ id: children[i], depth: depth + 1 })
|
||||
}
|
||||
|
||||
for (let i = postOrder.length - 1; i >= 0; i--) {
|
||||
const id = postOrder[i]
|
||||
const children = childrenOf.get(id)
|
||||
let count = 0
|
||||
if (children) {
|
||||
for (const child of children) count += 1 + (descendantCountOf.get(child) ?? 0)
|
||||
}
|
||||
descendantCountOf.set(id, count)
|
||||
}
|
||||
|
||||
const bounds: TimeWindow = Number.isFinite(minStart)
|
||||
? [minStart, Math.max(maxEnd, minStart)]
|
||||
: [0, 0]
|
||||
|
||||
return {
|
||||
spans,
|
||||
byId,
|
||||
childrenOf,
|
||||
roots,
|
||||
orphanIds,
|
||||
depthOf,
|
||||
descendantCountOf,
|
||||
bounds,
|
||||
nowMs: resolvedNow,
|
||||
serviceNames: Array.from(services).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
export const EMPTY_TRACE_INDEX: TraceIndex = buildTraceIndex([])
|
||||
|
||||
// Tree flattening
|
||||
|
||||
export function collectAncestors(
|
||||
index: TraceIndex,
|
||||
matchIds: ReadonlySet<string>
|
||||
): ReadonlySet<string> {
|
||||
const keep = new Set<string>(matchIds)
|
||||
for (const id of matchIds) {
|
||||
let current = index.byId.get(id)
|
||||
while (current && current.parentId !== null) {
|
||||
const parent = index.byId.get(current.parentId)
|
||||
if (!parent || keep.has(parent.id)) break
|
||||
keep.add(parent.id)
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
return keep
|
||||
}
|
||||
|
||||
interface Frame {
|
||||
id: string
|
||||
depth: number
|
||||
siblingIndex: number
|
||||
siblingCount: number
|
||||
guides: boolean[]
|
||||
}
|
||||
|
||||
export function flattenTree(
|
||||
index: TraceIndex,
|
||||
collapsedIds: ReadonlySet<string>,
|
||||
matchIds: ReadonlySet<string> | null
|
||||
): VisibleRow[] {
|
||||
const keep = matchIds ? collectAncestors(index, matchIds) : null
|
||||
const rows: VisibleRow[] = []
|
||||
|
||||
const visibleChildren = (id: string): string[] => {
|
||||
const children = index.childrenOf.get(id)
|
||||
if (!children) return []
|
||||
if (!keep) return children as string[]
|
||||
return children.filter((child) => keep.has(child))
|
||||
}
|
||||
|
||||
const countVisibleDescendants = (id: string): number => {
|
||||
if (!keep) return index.descendantCountOf.get(id) ?? 0
|
||||
let count = 0
|
||||
const stack = [...visibleChildren(id)]
|
||||
while (stack.length > 0) {
|
||||
const next = stack.pop()!
|
||||
count++
|
||||
for (const child of visibleChildren(next)) stack.push(child)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
const topLevel = keep ? index.roots.filter((id) => keep.has(id)) : index.roots
|
||||
const stack: Frame[] = []
|
||||
for (let i = topLevel.length - 1; i >= 0; i--) {
|
||||
stack.push({
|
||||
id: topLevel[i],
|
||||
depth: 0,
|
||||
siblingIndex: i,
|
||||
siblingCount: topLevel.length,
|
||||
guides: [],
|
||||
})
|
||||
}
|
||||
|
||||
while (stack.length > 0) {
|
||||
const frame = stack.pop()!
|
||||
const span = index.byId.get(frame.id)
|
||||
if (!span) continue
|
||||
|
||||
const children = visibleChildren(frame.id)
|
||||
const hasChildren = children.length > 0
|
||||
const isCollapsed = collapsedIds.has(frame.id)
|
||||
const isExpanded = hasChildren && !isCollapsed
|
||||
|
||||
let filterMatch: FilterMatch = 'inactive'
|
||||
if (matchIds) filterMatch = matchIds.has(frame.id) ? 'match' : 'ancestor'
|
||||
|
||||
rows.push({
|
||||
id: frame.id,
|
||||
span,
|
||||
index: rows.length,
|
||||
depth: frame.depth,
|
||||
parentId: span.parentId !== null && index.byId.has(span.parentId) ? span.parentId : null,
|
||||
hasChildren,
|
||||
isExpanded,
|
||||
hiddenDescendantCount: hasChildren && isCollapsed ? countVisibleDescendants(frame.id) : 0,
|
||||
isOrphan: index.orphanIds.has(frame.id),
|
||||
siblingIndex: frame.siblingIndex,
|
||||
siblingCount: frame.siblingCount,
|
||||
guides: frame.guides,
|
||||
filterMatch,
|
||||
})
|
||||
|
||||
if (!isExpanded) continue
|
||||
|
||||
const hasFollowingSibling = frame.siblingIndex < frame.siblingCount - 1
|
||||
const childGuides = [...frame.guides, hasFollowingSibling]
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({
|
||||
id: children[i],
|
||||
depth: frame.depth + 1,
|
||||
siblingIndex: i,
|
||||
siblingCount: children.length,
|
||||
guides: childGuides,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
export function indexRowsById(rows: readonly VisibleRow[]): ReadonlyMap<string, number> {
|
||||
const map = new Map<string, number>()
|
||||
for (const row of rows) map.set(row.id, row.index)
|
||||
return map
|
||||
}
|
||||
|
||||
// Time scale
|
||||
|
||||
const SECOND = 1000
|
||||
const MINUTE = 60 * SECOND
|
||||
const HOUR = 60 * MINUTE
|
||||
|
||||
const SUB_SECOND_STEPS = [
|
||||
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200, 500,
|
||||
]
|
||||
|
||||
const CLOCK_STEPS = [
|
||||
SECOND,
|
||||
2 * SECOND,
|
||||
5 * SECOND,
|
||||
10 * SECOND,
|
||||
15 * SECOND,
|
||||
30 * SECOND,
|
||||
MINUTE,
|
||||
2 * MINUTE,
|
||||
5 * MINUTE,
|
||||
10 * MINUTE,
|
||||
15 * MINUTE,
|
||||
30 * MINUTE,
|
||||
HOUR,
|
||||
2 * HOUR,
|
||||
3 * HOUR,
|
||||
6 * HOUR,
|
||||
12 * HOUR,
|
||||
24 * HOUR,
|
||||
]
|
||||
|
||||
export const NICE_STEPS_MS: readonly number[] = [...SUB_SECOND_STEPS, ...CLOCK_STEPS]
|
||||
|
||||
export function niceTickStep(durationMs: number, maxTicks: number): number {
|
||||
if (!(durationMs > 0) || !(maxTicks > 0)) return 0
|
||||
const raw = durationMs / maxTicks
|
||||
for (const step of NICE_STEPS_MS) {
|
||||
if (step >= raw) return step
|
||||
}
|
||||
const last = NICE_STEPS_MS[NICE_STEPS_MS.length - 1]
|
||||
return Math.ceil(raw / last) * last
|
||||
}
|
||||
|
||||
function trimZeros(value: string): string {
|
||||
return value.includes('.') ? value.replace(/\.?0+$/, '') : value
|
||||
}
|
||||
|
||||
export interface FormatMsOptions {
|
||||
precision?: number
|
||||
}
|
||||
|
||||
export function formatMs(ms: number, options: FormatMsOptions = {}): string {
|
||||
if (!Number.isFinite(ms)) return '–'
|
||||
const sign = ms < 0 ? '-' : ''
|
||||
const abs = Math.abs(ms)
|
||||
const { precision } = options
|
||||
|
||||
if (abs === 0) return '0ms'
|
||||
if (abs < 1) {
|
||||
const micro = abs * 1000
|
||||
const digits = precision ?? (micro < 10 ? 1 : 0)
|
||||
return `${sign}${trimZeros(micro.toFixed(digits))}µs`
|
||||
}
|
||||
if (abs < SECOND) {
|
||||
const digits = precision ?? (abs < 10 ? 2 : abs < 100 ? 1 : 0)
|
||||
return `${sign}${trimZeros(abs.toFixed(digits))}ms`
|
||||
}
|
||||
if (abs < MINUTE) {
|
||||
const digits = precision ?? 2
|
||||
return `${sign}${trimZeros((abs / SECOND).toFixed(digits))}s`
|
||||
}
|
||||
if (abs < HOUR) {
|
||||
const minutes = Math.floor(abs / MINUTE)
|
||||
const seconds = Math.round((abs - minutes * MINUTE) / SECOND)
|
||||
return seconds === 0 ? `${sign}${minutes}m` : `${sign}${minutes}m ${seconds}s`
|
||||
}
|
||||
const hours = Math.floor(abs / HOUR)
|
||||
const minutes = Math.round((abs - hours * HOUR) / MINUTE)
|
||||
return minutes === 0 ? `${sign}${hours}h` : `${sign}${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
export function precisionForStep(stepMs: number): number {
|
||||
if (stepMs <= 0) return 0
|
||||
if (stepMs < SECOND) return Math.max(0, -Math.floor(Math.log10(stepMs)))
|
||||
if (stepMs < MINUTE) return Math.max(0, -Math.floor(Math.log10(stepMs / SECOND)))
|
||||
return 0
|
||||
}
|
||||
|
||||
export function computeTicks(options: Required<TimeScaleOptions>): {
|
||||
ticks: TimeTick[]
|
||||
stepMs: number
|
||||
} {
|
||||
const { window, width, origin, targetTickSpacing } = options
|
||||
const [start, end] = window
|
||||
const durationMs = end - start
|
||||
if (!(durationMs > 0) || !(width > 0)) return { ticks: [], stepMs: 0 }
|
||||
|
||||
const maxTicks = Math.max(1, Math.floor(width / targetTickSpacing))
|
||||
const stepMs = niceTickStep(durationMs, maxTicks)
|
||||
if (stepMs <= 0) return { ticks: [], stepMs: 0 }
|
||||
|
||||
const precision = precisionForStep(stepMs)
|
||||
const ticks: TimeTick[] = []
|
||||
const firstOffset = Math.ceil((start - origin) / stepMs - 1e-9) * stepMs
|
||||
const guard = maxTicks + 2
|
||||
|
||||
for (let offset = firstOffset, i = 0; i < guard; offset += stepMs, i++) {
|
||||
const ms = origin + offset
|
||||
if (ms > end + 1e-9) break
|
||||
if (ms < start - 1e-9) continue
|
||||
const fraction = (ms - start) / durationMs
|
||||
ticks.push({
|
||||
ms,
|
||||
fraction,
|
||||
x: fraction * width,
|
||||
label: formatMs(ms - origin, { precision }),
|
||||
})
|
||||
}
|
||||
|
||||
return { ticks, stepMs }
|
||||
}
|
||||
|
||||
export function computeTimeScale(options: TimeScaleOptions): TimeScale {
|
||||
const window = options.window
|
||||
const width = Math.max(0, options.width)
|
||||
const origin = options.origin ?? window[0]
|
||||
const targetTickSpacing = options.targetTickSpacing ?? 80
|
||||
const durationMs = window[1] - window[0]
|
||||
const pxPerMs = durationMs > 0 ? width / durationMs : 0
|
||||
|
||||
const { ticks, stepMs } = computeTicks({ window, width, origin, targetTickSpacing })
|
||||
|
||||
return {
|
||||
window,
|
||||
origin,
|
||||
width,
|
||||
durationMs,
|
||||
pxPerMs,
|
||||
stepMs,
|
||||
ticks,
|
||||
toFraction: (ms) => (durationMs > 0 ? (ms - window[0]) / durationMs : 0),
|
||||
toX: (ms) => (durationMs > 0 ? ((ms - window[0]) / durationMs) * width : 0),
|
||||
toMs: (x) => (width > 0 ? window[0] + (x / width) * durationMs : window[0]),
|
||||
format: (ms) => formatMs(ms),
|
||||
}
|
||||
}
|
||||
|
||||
// Filter
|
||||
|
||||
export function parseFilterQuery(query: string): FilterTerm[] {
|
||||
const terms: FilterTerm[] = []
|
||||
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) ?? []
|
||||
for (const raw of tokens) {
|
||||
let token = raw
|
||||
let negate = false
|
||||
if (token.startsWith('-') && token.length > 1) {
|
||||
negate = true
|
||||
token = token.slice(1)
|
||||
}
|
||||
const separator = token.search(/[:=]/)
|
||||
if (separator > 0) {
|
||||
const key = token.slice(0, separator)
|
||||
const value = stripQuotes(token.slice(separator + 1))
|
||||
terms.push({ key, value, negate })
|
||||
} else {
|
||||
terms.push({ key: null, value: stripQuotes(token), negate })
|
||||
}
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
function stripQuotes(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) return value.slice(1, -1)
|
||||
return value
|
||||
}
|
||||
|
||||
function includesInsensitive(haystack: string, needle: string): boolean {
|
||||
return haystack.toLowerCase().includes(needle)
|
||||
}
|
||||
|
||||
function fieldValue(span: Span, key: string): unknown {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return span.name
|
||||
case 'service':
|
||||
case 'serviceName':
|
||||
return span.serviceName
|
||||
case 'status':
|
||||
return span.status
|
||||
case 'kind':
|
||||
return span.kind
|
||||
case 'id':
|
||||
return span.id
|
||||
default:
|
||||
return span.attributes[key]
|
||||
}
|
||||
}
|
||||
|
||||
function matchTerm(span: Span, term: FilterTerm): boolean {
|
||||
const needle = term.value.toLowerCase()
|
||||
if (term.key !== null) {
|
||||
const value = fieldValue(span, term.key)
|
||||
if (value === undefined || value === null) return needle === 'null' && value === null
|
||||
return needle === '' ? true : includesInsensitive(String(value), needle)
|
||||
}
|
||||
if (needle === '') return true
|
||||
if (includesInsensitive(span.name, needle)) return true
|
||||
if (includesInsensitive(span.serviceName, needle)) return true
|
||||
if (includesInsensitive(span.id, needle)) return true
|
||||
for (const value of Object.values(span.attributes)) {
|
||||
if (typeof value === 'string' && includesInsensitive(value, needle)) return true
|
||||
if (typeof value === 'number' && String(value).includes(needle)) return true
|
||||
}
|
||||
for (const event of span.events) {
|
||||
if (includesInsensitive(event.name, needle)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function matchTerms(span: Span, terms: readonly FilterTerm[]): boolean {
|
||||
for (const term of terms) {
|
||||
const matched = matchTerm(span, term)
|
||||
if (term.negate ? matched : !matched) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function matches(span: Span, query: string): boolean {
|
||||
const terms = parseFilterQuery(query)
|
||||
if (terms.length === 0) return true
|
||||
return matchTerms(span, terms)
|
||||
}
|
||||
|
||||
export function computeMatchIds(index: TraceIndex, query: string): ReadonlySet<string> | null {
|
||||
if (query.trim() === '') return null
|
||||
const result = new Set<string>()
|
||||
for (const span of index.byId.values()) {
|
||||
if (matches(span, query)) result.add(span.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Brush
|
||||
|
||||
export function boundsDuration(bounds: TimeWindow): number {
|
||||
return Math.max(0, bounds[1] - bounds[0])
|
||||
}
|
||||
|
||||
export function msToFraction(ms: number, bounds: TimeWindow): number {
|
||||
const duration = boundsDuration(bounds)
|
||||
if (duration === 0) return 0
|
||||
return (ms - bounds[0]) / duration
|
||||
}
|
||||
|
||||
export function fractionToMs(fraction: number, bounds: TimeWindow): number {
|
||||
return bounds[0] + fraction * boundsDuration(bounds)
|
||||
}
|
||||
|
||||
export function msToPx(ms: number, bounds: TimeWindow, width: number): number {
|
||||
return msToFraction(ms, bounds) * width
|
||||
}
|
||||
|
||||
export function pxToMs(px: number, bounds: TimeWindow, width: number): number {
|
||||
if (width <= 0) return bounds[0]
|
||||
return fractionToMs(px / width, bounds)
|
||||
}
|
||||
|
||||
export function resolveMinDuration(bounds: TimeWindow, minDurationMs?: number): number {
|
||||
const duration = boundsDuration(bounds)
|
||||
if (minDurationMs !== undefined) return Math.min(minDurationMs, duration)
|
||||
return Math.min(duration, Math.max(duration / 1000, 0.001))
|
||||
}
|
||||
|
||||
export function clampWindow(
|
||||
window: TimeWindow,
|
||||
bounds: TimeWindow,
|
||||
minDurationMs?: number
|
||||
): TimeWindow {
|
||||
const minDuration = resolveMinDuration(bounds, minDurationMs)
|
||||
const total = boundsDuration(bounds)
|
||||
let [start, end] = window[0] <= window[1] ? window : [window[1], window[0]]
|
||||
|
||||
if (end - start < minDuration) {
|
||||
const center = (start + end) / 2
|
||||
start = center - minDuration / 2
|
||||
end = center + minDuration / 2
|
||||
}
|
||||
if (end - start >= total) return [bounds[0], bounds[1]]
|
||||
|
||||
if (start < bounds[0]) {
|
||||
end += bounds[0] - start
|
||||
start = bounds[0]
|
||||
}
|
||||
if (end > bounds[1]) {
|
||||
start -= end - bounds[1]
|
||||
end = bounds[1]
|
||||
}
|
||||
return [Math.max(start, bounds[0]), Math.min(end, bounds[1])]
|
||||
}
|
||||
|
||||
export function panWindow(window: TimeWindow, deltaMs: number, bounds: TimeWindow): TimeWindow {
|
||||
const size = window[1] - window[0]
|
||||
let start = window[0] + deltaMs
|
||||
if (start < bounds[0]) start = bounds[0]
|
||||
if (start + size > bounds[1]) start = bounds[1] - size
|
||||
return [start, start + size]
|
||||
}
|
||||
|
||||
export function resizeWindow(
|
||||
window: TimeWindow,
|
||||
edge: BrushEdge,
|
||||
ms: number,
|
||||
bounds: TimeWindow,
|
||||
minDurationMs?: number
|
||||
): TimeWindow {
|
||||
const minDuration = resolveMinDuration(bounds, minDurationMs)
|
||||
const clampedMs = Math.min(Math.max(ms, bounds[0]), bounds[1])
|
||||
if (edge === 'start') {
|
||||
const start = Math.min(clampedMs, window[1] - minDuration)
|
||||
return [Math.max(start, bounds[0]), window[1]]
|
||||
}
|
||||
const end = Math.max(clampedMs, window[0] + minDuration)
|
||||
return [window[0], Math.min(end, bounds[1])]
|
||||
}
|
||||
|
||||
export function windowFromDrag(
|
||||
aMs: number,
|
||||
bMs: number,
|
||||
bounds: TimeWindow,
|
||||
minDurationMs?: number
|
||||
): TimeWindow {
|
||||
const start = Math.min(aMs, bMs)
|
||||
const end = Math.max(aMs, bMs)
|
||||
return clampWindow([start, end], bounds, minDurationMs)
|
||||
}
|
||||
|
||||
export function zoomWindow(
|
||||
window: TimeWindow,
|
||||
factor: number,
|
||||
anchorMs: number | undefined,
|
||||
bounds: TimeWindow,
|
||||
minDurationMs?: number
|
||||
): TimeWindow {
|
||||
if (!(factor > 0)) return window
|
||||
const size = window[1] - window[0]
|
||||
const anchor = anchorMs ?? (window[0] + window[1]) / 2
|
||||
const ratio = size > 0 ? (anchor - window[0]) / size : 0.5
|
||||
const nextSize = size * factor
|
||||
const start = anchor - ratio * nextSize
|
||||
return clampWindow([start, start + nextSize], bounds, minDurationMs)
|
||||
}
|
||||
|
||||
export function brushRect(window: TimeWindow, bounds: TimeWindow, width: number): BrushRect {
|
||||
const left = msToPx(window[0], bounds, width)
|
||||
const right = msToPx(window[1], bounds, width)
|
||||
return { left, width: Math.max(0, right - left) }
|
||||
}
|
||||
|
||||
export function isFullWindow(window: TimeWindow, bounds: TimeWindow): boolean {
|
||||
return window[0] <= bounds[0] && window[1] >= bounds[1]
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
|
||||
export interface ResolveKeyboardActionInput {
|
||||
key: string
|
||||
rows: readonly VisibleRow[]
|
||||
selectedId: string | null
|
||||
rowIndexById: ReadonlyMap<string, number>
|
||||
}
|
||||
|
||||
export function resolveKeyboardAction(input: ResolveKeyboardActionInput): KeyboardAction | null {
|
||||
const { key, rows, selectedId, rowIndexById } = input
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const currentIndex = selectedId !== null ? (rowIndexById.get(selectedId) ?? -1) : -1
|
||||
const current = currentIndex >= 0 ? rows[currentIndex] : null
|
||||
|
||||
switch (key) {
|
||||
case 'ArrowDown': {
|
||||
if (!current) return { type: 'select', id: rows[0].id }
|
||||
const next = rows[Math.min(currentIndex + 1, rows.length - 1)]
|
||||
return next.id === current.id ? null : { type: 'select', id: next.id }
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
if (!current) return { type: 'select', id: rows[rows.length - 1].id }
|
||||
const prev = rows[Math.max(currentIndex - 1, 0)]
|
||||
return prev.id === current.id ? null : { type: 'select', id: prev.id }
|
||||
}
|
||||
case 'Home':
|
||||
return rows[0].id === selectedId ? null : { type: 'select', id: rows[0].id }
|
||||
case 'End': {
|
||||
const last = rows[rows.length - 1]
|
||||
return last.id === selectedId ? null : { type: 'select', id: last.id }
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (!current) return { type: 'select', id: rows[0].id }
|
||||
if (current.hasChildren && !current.isExpanded) return { type: 'expand', id: current.id }
|
||||
if (current.isExpanded && currentIndex + 1 < rows.length) {
|
||||
return { type: 'select', id: rows[currentIndex + 1].id }
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (!current) return { type: 'select', id: rows[0].id }
|
||||
if (current.isExpanded) return { type: 'collapse', id: current.id }
|
||||
if (current.parentId !== null && rowIndexById.has(current.parentId)) {
|
||||
return { type: 'select', id: current.parentId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const NAVIGATION_KEYS: ReadonlySet<string> = new Set([
|
||||
'ArrowDown',
|
||||
'ArrowUp',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
])
|
||||
|
||||
// Bar geometry
|
||||
|
||||
export function computeBarGeometry(
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
window: TimeWindow
|
||||
): BarGeometry {
|
||||
const [windowStart, windowEnd] = window
|
||||
const duration = windowEnd - windowStart
|
||||
const end = Math.max(startMs, endMs)
|
||||
const isZeroDuration = end === startMs
|
||||
|
||||
if (!(duration > 0)) {
|
||||
return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 }
|
||||
}
|
||||
|
||||
const startFraction = (startMs - windowStart) / duration
|
||||
const endFraction = (end - windowStart) / duration
|
||||
|
||||
if (endFraction < 0 || startFraction > 1) {
|
||||
return { isVisible: false, isZeroDuration, clip: 'none', leftFraction: 0, widthFraction: 0 }
|
||||
}
|
||||
|
||||
const clipStart = startFraction < 0
|
||||
const clipEnd = endFraction > 1
|
||||
let clip: BarGeometry['clip'] = 'none'
|
||||
if (clipStart && clipEnd) clip = 'both'
|
||||
else if (clipStart) clip = 'start'
|
||||
else if (clipEnd) clip = 'end'
|
||||
|
||||
const left = Math.max(0, startFraction)
|
||||
const right = Math.min(1, endFraction)
|
||||
|
||||
return {
|
||||
isVisible: true,
|
||||
isZeroDuration,
|
||||
clip,
|
||||
leftFraction: left,
|
||||
widthFraction: Math.max(0, right - left),
|
||||
}
|
||||
}
|
||||
|
||||
// Markers
|
||||
|
||||
export function inferMarkerKind(event: SpanEvent): MarkerKind {
|
||||
const name = event.name.toLowerCase()
|
||||
const attributes = event.attributes ?? {}
|
||||
if (name === 'exception' || name.includes('error') || 'exception.type' in attributes) {
|
||||
return 'exception'
|
||||
}
|
||||
if (name === 'log' || name.startsWith('log.') || 'log.severity' in attributes) {
|
||||
return 'log'
|
||||
}
|
||||
return 'milestone'
|
||||
}
|
||||
|
||||
export const MARKER_KIND_LABEL: Record<MarkerKind, string> = {
|
||||
log: 'Log',
|
||||
exception: 'Exception',
|
||||
milestone: 'Milestone',
|
||||
}
|
||||
|
||||
// Key-value rendering
|
||||
|
||||
const URL_PATTERN = /^https?:\/\/\S+$/i
|
||||
const ID_KEY_PATTERN = /(^|[._-])(span_?id|trace_?id|parent_?(span_?)?id|id)$/i
|
||||
const ERROR_KEY_PATTERN = /(exception|error|stack|stacktrace)/i
|
||||
|
||||
export function detectValueKind(key: string, value: unknown): ValueKind {
|
||||
if (value === null || value === undefined) return 'null'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number' || typeof value === 'bigint') return 'number'
|
||||
if (typeof value === 'object') return 'json'
|
||||
if (typeof value === 'string') {
|
||||
if (URL_PATTERN.test(value)) return 'url'
|
||||
if (ID_KEY_PATTERN.test(key)) return 'id'
|
||||
if (ERROR_KEY_PATTERN.test(key)) return 'error'
|
||||
return 'string'
|
||||
}
|
||||
return 'string'
|
||||
}
|
||||
|
||||
export function stringifyValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function summarizeJson(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length === 1 ? 'Array (1 item)' : `Array (${value.length} items)`
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const size = Object.keys(value).length
|
||||
return size === 1 ? 'Object (1 key)' : `Object (${size} keys)`
|
||||
}
|
||||
return 'Value'
|
||||
}
|
||||
|
||||
export function sortedEntries(data: Record<string, unknown>): Array<[string, unknown]> {
|
||||
return Object.entries(data).sort(([a], [b]) => a.localeCompare(b))
|
||||
}
|
||||
Generated
+3
@@ -2836,6 +2836,9 @@ importers:
|
||||
'@tanstack/react-table':
|
||||
specifier: 'catalog:'
|
||||
version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.24
|
||||
version: 3.14.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
|
||||
Reference in new issue
Block a user