@spunto/design-system 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/package.json +5 -1
- package/src/components/observability/analytics.ts +153 -0
- package/src/components/observability/index.ts +39 -0
- package/src/components/observability/latency.ts +47 -0
- package/src/components/observability/log-lines.tsx +117 -0
- package/src/components/observability/observability.test.tsx +244 -0
- package/src/components/observability/span-waterfall.tsx +493 -0
- package/src/components/observability/trace-filters.tsx +213 -0
- package/src/components/observability/trace-list.tsx +193 -0
- package/src/components/observability/traces-overview.tsx +284 -0
- package/src/components/observability/types.ts +29 -0
- package/src/components/tasks/event-stream.tsx +9 -2
- package/src/components/tasks/types.ts +1 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { RefreshCwIcon } from "lucide-react"
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select"
|
|
7
|
+
import { methodOf, routeOf } from "./analytics"
|
|
8
|
+
import type { TraceItem } from "./types"
|
|
9
|
+
|
|
10
|
+
// ── Trace filters ─────────────────────────────────────────────────────────
|
|
11
|
+
// Two kinds of filter live in one bar, and the split is the point: `lookbackMinutes` and
|
|
12
|
+
// `minDurationMs` are meant for the backend query (they change what is fetched), `status`,
|
|
13
|
+
// `method` and `route` are applied client-side by `filterTraces` (they narrow what was fetched,
|
|
14
|
+
// without a round-trip). The bar draws; which is which is the app's call.
|
|
15
|
+
|
|
16
|
+
export interface TraceFilters {
|
|
17
|
+
lookbackMinutes: number
|
|
18
|
+
minDurationMs: number
|
|
19
|
+
status: "all" | "errors"
|
|
20
|
+
/** "all" or an HTTP method. */
|
|
21
|
+
method: string
|
|
22
|
+
/** "all" or a route template. */
|
|
23
|
+
route: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_TRACE_FILTERS: TraceFilters = {
|
|
27
|
+
lookbackMinutes: 60,
|
|
28
|
+
minDurationMs: 0,
|
|
29
|
+
status: "all",
|
|
30
|
+
method: "all",
|
|
31
|
+
route: "all",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Applies the client-side half of the filters (status, method, route). Order is kept. */
|
|
35
|
+
export function filterTraces<T extends TraceItem>(traces: T[], filters: Pick<TraceFilters, "status" | "method" | "route">): T[] {
|
|
36
|
+
return traces.filter(
|
|
37
|
+
(t) =>
|
|
38
|
+
(filters.status !== "errors" || t.error) &&
|
|
39
|
+
(filters.method === "all" || methodOf(t.name) === filters.method) &&
|
|
40
|
+
(filters.route === "all" || routeOf(t.name) === filters.route)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The method and route options a set of traces offers, sorted — to feed the bar's selects. */
|
|
45
|
+
export function traceFilterOptions(traces: TraceItem[]): { methods: string[]; routes: string[] } {
|
|
46
|
+
return {
|
|
47
|
+
methods: Array.from(new Set(traces.map((t) => methodOf(t.name)).filter((m) => m !== "—"))).sort(),
|
|
48
|
+
routes: Array.from(new Set(traces.map((t) => routeOf(t.name)))).sort(),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const LOOKBACKS = [
|
|
53
|
+
{ label: "15m", v: 15 },
|
|
54
|
+
{ label: "1h", v: 60 },
|
|
55
|
+
{ label: "6h", v: 360 },
|
|
56
|
+
{ label: "24h", v: 1440 },
|
|
57
|
+
]
|
|
58
|
+
const LATENCIES = [
|
|
59
|
+
{ label: "Any", v: 0 },
|
|
60
|
+
{ label: ">100ms", v: 100 },
|
|
61
|
+
{ label: ">300ms", v: 300 },
|
|
62
|
+
{ label: ">1s", v: 1000 },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
function Segmented<T extends string | number>({
|
|
66
|
+
value,
|
|
67
|
+
options,
|
|
68
|
+
onChange,
|
|
69
|
+
accent,
|
|
70
|
+
}: {
|
|
71
|
+
value: T
|
|
72
|
+
options: { label: string; v: T }[]
|
|
73
|
+
onChange: (v: T) => void
|
|
74
|
+
accent?: string
|
|
75
|
+
}) {
|
|
76
|
+
return (
|
|
77
|
+
<div className="flex overflow-hidden rounded-lg border border-border" role="group">
|
|
78
|
+
{options.map((o) => (
|
|
79
|
+
<button
|
|
80
|
+
key={String(o.v)}
|
|
81
|
+
type="button"
|
|
82
|
+
aria-pressed={value === o.v}
|
|
83
|
+
onClick={() => onChange(o.v)}
|
|
84
|
+
className={cn(
|
|
85
|
+
"px-2.5 py-1.5 text-xs font-medium transition-colors",
|
|
86
|
+
value === o.v
|
|
87
|
+
? accent
|
|
88
|
+
? "text-white"
|
|
89
|
+
: "bg-primary text-primary-foreground"
|
|
90
|
+
: "bg-card text-muted-foreground hover:bg-muted"
|
|
91
|
+
)}
|
|
92
|
+
style={value === o.v && accent ? { background: accent } : undefined}
|
|
93
|
+
>
|
|
94
|
+
{o.label}
|
|
95
|
+
</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function FilterField({ label, children }: { label: string; children: ReactNode }) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex flex-col gap-1">
|
|
104
|
+
<span className="px-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
|
|
105
|
+
{label}
|
|
106
|
+
</span>
|
|
107
|
+
{children}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface TraceFiltersBarProps {
|
|
113
|
+
filters: TraceFilters
|
|
114
|
+
onChange: (filters: TraceFilters) => void
|
|
115
|
+
/** Options of the Method select — see `traceFilterOptions`. */
|
|
116
|
+
methods: string[]
|
|
117
|
+
/** Options of the Route select — see `traceFilterOptions`. */
|
|
118
|
+
routes: string[]
|
|
119
|
+
/** Shows a refresh button when given. */
|
|
120
|
+
onRefresh?: () => void
|
|
121
|
+
/** Spins the refresh icon. */
|
|
122
|
+
isFetching?: boolean
|
|
123
|
+
className?: string
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function TraceFiltersBar({ filters, onChange, methods, routes, onRefresh, isFetching, className }: TraceFiltersBarProps) {
|
|
127
|
+
const set = (patch: Partial<TraceFilters>) => onChange({ ...filters, ...patch })
|
|
128
|
+
// Base UI renders the raw value in SelectValue unless it knows the labels: without `items`, the
|
|
129
|
+
// trigger reads "all" instead of "All routes".
|
|
130
|
+
const methodItems = { all: "All methods", ...Object.fromEntries(methods.map((m) => [m, m])) }
|
|
131
|
+
const routeItems = { all: "All routes", ...Object.fromEntries(routes.map((r) => [r, r])) }
|
|
132
|
+
const narrowed =
|
|
133
|
+
filters.status !== "all" || filters.method !== "all" || filters.route !== "all" || filters.minDurationMs !== 0
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn("flex flex-wrap items-end gap-x-5 gap-y-3 rounded-xl border border-border bg-card px-4 py-3", className)}>
|
|
137
|
+
<FilterField label="Window">
|
|
138
|
+
<Segmented value={filters.lookbackMinutes} options={LOOKBACKS} onChange={(v) => set({ lookbackMinutes: v })} />
|
|
139
|
+
</FilterField>
|
|
140
|
+
|
|
141
|
+
<FilterField label="Min latency">
|
|
142
|
+
<Segmented value={filters.minDurationMs} options={LATENCIES} onChange={(v) => set({ minDurationMs: v })} />
|
|
143
|
+
</FilterField>
|
|
144
|
+
|
|
145
|
+
<FilterField label="Status">
|
|
146
|
+
<Segmented
|
|
147
|
+
value={filters.status}
|
|
148
|
+
options={[
|
|
149
|
+
{ label: "All", v: "all" as const },
|
|
150
|
+
{ label: "Errors", v: "errors" as const },
|
|
151
|
+
]}
|
|
152
|
+
onChange={(v) => set({ status: v })}
|
|
153
|
+
accent={filters.status === "errors" ? "var(--destructive)" : undefined}
|
|
154
|
+
/>
|
|
155
|
+
</FilterField>
|
|
156
|
+
|
|
157
|
+
<FilterField label="Method">
|
|
158
|
+
<Select items={methodItems} value={filters.method} onValueChange={(v) => set({ method: (v as string | null) ?? "all" })}>
|
|
159
|
+
<SelectTrigger className="h-8 w-[132px]">
|
|
160
|
+
<SelectValue />
|
|
161
|
+
</SelectTrigger>
|
|
162
|
+
<SelectContent>
|
|
163
|
+
<SelectItem value="all">All methods</SelectItem>
|
|
164
|
+
{methods.map((m) => (
|
|
165
|
+
<SelectItem key={m} value={m}>
|
|
166
|
+
{m}
|
|
167
|
+
</SelectItem>
|
|
168
|
+
))}
|
|
169
|
+
</SelectContent>
|
|
170
|
+
</Select>
|
|
171
|
+
</FilterField>
|
|
172
|
+
|
|
173
|
+
<FilterField label="Route">
|
|
174
|
+
<Select items={routeItems} value={filters.route} onValueChange={(v) => set({ route: (v as string | null) ?? "all" })}>
|
|
175
|
+
<SelectTrigger className="h-8 w-[260px] max-w-[52vw]">
|
|
176
|
+
<SelectValue />
|
|
177
|
+
</SelectTrigger>
|
|
178
|
+
<SelectContent className="max-w-[70vw]">
|
|
179
|
+
<SelectItem value="all">All routes</SelectItem>
|
|
180
|
+
{routes.map((r) => (
|
|
181
|
+
<SelectItem key={r} value={r} className="font-mono text-xs">
|
|
182
|
+
{r}
|
|
183
|
+
</SelectItem>
|
|
184
|
+
))}
|
|
185
|
+
</SelectContent>
|
|
186
|
+
</Select>
|
|
187
|
+
</FilterField>
|
|
188
|
+
|
|
189
|
+
<div className="ml-auto flex items-end gap-2">
|
|
190
|
+
{narrowed && (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
onClick={() => onChange({ ...DEFAULT_TRACE_FILTERS, lookbackMinutes: filters.lookbackMinutes })}
|
|
194
|
+
className="h-8 rounded-lg border border-border px-2.5 text-xs text-muted-foreground transition-colors hover:bg-muted"
|
|
195
|
+
>
|
|
196
|
+
Reset
|
|
197
|
+
</button>
|
|
198
|
+
)}
|
|
199
|
+
{onRefresh && (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={onRefresh}
|
|
203
|
+
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-muted-foreground transition-colors hover:bg-muted"
|
|
204
|
+
title="Refresh"
|
|
205
|
+
aria-label="Refresh"
|
|
206
|
+
>
|
|
207
|
+
<RefreshCwIcon className={cn("h-3.5 w-3.5", isFetching && "animate-spin")} />
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ChevronRightIcon } from "lucide-react"
|
|
4
|
+
import { cn, formatRelativeTime } from "../../utils"
|
|
5
|
+
import { computeStats, exampleTraces, methodOf, routeOf } from "./analytics"
|
|
6
|
+
import { formatLatency, latencyColor, latencyTextClass } from "./latency"
|
|
7
|
+
import { TraceStatTile } from "./traces-overview"
|
|
8
|
+
import type { TraceItem } from "./types"
|
|
9
|
+
|
|
10
|
+
function MethodChip({ method, className }: { method: string; className?: string }) {
|
|
11
|
+
if (method === "—") return null
|
|
12
|
+
return (
|
|
13
|
+
<span className={cn("shrink-0 rounded-sm bg-muted px-1.5 py-0.5 font-mono text-[10px] font-semibold text-muted-foreground", className)}>
|
|
14
|
+
{method}
|
|
15
|
+
</span>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Trace list ────────────────────────────────────────────────────────────
|
|
20
|
+
// One row per request: error dot, method, route, a latency bar relative to the slowest of the
|
|
21
|
+
// set, when. The app sorts and filters; the list draws what it's given, in that order.
|
|
22
|
+
// `compact` is the dense variant for traces that aren't necessarily HTTP (any service's OTLP
|
|
23
|
+
// spans): wall-clock time, service, root span name, duration — one line each.
|
|
24
|
+
|
|
25
|
+
export interface TraceListProps {
|
|
26
|
+
traces: TraceItem[]
|
|
27
|
+
onSelectTrace: (traceId: string) => void
|
|
28
|
+
/** Highlights the row whose waterfall is open. */
|
|
29
|
+
selectedTraceId?: string | null
|
|
30
|
+
variant?: "requests" | "compact"
|
|
31
|
+
className?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function TraceList({ traces, onSelectTrace, selectedTraceId, variant = "requests", className }: TraceListProps) {
|
|
35
|
+
if (variant === "compact") {
|
|
36
|
+
return (
|
|
37
|
+
<div className={cn("max-h-96 overflow-auto rounded-lg border border-border", className)}>
|
|
38
|
+
{traces.map((t) => (
|
|
39
|
+
<button
|
|
40
|
+
key={t.traceId}
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={() => onSelectTrace(t.traceId)}
|
|
43
|
+
className={cn(
|
|
44
|
+
"grid w-full grid-cols-[5.5rem_7rem_1fr_5rem] items-center gap-3 border-b border-border/60 px-3 py-1.5 text-left text-xs last:border-b-0 hover:bg-muted/50",
|
|
45
|
+
selectedTraceId === t.traceId && "bg-muted/60"
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<span className="font-mono tabular-nums text-muted-foreground" suppressHydrationWarning>
|
|
49
|
+
{new Date(t.startTimeMs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
|
50
|
+
</span>
|
|
51
|
+
<span className="truncate font-mono text-muted-foreground">{t.service ?? "—"}</span>
|
|
52
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
53
|
+
{t.error && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-destructive" aria-label="error" />}
|
|
54
|
+
<span className="truncate font-mono">{t.name}</span>
|
|
55
|
+
</span>
|
|
56
|
+
<span className="text-right font-mono tabular-nums">{formatLatency(t.durationMs)}</span>
|
|
57
|
+
</button>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
const maxDuration = Math.max(1, ...traces.map((t) => t.durationMs))
|
|
63
|
+
return (
|
|
64
|
+
<div className={cn("overflow-hidden rounded-xl border border-border bg-card", className)}>
|
|
65
|
+
<div className="hidden grid-cols-[1fr_auto_auto_auto] items-center gap-4 border-b border-border bg-muted/30 px-4 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground sm:grid">
|
|
66
|
+
<span>Request</span>
|
|
67
|
+
<span className="w-28 text-right">Latency</span>
|
|
68
|
+
<span className="w-24 text-right">When</span>
|
|
69
|
+
<span className="w-4" />
|
|
70
|
+
</div>
|
|
71
|
+
{traces.map((t) => (
|
|
72
|
+
<button
|
|
73
|
+
key={t.traceId}
|
|
74
|
+
type="button"
|
|
75
|
+
onClick={() => onSelectTrace(t.traceId)}
|
|
76
|
+
className={cn(
|
|
77
|
+
"grid w-full grid-cols-[1fr_auto_auto] items-center gap-4 border-b border-border/60 px-4 py-3 text-left transition-colors last:border-0 hover:bg-muted/40 sm:grid-cols-[1fr_auto_auto_auto]",
|
|
78
|
+
selectedTraceId === t.traceId && "bg-muted/60"
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<div className="min-w-0">
|
|
82
|
+
<div className="flex items-center gap-2">
|
|
83
|
+
{t.error && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-destructive" aria-label="error" />}
|
|
84
|
+
<MethodChip method={methodOf(t.name)} />
|
|
85
|
+
<span className="truncate font-mono text-[13px]">{routeOf(t.name)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="mt-1.5 h-1 w-full max-w-xs overflow-hidden rounded-full bg-muted">
|
|
88
|
+
<div
|
|
89
|
+
className="h-full rounded-full"
|
|
90
|
+
style={{
|
|
91
|
+
width: `${Math.max(2, Math.round((t.durationMs / maxDuration) * 100))}%`,
|
|
92
|
+
background: latencyColor(t.durationMs),
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<span className={cn("w-28 text-right font-mono text-sm tabular-nums", latencyTextClass(t.durationMs))}>
|
|
98
|
+
{formatLatency(t.durationMs)}
|
|
99
|
+
</span>
|
|
100
|
+
<span className="hidden w-24 text-right text-xs text-muted-foreground tabular-nums sm:inline" suppressHydrationWarning>
|
|
101
|
+
{formatRelativeTime(t.startTimeMs)}
|
|
102
|
+
</span>
|
|
103
|
+
<ChevronRightIcon className="hidden h-4 w-4 text-muted-foreground/50 sm:block" />
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Placeholder rows with the list's height, while the app fetches. */
|
|
111
|
+
export function TraceListSkeleton({ rows = 8, className }: { rows?: number; className?: string }) {
|
|
112
|
+
return (
|
|
113
|
+
<div className={cn("space-y-2", className)}>
|
|
114
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
115
|
+
<div key={i} className="h-14 animate-pulse rounded-lg border border-border bg-muted/40" />
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Route detail ──────────────────────────────────────────────────────────
|
|
122
|
+
// What a click on a route of the overview opens: its own stats, then example traces (errors,
|
|
123
|
+
// slowest, most recent) to click into a waterfall.
|
|
124
|
+
|
|
125
|
+
export interface TraceRouteDetailProps {
|
|
126
|
+
/** Full root span name, as `RouteStat.name` / `onRouteClick` hand it. */
|
|
127
|
+
routeName: string
|
|
128
|
+
/** The whole set — the component keeps the rows of `routeName`. */
|
|
129
|
+
traces: TraceItem[]
|
|
130
|
+
onSelectTrace: (traceId: string) => void
|
|
131
|
+
className?: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function TraceRouteDetail({ routeName, traces, onSelectTrace, className }: TraceRouteDetailProps) {
|
|
135
|
+
const rows = traces.filter((t) => t.name === routeName)
|
|
136
|
+
const s = computeStats(rows)
|
|
137
|
+
const examples = exampleTraces(rows)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cn("space-y-5", className)}>
|
|
141
|
+
<div>
|
|
142
|
+
<MethodChip method={methodOf(routeName)} className="text-[11px]" />
|
|
143
|
+
<p className="mt-1.5 break-all font-mono text-sm">{routeOf(routeName)}</p>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div className="grid grid-cols-3 gap-2">
|
|
147
|
+
<TraceStatTile size="sm" label="Requests" value={s.count.toLocaleString()} />
|
|
148
|
+
<TraceStatTile
|
|
149
|
+
size="sm"
|
|
150
|
+
label="Error rate"
|
|
151
|
+
value={`${(s.errorRate * 100).toFixed(s.errorRate ? 1 : 0)}%`}
|
|
152
|
+
accent={s.errorRate > 0 ? "var(--destructive)" : undefined}
|
|
153
|
+
/>
|
|
154
|
+
<TraceStatTile size="sm" label="Max" value={formatLatency(s.max)} accent={latencyColor(s.max)} />
|
|
155
|
+
<TraceStatTile size="sm" label="p50" value={formatLatency(s.p50)} accent={latencyColor(s.p50)} />
|
|
156
|
+
<TraceStatTile size="sm" label="p95" value={formatLatency(s.p95)} accent={latencyColor(s.p95)} />
|
|
157
|
+
<TraceStatTile size="sm" label="p99" value={formatLatency(s.p99)} accent={latencyColor(s.p99)} />
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div>
|
|
161
|
+
<p className="mb-2 font-mono text-[9.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70">
|
|
162
|
+
Example traces
|
|
163
|
+
</p>
|
|
164
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
165
|
+
{examples.length === 0 ? (
|
|
166
|
+
<p className="px-3 py-6 text-center text-xs text-muted-foreground">No traces for this route.</p>
|
|
167
|
+
) : (
|
|
168
|
+
examples.map((t) => (
|
|
169
|
+
<button
|
|
170
|
+
key={t.traceId}
|
|
171
|
+
type="button"
|
|
172
|
+
onClick={() => onSelectTrace(t.traceId)}
|
|
173
|
+
className="flex w-full items-center gap-3 border-b border-border/60 px-3 py-2.5 text-left transition-colors last:border-0 hover:bg-muted/40"
|
|
174
|
+
>
|
|
175
|
+
{t.error && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-destructive" aria-label="error" />}
|
|
176
|
+
<span className="min-w-0 flex-1">
|
|
177
|
+
<span className="block truncate font-mono text-[11px] text-muted-foreground">{t.traceId}</span>
|
|
178
|
+
<span className="text-[10px] text-muted-foreground/70" suppressHydrationWarning>
|
|
179
|
+
{formatRelativeTime(t.startTimeMs)}
|
|
180
|
+
</span>
|
|
181
|
+
</span>
|
|
182
|
+
<span className="shrink-0 font-mono text-xs tabular-nums" style={{ color: latencyColor(t.durationMs) }}>
|
|
183
|
+
{formatLatency(t.durationMs)}
|
|
184
|
+
</span>
|
|
185
|
+
<ChevronRightIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
|
|
186
|
+
</button>
|
|
187
|
+
))
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
}
|