@spunto/design-system 0.34.0 → 0.35.1

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.
@@ -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
+ }