diff --git a/apps/web/src/lab/charts/spike-timeseries.ts b/apps/web/src/lab/charts/spike-timeseries.ts index d1d02f0333..59e35501c1 100644 --- a/apps/web/src/lab/charts/spike-timeseries.ts +++ b/apps/web/src/lab/charts/spike-timeseries.ts @@ -15,6 +15,7 @@ import { scaleTime } from "d3-scale" import { Option, Array as Arr, String as Str } from "effect" import { ChartTooltip } from "@maple/views/charts/chart-spec" import type { BuiltChart } from "@maple/views/charts/definitions/built-chart" +import { gradientId, gradientUrl } from "@maple/views/charts/definitions/gradient-id" import { AREA_FILL_END, roundCapDasharray, @@ -191,18 +192,14 @@ export const buildAreaSpike = (spec: SpikeSpec): BuiltChart + faded ? gradientId("areaSpike", spec.arm, id, "faded") : gradientId("areaSpike", spec.arm, id) const opacityFor = (id: string) => (muted.has(id) ? MUTED_OPACITY : 1) const x = (d: TimeseriesSpikeRow) => d.date const definition = defineChart({ gradients: Arr.flatMap(series, (entry) => [ - verticalGradient(`${idPrefix}${entry.id}`, entry.color), - verticalGradient( - `${idPrefix}${entry.id}Faded`, - entry.color, - FADED_START_OPACITY, - FADED_END_OPACITY, - ), + verticalGradient(fillOf(entry.id, false), entry.color), + verticalGradient(fillOf(entry.id, true), entry.color, FADED_START_OPACITY, FADED_END_OPACITY), ]), marks: [ ...Arr.flatMap(series, (entry) => [ @@ -210,7 +207,7 @@ export const buildAreaSpike = (spec: SpikeSpec): BuiltChart = [ { spanName: "POST /v1/traces", @@ -174,6 +176,27 @@ export const LAB_ENDPOINTS: ReadonlyArray = [ requestBodyBytes: 14195, requestBodyCount: 34, }, + // The ingest router names unmatched requests `GET (unmatched)`; the parens once crashed the sparkline. + { + spanName: UNMATCHED, + method: "GET", + route: "(unmatched)", + spanCount: 412, + estimatedSpanCount: 8240, + errorCount: 412, + estimatedErrorCount: 8240, + errorRate: 1, + avgDurationMs: 0.21, + p50DurationMs: 0.12, + p95DurationMs: 0.6, + p99DurationMs: 1.4, + httpClassifiedSpanCount: 412, + httpStatusCount: 412, + http4xxCount: 412, + http5xxCount: 0, + requestBodyBytes: 0, + requestBodyCount: 0, + }, ] export const LAB_STATUS_ROWS: ReadonlyArray = [ @@ -312,7 +335,7 @@ export const LAB_STATUS_ROWS: ReadonlyArray = [ }, ] -export const LAB_ENDPOINT_SERIES: ReadonlyArray = [ +const RECORDED_SERIES: ReadonlyArray = [ { bucket: "2026-10-24T03:00:00.000Z", spanName: "POST /v1/traces", @@ -5723,6 +5746,15 @@ export const LAB_ENDPOINT_SERIES: ReadonlyArray = [ }, ] +/** The unmatched route over the `/v1/events` buckets, a few probes per bucket. */ +export const LAB_ENDPOINT_SERIES: ReadonlyArray = [ + ...RECORDED_SERIES, + ...Arr.map( + Arr.filter(RECORDED_SERIES, (row) => row.spanName === "POST /v1/events"), + (row, index) => ({ ...row, spanName: UNMATCHED, estimatedSpanCount: 120 + ((index * 37) % 90) }), + ), +] + export const LAB_API_OVERVIEW: ApiOverview = { totals: { spanCount: 234370, diff --git a/apps/web/src/page/dashboards/kit/tiles.test.ts b/apps/web/src/page/dashboards/kit/tiles.test.ts index 537f67e2ba..965d3e5298 100644 --- a/apps/web/src/page/dashboards/kit/tiles.test.ts +++ b/apps/web/src/page/dashboards/kit/tiles.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { EMPTY_VALUE } from "@maple/ui/lib/format" -import { listCellText } from "./tiles" +import { EMPTY_VALUE, formatValueByUnit } from "@maple/ui/lib/format" +import { formatCell, listCellText } from "./tiles" describe("listCellText", () => { it("formats object items instead of printing [object Object]", () => { @@ -19,3 +19,27 @@ describe("listCellText", () => { expect(listCellText(null)).toBe(EMPTY_VALUE) }) }) + +describe("formatCell", () => { + it("trims float noise from a unitless measure", () => { + expect(formatCell(0.1 + 0.2)).toBe("0.3") + expect(formatCell(1234567.891)).toBe("1,234,567.89") + expect(formatCell(0.004567891)).toBe("0.00457") + expect(formatCell(1234.5)).toBe("1234.5") + }) + + it("keeps small integers and every string exactly as they came", () => { + expect(formatCell(200)).toBe("200") + expect(formatCell("3.10")).toBe("3.10") + expect(formatCell("18446744073709551615")).toBe("18446744073709551615") + expect(formatCell("GET /users/{id}")).toBe("GET /users/{id}") + }) + + it("groups a large integer measure", () => { + expect(formatCell(4687400)).toBe("4,687,400") + }) + + it("still formats a cell that has a unit by its unit", () => { + expect(formatCell(0.25, "percent")).toBe(formatValueByUnit(0.25, "percent")) + }) +}) diff --git a/apps/web/src/page/dashboards/kit/tiles.ts b/apps/web/src/page/dashboards/kit/tiles.ts index bdd8a05492..2ed941d50f 100644 --- a/apps/web/src/page/dashboards/kit/tiles.ts +++ b/apps/web/src/page/dashboards/kit/tiles.ts @@ -90,11 +90,25 @@ const cellText = (value: unknown): string => ? String(value) : Formatter.format(value) -/** `formatCellValue`: units format numbers; unitless cells stay raw. */ +/** + * A unitless number, readable but exact: float noise trimmed (`0.1 + 0.2` is `0.3`), 10,000 and up + * grouped. Strings stay raw: ids, versions (`3.10`) and UInt64 counts arrive as strings. + */ +const plainNumber = (value: unknown): string | undefined => + typeof value === "number" && Number.isFinite(value) + ? value.toLocaleString("en-US", { + useGrouping: Math.abs(value) >= 10_000, + ...(Math.abs(value) < 1 && value !== 0 + ? { maximumSignificantDigits: 3 } + : { maximumFractionDigits: 2 }), + }) + : undefined + +/** `formatCellValue`: units format numbers; unitless measures are trimmed, other cells stay raw. */ export const formatCell = (value: unknown, unit?: string): string => { if (value === null || value === undefined) return EMPTY_VALUE const num = Number(value) - if (Number.isNaN(num) || unit === undefined || unit === "") return cellText(value) + if (Number.isNaN(num) || unit === undefined || unit === "") return plainNumber(value) ?? cellText(value) return formatValueByUnit(num, unit) } diff --git a/packages/views/src/charts/definitions/alert-rule.ts b/packages/views/src/charts/definitions/alert-rule.ts index bc66dfd987..623ee6599b 100644 --- a/packages/views/src/charts/definitions/alert-rule.ts +++ b/packages/views/src/charts/definitions/alert-rule.ts @@ -15,6 +15,7 @@ import { ChartTooltip, type ChartSpec } from "../chart-spec" import type { BuiltChart } from "./built-chart" import { roundCapDasharray, SERIES_STROKE_WIDTH } from "./fixed-metric-axes" import { cursorTooltip } from "./plot-constants" +import { gradientId as gradientIdOf, gradientUrl } from "./gradient-id" // The alert rule chart's definition. The view half is `apps/web/src/page/alerts/shared/rule-chart.ts`. @@ -66,7 +67,7 @@ export const buildAlertRule = (spec: AlertRuleSpec): BuiltChart seriesRuns(points, valueOf(key), at) const loneDot = (key: string, fill: string) => dot(runsOf(key).lone, { x: at, y: valueOf(key), r: 2.5, fill }) @@ -188,7 +189,7 @@ export const buildAlertRule = (spec: AlertRuleSpec): BuiltChart yDomain[0], - fill: `url(#${gradientId})`, + fill: gradientUrl(gradientId), stroke: "none", curve, }), diff --git a/packages/views/src/charts/definitions/analytics-traffic.ts b/packages/views/src/charts/definitions/analytics-traffic.ts index 8566e8e1a6..0f773e89ba 100644 --- a/packages/views/src/charts/definitions/analytics-traffic.ts +++ b/packages/views/src/charts/definitions/analytics-traffic.ts @@ -11,6 +11,7 @@ import { ChartTooltip, type ChartSpec } from "../chart-spec" import type { BuiltChart } from "./built-chart" import { verticalGradient } from "./fixed-metric-axes" import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" +import { gradientId, gradientUrl } from "./gradient-id" // The analytics traffic chart's definition. Colours arrive resolved in the spec; series arrive in paint order. @@ -52,7 +53,7 @@ export const buildAnalyticsTraffic = (spec: TrafficSpec): BuiltChart - verticalGradient(`${spec.gradientPrefix}-${entry.key}`, colorOf(entry.key)), + verticalGradient(gradientId(spec.gradientPrefix, entry.key), colorOf(entry.key)), ), marks: [ ...Arr.flatMap(spec.series, (entry) => { @@ -71,7 +72,7 @@ export const buildAnalyticsTraffic = (spec: TrafficSpec): BuiltChart 0, - fill: `url(#${spec.gradientPrefix}-${entry.key})`, + fill: gradientUrl(gradientId(spec.gradientPrefix, entry.key)), fillOpacity: 1, stroke: "none", curve, diff --git a/packages/views/src/charts/definitions/anomaly-signal.ts b/packages/views/src/charts/definitions/anomaly-signal.ts index 93b8ee77dc..dbf3a726bc 100644 --- a/packages/views/src/charts/definitions/anomaly-signal.ts +++ b/packages/views/src/charts/definitions/anomaly-signal.ts @@ -12,6 +12,7 @@ import { ChartTooltip, type ChartSpec } from "../chart-spec" import type { BuiltChart } from "./built-chart" import { SERIES_STROKE_WIDTH, verticalGradient } from "./fixed-metric-axes" import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" +import { gradientId, gradientUrl } from "./gradient-id" // The anomaly timeseries chart's definition. `thresholdRules` is copied below because // threshold-rules.ts reads the theme through a React module; colours arrive resolved. @@ -105,7 +106,7 @@ export const buildAnomalySignal = (spec: AnomalySignalSpec): BuiltChart yDomain[0], - fill: `url(#${spec.gradientId})`, + fill: gradientUrl(gradientId(spec.gradientId)), fillOpacity: 1, stroke: "none", curve: d3Curve(curveMonotoneX), diff --git a/packages/views/src/charts/definitions/area.ts b/packages/views/src/charts/definitions/area.ts index 0edb866f12..a30220990d 100644 --- a/packages/views/src/charts/definitions/area.ts +++ b/packages/views/src/charts/definitions/area.ts @@ -30,6 +30,7 @@ import { verticalGradient, } from "./fixed-metric-axes" import { cursorTooltip, UNBOUNDED_FOCUS_DISTANCE } from "./plot-constants" +import { gradientId, gradientUrl } from "./gradient-id" // Port of `QueryBuilderAreaChart` and `QueryBuilderLineChart` (packages/ui/src/components/charts/area/, // line/), definition half: bands or lines, stacked or not, linear or monotone. @@ -128,7 +129,7 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart - `${spec.gradientPrefix}-${key}${partial ? "-partial" : ""}` + partial ? gradientId(spec.gradientPrefix, key, "partial") : gradientId(spec.gradientPrefix, key) const hasDashed = dashed.length > 0 const fillOpacityFor = (partial: boolean) => isStacked ? (partial ? PARTIAL_STACKED_FILL_OPACITY : STACKED_FILL_OPACITY) : 1 @@ -139,7 +140,7 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart row.date, y: (row: TimeseriesRow) => topOf(row, entry.key), y1: (row: TimeseriesRow) => baseOf(row, entry.key), - fill: isStacked ? entry.color : `url(#${gradientIdFor(entry.key, partial)})`, + fill: isStacked ? entry.color : gradientUrl(gradientIdFor(entry.key, partial)), fillOpacity: fillOpacityFor(partial), stroke: "none", curve, diff --git a/packages/views/src/charts/definitions/bench-overview.ts b/packages/views/src/charts/definitions/bench-overview.ts index 3724099679..e8dc89ad65 100644 --- a/packages/views/src/charts/definitions/bench-overview.ts +++ b/packages/views/src/charts/definitions/bench-overview.ts @@ -18,6 +18,7 @@ import type { BuiltChart } from "./built-chart" import { verticalGradient } from "./fixed-metric-axes" import { cursorTooltip } from "./plot-constants" import { nonZeroOr } from "@maple/ui/lib/number" +import { gradientId, gradientUrl } from "./gradient-id" // The `/lab/bench/tanstack` chart definitions: throughput area, error rate area and latency line. @@ -171,7 +172,7 @@ export const buildBenchOverview = (spec: BenchSpec): BuiltChart 0 - const gradientId = `${spec.gradientPrefix}-fill` - const fadedGradientId = `${spec.gradientPrefix}-fill-partial` + const fillId = gradientId(spec.gradientPrefix, "fill") + const fadedFillId = gradientId(spec.gradientPrefix, "fill", "partial") const partialDash = roundCapDasharray(4, 4, STROKE_WIDTH) const errorDash = roundCapDasharray(3, 3, 1.5) const tracedDash = roundCapDasharray(4, 4, 1) - const band = (slice: ReadonlyArray, fill: string, partial: boolean) => + const band = (slice: ReadonlyArray, fill: GradientId, partial: boolean) => areaY(slice, { id: partial ? "throughput-area-partial" : "throughput-area", x: at, y: valueOf(THROUGHPUT_KEY), y1: () => 0, - fill: `url(#${fill})`, + fill: gradientUrl(fill), fillOpacity: 1, curve, stroke: "none", @@ -172,14 +173,14 @@ const throughputChart = (spec: FixedMetricSpec): BuiltChart, fill: string) => + const band = (slice: ReadonlyArray, fill: GradientId) => areaY(slice, { - id: fill === gradientId ? "error-rate-area" : "error-rate-area-partial", + id: fill === fillId ? "error-rate-area" : "error-rate-area-partial", x: at, y: value, y1: () => 0, - fill: `url(#${fill})`, + fill: gradientUrl(fill), fillOpacity: 1, curve, stroke: "none", @@ -288,14 +289,12 @@ const errorRateChart = (spec: FixedMetricSpec): BuiltChart, fill: string) => + const band = (slice: ReadonlyArray, fill: GradientId) => areaY(slice, { - id: fill === gradientId ? "apdex-area" : "apdex-area-partial", + id: fill === fillId ? "apdex-area" : "apdex-area-partial", x: at, y: value, y1: () => 0, - fill: `url(#${fill})`, + fill: gradientUrl(fill), fillOpacity: 1, curve, stroke: "none", @@ -453,14 +452,12 @@ const apdexChart = (spec: FixedMetricSpec): BuiltChart { + const { definition } = buildSparkline({ + _tag: "Sparkline", + values: [1, 4, 2, 5], + color: "#0af", + gradientId: `spark-${chartId}`, + fillStart: 0.5, + fillEnd: 0.1, + revision: 0, + curve: "linear", + }) + // The sparkline is a static definition; a responsive one has a `chart` builder instead. + if ("chart" in definition) return expect.unreachable("sparkline definition is responsive") + return createChartScene(definition, { width: 120, height: 32 }) +} + +/** Every `url(#...)` paint in the markup, the way the canvas painter parses it. */ +const paintRefs = (markup: string) => + Arr.map(Arr.fromIterable(Str.matchAll(/url\(#([^)]+)\)/g)(markup)), (match) => match[1]) + +describe("gradient ids", () => { + test("ids built from caller text only use characters both renderers keep", () => { + for (const raw of NASTY) expect(gradientId("spark", raw)).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + test("distinct caller ids never collapse onto the same gradient", () => { + const ids = Arr.map(NASTY, (raw) => gradientId("spark", raw)) + expect(new Set(ids).size).toBe(NASTY.length) + }) + + test("an id that is already safe is kept as is", () => { + expect(gradientId("qbArea", "chart-1", "partial")).toBe("qbArea-chart-1-partial") + }) + + test.each(NASTY)("a sparkline for %j paints its fill from a gradient the scene defines", (chartId) => { + const scene = sparkScene(chartId) + const defined = new Set(Arr.map(scene.gradients, (gradient) => gradient.id)) + const svg = renderChartSvg(scene, { ariaLabel: "Trend", idPrefix: "t" }) + const refs = paintRefs(svg) + expect(refs.length).toBeGreaterThan(0) + for (const ref of refs) expect(svg).toContain(`id="${ref}"`) + for (const gradient of defined) expect(`url(#${gradient})`).toMatch(/^url\(#([^)]+)\)$/) + }) +}) diff --git a/packages/views/src/charts/definitions/gradient-id.ts b/packages/views/src/charts/definitions/gradient-id.ts new file mode 100644 index 0000000000..c973f35ea7 --- /dev/null +++ b/packages/views/src/charts/definitions/gradient-id.ts @@ -0,0 +1,19 @@ +import { Array as Arr, Brand, Hash, String as Str } from "effect" + +/** + * A gradient id both renderers can resolve. The canvas painter matches `url\(#([^)]+)\)` and + * parses anything else as a CSS colour (throwing on `GET (unmatched)`), and the SVG renderer + * strips non `[A-Za-z0-9_-]` characters, so caller text (routes, series names) goes through here. + */ +export type GradientId = string & Brand.Brand<"GradientId"> + +const brand = Brand.nominal() + +/** Joins the parts with `-`; unsafe characters become `_` plus a hash, so distinct inputs stay distinct. */ +export const gradientId = (...parts: ReadonlyArray): GradientId => { + const raw = Arr.join(parts, "-") + const safe = Str.replace(/[^A-Za-z0-9_-]+/g, "_")(raw) + return brand(safe === raw ? raw : `${safe}-${(Hash.string(raw) >>> 0).toString(36)}`) +} + +export const gradientUrl = (id: GradientId): string => `url(#${id})` diff --git a/packages/views/src/charts/definitions/infra-metric.ts b/packages/views/src/charts/definitions/infra-metric.ts index ff30d8c512..dcfcd22d74 100644 --- a/packages/views/src/charts/definitions/infra-metric.ts +++ b/packages/views/src/charts/definitions/infra-metric.ts @@ -32,6 +32,7 @@ import type { BuiltChart } from "./built-chart" import { SERIES_STROKE_WIDTH, verticalGradient } from "./fixed-metric-axes" import { chartEventMarkerMarks, placeMarkersInWindow } from "./event-markers" import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" +import { gradientId, gradientUrl } from "./gradient-id" // The infra metric chart's definition. Colours arrive resolved in the spec. @@ -145,7 +146,7 @@ export const buildInfraMetric = (spec: InfraMetricSpec): BuiltChart `${spec.gradientPrefix}-${Str.replace(/\W+/g, "_")(name)}` + const gradientFor = (name: string) => gradientId(spec.gradientPrefix, name) const curve = d3Curve(curveMonotoneX) const shareScale = (point: InfraPoint) => { const sum = Arr.reduce(series, 0, (total, name) => total + (valueOf(name)(point) ?? 0)) @@ -182,7 +183,7 @@ export const buildInfraMetric = (spec: InfraMetricSpec): BuiltChart cell.point.date, y: (cell: InfraCell) => cell.value, z: (cell: InfraCell) => cell.name, - fill: (cell: InfraCell) => `url(#${gradientFor(cell.name)})`, + fill: (cell: InfraCell) => gradientUrl(gradientFor(cell.name)), stroke: (cell: InfraCell) => colorOf(cell.name), strokeWidth: STROKE_WIDTH, curve, diff --git a/packages/views/src/charts/definitions/sparkline.ts b/packages/views/src/charts/definitions/sparkline.ts index 72400a209a..8680055b1a 100644 --- a/packages/views/src/charts/definitions/sparkline.ts +++ b/packages/views/src/charts/definitions/sparkline.ts @@ -4,6 +4,7 @@ import { curveMonotoneX } from "d3-shape" import { Option, Array as Arr } from "effect" import type { ChartSpec } from "../chart-spec" import type { BuiltChart } from "./built-chart" +import { gradientId, gradientUrl, type GradientId } from "./gradient-id" // Port of `PlotSparkline` (packages/ui/src/components/plot/sparkline.tsx), definition half. @@ -18,7 +19,7 @@ const STROKE_WIDTH = 1.5 // copied: `verticalGradient` (plot-paint.ts imports React's `useId`). const verticalGradient = ( - id: string, + id: GradientId, color: string, startOpacity: number, endOpacity: number, @@ -40,13 +41,13 @@ export const buildSparkline = (spec: SparklineSpec): BuiltChart point.value const interpolation = spec.curve === "monotone" ? d3Curve(curveMonotoneX) : undefined const definition = defineChart({ - gradients: [verticalGradient(spec.gradientId, spec.color, spec.fillStart, spec.fillEnd)], + gradients: [verticalGradient(gradientId(spec.gradientId), spec.color, spec.fillStart, spec.fillEnd)], marks: [ areaY(points, { x: at, y: value, y1: () => 0, - fill: `url(#${spec.gradientId})`, + fill: gradientUrl(gradientId(spec.gradientId)), stroke: "none", curve: interpolation, }), diff --git a/packages/views/src/charts/definitions/stacked-breakdown.ts b/packages/views/src/charts/definitions/stacked-breakdown.ts index 41e647ea54..a994c94aca 100644 --- a/packages/views/src/charts/definitions/stacked-breakdown.ts +++ b/packages/views/src/charts/definitions/stacked-breakdown.ts @@ -6,12 +6,13 @@ import { DASHED_Y_GRID } from "@maple/ui/components/plot/plot-grid" import { linearYDomain, niceLinearDomain } from "@maple/ui/components/plot/plot-scales" import { formatValueByUnit } from "@maple/ui/lib/format" import { curveMonotoneX } from "d3-shape" -import { Array as Arr, Option, Order, String as Str } from "effect" +import { Array as Arr, Option, Order } from "effect" import { ChartTooltip, type ChartSpec } from "../chart-spec" import { type InfraPoint, transformRows } from "../infra-metric-series" import type { BuiltChart } from "./built-chart" import { verticalGradient } from "./fixed-metric-axes" import { cursorTooltip, tooltipFocusCapture } from "./plot-constants" +import { gradientId, gradientUrl } from "./gradient-id" // The Cloudflare zone detail's stacked breakdown chart definition. @@ -41,7 +42,7 @@ export const buildStackedBreakdown = ( const yDomain = niceLinearDomain(linearYDomain({ rows: data, keys: series, stacked: true })) const chrome = { border: spec.colors.border ?? "", background: spec.colors.background ?? "" } const colorOf = (name: string) => spec.colors[name] ?? chrome.border - const gradientFor = (name: string) => `${spec.gradientPrefix}-${Str.replace(/\W+/g, "_")(name)}` + const gradientFor = (name: string) => gradientId(spec.gradientPrefix, name) const cells: ReadonlyArray = Arr.flatMap(data, (row) => Arr.reduce(series, [], (stacked: ReadonlyArray, name) => { const bottom = stacked.at(-1)?.top ?? 0 @@ -75,7 +76,7 @@ export const buildStackedBreakdown = ( y: (cell: BreakdownCell) => cell.top, y1: (cell: BreakdownCell) => cell.bottom, z: (cell: BreakdownCell) => cell.name, - fill: (cell: BreakdownCell) => `url(#${gradientFor(cell.name)})`, + fill: (cell: BreakdownCell) => gradientUrl(gradientFor(cell.name)), stroke: "none", curve, }),