@spunto/design-system 0.34.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 CHANGED
@@ -188,7 +188,7 @@ const nextConfig = { transpilePackages: ["@spunto/design-system"] }
188
188
  provider is also the umbrella that mounts the tooltip delay group and the overlay
189
189
  portal container the dialogs/selects render into.
190
190
 
191
- ## Domain components — `/workers`, `/devcontainer`, `/projects`, `/tasks`
191
+ ## Domain components — `/workers`, `/devcontainer`, `/projects`, `/tasks`, `/observability`
192
192
 
193
193
  Everything above is domain-free: a `Button` knows nothing about Spunto. Components
194
194
  that **do** know a Spunto concept live behind their own entry point, so the root
@@ -202,6 +202,7 @@ import { WorkerCard } from "@spunto/design-system/workers"
202
202
  import { ImageCard, FeatureCard, ExtensionCard } from "@spunto/design-system/devcontainer" // domain
203
203
  import { ProjectForm, ProjectPanel } from "@spunto/design-system/projects" // domain
204
204
  import { TaskList, TaskCockpit, TaskEventStream } from "@spunto/design-system/tasks" // domain
205
+ import { SpanWaterfall, TracesOverview } from "@spunto/design-system/observability" // domain
205
206
  import { Section, ProductHeader } from "@spunto/design-system/marketing" // not a domain — see below
206
207
  ```
207
208
 
@@ -330,6 +331,36 @@ review. Every surface that shows one:
330
331
  Same rules as `/workers`: nothing fetches, types are structural and lax
331
332
  (`TaskItem`, `TaskEvent`, `TaskDiff`…), links go through `render.link`.
332
333
 
334
+ ### `@spunto/design-system/observability`
335
+
336
+ Traces — what an OpenTelemetry backend hands back, drawn. Every surface that shows one:
337
+
338
+ - **`SpanWaterfall`** — a trace as a tree of spans on a shared time axis (children under
339
+ their parent, not the backend's start-time order), colour by nature (http → build, db →
340
+ ship, agent RPC / job → run, error → destructive), attributes of the clicked span. Any
341
+ span with children folds (chevron, Collapse/Expand all, ←/→; a folded row shows `+N`, red
342
+ when an error is hidden under it; `defaultCollapsedDepth`, or controlled
343
+ `collapsedSpanIds`). Hand it `logs` and a Logs section appears underneath, linked by the
344
+ span id the logger stamped: a line selects its span, unfolding its ancestors if needed.
345
+ `SpanWaterfallSkeleton` for the fetch.
346
+ - **`TraceList`** — one row per request (method, route, latency bar, when), or
347
+ `variant="compact"` for root spans that aren't necessarily HTTP. **`TraceFiltersBar`** next
348
+ to it: window and min latency are meant for the backend query, status / method / route are
349
+ applied client-side by `filterTraces`; `traceFilterOptions` derives the select options.
350
+ - **`TracesOverview`** — stat tiles, requests and p95 over time, per-service cards
351
+ (`showServices`), slowest routes. **`TraceRouteDetail`** — one route's stats and example
352
+ traces. Both are *derived* from the traces they're handed, so a list and its dashboard
353
+ can't disagree and no aggregation endpoint is needed.
354
+ - **`LogLines`** — log records with severity and trace/span ids: under a waterfall
355
+ (`origin`, `onSelectSpan`) or as a service's feed (`onOpenTrace`).
356
+ - Pure helpers: `computeStats`, `byRoute`, `byService`, `timeBuckets`, `exampleTraces`,
357
+ `percentile`, `methodOf`/`routeOf`, `orderSpans`, `spanColor`; and **one** latency scale —
358
+ `formatLatency` (sub-millisecond precise), `latencyTone`/`latencyColor`/`latencyTextClass`.
359
+
360
+ Same rules as `/workers`: nothing fetches (no URL, no query client — the org's traces view,
361
+ a deployment's Observability tab and the admin each pass their own data), types are
362
+ structural and lax (`TraceItem`, `TraceSpan`, `TelemetryLogLine`).
363
+
333
364
  ### `@spunto/design-system/projects`
334
365
 
335
366
  - **`ProjectForm`** — the whole "compose a dev environment" screen: identity,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/design-system",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Spunto's shared design system — warm/flame tokens, color constants, and UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,10 @@
45
45
  "types": "./src/components/tasks/index.ts",
46
46
  "import": "./src/components/tasks/index.ts"
47
47
  },
48
+ "./observability": {
49
+ "types": "./src/components/observability/index.ts",
50
+ "import": "./src/components/observability/index.ts"
51
+ },
48
52
  "./projects": {
49
53
  "types": "./src/components/projects/index.ts",
50
54
  "import": "./src/components/projects/index.ts"
@@ -0,0 +1,153 @@
1
+ // Client-side aggregations over a set of traces. Everything is derived from the list a backend
2
+ // already returned, so a list and its dashboard stay driven by exactly the same filters — and the
3
+ // dashboard never needs an endpoint of its own. Pure functions: no Date.now() unless defaulted.
4
+
5
+ import type { TraceItem } from "./types"
6
+
7
+ const METHODS = ["GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS"]
8
+
9
+ /** HTTP method of a root span name ("GET /x" → "GET"), "—" when it isn't one. */
10
+ export function methodOf(name: string): string {
11
+ const m = name.split(" ")[0]
12
+ return METHODS.includes(m) ? m : "—"
13
+ }
14
+
15
+ /** Route template of a root span name ("GET /x" → "/x"); non-HTTP names come back whole. */
16
+ export function routeOf(name: string): string {
17
+ const m = name.split(" ")[0]
18
+ return METHODS.includes(m) ? name.slice(m.length + 1) || name : name
19
+ }
20
+
21
+ /** Percentile, linear interpolation, over an ascending-sorted array. */
22
+ export function percentile(sortedAsc: number[], p: number): number {
23
+ if (sortedAsc.length === 0) return 0
24
+ if (sortedAsc.length === 1) return sortedAsc[0]
25
+ const idx = (p / 100) * (sortedAsc.length - 1)
26
+ const lo = Math.floor(idx)
27
+ const hi = Math.ceil(idx)
28
+ if (lo === hi) return sortedAsc[lo]
29
+ return sortedAsc[lo] + (sortedAsc[hi] - sortedAsc[lo]) * (idx - lo)
30
+ }
31
+
32
+ export interface TraceStats {
33
+ count: number
34
+ errorCount: number
35
+ errorRate: number
36
+ p50: number
37
+ p95: number
38
+ p99: number
39
+ max: number
40
+ }
41
+
42
+ export function computeStats(traces: TraceItem[]): TraceStats {
43
+ const durations = traces.map((t) => t.durationMs).sort((a, b) => a - b)
44
+ const errorCount = traces.filter((t) => t.error).length
45
+ return {
46
+ count: traces.length,
47
+ errorCount,
48
+ errorRate: traces.length ? errorCount / traces.length : 0,
49
+ p50: percentile(durations, 50),
50
+ p95: percentile(durations, 95),
51
+ p99: percentile(durations, 99),
52
+ max: durations.length ? durations[durations.length - 1] : 0,
53
+ }
54
+ }
55
+
56
+ function groupBy(traces: TraceItem[], key: (t: TraceItem) => string): Map<string, TraceItem[]> {
57
+ const groups = new Map<string, TraceItem[]>()
58
+ for (const t of traces) {
59
+ const k = key(t)
60
+ const arr = groups.get(k)
61
+ if (arr) arr.push(t)
62
+ else groups.set(k, [t])
63
+ }
64
+ return groups
65
+ }
66
+
67
+ export interface RouteStat {
68
+ /** Full root span name ("GET /api/orgs/:orgId/…") — the grouping key. */
69
+ name: string
70
+ route: string
71
+ method: string
72
+ count: number
73
+ p50: number
74
+ p95: number
75
+ p99: number
76
+ errorRate: number
77
+ }
78
+
79
+ /** Per route (method + template), slowest p95 first. */
80
+ export function byRoute(traces: TraceItem[]): RouteStat[] {
81
+ const rows: RouteStat[] = []
82
+ for (const [name, items] of groupBy(traces, (t) => t.name)) {
83
+ const s = computeStats(items)
84
+ rows.push({ name, route: routeOf(name), method: methodOf(name), count: s.count, p50: s.p50, p95: s.p95, p99: s.p99, errorRate: s.errorRate })
85
+ }
86
+ return rows.sort((a, b) => b.p95 - a.p95)
87
+ }
88
+
89
+ export interface ServiceStat {
90
+ service: string
91
+ count: number
92
+ errorRate: number
93
+ p95: number
94
+ }
95
+
96
+ /** Per emitting service, busiest first. A trace without a service lands in "(unknown)". */
97
+ export function byService(traces: TraceItem[]): ServiceStat[] {
98
+ const rows: ServiceStat[] = []
99
+ for (const [service, items] of groupBy(traces, (t) => t.service || "(unknown)")) {
100
+ const s = computeStats(items)
101
+ rows.push({ service, count: s.count, errorRate: s.errorRate, p95: s.p95 })
102
+ }
103
+ return rows.sort((a, b) => b.count - a.count)
104
+ }
105
+
106
+ export interface TimeBucket {
107
+ start: number
108
+ total: number
109
+ errors: number
110
+ p95: number
111
+ }
112
+
113
+ /**
114
+ * Splits the traces into `bucketCount` equal intervals over the last `lookbackMinutes`, ending at
115
+ * `now`. A trace outside the window is clamped into the first/last bucket rather than dropped:
116
+ * the list next to the chart shows it, so the chart must count it.
117
+ */
118
+ export function timeBuckets(
119
+ traces: TraceItem[],
120
+ lookbackMinutes: number,
121
+ bucketCount = 24,
122
+ now: number = Date.now()
123
+ ): TimeBucket[] {
124
+ const windowMs = lookbackMinutes * 60_000
125
+ const start = now - windowMs
126
+ const size = windowMs / bucketCount
127
+ const buckets = Array.from({ length: bucketCount }, (_, i) => ({ start: start + i * size, items: [] as TraceItem[] }))
128
+ for (const t of traces) {
129
+ const i = Math.min(bucketCount - 1, Math.max(0, Math.floor((t.startTimeMs - start) / size)))
130
+ buckets[i].items.push(t)
131
+ }
132
+ return buckets.map((b) => {
133
+ const s = computeStats(b.items)
134
+ return { start: b.start, total: s.count, errors: s.errorCount, p95: s.p95 }
135
+ })
136
+ }
137
+
138
+ /**
139
+ * Up to `limit` example traces for a set: errors first, then the slowest, then the most recent,
140
+ * de-duplicated. What a route's detail shows to click into.
141
+ */
142
+ export function exampleTraces(traces: TraceItem[], limit = 12): TraceItem[] {
143
+ const slowest = [...traces].sort((a, b) => b.durationMs - a.durationMs).slice(0, Math.ceil(limit / 2))
144
+ const recent = [...traces].sort((a, b) => b.startTimeMs - a.startTimeMs).slice(0, Math.ceil(limit / 2))
145
+ const seen = new Set<string>()
146
+ return [...traces.filter((t) => t.error), ...slowest, ...recent]
147
+ .filter((t) => {
148
+ if (seen.has(t.traceId)) return false
149
+ seen.add(t.traceId)
150
+ return true
151
+ })
152
+ .slice(0, limit)
153
+ }
@@ -0,0 +1,39 @@
1
+ // @spunto/design-system/observability — traces: span waterfall, list, filters, overview dashboard.
2
+ //
3
+ // Domain sub-export (see the README's "Domain components" rule): these know what a trace, a span
4
+ // and a route are. They know nothing of a backend — every component takes the traces or spans the
5
+ // app already fetched (Cloud's per-org `/traces`, the admin's global Cloud Trace view, Jaeger…),
6
+ // and the aggregations are pure functions exported alongside, so a number can be derived without
7
+ // drawing anything.
8
+
9
+ export type { TraceItem, TraceSpan } from "./types"
10
+
11
+ export { SpanWaterfall, SpanWaterfallSkeleton, orderSpans, spanColor, spanTree } from "./span-waterfall"
12
+ export type { SpanTreeInfo, SpanWaterfallProps } from "./span-waterfall"
13
+
14
+ export { LogLines, severityClass, stripAnsi } from "./log-lines"
15
+ export type { LogLinesProps, TelemetryLogLine } from "./log-lines"
16
+
17
+ export { TracesOverview, TraceStatTile, RequestsOverTime, LatencyOverTime } from "./traces-overview"
18
+ export type { TracesOverviewProps } from "./traces-overview"
19
+
20
+ export { TraceList, TraceListSkeleton, TraceRouteDetail } from "./trace-list"
21
+ export type { TraceListProps, TraceRouteDetailProps } from "./trace-list"
22
+
23
+ export { TraceFiltersBar, DEFAULT_TRACE_FILTERS, filterTraces, traceFilterOptions } from "./trace-filters"
24
+ export type { TraceFilters, TraceFiltersBarProps } from "./trace-filters"
25
+
26
+ export {
27
+ byRoute,
28
+ byService,
29
+ computeStats,
30
+ exampleTraces,
31
+ methodOf,
32
+ percentile,
33
+ routeOf,
34
+ timeBuckets,
35
+ } from "./analytics"
36
+ export type { RouteStat, ServiceStat, TimeBucket, TraceStats } from "./analytics"
37
+
38
+ export { formatLatency, latencyColor, latencyTextClass, latencyTone } from "./latency"
39
+ export type { LatencyTone } from "./latency"
@@ -0,0 +1,47 @@
1
+ // Latency formatting and tone — the one place that decides what "slow" looks like. The dashboard
2
+ // had this threshold copied four times (overview, route sheet, list bar, list text) across two
3
+ // apps, in two notations (a CSS colour and a Tailwind class).
4
+
5
+ /**
6
+ * A latency, precise enough for a span: "1.24s", "38ms", "0.42ms". `formatDuration` (root entry)
7
+ * stops at whole milliseconds and one decimal of a second — right for a setup phase, too coarse
8
+ * for a 0.3 ms database call next to a 1.2 s request.
9
+ */
10
+ export function formatLatency(ms: number): string {
11
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
12
+ if (ms >= 1) return `${Math.round(ms)}ms`
13
+ return `${Math.max(0, ms).toFixed(2)}ms`
14
+ }
15
+
16
+ export type LatencyTone = "fast" | "slow" | "critical"
17
+
18
+ /** ≥ 1 s is critical, ≥ 300 ms is slow. The thresholds of an interactive HTTP request. */
19
+ export function latencyTone(ms: number): LatencyTone {
20
+ if (ms >= 1000) return "critical"
21
+ if (ms >= 300) return "slow"
22
+ return "fast"
23
+ }
24
+
25
+ // Inline colours (SVG fills, bar widths set by style) — amber-600 / emerald-600, the same the
26
+ // Tailwind classes below resolve to in light mode.
27
+ const TONE_COLOR: Record<LatencyTone, string> = {
28
+ critical: "var(--destructive)",
29
+ slow: "#d97706",
30
+ fast: "#059669",
31
+ }
32
+
33
+ const TONE_TEXT: Record<LatencyTone, string> = {
34
+ critical: "text-destructive",
35
+ slow: "text-amber-600 dark:text-amber-400",
36
+ fast: "text-emerald-600 dark:text-emerald-400",
37
+ }
38
+
39
+ /** CSS colour of a latency, for a `style` or an SVG `fill`. */
40
+ export function latencyColor(ms: number): string {
41
+ return TONE_COLOR[latencyTone(ms)]
42
+ }
43
+
44
+ /** Tailwind text classes of a latency, dark mode included. */
45
+ export function latencyTextClass(ms: number): string {
46
+ return TONE_TEXT[latencyTone(ms)]
47
+ }
@@ -0,0 +1,117 @@
1
+ "use client"
2
+
3
+ import { cn } from "../../utils"
4
+ import { formatLatency } from "./latency"
5
+
6
+ // ── Log lines ─────────────────────────────────────────────────────────────
7
+ // Log records as an OTLP pipeline stores them: a body, a severity, and — when the logger wrote
8
+ // them inside a span — the exact trace/span ids. That id is the whole point: a line is tied to a
9
+ // span by what the logger stamped, not by "same container, same second".
10
+
11
+ /** One log record. Structural and lax: only `timestamp` and `body` are required. */
12
+ export interface TelemetryLogLine {
13
+ /** ISO timestamp (or anything `Date.parse` reads). */
14
+ timestamp: string
15
+ body: string
16
+ service?: string | null
17
+ severity?: string | null
18
+ traceId?: string | null
19
+ spanId?: string | null
20
+ attributes?: Record<string, string>
21
+ }
22
+
23
+ /** Tailwind text classes of an OTel severity text ("ERROR", "WARN", "INFO2"…). */
24
+ export function severityClass(severity: string | null | undefined): string {
25
+ const s = (severity ?? "").toUpperCase()
26
+ if (s.startsWith("ERROR") || s.startsWith("FATAL")) return "text-destructive"
27
+ if (s.startsWith("WARN")) return "text-amber-600 dark:text-amber-400"
28
+ return "text-muted-foreground"
29
+ }
30
+
31
+ /** Drops ANSI colour codes a logger meant for a terminal. */
32
+ export function stripAnsi(text: string): string {
33
+ // eslint-disable-next-line no-control-regex
34
+ return text.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "")
35
+ }
36
+
37
+ function clock(ms: number): string {
38
+ return new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" })
39
+ }
40
+
41
+ export interface LogLinesProps {
42
+ lines: TelemetryLogLine[]
43
+ /**
44
+ * Time column. A number is the start of a trace: each line shows "+12ms" from it — what
45
+ * `SpanWaterfall` does when handed `logs`. Omitted: wall-clock time.
46
+ */
47
+ origin?: number
48
+ /**
49
+ * Makes a line whose `spanId` is in this set a button that selects that span, and highlights
50
+ * the lines of `selectedSpanId`. For the logs under a waterfall.
51
+ */
52
+ spanIds?: Set<string>
53
+ selectedSpanId?: string | null
54
+ onSelectSpan?: (spanId: string | null) => void
55
+ /** A "trace" link at the end of each line that carries a trace id. For a service's log feed. */
56
+ onOpenTrace?: (traceId: string) => void
57
+ /** Service name when a line has none (e.g. resolved from a resource attribute). */
58
+ serviceOf?: (line: TelemetryLogLine) => string | null | undefined
59
+ className?: string
60
+ }
61
+
62
+ export function LogLines({
63
+ lines,
64
+ origin,
65
+ spanIds,
66
+ selectedSpanId,
67
+ onSelectSpan,
68
+ onOpenTrace,
69
+ serviceOf,
70
+ className,
71
+ }: LogLinesProps) {
72
+ const cols = onOpenTrace ? "grid-cols-[5.5rem_7rem_3.5rem_1fr_auto]" : "grid-cols-[5.5rem_7rem_3.5rem_1fr]"
73
+ return (
74
+ <div className={cn("max-h-96 overflow-auto rounded-lg border border-border", className)}>
75
+ {lines.map((l, i) => {
76
+ const at = Date.parse(l.timestamp)
77
+ // A row holding a "trace" button can't itself be a button: the two uses don't mix.
78
+ const linked = !onOpenTrace && !!onSelectSpan && !!l.spanId && (!spanIds || spanIds.has(l.spanId))
79
+ const isSel = linked && l.spanId === selectedSpanId
80
+ const cells = (
81
+ <>
82
+ <span className="tabular-nums text-muted-foreground">
83
+ {origin !== undefined ? `+${formatLatency(Math.max(0, at - origin))}` : clock(at)}
84
+ </span>
85
+ <span className="truncate text-muted-foreground">{l.service ?? serviceOf?.(l) ?? "—"}</span>
86
+ <span className={cn("truncate", severityClass(l.severity))}>{l.severity ?? ""}</span>
87
+ <span className="whitespace-pre-wrap break-all text-foreground">{stripAnsi(l.body)}</span>
88
+ {onOpenTrace &&
89
+ (l.traceId ? (
90
+ <button type="button" onClick={() => onOpenTrace(l.traceId!)} className="text-primary hover:underline">
91
+ trace
92
+ </button>
93
+ ) : (
94
+ <span />
95
+ ))}
96
+ </>
97
+ )
98
+ const rowClass = cn("grid w-full items-start gap-3 border-b border-border/60 px-3 py-1 text-left font-mono text-[11px] last:border-b-0", cols)
99
+ return linked ? (
100
+ <button
101
+ key={i}
102
+ type="button"
103
+ aria-pressed={isSel}
104
+ onClick={() => onSelectSpan!(isSel ? null : l.spanId!)}
105
+ className={cn(rowClass, "hover:bg-muted/50", isSel && "bg-primary/10")}
106
+ >
107
+ {cells}
108
+ </button>
109
+ ) : (
110
+ <div key={i} className={rowClass}>
111
+ {cells}
112
+ </div>
113
+ )
114
+ })}
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,244 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import { fireEvent, render, screen } from "@testing-library/react"
3
+ import {
4
+ byRoute,
5
+ byService,
6
+ computeStats,
7
+ exampleTraces,
8
+ filterTraces,
9
+ formatLatency,
10
+ latencyTone,
11
+ methodOf,
12
+ orderSpans,
13
+ percentile,
14
+ routeOf,
15
+ SpanWaterfall,
16
+ spanTree,
17
+ timeBuckets,
18
+ TracesOverview,
19
+ type TraceItem,
20
+ type TraceSpan,
21
+ } from "./index"
22
+
23
+ const NOW = Date.parse("2026-09-28T12:00:00Z")
24
+ const MIN = 60_000
25
+
26
+ function trace(id: string, name: string, durationMs: number, agoMs: number, extra: Partial<TraceItem> = {}): TraceItem {
27
+ return { traceId: id, name, service: "spunto-api", startTimeMs: NOW - agoMs, durationMs, ...extra }
28
+ }
29
+
30
+ describe("analytics", () => {
31
+ it("sépare méthode et route, et laisse entier un nom qui n'est pas HTTP", () => {
32
+ expect(methodOf("GET /api/orgs/:orgId")).toBe("GET")
33
+ expect(routeOf("GET /api/orgs/:orgId")).toBe("/api/orgs/:orgId")
34
+ expect(methodOf("job.worker.create")).toBe("—")
35
+ expect(routeOf("job.worker.create")).toBe("job.worker.create")
36
+ })
37
+
38
+ it("interpole les percentiles", () => {
39
+ expect(percentile([], 95)).toBe(0)
40
+ expect(percentile([10], 95)).toBe(10)
41
+ expect(percentile([0, 100], 50)).toBe(50)
42
+ expect(percentile([1, 2, 3, 4, 5], 100)).toBe(5)
43
+ })
44
+
45
+ it("compte les erreurs et le max", () => {
46
+ const s = computeStats([trace("a", "GET /x", 10, 0), trace("b", "GET /x", 30, 0, { error: true })])
47
+ expect(s).toMatchObject({ count: 2, errorCount: 1, errorRate: 0.5, max: 30 })
48
+ })
49
+
50
+ it("trie les routes par p95 décroissant, les services par volume", () => {
51
+ const set = [
52
+ trace("a", "GET /fast", 5, 0),
53
+ trace("b", "GET /fast", 7, 0),
54
+ trace("c", "POST /slow", 900, 0, { service: "spunto-proxy" }),
55
+ ]
56
+ expect(byRoute(set).map((r) => r.name)).toEqual(["POST /slow", "GET /fast"])
57
+ expect(byService(set).map((s) => s.service)).toEqual(["spunto-api", "spunto-proxy"])
58
+ expect(byService([trace("x", "GET /", 1, 0, { service: null })])[0].service).toBe("(unknown)")
59
+ })
60
+
61
+ it("répartit dans la fenêtre, et ramène au bord ce qui en déborde au lieu de le perdre", () => {
62
+ const b = timeBuckets([trace("old", "GET /", 1, 90 * MIN), trace("new", "GET /", 1, 1 * MIN)], 60, 6, NOW)
63
+ expect(b).toHaveLength(6)
64
+ expect(b[0].total).toBe(1)
65
+ expect(b[5].total).toBe(1)
66
+ expect(b.reduce((n, x) => n + x.total, 0)).toBe(2)
67
+ })
68
+
69
+ it("choisit les exemples : erreurs, puis lentes, puis récentes, sans doublon", () => {
70
+ const set = [trace("e", "GET /", 1, 5, { error: true }), trace("slow", "GET /", 500, 10), trace("recent", "GET /", 2, 0)]
71
+ const ids = exampleTraces(set, 4).map((t) => t.traceId)
72
+ expect(ids[0]).toBe("e")
73
+ expect(new Set(ids).size).toBe(ids.length)
74
+ expect(ids).toContain("slow")
75
+ expect(ids).toContain("recent")
76
+ })
77
+
78
+ it("filtre côté client sur statut, méthode et route", () => {
79
+ const set = [trace("a", "GET /x", 1, 0), trace("b", "POST /x", 1, 0, { error: true }), trace("c", "GET /y", 1, 0)]
80
+ expect(filterTraces(set, { status: "errors", method: "all", route: "all" }).map((t) => t.traceId)).toEqual(["b"])
81
+ expect(filterTraces(set, { status: "all", method: "GET", route: "/x" }).map((t) => t.traceId)).toEqual(["a"])
82
+ })
83
+ })
84
+
85
+ describe("latence", () => {
86
+ it("garde la précision d'un span sous la milliseconde", () => {
87
+ expect(formatLatency(0.42)).toBe("0.42ms")
88
+ expect(formatLatency(38.4)).toBe("38ms")
89
+ expect(formatLatency(1234)).toBe("1.23s")
90
+ })
91
+
92
+ it("a un seul jeu de seuils", () => {
93
+ expect(latencyTone(299)).toBe("fast")
94
+ expect(latencyTone(300)).toBe("slow")
95
+ expect(latencyTone(1000)).toBe("critical")
96
+ })
97
+ })
98
+
99
+ const SPANS: TraceSpan[] = [
100
+ { spanId: "db", parentSpanId: "root", name: "pg.query", startTimeMs: 20, durationMs: 5, attributes: { "db.system": "postgresql" } },
101
+ { spanId: "root", parentSpanId: null, name: "GET /api/x", startTimeMs: 0, durationMs: 100, service: "spunto-api" },
102
+ { spanId: "rpc", parentSpanId: "root", name: "agent.cmd spawn", startTimeMs: 10, durationMs: 50 },
103
+ { spanId: "leaf", parentSpanId: "rpc", name: "docker.create", startTimeMs: 12, durationMs: 30 },
104
+ { spanId: "orphan", parentSpanId: "not-in-trace", name: "late", startTimeMs: 90, durationMs: 1 },
105
+ ]
106
+
107
+ describe("orderSpans", () => {
108
+ it("met chaque enfant sous son parent, les frères par heure de début", () => {
109
+ expect(orderSpans(SPANS).map((o) => `${o.span.spanId}:${o.depth}`)).toEqual([
110
+ "root:0",
111
+ "rpc:1",
112
+ "leaf:2",
113
+ "db:1",
114
+ "orphan:0",
115
+ ])
116
+ })
117
+
118
+ it("ne boucle pas sur un span qui est son propre parent", () => {
119
+ const loop: TraceSpan[] = [{ spanId: "a", parentSpanId: "a", name: "a", startTimeMs: 0, durationMs: 1 }]
120
+ // Son parent est dans le jeu, donc ce n'est pas une racine : rien à dessiner, mais pas de boucle.
121
+ expect(orderSpans(loop)).toEqual([])
122
+ })
123
+ })
124
+
125
+ describe("SpanWaterfall", () => {
126
+ it("dessine les spans, et montre les attributs d'un span cliqué", () => {
127
+ render(<SpanWaterfall traceId="abc123" spans={SPANS} />)
128
+ expect(screen.getByText("abc123")).toBeTruthy()
129
+ expect(screen.getByText("5 spans")).toBeTruthy()
130
+ expect(screen.queryByText("postgresql")).toBeNull()
131
+ fireEvent.click(screen.getByText("pg.query"))
132
+ expect(screen.getByText("postgresql")).toBeTruthy()
133
+ })
134
+
135
+ it("relie une ligne de log à son span", () => {
136
+ render(
137
+ <SpanWaterfall
138
+ spans={SPANS}
139
+ logs={[{ timestamp: new Date(25).toISOString(), body: "\u001b[31mslow query\u001b[0m", spanId: "db", severity: "WARN" }]}
140
+ />
141
+ )
142
+ expect(screen.getByText("1 line with this trace id")).toBeTruthy()
143
+ fireEvent.click(screen.getByText("slow query"))
144
+ // La ligne a sélectionné le span : son détail est ouvert.
145
+ expect(screen.getByText("postgresql")).toBeTruthy()
146
+ })
147
+
148
+ it("dit quand aucun log ne porte ce trace id", () => {
149
+ render(<SpanWaterfall spans={SPANS} logs={[]} />)
150
+ expect(screen.getByText(/No log line carries this trace id/)).toBeTruthy()
151
+ })
152
+ })
153
+
154
+ describe("TracesOverview", () => {
155
+ it("n'a de lignes cliquables que si on lui donne onRouteClick", () => {
156
+ const set = [trace("a", "GET /x", 10, MIN)]
157
+ const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
158
+ expect(screen.getByText("/x").closest("button")).toBeNull()
159
+ let clicked = ""
160
+ rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} onRouteClick={(n) => (clicked = n)} />)
161
+ fireEvent.click(screen.getByText("/x"))
162
+ expect(clicked).toBe("GET /x")
163
+ })
164
+
165
+ it("n'affiche les services que sur demande", () => {
166
+ const set = [trace("a", "GET /x", 10, MIN, { service: "spunto-proxy" })]
167
+ const { rerender } = render(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} />)
168
+ expect(screen.queryByText("spunto-proxy")).toBeNull()
169
+ rerender(<TracesOverview traces={set} lookbackMinutes={60} now={NOW} showServices />)
170
+ expect(screen.getByText("spunto-proxy")).toBeTruthy()
171
+ })
172
+ })
173
+
174
+ describe("SpanWaterfall — repli", () => {
175
+ it("sait ce que replier cacherait, erreurs comprises", () => {
176
+ const t = spanTree(orderSpans([...SPANS, { spanId: "boom", parentSpanId: "leaf", name: "x", startTimeMs: 13, durationMs: 1, error: true }]))
177
+ expect(t.get("root")).toMatchObject({ parentId: null, descendants: 4, descendantError: true })
178
+ expect(t.get("rpc")).toMatchObject({ parentId: "root", descendants: 2, descendantError: true })
179
+ expect(t.get("db")).toMatchObject({ parentId: "root", descendants: 0, descendantError: false })
180
+ })
181
+
182
+ it("replie un sous-arbre au chevron, sans sélectionner le span", () => {
183
+ render(<SpanWaterfall spans={SPANS} />)
184
+ expect(screen.getByText("docker.create")).toBeTruthy()
185
+ fireEvent.click(screen.getByLabelText("Collapse agent.cmd spawn"))
186
+ expect(screen.queryByText("docker.create")).toBeNull()
187
+ expect(screen.getByText("+1")).toBeTruthy()
188
+ // Le clic sur le chevron n'a pas ouvert le détail du span.
189
+ expect(screen.queryByText("kind")).toBeNull()
190
+ fireEvent.click(screen.getByLabelText("Expand agent.cmd spawn"))
191
+ expect(screen.getByText("docker.create")).toBeTruthy()
192
+ })
193
+
194
+ it("replie et déplie tout", () => {
195
+ render(<SpanWaterfall spans={SPANS} />)
196
+ fireEvent.click(screen.getByText("Collapse all"))
197
+ expect(screen.queryByText("pg.query")).toBeNull()
198
+ expect(screen.getByText("GET /api/x")).toBeTruthy()
199
+ expect(screen.getByText("late")).toBeTruthy()
200
+ fireEvent.click(screen.getByText("Expand all"))
201
+ expect(screen.getByText("docker.create")).toBeTruthy()
202
+ })
203
+
204
+ it("part replié à partir d'une profondeur", () => {
205
+ render(<SpanWaterfall spans={SPANS} defaultCollapsedDepth={1} />)
206
+ expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
207
+ expect(screen.queryByText("docker.create")).toBeNull()
208
+ })
209
+
210
+ it("se replie au clavier", () => {
211
+ render(<SpanWaterfall spans={SPANS} />)
212
+ const row = screen.getByText("agent.cmd spawn").closest("[role=button]") as HTMLElement
213
+ fireEvent.keyDown(row, { key: "ArrowLeft" })
214
+ expect(screen.queryByText("docker.create")).toBeNull()
215
+ fireEvent.keyDown(row, { key: "ArrowRight" })
216
+ expect(screen.getByText("docker.create")).toBeTruthy()
217
+ fireEvent.keyDown(row, { key: "Enter" })
218
+ expect(row.getAttribute("aria-pressed")).toBe("true")
219
+ })
220
+
221
+ it("déplie les ancêtres d'un span choisi depuis une ligne de log", () => {
222
+ render(
223
+ <SpanWaterfall
224
+ spans={SPANS}
225
+ defaultCollapsedDepth={0}
226
+ logs={[{ timestamp: new Date(20).toISOString(), body: "creating container", spanId: "leaf" }]}
227
+ />
228
+ )
229
+ expect(screen.queryByText("docker.create")).toBeNull()
230
+ fireEvent.click(screen.getByText("creating container"))
231
+ expect(screen.getAllByText("docker.create").length).toBeGreaterThan(0)
232
+ expect(screen.getByText("agent.cmd spawn")).toBeTruthy()
233
+ })
234
+
235
+ it("se laisse contrôler", () => {
236
+ let last: string[] = []
237
+ render(<SpanWaterfall spans={SPANS} collapsedSpanIds={["root"]} onCollapsedSpanIdsChange={(ids) => (last = ids)} />)
238
+ expect(screen.queryByText("agent.cmd spawn")).toBeNull()
239
+ fireEvent.click(screen.getByLabelText("Expand GET /api/x"))
240
+ expect(last).toEqual([])
241
+ // Contrôlé : tant que le parent ne change pas la prop, rien ne bouge.
242
+ expect(screen.queryByText("agent.cmd spawn")).toBeNull()
243
+ })
244
+ })