@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.
- 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 +120 -0
- package/src/components/observability/observability.test.tsx +255 -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
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react"
|
|
4
|
+
import { ChevronRightIcon } from "lucide-react"
|
|
5
|
+
import { cn } from "../../utils"
|
|
6
|
+
import { byRoute, byService, computeStats, timeBuckets } from "./analytics"
|
|
7
|
+
import { formatLatency, latencyColor } from "./latency"
|
|
8
|
+
import type { TraceItem } from "./types"
|
|
9
|
+
|
|
10
|
+
// ── Traces overview ───────────────────────────────────────────────────────
|
|
11
|
+
// The dashboard of a set of traces: stat tiles, requests and p95 over time, per-service cards,
|
|
12
|
+
// slowest routes. Everything is derived from the `traces` it is handed (see analytics.ts), so the
|
|
13
|
+
// app filters once and the list and this view can't disagree.
|
|
14
|
+
|
|
15
|
+
const KICKER = "font-mono text-[9.5px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/70"
|
|
16
|
+
|
|
17
|
+
/** A labelled number. `size="sm"` is the denser tile of a side sheet. */
|
|
18
|
+
export function TraceStatTile({
|
|
19
|
+
label,
|
|
20
|
+
value,
|
|
21
|
+
sub,
|
|
22
|
+
accent,
|
|
23
|
+
size = "md",
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
label: string
|
|
27
|
+
value: ReactNode
|
|
28
|
+
sub?: string
|
|
29
|
+
/** CSS colour of the value (a `latencyColor`, `var(--destructive)`…). */
|
|
30
|
+
accent?: string
|
|
31
|
+
size?: "sm" | "md"
|
|
32
|
+
className?: string
|
|
33
|
+
}) {
|
|
34
|
+
const sm = size === "sm"
|
|
35
|
+
return (
|
|
36
|
+
<div className={cn("border border-border bg-card", sm ? "rounded-lg p-2.5" : "rounded-xl p-4", className)}>
|
|
37
|
+
<p className={cn(KICKER, sm && "text-[9px]")}>{label}</p>
|
|
38
|
+
<p
|
|
39
|
+
className={cn("font-display font-bold tabular-nums", sm ? "mt-0.5 text-lg" : "mt-1 text-2xl")}
|
|
40
|
+
style={accent ? { color: accent } : undefined}
|
|
41
|
+
>
|
|
42
|
+
{value}
|
|
43
|
+
</p>
|
|
44
|
+
{sub && <p className="mt-0.5 text-[11px] text-muted-foreground">{sub}</p>}
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clock(ms: number): string {
|
|
50
|
+
const d = new Date(ms)
|
|
51
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ChartProps {
|
|
55
|
+
traces: TraceItem[]
|
|
56
|
+
lookbackMinutes: number
|
|
57
|
+
/** End of the window. Defaults to render time — pass it to freeze a demo or a test. */
|
|
58
|
+
now?: number
|
|
59
|
+
buckets?: number
|
|
60
|
+
className?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Stacked bars (ok + errors) per time bucket. Plain SVG on the run/destructive tokens. */
|
|
64
|
+
export function RequestsOverTime({ traces, lookbackMinutes, now = Date.now(), buckets: n = 32, className }: ChartProps) {
|
|
65
|
+
const buckets = timeBuckets(traces, lookbackMinutes, n, now)
|
|
66
|
+
const max = Math.max(1, ...buckets.map((b) => b.total))
|
|
67
|
+
const W = 100
|
|
68
|
+
const H = 34
|
|
69
|
+
const bw = W / buckets.length
|
|
70
|
+
return (
|
|
71
|
+
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
|
72
|
+
<div className="mb-3 flex items-center justify-between">
|
|
73
|
+
<p className={KICKER}>Requests over time</p>
|
|
74
|
+
<span className="text-[11px] text-muted-foreground">peak {max}/bucket</span>
|
|
75
|
+
</div>
|
|
76
|
+
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="h-24 w-full overflow-visible" role="img" aria-label="Requests over time">
|
|
77
|
+
{buckets.map((b, i) => {
|
|
78
|
+
const h = (b.total / max) * H
|
|
79
|
+
const eh = (b.errors / max) * H
|
|
80
|
+
const x = i * bw
|
|
81
|
+
return (
|
|
82
|
+
<g key={i}>
|
|
83
|
+
<rect x={x + bw * 0.12} y={H - h} width={bw * 0.76} height={h} fill="var(--run)" opacity={0.85} rx={0.4} />
|
|
84
|
+
{eh > 0 && <rect x={x + bw * 0.12} y={H - eh} width={bw * 0.76} height={eh} fill="var(--destructive)" rx={0.4} />}
|
|
85
|
+
</g>
|
|
86
|
+
)
|
|
87
|
+
})}
|
|
88
|
+
</svg>
|
|
89
|
+
<div className="mt-1 flex justify-between font-mono text-[10px] text-muted-foreground/70">
|
|
90
|
+
<span>{clock(buckets[0]?.start ?? now)}</span>
|
|
91
|
+
<span>{clock(buckets[Math.floor(buckets.length / 2)]?.start ?? now)}</span>
|
|
92
|
+
<span>now</span>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** p95 latency per time bucket, area + line on the ship token. */
|
|
99
|
+
export function LatencyOverTime({ traces, lookbackMinutes, now = Date.now(), buckets: n = 32, className }: ChartProps) {
|
|
100
|
+
const buckets = timeBuckets(traces, lookbackMinutes, n, now)
|
|
101
|
+
const max = Math.max(1, ...buckets.map((b) => b.p95))
|
|
102
|
+
const W = 100
|
|
103
|
+
const H = 34
|
|
104
|
+
const pts = buckets.map((b, i) => [(i / (buckets.length - 1 || 1)) * W, H - (b.p95 / max) * H] as const)
|
|
105
|
+
const line = pts.map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`).join(" ")
|
|
106
|
+
const area = `${line} L ${W} ${H} L 0 ${H} Z`
|
|
107
|
+
return (
|
|
108
|
+
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
|
|
109
|
+
<div className="mb-3 flex items-center justify-between">
|
|
110
|
+
<p className={KICKER}>p95 latency over time</p>
|
|
111
|
+
<span className="text-[11px] text-muted-foreground">peak {formatLatency(max)}</span>
|
|
112
|
+
</div>
|
|
113
|
+
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="h-24 w-full" role="img" aria-label="p95 latency over time">
|
|
114
|
+
<path d={area} fill="var(--ship)" opacity={0.12} />
|
|
115
|
+
<path d={line} fill="none" stroke="var(--ship)" strokeWidth={0.8} vectorEffect="non-scaling-stroke" />
|
|
116
|
+
</svg>
|
|
117
|
+
<div className="mt-1 flex justify-between font-mono text-[10px] text-muted-foreground/70">
|
|
118
|
+
<span>{clock(buckets[0]?.start ?? now)}</span>
|
|
119
|
+
<span>now</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function pct(rate: number): string {
|
|
126
|
+
return `${(rate * 100).toFixed(rate ? 1 : 0)}%`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TracesOverviewProps {
|
|
130
|
+
traces: TraceItem[]
|
|
131
|
+
lookbackMinutes: number
|
|
132
|
+
now?: number
|
|
133
|
+
/** Makes each route row a button (with a chevron). Omit for a read-only table. */
|
|
134
|
+
onRouteClick?: (routeName: string) => void
|
|
135
|
+
/** Per-service cards between the charts and the routes — for a view spanning several services. */
|
|
136
|
+
showServices?: boolean
|
|
137
|
+
/** Rows of the slowest-routes table. */
|
|
138
|
+
routeLimit?: number
|
|
139
|
+
className?: string
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function TracesOverview({
|
|
143
|
+
traces,
|
|
144
|
+
lookbackMinutes,
|
|
145
|
+
now,
|
|
146
|
+
onRouteClick,
|
|
147
|
+
showServices = false,
|
|
148
|
+
routeLimit = 12,
|
|
149
|
+
className,
|
|
150
|
+
}: TracesOverviewProps) {
|
|
151
|
+
if (traces.length === 0) {
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
"flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/20 py-16 text-center text-sm text-muted-foreground",
|
|
156
|
+
className
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
No traces match the current filters — nothing to analyze yet.
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const s = computeStats(traces)
|
|
165
|
+
const routes = byRoute(traces).slice(0, routeLimit)
|
|
166
|
+
const services = showServices ? byService(traces) : []
|
|
167
|
+
const maxRouteP95 = Math.max(1, ...routes.map((r) => r.p95))
|
|
168
|
+
const cols = onRouteClick ? "grid-cols-[auto_1fr_auto_auto_auto_auto]" : "grid-cols-[auto_1fr_auto_auto_auto]"
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div className={cn("space-y-4", className)}>
|
|
172
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
173
|
+
<TraceStatTile label="Requests" value={s.count.toLocaleString()} sub="over the window" />
|
|
174
|
+
<TraceStatTile
|
|
175
|
+
label="Error rate"
|
|
176
|
+
value={pct(s.errorRate)}
|
|
177
|
+
sub={`${s.errorCount} error${s.errorCount !== 1 ? "s" : ""}`}
|
|
178
|
+
accent={s.errorRate > 0 ? "var(--destructive)" : undefined}
|
|
179
|
+
/>
|
|
180
|
+
<TraceStatTile label="p50" value={formatLatency(s.p50)} accent={latencyColor(s.p50)} />
|
|
181
|
+
<TraceStatTile label="p95" value={formatLatency(s.p95)} accent={latencyColor(s.p95)} />
|
|
182
|
+
<TraceStatTile label="p99" value={formatLatency(s.p99)} accent={latencyColor(s.p99)} />
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
186
|
+
<RequestsOverTime traces={traces} lookbackMinutes={lookbackMinutes} now={now} />
|
|
187
|
+
<LatencyOverTime traces={traces} lookbackMinutes={lookbackMinutes} now={now} />
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
{services.length > 0 && (
|
|
191
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
192
|
+
{services.map((svc) => (
|
|
193
|
+
<div key={svc.service} className="rounded-xl border border-border bg-card p-4">
|
|
194
|
+
<div className="flex items-center justify-between">
|
|
195
|
+
<span className="font-mono text-xs font-semibold">{svc.service}</span>
|
|
196
|
+
<span
|
|
197
|
+
className="font-mono text-[11px] tabular-nums"
|
|
198
|
+
style={{ color: svc.errorRate > 0 ? "var(--destructive)" : "var(--muted-foreground)" }}
|
|
199
|
+
>
|
|
200
|
+
{pct(svc.errorRate)} err
|
|
201
|
+
</span>
|
|
202
|
+
</div>
|
|
203
|
+
<div className="mt-2 flex items-baseline justify-between">
|
|
204
|
+
<span className="font-display text-xl font-bold tabular-nums">{svc.count.toLocaleString()}</span>
|
|
205
|
+
<span className="font-mono text-xs tabular-nums" style={{ color: latencyColor(svc.p95) }}>
|
|
206
|
+
p95 {formatLatency(svc.p95)}
|
|
207
|
+
</span>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
))}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
|
|
214
|
+
{/* Slowest routes — horizontal scroll on mobile */}
|
|
215
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
|
216
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
|
217
|
+
<p className={KICKER}>Slowest routes (by p95)</p>
|
|
218
|
+
{onRouteClick && <span className="text-[10px] text-muted-foreground/60">tap a row for details</span>}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="overflow-x-auto">
|
|
221
|
+
<div className="min-w-[560px]">
|
|
222
|
+
<div
|
|
223
|
+
className={cn(
|
|
224
|
+
"grid items-center gap-x-4 border-b border-border bg-muted/30 px-4 py-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground",
|
|
225
|
+
cols
|
|
226
|
+
)}
|
|
227
|
+
>
|
|
228
|
+
<span className="w-12">Method</span>
|
|
229
|
+
<span>Route</span>
|
|
230
|
+
<span className="w-14 text-right">Count</span>
|
|
231
|
+
<span className="w-16 text-right">p50</span>
|
|
232
|
+
<span className="w-24 text-right">p95</span>
|
|
233
|
+
{onRouteClick && <span className="w-4" />}
|
|
234
|
+
</div>
|
|
235
|
+
{routes.map((r) => {
|
|
236
|
+
const cells = (
|
|
237
|
+
<>
|
|
238
|
+
<span className="w-12 font-mono text-[10px] font-semibold text-muted-foreground">{r.method}</span>
|
|
239
|
+
<div className="min-w-0">
|
|
240
|
+
<span className="block truncate font-mono text-[12px]">{r.route}</span>
|
|
241
|
+
{r.errorRate > 0 && (
|
|
242
|
+
<span className="text-[10px] text-destructive">{(r.errorRate * 100).toFixed(0)}% errors</span>
|
|
243
|
+
)}
|
|
244
|
+
</div>
|
|
245
|
+
<span className="w-14 text-right text-xs text-muted-foreground tabular-nums">{r.count}</span>
|
|
246
|
+
<span className="w-16 text-right font-mono text-xs text-muted-foreground tabular-nums">
|
|
247
|
+
{formatLatency(r.p50)}
|
|
248
|
+
</span>
|
|
249
|
+
<div className="flex w-24 items-center justify-end gap-2">
|
|
250
|
+
<span className="h-1.5 w-10 overflow-hidden rounded-full bg-muted">
|
|
251
|
+
<span
|
|
252
|
+
className="block h-full rounded-full"
|
|
253
|
+
style={{ width: `${Math.max(4, (r.p95 / maxRouteP95) * 100)}%`, background: latencyColor(r.p95) }}
|
|
254
|
+
/>
|
|
255
|
+
</span>
|
|
256
|
+
<span className="font-mono text-xs tabular-nums" style={{ color: latencyColor(r.p95) }}>
|
|
257
|
+
{formatLatency(r.p95)}
|
|
258
|
+
</span>
|
|
259
|
+
</div>
|
|
260
|
+
</>
|
|
261
|
+
)
|
|
262
|
+
const rowClass = cn("grid w-full items-center gap-x-4 border-b border-border/50 px-4 py-2 text-left last:border-0", cols)
|
|
263
|
+
return onRouteClick ? (
|
|
264
|
+
<button
|
|
265
|
+
key={r.name}
|
|
266
|
+
type="button"
|
|
267
|
+
onClick={() => onRouteClick(r.name)}
|
|
268
|
+
className={cn(rowClass, "transition-colors hover:bg-muted/40")}
|
|
269
|
+
>
|
|
270
|
+
{cells}
|
|
271
|
+
<ChevronRightIcon className="h-4 w-4 text-muted-foreground/50" />
|
|
272
|
+
</button>
|
|
273
|
+
) : (
|
|
274
|
+
<div key={r.name} className={rowClass}>
|
|
275
|
+
{cells}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
})}
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The shapes the observability components read. Structural and lax on purpose (the package rule —
|
|
2
|
+
// see docs/shared-packages.md): they are what a trace backend hands back once flattened, not the
|
|
3
|
+
// OpenAPI type of one app, so Cloud's per-org `/traces` and the admin's global one both fit.
|
|
4
|
+
|
|
5
|
+
/** One trace, as a list row: its root span flattened. */
|
|
6
|
+
export interface TraceItem {
|
|
7
|
+
traceId: string
|
|
8
|
+
/** Name of the root span — for an HTTP trace, "GET /api/orgs/:orgId/…". Grouping key. */
|
|
9
|
+
name: string
|
|
10
|
+
/** Emitting service ("spunto-api", "spunto-proxy"…). */
|
|
11
|
+
service?: string | null
|
|
12
|
+
startTimeMs: number
|
|
13
|
+
durationMs: number
|
|
14
|
+
error?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One span of a trace, for the waterfall. */
|
|
18
|
+
export interface TraceSpan {
|
|
19
|
+
spanId: string
|
|
20
|
+
/** A parent absent from the set is treated as a root, never as an error. */
|
|
21
|
+
parentSpanId?: string | null
|
|
22
|
+
name: string
|
|
23
|
+
service?: string | null
|
|
24
|
+
kind?: string | null
|
|
25
|
+
startTimeMs: number
|
|
26
|
+
durationMs: number
|
|
27
|
+
error?: boolean
|
|
28
|
+
attributes?: Record<string, string>
|
|
29
|
+
}
|