| 1 | import { A } from "@solidjs/router"; |
| 2 | import ChevronRight from "lucide-solid/icons/chevron-right"; |
| 3 | import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from "solid-js"; |
| 4 | import type { Span, Trace } from "../types/model.ts"; |
| 5 | import { Copy } from "../components/Copy.tsx"; |
| 6 | import { percent } from "../format.ts"; |
| 7 | |
| 8 | /** Milliseconds under a second, else seconds; `seconds` may be a span's length or an offset into a trace. */ |
| 9 | export const ms = (seconds: number) => { |
| 10 | const value = seconds * 1000; |
| 11 | if (value >= 1000) return `${seconds.toFixed(seconds < 10 ? 2 : 1)} s`; |
| 12 | if (value >= 10 || value === 0) return `${Math.round(value)} ms`; |
| 13 | return `${value.toFixed(value < 1 ? 2 : 1)} ms`; |
| 14 | }; |
| 15 | |
| 16 | const ROW = 20; |
| 17 | const AXIS = 20; |
| 18 | /** Narrower bars go unlabelled. */ |
| 19 | const LABEL = 28; |
| 20 | /** How much of a service's color each shade mixes into the page. */ |
| 21 | const SHADES = [0.36, 0.48, 0.6, 0.72]; |
| 22 | |
| 23 | interface Bar { |
| 24 | span: Span; |
| 25 | /** Seconds from the trace's start. */ |
| 26 | from: number; |
| 27 | to: number; |
| 28 | /** Time not covered by any child. */ |
| 29 | self: number; |
| 30 | row: number; |
| 31 | service: number; |
| 32 | /** Spans sharing a name's first word ("index", "tokenize") share a shade of their service's color. */ |
| 33 | shade: number; |
| 34 | parent: Bar | undefined; |
| 35 | /** By start. */ |
| 36 | children: Bar[]; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Bars in rows below their parents. Siblings that overlap in time, like concurrent calls, take separate lanes, and |
| 41 | * each keeps its whole subtree as one block under it so no bar ever sits beside a stranger's children. |
| 42 | */ |
| 43 | function layout(trace: Trace) { |
| 44 | const t0 = trace.spans[0]!.start; |
| 45 | const byId = new Map<string, Bar>(); |
| 46 | const services: string[] = []; |
| 47 | const bars = trace.spans.map((span) => { |
| 48 | const parent = span.parent === null ? undefined : byId.get(span.parent); |
| 49 | if (!services.includes(span.service)) services.push(span.service); |
| 50 | const from = span.start - t0; |
| 51 | const word = span.name.split(" ")[0]!; |
| 52 | let shade = 0; |
| 53 | for (const char of word) shade = (shade * 31 + char.charCodeAt(0)) % SHADES.length; |
| 54 | const bar: Bar = { |
| 55 | span, from, to: from + span.duration, self: span.duration, row: 0, service: services.indexOf(span.service), shade, parent, |
| 56 | children: [], |
| 57 | }; |
| 58 | byId.set(span.id, bar); |
| 59 | parent?.children.push(bar); |
| 60 | return bar; |
| 61 | }); |
| 62 | const stack = (group: Bar[]) => { |
| 63 | group.sort((a, b) => a.from - b.from); |
| 64 | const lanes: [number, number][][] = []; |
| 65 | let height = 0; |
| 66 | let end = 0; |
| 67 | for (const bar of group) { |
| 68 | const below = stack(bar.children); |
| 69 | const rows = 1 + below.height; |
| 70 | const until = Math.max(bar.to, below.end); |
| 71 | let lane = 0; |
| 72 | while (lanes.slice(lane, lane + rows).some((row) => row.some(([a, b]) => a < until && bar.from < b))) lane++; |
| 73 | for (let i = lane; i < lane + rows; i++) (lanes[i] ??= []).push([bar.from, until]); |
| 74 | bar.row = lane; |
| 75 | height = Math.max(height, lane + rows); |
| 76 | end = Math.max(end, until); |
| 77 | } |
| 78 | return { height, end }; |
| 79 | }; |
| 80 | const { height, end } = stack(bars.filter((bar) => !bar.parent)); |
| 81 | const rows: Bar[][] = Array.from({ length: height }, () => []); |
| 82 | for (const bar of bars) { |
| 83 | if (bar.parent) bar.row += bar.parent.row + 1; |
| 84 | rows[bar.row]!.push(bar); |
| 85 | let reach = bar.from; |
| 86 | for (const child of bar.children) { |
| 87 | const to = Math.min(child.to, bar.to); |
| 88 | bar.self -= Math.max(0, to - Math.max(child.from, reach)); |
| 89 | reach = Math.max(reach, to); |
| 90 | } |
| 91 | } |
| 92 | for (const row of rows) row.sort((a, b) => a.from - b.from); |
| 93 | return { bars, rows, end, services }; |
| 94 | } |
| 95 | |
| 96 | /** A step of 1, 2 or 5 times a power of ten, at least `min`. */ |
| 97 | function niceStep(min: number) { |
| 98 | const power = 10 ** Math.floor(Math.log10(min)); |
| 99 | return [1, 2, 5, 10].map((k) => k * power).find((step) => step >= min)!; |
| 100 | } |
| 101 | |
| 102 | const token = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); |
| 103 | /** `a` over `b` at `amount`, both `#rrggbb`. */ |
| 104 | function mix(a: string, b: string, amount: number) { |
| 105 | const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16); |
| 106 | return `rgb(${[0, 1, 2].map((i) => Math.round(channel(a, i) * amount + channel(b, i) * (1 - amount))).join(" ")})`; |
| 107 | } |
| 108 | |
| 109 | function theme() { |
| 110 | const page = token("--page"); |
| 111 | const fills = (color: string) => |
| 112 | ({ shades: SHADES.map((amount) => mix(color, page, amount)), hot: mix(color, page, 0.9), faint: mix(color, page, 0.2) }); |
| 113 | return { |
| 114 | font: token("--sans"), |
| 115 | text: token("--text"), |
| 116 | muted: token("--muted"), |
| 117 | line: token("--line"), |
| 118 | series: Array.from({ length: 8 }, (_, i) => fills(token(`--series-${i + 1}`))), |
| 119 | failed: fills(token("--critical")), |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * A flame chart of one trace: time runs left to right and each span sits under its parent. Clicking a span zooms to |
| 125 | * it and shows its attributes; Esc zooms back out. Drag or scroll pans, pinch or ⌘/ctrl scroll zooms at the pointer. |
| 126 | * The arrow keys walk the tree. Spans holding all of `needles` stay bright. |
| 127 | */ |
| 128 | export function Flame(props: { trace: Trace; needles: string[]; job: string }) { |
| 129 | const job = (service: string) => service === props.trace.spans[0]?.service ? props.job : service; |
| 130 | let wrap!: HTMLDivElement; |
| 131 | let canvas!: HTMLCanvasElement; |
| 132 | const graph = createMemo(() => layout(props.trace)); |
| 133 | const [focus, setFocus] = createSignal<Bar>(); |
| 134 | const [hover, setHover] = createSignal<{ bar: Bar; x: number; y: number }>(); |
| 135 | let view = { from: 0, to: 1 }; |
| 136 | let scroll = 0; |
| 137 | let width = 0; |
| 138 | let height = 0; |
| 139 | let colors = theme(); |
| 140 | const widths = new Map<string, number>(); |
| 141 | let frame = 0; |
| 142 | let animation = 0; |
| 143 | const reduced = matchMedia("(prefers-reduced-motion: reduce)"); |
| 144 | const mac = /Mac|iPhone|iPad/.test(navigator.platform); |
| 145 | |
| 146 | const total = () => graph().end || 1e-6; |
| 147 | const scale = () => width / (view.to - view.from); |
| 148 | const maxScroll = () => Math.max(0, graph().rows.length * ROW - (height - AXIS)); |
| 149 | const clampView = (from: number, to: number) => { |
| 150 | const span = Math.min(total(), Math.max(to - from, total() * 1e-5)); |
| 151 | const start = Math.min(Math.max(0, from), total() - span); |
| 152 | return { from: start, to: start + span }; |
| 153 | }; |
| 154 | /** Bars matching the search, or null when there is none. */ |
| 155 | const matching = createMemo(() => { |
| 156 | const needles = props.needles.map((needle) => needle.toLowerCase()); |
| 157 | return needles.length ? new Set(graph().bars.filter(({ span }) => { |
| 158 | const values = [span.name, ...Object.values(span.attributes)].map((value) => String(value).toLowerCase()); |
| 159 | return needles.every((needle) => values.some((value) => value.includes(needle))); |
| 160 | })) : null; |
| 161 | }); |
| 162 | |
| 163 | const measure = (text: string, ctx: CanvasRenderingContext2D) => { |
| 164 | let known = widths.get(text); |
| 165 | if (known === undefined) widths.set(text, (known = ctx.measureText(text).width)); |
| 166 | return known; |
| 167 | }; |
| 168 | const fit = (text: string, space: number, ctx: CanvasRenderingContext2D) => { |
| 169 | const full = measure(text, ctx); |
| 170 | if (full <= space) return text; |
| 171 | const chars = Math.floor((text.length * space) / full) - 1; |
| 172 | return chars < 2 ? "" : text.slice(0, chars) + "…"; |
| 173 | }; |
| 174 | |
| 175 | const draw = () => { |
| 176 | frame = 0; |
| 177 | const ctx = canvas.getContext("2d"); |
| 178 | if (!ctx || !width) return; |
| 179 | const dpr = devicePixelRatio; |
| 180 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 181 | ctx.clearRect(0, 0, width, height); |
| 182 | ctx.font = `11px ${colors.font}`; |
| 183 | ctx.textBaseline = "middle"; |
| 184 | const k = scale(); |
| 185 | const x = (t: number) => (t - view.from) * k; |
| 186 | const step = niceStep(90 / k); |
| 187 | const seconds = step >= 1; |
| 188 | const digits = Math.max(0, -Math.floor(Math.log10(seconds ? step : step * 1000) + 1e-9)); |
| 189 | for (let t = Math.ceil(view.from / step) * step; t <= view.to; t += step) { |
| 190 | const at = Math.round(x(t)); |
| 191 | ctx.fillStyle = colors.line; |
| 192 | ctx.fillRect(at, AXIS - 4, 1, height); |
| 193 | const label = seconds ? `${t.toFixed(digits)} s` : `${(t * 1000).toFixed(digits)} ms`; |
| 194 | if (at + 4 + measure(label, ctx) > width) continue; |
| 195 | ctx.fillStyle = colors.muted; |
| 196 | ctx.fillText(label, at + 4, AXIS / 2 - 1); |
| 197 | } |
| 198 | |
| 199 | ctx.save(); |
| 200 | ctx.beginPath(); |
| 201 | ctx.rect(0, AXIS, width, height - AXIS); |
| 202 | ctx.clip(); |
| 203 | const { rows } = graph(); |
| 204 | const hovered = hover()?.bar; |
| 205 | const lit = matching(); |
| 206 | const focused = focus(); |
| 207 | const last = Math.min(rows.length, Math.ceil((scroll + height - AXIS) / ROW)); |
| 208 | for (let r = Math.floor(scroll / ROW); r < last; r++) { |
| 209 | const y = AXIS + r * ROW - scroll; |
| 210 | for (const bar of rows[r]!) { |
| 211 | if (bar.to < view.from || bar.from > view.to) continue; |
| 212 | const left = Math.max(-1, x(bar.from)); |
| 213 | const w = Math.max(1, Math.min(width + 1, x(bar.to)) - left); |
| 214 | const fills = bar.span.error ? colors.failed : colors.series[bar.service % 8]!; |
| 215 | const bright = !lit || lit.has(bar); |
| 216 | ctx.fillStyle = !bright ? fills.faint : bar === hovered ? fills.hot : fills.shades[bar.shade]!; |
| 217 | ctx.fillRect(left, y, w > 2 ? w - 1 : w, ROW - 1); |
| 218 | if (bar === focused) { |
| 219 | ctx.strokeStyle = colors.text; |
| 220 | ctx.lineWidth = 1.5; |
| 221 | ctx.strokeRect(left + 0.75, y + 0.75, Math.max(0, w - 2.5), ROW - 2.5); |
| 222 | } |
| 223 | if (w < LABEL) continue; |
| 224 | const text = Math.max(0, left) + 5; |
| 225 | const name = fit(bar.span.name, w - 10, ctx); |
| 226 | ctx.fillStyle = bright ? colors.text : colors.muted; |
| 227 | ctx.fillText(name, text, y + ROW / 2); |
| 228 | if (name !== bar.span.name) continue; |
| 229 | const time = ms(bar.span.duration); |
| 230 | const after = measure(name, ctx) + 6; |
| 231 | if (after + measure(time, ctx) + 10 > w) continue; |
| 232 | ctx.globalAlpha = 0.6; |
| 233 | ctx.fillText(time, text + after, y + ROW / 2); |
| 234 | ctx.globalAlpha = 1; |
| 235 | } |
| 236 | } |
| 237 | ctx.restore(); |
| 238 | }; |
| 239 | const redraw = () => (frame ||= requestAnimationFrame(draw)); |
| 240 | |
| 241 | /** Eases to a view and a scroll, width geometrically so deep zooms feel even. */ |
| 242 | const animate = (target: { from: number; to: number }, targetScroll = scroll) => { |
| 243 | cancelAnimationFrame(animation); |
| 244 | const start = { ...view, scroll }; |
| 245 | const began = performance.now(); |
| 246 | const duration = reduced.matches ? 0 : 220; |
| 247 | const tick = (now: number) => { |
| 248 | const p = duration ? Math.min(1, (now - began) / duration) : 1; |
| 249 | const e = 1 - (1 - p) ** 3; |
| 250 | const [a, b] = [start.to - start.from, target.to - target.from]; |
| 251 | const span = a * (b / a) ** e; |
| 252 | const center = (start.from + start.to) / 2 + (((target.from + target.to) / 2) - (start.from + start.to) / 2) * e; |
| 253 | view = p === 1 ? target : { from: center - span / 2, to: center + span / 2 }; |
| 254 | scroll = start.scroll + (targetScroll - start.scroll) * e; |
| 255 | draw(); |
| 256 | if (p < 1) animation = requestAnimationFrame(tick); |
| 257 | }; |
| 258 | animation = requestAnimationFrame(tick); |
| 259 | }; |
| 260 | |
| 261 | const zoomTo = (bar: Bar) => { |
| 262 | setFocus(bar); |
| 263 | const pad = (bar.to - bar.from) * 0.03; |
| 264 | const top = bar.row * ROW; |
| 265 | const room = height - AXIS; |
| 266 | const targetScroll = top < scroll ? top : top + ROW > scroll + room ? top + ROW - room : scroll; |
| 267 | animate(clampView(bar.from - pad, bar.to + pad), Math.min(maxScroll(), Math.max(0, targetScroll))); |
| 268 | }; |
| 269 | |
| 270 | const zoomAt = (px: number, factor: number) => { |
| 271 | const t = view.from + px / scale(); |
| 272 | const span = (view.to - view.from) * factor; |
| 273 | cancelAnimationFrame(animation); |
| 274 | view = clampView(t - (px / width) * span, t - (px / width) * span + span); |
| 275 | redraw(); |
| 276 | }; |
| 277 | |
| 278 | const hit = (px: number, py: number) => { |
| 279 | if (py < AXIS) return undefined; |
| 280 | const row = graph().rows[Math.floor((py - AXIS + scroll) / ROW)] ?? []; |
| 281 | const t = view.from + px / scale(); |
| 282 | const slop = 3 / scale(); |
| 283 | let found: Bar | undefined; |
| 284 | for (const bar of row) { |
| 285 | if (bar.from - slop > t) break; |
| 286 | if (t <= bar.to + slop && (!found || (bar.from <= t && t <= bar.to))) found = bar; |
| 287 | } |
| 288 | return found; |
| 289 | }; |
| 290 | |
| 291 | let drag: { x: number; y: number; view: typeof view; scroll: number; moved: boolean } | undefined; |
| 292 | const point = (event: MouseEvent) => { |
| 293 | const box = canvas.getBoundingClientRect(); |
| 294 | return [event.clientX - box.left, event.clientY - box.top] as const; |
| 295 | }; |
| 296 | const onPointerDown = (event: PointerEvent) => { |
| 297 | if (event.button !== 0) return; |
| 298 | canvas.setPointerCapture(event.pointerId); |
| 299 | drag = { x: event.clientX, y: event.clientY, view, scroll, moved: false }; |
| 300 | }; |
| 301 | const onPointerMove = (event: PointerEvent) => { |
| 302 | const [px, py] = point(event); |
| 303 | if (drag) { |
| 304 | const dx = event.clientX - drag.x; |
| 305 | const dy = event.clientY - drag.y; |
| 306 | if (!drag.moved && Math.hypot(dx, dy) < 4) return; |
| 307 | if (!drag.moved) cancelAnimationFrame(animation); |
| 308 | drag.moved = true; |
| 309 | canvas.style.cursor = "grabbing"; |
| 310 | setHover(undefined); |
| 311 | const shift = dx / scale(); |
| 312 | view = clampView(drag.view.from - shift, drag.view.to - shift); |
| 313 | scroll = Math.min(maxScroll(), Math.max(0, drag.scroll - dy)); |
| 314 | redraw(); |
| 315 | return; |
| 316 | } |
| 317 | const bar = hit(px, py); |
| 318 | canvas.style.cursor = bar ? "pointer" : ""; |
| 319 | setHover(bar && { bar, x: px, y: py }); |
| 320 | }; |
| 321 | const onPointerUp = (event: PointerEvent) => { |
| 322 | if (drag && !drag.moved) { |
| 323 | const bar = hit(...point(event)); |
| 324 | if (bar) zoomTo(bar); |
| 325 | } |
| 326 | drag = undefined; |
| 327 | canvas.style.cursor = ""; |
| 328 | }; |
| 329 | const onWheel = (event: WheelEvent) => { |
| 330 | event.preventDefault(); |
| 331 | const [px] = point(event); |
| 332 | const lines = event.deltaMode === 1 ? 16 : 1; |
| 333 | if (event.ctrlKey || event.metaKey) return zoomAt(px, Math.exp(event.deltaY * lines * 0.01)); |
| 334 | const dx = (event.shiftKey ? event.deltaY : event.deltaX) * lines; |
| 335 | const dy = event.shiftKey ? 0 : event.deltaY * lines; |
| 336 | cancelAnimationFrame(animation); |
| 337 | view = clampView(view.from + dx / scale(), view.to + dx / scale()); |
| 338 | scroll = Math.min(maxScroll(), Math.max(0, scroll + dy)); |
| 339 | setHover(undefined); |
| 340 | redraw(); |
| 341 | }; |
| 342 | const onKeyDown = (event: KeyboardEvent) => { |
| 343 | const bar = focus(); |
| 344 | if (!bar) return; |
| 345 | const siblings = bar.parent?.children ?? graph().bars.filter((other) => !other.parent); |
| 346 | const index = siblings.indexOf(bar); |
| 347 | const next = ({ |
| 348 | Escape: bar.parent ?? graph().bars[0], |
| 349 | ArrowUp: bar.parent, |
| 350 | ArrowDown: bar.children[0], |
| 351 | ArrowLeft: siblings[index - 1], |
| 352 | ArrowRight: siblings[index + 1], |
| 353 | Home: graph().bars[0], |
| 354 | } as Record<string, Bar | undefined>)[event.key]; |
| 355 | if (event.key === "+" || event.key === "=" || event.key === "-") { |
| 356 | event.preventDefault(); |
| 357 | return zoomAt(width / 2, event.key === "-" ? 1.5 : 1 / 1.5); |
| 358 | } |
| 359 | if (!next) return; |
| 360 | event.preventDefault(); |
| 361 | zoomTo(next); |
| 362 | }; |
| 363 | |
| 364 | onMount(() => { |
| 365 | const resize = new ResizeObserver(() => { |
| 366 | width = canvas.clientWidth; |
| 367 | height = canvas.clientHeight; |
| 368 | canvas.width = Math.round(width * devicePixelRatio); |
| 369 | canvas.height = Math.round(height * devicePixelRatio); |
| 370 | scroll = Math.min(scroll, maxScroll()); |
| 371 | draw(); |
| 372 | }); |
| 373 | resize.observe(canvas); |
| 374 | const scheme = matchMedia("(prefers-color-scheme: dark)"); |
| 375 | const retheme = () => { |
| 376 | colors = theme(); |
| 377 | widths.clear(); |
| 378 | redraw(); |
| 379 | }; |
| 380 | scheme.addEventListener("change", retheme); |
| 381 | document.fonts.addEventListener("loadingdone", retheme); |
| 382 | canvas.addEventListener("wheel", onWheel, { passive: false }); |
| 383 | onCleanup(() => { |
| 384 | resize.disconnect(); |
| 385 | scheme.removeEventListener("change", retheme); |
| 386 | document.fonts.removeEventListener("loadingdone", retheme); |
| 387 | cancelAnimationFrame(frame); |
| 388 | cancelAnimationFrame(animation); |
| 389 | }); |
| 390 | }); |
| 391 | createEffect(on(graph, (next) => { |
| 392 | cancelAnimationFrame(animation); |
| 393 | setFocus(next.bars[0]); |
| 394 | setHover(undefined); |
| 395 | view = { from: 0, to: total() }; |
| 396 | scroll = 0; |
| 397 | redraw(); |
| 398 | })); |
| 399 | createEffect(on([focus, hover, matching], redraw, { defer: true })); |
| 400 | |
| 401 | const path = () => { |
| 402 | const chain: Bar[] = []; |
| 403 | for (let bar = focus(); bar; bar = bar.parent) chain.unshift(bar); |
| 404 | return chain; |
| 405 | }; |
| 406 | const root = () => graph().bars[0]!; |
| 407 | const facts = (bar: Bar) => [ |
| 408 | ["duration", `${ms(bar.span.duration)} (${percent((bar.span.duration / (root().span.duration || 1)) * 100)})`], |
| 409 | ...(bar.children.length ? [["self", ms(bar.self)]] : []), |
| 410 | ["start", `+${ms(bar.from)}`], |
| 411 | ...(bar.span.service !== root().span.service ? [["service", bar.span.service]] : []), |
| 412 | ]; |
| 413 | |
| 414 | return ( |
| 415 | <div class="flame"> |
| 416 | <div class="flame-bar"> |
| 417 | <nav class="flame-path" aria-label="Zoom"> |
| 418 | <For each={path()}> |
| 419 | {(bar, i) => ( |
| 420 | <> |
| 421 | <Show when={i()}><ChevronRight size={12} aria-hidden="true" /></Show> |
| 422 | <button aria-current={bar === focus() ? "location" : undefined} onClick={() => zoomTo(bar)}> |
| 423 | {bar.span.name} |
| 424 | </button> |
| 425 | </> |
| 426 | )} |
| 427 | </For> |
| 428 | </nav> |
| 429 | <Show when={graph().services.length > 1}> |
| 430 | <span class="legend"> |
| 431 | <For each={graph().services}> |
| 432 | {(service, i) => ( |
| 433 | <A href={`/services/${job(service)}/traces?trace=${props.trace.id}`} style={{ "--color": `var(--series-${(i() % 8) + 1})` }} |
| 434 | data-tip={`${service}'s traces`}> |
| 435 | {service} |
| 436 | </A> |
| 437 | )} |
| 438 | </For> |
| 439 | </span> |
| 440 | </Show> |
| 441 | <span class="hint"><kbd>{mac ? "⌘" : "ctrl"}</kbd> scroll to zoom</span> |
| 442 | </div> |
| 443 | <div ref={wrap} class="flame-canvas"> |
| 444 | <canvas ref={canvas} tabIndex={0} role="img" |
| 445 | aria-label={`Flame chart of ${graph().bars.length} spans. Arrow keys move between spans, Escape zooms out.`} |
| 446 | onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} |
| 447 | onPointerCancel={() => (drag = undefined)} onPointerLeave={() => !drag && setHover(undefined)} onKeyDown={onKeyDown} /> |
| 448 | <Show when={hover()}> |
| 449 | {(at) => ( |
| 450 | <div class="flame-hover" classList={{ left: at().x > wrap.clientWidth / 2, up: at().y > wrap.clientHeight / 2 }} |
| 451 | style={{ left: `${at().x}px`, top: `${at().y}px` }}> |
| 452 | <strong>{at().bar.span.name}</strong> |
| 453 | <dl> |
| 454 | <For each={facts(at().bar)}>{([key, value]) => <><dt>{key}</dt><dd>{value}</dd></>}</For> |
| 455 | </dl> |
| 456 | <Show when={at().bar.span.error}>{(error) => <p class="error">{error()}</p>}</Show> |
| 457 | </div> |
| 458 | )} |
| 459 | </Show> |
| 460 | </div> |
| 461 | <Show when={focus()}> |
| 462 | {(bar) => ( |
| 463 | <dl class="span-facts"> |
| 464 | <For each={facts(bar())}>{([key, value]) => <div><dt>{key}</dt><dd>{value}</dd></div>}</For> |
| 465 | <Show when={bar().span.error}>{(error) => <div><dt>error</dt><dd class="error">{error()}</dd></div>}</Show> |
| 466 | <For each={Object.entries(bar().span.attributes)}> |
| 467 | {([key, value]) => <div><dt>{key}</dt><dd class="mono"><Copy value={String(value)} /></dd></div>} |
| 468 | </For> |
| 469 | <div><dt>span</dt><dd class="mono"><Copy value={bar().span.id} /></dd></div> |
| 470 | <div> |
| 471 | <A href={`/services/${job(bar().span.service)}?at=${Math.floor(bar().span.start)}`} |
| 472 | data-tip={`${bar().span.service} logs from this moment`}> |
| 473 | logs |
| 474 | </A> |
| 475 | </div> |
| 476 | </dl> |
| 477 | )} |
| 478 | </Show> |
| 479 | </div> |
| 480 | ); |
| 481 | } |