From 92b329dad8948437fa23081d68eaafbb2c4cb860 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 31 Oct 2026 02:25:46 +0100 Subject: [PATCH] style(web): denser trace waterfall aligned with the traces list Rows are a fixed 28px (were 36-40px), matching the agent-session waterfall. The status column uses the traces list's soft badges, leaf squares are neutral unless the span errored, duration bars take the service colour like the Timeline tab, and the header uses eyebrow labels (Timeline / Duration / Status). --- .../src/page/traces/detail/waterfall/row.ts | 78 ++++++++----------- .../src/page/traces/detail/waterfall/view.ts | 7 +- .../traces/detail/waterfall/virtualize.ts | 2 +- apps/web/src/ui/trace-badges.ts | 17 ++-- 4 files changed, 48 insertions(+), 56 deletions(-) diff --git a/apps/web/src/page/traces/detail/waterfall/row.ts b/apps/web/src/page/traces/detail/waterfall/row.ts index a99ec8a17d..01b7377502 100644 --- a/apps/web/src/page/traces/detail/waterfall/row.ts +++ b/apps/web/src/page/traces/detail/waterfall/row.ts @@ -2,15 +2,16 @@ import { cacheResultStyles, getCacheInfo } from "@maple/ui/lib/cache" import { getServiceColor } from "@maple/ui/lib/colors" import { formatDuration } from "@maple/ui/lib/format" import { getHttpInfo, HTTP_METHOD_COLORS, httpStatusTone } from "@maple/ui/lib/http" -import { getSpanKindLabel, getSpanStatusBadgeClass } from "@maple/ui/lib/span-kind" +import { getSpanKindLabel } from "@maple/ui/lib/span-kind" import { spanStartMs } from "@maple/ui/lib/span-tree" -import { TONE_FILL, TONE_TEXT } from "@maple/ui/lib/tone" +import { severityTone, TONE_COLOR, TONE_FILL, TONE_SOFT } from "@maple/ui/lib/tone" import { cn } from "@maple/ui/lib/utils" import { Option } from "effect" import type { Html, HtmlBuilder } from "foldkit/html" import { badge } from "../../../../ui/display" import { icon } from "../../../../ui/icon" import { iconButton } from "../../../../ui/icon-button" +import { traceStatusBadge } from "../../../../ui/trace-badges" import { truncatedId } from "../../../../ui/truncated-id" import { truncatedText } from "../../../../ui/truncated-text" import { serviceDot } from "../../../../ui/values" @@ -51,7 +52,7 @@ const treeGuides = (h: HtmlBuilder, row: TreeRow, isExpanded: boolean): h.div( [ h.AriaHidden(true), - h.Class("relative -my-1.5 shrink-0 self-stretch"), + h.Class("relative shrink-0 self-stretch"), h.Style({ width: `${shown * COLUMN_PX}px`, "margin-right": shown === 0 ? "-8px" : "0px" }), ], [ @@ -107,8 +108,9 @@ const treeGuides = (h: HtmlBuilder, row: TreeRow, isExpanded: boolean): ] } +// The status column carries Ok; the tree only flags errors. const statusFill = (statusCode: string): string => - statusCode === "Error" ? TONE_FILL.crit : statusCode === "Ok" ? TONE_FILL.ok : TONE_FILL.neutral + statusCode === "Error" ? TONE_FILL.crit : TONE_FILL.neutral /** A parent's chevron, or a leaf's status square where the elbow lands. */ const toggle = (h: HtmlBuilder, span: SpanNodeData, isExpanded: boolean): Html => @@ -126,7 +128,7 @@ const toggle = (h: HtmlBuilder, span: SpanNodeData, isExpanded: boolean [icon(h, isExpanded ? "ChevronDownIcon" : "ChevronRightIcon")], ) : h.div( - [h.Class("flex h-7 w-6 shrink-0 items-center justify-center")], + [h.Class("flex size-6 shrink-0 items-center justify-center")], [ h.span( [ @@ -155,8 +157,9 @@ const pixelBar = ( [ h.Attribute( "class", - `w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? color : "bg-muted"}`, + `w-[5px] h-2.5 rounded-2xs shrink-0 ${isActive ? "" : "bg-muted"}`, ), + ...(isActive ? [h.Style({ "background-color": color })] : []), ], [], ) @@ -170,14 +173,14 @@ const missingRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): Htm [ h.Class( cn( - "@container/row group flex items-stretch px-2 bg-surface-sunken", + "@container/row group flex h-7 items-stretch px-2 bg-surface-sunken", ctx.isSelected && "bg-primary-a1 shadow-accent-start", ), ), ], [ h.div( - [h.Class("flex items-center gap-2 flex-1 min-w-0 overflow-hidden py-1.5")], + [h.Class("flex items-center gap-2 flex-1 min-w-0 overflow-hidden")], [ ...treeGuides(h, row, ctx.isExpanded), toggle(h, span, ctx.isExpanded), @@ -201,7 +204,7 @@ const missingRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): Htm ], ), h.div( - [h.Class("flex items-center gap-2 shrink-0 ml-2 py-1.5")], + [h.Class("flex items-center gap-2 shrink-0 ml-2")], [ h.div([h.Class("hidden w-48 @min-[560px]/row:block")], []), truncatedId(h, { @@ -346,49 +349,31 @@ const locationLabel = (h: HtmlBuilder, row: TreeRow): ReadonlyArray, span: SpanNodeData, httpInfo: HttpInfo | null): Html => { const cacheInfo = getCacheInfo({ ...span.spanAttributes }) if (cacheInfo?.result === "hit" || cacheInfo?.result === "miss") { return badge( h, - { - variant: "outline", - className: cn( - "text-3xs w-14 justify-center font-medium", - cacheResultStyles[cacheInfo.result], - ), - }, + { variant: "outline", className: cn(STATUS_CELL, cacheResultStyles[cacheInfo.result]) }, [cacheInfo.result === "hit" ? "HIT" : "MISS"], ) } const code = httpInfo?.statusCode ?? null if (code !== null && code !== 0) { - const tone = httpStatusTone(code) - return h.span( - [ - h.Class( - cn( - "w-14 text-center font-mono text-xs font-medium", - tone !== "neutral" - ? TONE_TEXT[tone] - : code >= 300 - ? "text-chart-p50" - : "text-severity-info", - ), - ), - ], - [String(code)], - ) + return traceStatusBadge(h, { + hasError: span.statusCode === "Error", + httpStatusCode: code, + className: STATUS_CELL, + }) } return badge( h, { - variant: "outline", - className: cn( - "text-3xs w-14 justify-center font-medium", - getSpanStatusBadgeClass(span.statusCode), - ), + variant: "secondary", + className: cn(STATUS_CELL, TONE_SOFT[severityTone(span.statusCode)]), }, [span.statusCode === "" ? "Unset" : span.statusCode], ) @@ -405,18 +390,17 @@ const rightSection = ( ctx.totalDurationMs > 0 ? ((spanStartMs(span) - traceStartMs) / ctx.totalDurationMs) * 100 : 0 const widthPercent = ctx.totalDurationMs > 0 ? (span.durationMs / ctx.totalDurationMs) * 100 : 0 const httpTone = httpStatusTone(httpInfo?.statusCode) + // Errors and HTTP warnings keep their tone; everything else takes the service colour, as on the Timeline tab. const barColor = - httpTone === "crit" - ? TONE_FILL.crit + httpTone === "crit" || span.statusCode === "Error" + ? TONE_COLOR.crit : httpTone === "warn" - ? TONE_FILL.warn - : span.statusCode === "Error" - ? TONE_FILL.crit - : "bg-primary" + ? TONE_COLOR.warn + : getServiceColor(span.serviceName) const platform = rowPlatform({ ...span.spanAttributes }) const outcome = Option.fromNullishOr(platform?.outcome).pipe(Option.filter((o) => o.bad)) return h.div( - [h.Class("flex items-center gap-2 shrink-0 ml-2 py-1.5")], + [h.Class("flex items-center gap-2 shrink-0 ml-2")], [ pixelBar(h, leftPercent, Math.max(widthPercent, 1), barColor), h.span( @@ -461,7 +445,7 @@ export const spanRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): [ h.Class( cn( - "@container/row group flex items-stretch hover:bg-surface-sunken active:bg-surface-sunken cursor-pointer px-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", + "@container/row group flex h-7 items-stretch hover:bg-surface-sunken active:bg-surface-sunken cursor-pointer px-2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", span.statusCode === "Error" && "bg-severity-error-a1", ctx.isSelected && "bg-primary-a1 shadow-accent-start", ), @@ -473,7 +457,7 @@ export const spanRow = (h: HtmlBuilder, row: TreeRow, ctx: RowContext): ], [ h.div( - [h.Class("@container flex items-center gap-2 flex-1 min-w-0 overflow-hidden py-1.5")], + [h.Class("@container flex items-center gap-2 flex-1 min-w-0 overflow-hidden")], [ ...treeGuides(h, row, ctx.isExpanded), toggle(h, span, ctx.isExpanded), diff --git a/apps/web/src/page/traces/detail/waterfall/view.ts b/apps/web/src/page/traces/detail/waterfall/view.ts index 397b154f43..e0f19675f4 100644 --- a/apps/web/src/page/traces/detail/waterfall/view.ts +++ b/apps/web/src/page/traces/detail/waterfall/view.ts @@ -1,6 +1,7 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { emptyMessage } from "../../../../ui/empty" +import { eyebrow } from "../../../../ui/eyebrow" import { depthControls } from "../depth-controls" import { Message, type Model } from "./model" import { spanRow } from "./row" @@ -53,9 +54,9 @@ const header = (h: HtmlBuilder, shown: number, total: number): Html => h.div( [h.Class("flex items-center gap-2 shrink-0 ml-2")], [ - h.span([h.Class("hidden w-48 text-center @min-[560px]/row:block")], ["Duration"]), - h.span([h.Class("w-16 text-right")], ["Time"]), - h.span([h.Class("w-14 text-center")], ["Status"]), + eyebrow(h, { className: "hidden w-48 @min-[560px]/row:block" }, ["Timeline"]), + eyebrow(h, { className: "w-16 text-right" }, ["Duration"]), + eyebrow(h, { className: "w-14 text-center" }, ["Status"]), ], ), ], diff --git a/apps/web/src/page/traces/detail/waterfall/virtualize.ts b/apps/web/src/page/traces/detail/waterfall/virtualize.ts index 40f299c0d7..a8d844e911 100644 --- a/apps/web/src/page/traces/detail/waterfall/virtualize.ts +++ b/apps/web/src/page/traces/detail/waterfall/virtualize.ts @@ -8,7 +8,7 @@ export const KEYS_ATTRIBUTE = "waterfall-keys" export const SELECTED_ATTRIBUTE = "waterfall-selected" /** Estimated row height; measured rows replace it. */ -export const ROW_HEIGHT = 33 +export const ROW_HEIGHT = 28 const OVERSCAN = 12 const MAX_SCROLL_ATTEMPTS = 15 diff --git a/apps/web/src/ui/trace-badges.ts b/apps/web/src/ui/trace-badges.ts index 64afe85889..348b2b5b5f 100644 --- a/apps/web/src/ui/trace-badges.ts +++ b/apps/web/src/ui/trace-badges.ts @@ -15,25 +15,32 @@ import { serviceDot } from "./values" /** The root's HTTP status toned by class (3xx keeps its own blue), else OK / Error. */ export const traceStatusBadge = ( h: HtmlBuilder, - options: Readonly<{ hasError: boolean; httpStatusCode?: number | null }>, + options: Readonly<{ hasError: boolean; httpStatusCode?: number | null; className?: string }>, ): Html => { const code = options.httpStatusCode if (code === undefined || code === null) { - return badge(h, { variant: "secondary", className: TONE_SOFT[options.hasError ? "crit" : "ok"] }, [ - options.hasError ? "Error" : "OK", - ]) + return badge( + h, + { + variant: "secondary", + className: cn(TONE_SOFT[options.hasError ? "crit" : "ok"], options.className), + }, + [options.hasError ? "Error" : "OK"], + ) } const tone = httpStatusTone(code) return badge( h, { variant: "secondary", - className: + className: cn( tone !== "neutral" ? TONE_SOFT[tone] : code >= 300 ? "bg-chart-p50/15 text-chart-p50" : TONE_SOFT.ok, + options.className, + ), }, [String(code)], )