From f8d5946962076c98bcf9d7968a93eaae71820001 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 24 Jul 2026 02:15:27 +0200 Subject: [PATCH 1/2] feat: color latency percentiles by magnitude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P50/P95/P99 rendered as flat, uncolored mono text everywhere — services table, operations/dependencies tabs, service map, Cloudflare/PlanetScale infra tables, chat renderers. Every cell in a percentile column looked identical, so finding the slow service in a 40-row table meant reading every number. Error rate already had per-site tone helpers; latency had none. Adds one shared scale in @maple/ui and applies it to 17 call sites. Tone is value ÷ budget(scale), with per-percentile budgets (p50 300ms, avg 500ms, p95 1s, p99 2s, cpu 50ms) rather than one absolute threshold — p99 is *expected* to exceed p50, so a single threshold would paint every p99 column red. A healthy service now reads flat across all three. The p95 budget's slow/critical boundaries land exactly on P95_DEGRADED_MS / P95_UNHEALTHY_MS from service-health.ts, so a cell tone cannot disagree with the health badge on its own row. `cpu` exists because Cloudflare Worker CPU time is ~20x tighter than wall-clock; on the p99 budget every worker collapsed to the quiet step. The ramp is warm-only — nothing is colored for being fine, so fast values recede and the eye lands on outliers: muted → foreground/70 → foreground → severity-warn → severity-error It reuses existing tokens only; no new design tokens, check:tokens still passes. The quiet step is plain `text-muted-foreground` rather than a dimmer opacity variant: eyebrow labels beside these values already sit at `text-muted-foreground/60`, and a value fainter than its own label reads as the label instead of the number. Where a stronger signal already exists it wins, and the ramp is only the fallback: an open incident/anomaly in service-health-section, and the "p95 > 3x this node's own p50" tail check in the service map. Also deletes the third private copy of formatLatency (services-table.tsx) and routes web's percentile cells onto the @maple/ui formatter, which continues past seconds — a 90s p99 now renders 1.5min, not 90.00s. Verified: typecheck green across all 32 packages; 10 new boundary tests covering every level per scale, the service-health agreement, the budgetMs override and the 0/NaN/Infinity guard; ramp confirmed monotonic and legible in both light and dark via getComputedStyle against the live stylesheet. Not done: no authenticated pass over the live UI — local sign-in needs a password, so these tones have not been seen against real warehouse data. /services and a service's Operations tab are the views to eyeball. Co-Authored-By: Claude Opus 4.8 --- .../src/views/service-detail-view.tsx | 72 ++++++++---- .../local-ui/src/views/services-list-view.tsx | 24 ++-- .../ai-elements/inline/inline-service.tsx | 5 +- .../renderers/components/service-table.tsx | 15 +-- .../components/system-health-card.tsx | 13 ++- .../renderers/components/trace-list.tsx | 9 +- .../dashboard/service-health-section.tsx | 14 ++- .../cloudflare/cloudflare-worker-table.tsx | 13 ++- .../cloudflare/cloudflare-zone-table.tsx | 28 ++++- .../planetscale/planetscale-top-queries.tsx | 11 +- .../service-map/service-map-node.tsx | 41 +++++-- .../service-map/service-map-view.tsx | 59 ++++++++-- .../components/services/dependency-table.tsx | 27 +++-- .../services/service-dependencies-tab.tsx | 23 +++- .../services/service-operations-tab.tsx | 56 ++++++--- .../services/service-top-operations-panel.tsx | 4 +- .../components/services/services-table.tsx | 62 +++++----- apps/web/src/lib/latency-tone.test.ts | 95 ++++++++++++++++ packages/ui/src/components/latency-value.tsx | 37 ++++++ packages/ui/src/lib/latency-tone.ts | 107 ++++++++++++++++++ 20 files changed, 563 insertions(+), 152 deletions(-) create mode 100644 apps/web/src/lib/latency-tone.test.ts create mode 100644 packages/ui/src/components/latency-value.tsx create mode 100644 packages/ui/src/lib/latency-tone.ts diff --git a/apps/local-ui/src/views/service-detail-view.tsx b/apps/local-ui/src/views/service-detail-view.tsx index 69393995c..b0092bdb9 100644 --- a/apps/local-ui/src/views/service-detail-view.tsx +++ b/apps/local-ui/src/views/service-detail-view.tsx @@ -2,16 +2,11 @@ import { useMemo } from "react" import { ArrowLeftIcon } from "@maple/ui/components/icons" import { Badge } from "@maple/ui/components/ui/badge" import { Button } from "@maple/ui/components/ui/button" +import { LatencyValue } from "@maple/ui/components/latency-value" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { ServiceDot } from "@maple/ui/components/service-dot" import { Spinner } from "@maple/ui/components/ui/spinner" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@maple/ui/components/ui/table" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { QueryBuilderLineChart } from "@maple/ui/components/charts/line/query-builder-line-chart" import { formatDuration, formatNumber } from "@maple/ui/format" import { cn } from "@maple/ui/utils" @@ -95,11 +90,7 @@ export function ServiceDetailView({ serviceName, onBack }: ServiceDetailViewProp ) : overview.isError ? ( - overview.refetch()} - /> + overview.refetch()} /> ) : !stats ? ( 0.05} /> - - - + + +
@@ -195,19 +198,21 @@ export function ServiceDetailView({ serviceName, onBack }: ServiceDetailViewProp op.errorCount > 0 && "text-destructive", )} > - {formatNumber(op.estimatedErrorCount || op.errorCount)} + {formatNumber( + op.estimatedErrorCount || op.errorCount, + )} {(op.errorRate * 100).toFixed(1)}% - - {formatDuration(op.avgDurationMs)} + + - - {formatDuration(op.p50DurationMs)} + + - - {formatDuration(op.p95DurationMs)} + + ))} @@ -223,13 +228,30 @@ export function ServiceDetailView({ serviceName, onBack }: ServiceDetailViewProp ) } -function StatCard({ label, value, danger }: { label: string; value: string; danger?: boolean }) { +function StatCard({ + label, + value, + danger, + valueClassName, +}: { + label: string + value: string + danger?: boolean + /** Applied after `danger`, so it wins — carries the latency magnitude ramp. */ + valueClassName?: string +}) { return (
{label}
-
+
{value}
diff --git a/apps/local-ui/src/views/services-list-view.tsx b/apps/local-ui/src/views/services-list-view.tsx index 8dd38c143..f26372046 100644 --- a/apps/local-ui/src/views/services-list-view.tsx +++ b/apps/local-ui/src/views/services-list-view.tsx @@ -1,14 +1,8 @@ import { DatabaseIcon } from "@maple/ui/components/icons" +import { LatencyValue } from "@maple/ui/components/latency-value" import { ServiceDot } from "@maple/ui/components/service-dot" -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@maple/ui/components/ui/table" -import { formatDuration, formatNumber } from "@maple/ui/format" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" +import { formatNumber } from "@maple/ui/format" import { cn } from "@maple/ui/utils" import { SearchableFilterSection } from "@maple/ui/components/filters/filter-section" import { @@ -148,9 +142,7 @@ function ServiceRow({ entry, onSelect }: { entry: ServiceCatalogEntry; onSelect: {formatNumber(entry.spanCount)} - 0 && "text-destructive")} - > + 0 && "text-destructive")}> {formatNumber(entry.errorCount)} {(entry.errorRate * 100).toFixed(1)}% - {formatDuration(entry.p50LatencyMs)} - {formatDuration(entry.p95LatencyMs)} + + + + + + {formatNumber(entry.logCount)} ) diff --git a/apps/web/src/components/ai-elements/inline/inline-service.tsx b/apps/web/src/components/ai-elements/inline/inline-service.tsx index 4faf18559..f3613e1a8 100644 --- a/apps/web/src/components/ai-elements/inline/inline-service.tsx +++ b/apps/web/src/components/ai-elements/inline/inline-service.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import { cn } from "@maple/ui/utils" -import { formatDuration, formatErrorRate, formatNumber } from "@/lib/format" +import { LatencyValue } from "@maple/ui/components/latency-value" +import { formatErrorRate, formatNumber } from "@/lib/format" import type { InlineServiceData } from "./types" export function InlineService({ data }: { data: InlineServiceData }) { @@ -30,7 +31,7 @@ export function InlineService({ data }: { data: InlineServiceData }) { )} {data.p99Ms != null && ( - P99: {formatDuration(data.p99Ms)} + P99: )} diff --git a/apps/web/src/components/ai-elements/renderers/components/service-table.tsx b/apps/web/src/components/ai-elements/renderers/components/service-table.tsx index 6d6d3c680..a9efc0aa1 100644 --- a/apps/web/src/components/ai-elements/renderers/components/service-table.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/service-table.tsx @@ -1,6 +1,7 @@ import type { BaseComponentProps } from "@json-render/react" import { cn } from "@maple/ui/utils" -import { formatDuration, formatErrorRate, formatNumber } from "@/lib/format" +import { LatencyValue } from "@maple/ui/components/latency-value" +import { formatErrorRate, formatNumber } from "@/lib/format" interface ServiceTableProps { services: Array<{ @@ -56,14 +57,14 @@ export function ServiceTable({ props }: BaseComponentProps) { > {formatErrorRate(svc.errorRate)} - - {formatDuration(svc.p50Ms)} + + - - {formatDuration(svc.p95Ms)} + + - - {formatDuration(svc.p99Ms)} + + ))} diff --git a/apps/web/src/components/ai-elements/renderers/components/system-health-card.tsx b/apps/web/src/components/ai-elements/renderers/components/system-health-card.tsx index dbfff1c44..f4a4bd671 100644 --- a/apps/web/src/components/ai-elements/renderers/components/system-health-card.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/system-health-card.tsx @@ -1,5 +1,6 @@ import type { BaseComponentProps } from "@json-render/react" import { cn } from "@maple/ui/utils" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { formatDuration, formatErrorRate, formatNumber } from "@/lib/format" interface SystemHealthCardProps { @@ -49,8 +50,16 @@ export function SystemHealthCard({ props }: BaseComponentProps - - + +
{topErrors.length > 0 && (
diff --git a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx index 7eb92ba6d..d20cef9c4 100644 --- a/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx +++ b/apps/web/src/components/ai-elements/renderers/components/trace-list.tsx @@ -1,5 +1,6 @@ import type { BaseComponentProps } from "@json-render/react" import { cn } from "@maple/ui/utils" +import { LatencyValue } from "@maple/ui/components/latency-value" import { formatDuration } from "@/lib/format" import { HttpSpanLabel } from "@maple/ui/components/traces/http-span-label" @@ -29,8 +30,12 @@ export function TraceList({ props }: BaseComponentProps) {
{stats && (
- P50: {formatDuration(stats.p50Ms)} - P95: {formatDuration(stats.p95Ms)} + + P50: + + + P95: + Min: {formatDuration(stats.minMs)} Max: {formatDuration(stats.maxMs)}
diff --git a/apps/web/src/components/dashboard/service-health-section.tsx b/apps/web/src/components/dashboard/service-health-section.tsx index 220f18fa9..ee7ee65c2 100644 --- a/apps/web/src/components/dashboard/service-health-section.tsx +++ b/apps/web/src/components/dashboard/service-health-section.tsx @@ -19,6 +19,7 @@ import { Card } from "@maple/ui/components/ui/card" import { Badge } from "@maple/ui/components/ui/badge" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { formatErrorRate, formatLatency } from "@maple/ui/lib/format" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { cn } from "@maple/ui/utils" import { @@ -419,6 +420,14 @@ function ServiceHealthRow({ label="p95" value={formatLatency(service.p95LatencyMs)} tone={metricTone(latencyCause)} + // An open incident/anomaly is a stronger signal than raw + // magnitude, so it keeps the tone. Without one, fall back to + // the shared magnitude ramp. + valueClassName={ + latencyCause === undefined + ? latencyToneClass(service.p95LatencyMs, "p95") + : undefined + } /> - {value} + {value} {label}
) diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx index 7d68f6dc4..8fb6f9858 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-worker-table.tsx @@ -1,7 +1,8 @@ import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { LatencyValue } from "@maple/ui/components/latency-value" import type { CloudflareWorkerRow } from "@/api/warehouse/cloudflare-infra" -import { formatLatency, formatNumber } from "@/lib/format" +import { formatNumber } from "@/lib/format" import { ColumnHead, TableShell, TableSkeleton, useTableSort } from "../primitives/data-table" import { formatPercent } from "../format" import { errorRateClass } from "./constants" @@ -152,11 +153,13 @@ export function CloudflareWorkerTable({ workers, waiting }: CloudflareWorkerTabl
{formatNumber(worker.subrequests)}
-
- {formatLatency(worker.cpuP99Ms)} +
+ {/* Worker CPU time runs ~20x tighter than wall-clock latency, + hence the dedicated "cpu" budget. */} +
-
- {formatLatency(worker.durationP99Ms)} +
+
))} diff --git a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx index 5f86cc94c..d89487b66 100644 --- a/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx +++ b/apps/web/src/components/infra/cloudflare/cloudflare-zone-table.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router" import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { LatencyValue } from "@maple/ui/components/latency-value" import type { CloudflareZoneRow } from "@/api/warehouse/cloudflare-infra" import { formatLatency, formatNumber } from "@/lib/format" @@ -193,12 +194,29 @@ export function CloudflareZoneTable({ zones, waiting }: CloudflareZoneTableProps
{formatNumber(zone.visits)}
-
- {formatOptionalLatency(zone.ttfbP50Ms)} +
+
- {numCell(formatOptionalLatency(zone.ttfbP99Ms))} -
- {formatOptionalLatency(zone.originP99Ms)} +
+ +
+
+
))} diff --git a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx index 6b0cada7d..4ed69a7da 100644 --- a/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx +++ b/apps/web/src/components/infra/planetscale/planetscale-top-queries.tsx @@ -2,11 +2,12 @@ import { useMemo } from "react" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { cn } from "@maple/ui/lib/utils" +import { LatencyValue } from "@maple/ui/components/latency-value" import { Result } from "@/lib/effect-atom" import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value" import { planetscaleQueryInsightsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { formatLatency, formatNumber, formatRelativeTime } from "@/lib/format" +import { formatNumber, formatRelativeTime } from "@/lib/format" /** Warehouse "YYYY-MM-DD HH:mm:ss" → epoch ms (values are UTC). */ const warehouseTimeToMs = (value: string): number => new Date(`${value.replace(" ", "T")}Z`).getTime() @@ -90,14 +91,12 @@ export function PlanetScaleTopQueries({
+ {formatNumber(row.queryCount)} calls - {formatNumber(row.queryCount)} calls + p50 - p50 {formatLatency(row.p50LatencyMillis)} - - - p99 {formatLatency(row.p99LatencyMillis)} + p99 {formatNumber(row.rowsReadPerQuery)} rows read/query diff --git a/apps/web/src/components/service-map/service-map-node.tsx b/apps/web/src/components/service-map/service-map-node.tsx index 4d03a8bf3..87412cbb7 100644 --- a/apps/web/src/components/service-map/service-map-node.tsx +++ b/apps/web/src/components/service-map/service-map-node.tsx @@ -1,6 +1,7 @@ import { memo } from "react" import { Handle, Position } from "@xyflow/react" import { cn } from "@maple/ui/utils" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { Tooltip, TooltipTrigger, TooltipContent } from "@maple/ui/components/ui/tooltip" import { AwsLambdaIcon, @@ -132,8 +133,16 @@ const Handles = () => ( * infrastructure dependencies stand out from application services on the map. */ function DatabaseNode({ data }: { data: ServiceNodeData }) { - const { throughput, errorRate, avgLatencyMs, p95LatencyMs, dbSystem, dbNamespace, selected, planetscale } = - data + const { + throughput, + errorRate, + avgLatencyMs, + p95LatencyMs, + dbSystem, + dbNamespace, + selected, + planetscale, + } = data // Named databases show their identity as the title; the system name takes over // the small badge slot (the generic node keeps the coarse category). Databases // behind Cloudflare Hyperdrive collapse to a single "Hyperdrive"-branded node; @@ -192,8 +201,16 @@ function DatabaseNode({ data }: { data: ServiceNodeData }) { value={`${(errorRate * 100).toFixed(1)}%`} valueClassName={errorRateClass(errorRate)} /> - - + +
{/* PlanetScale live health (scraped branch metrics, window rollup) */} @@ -322,7 +339,11 @@ function ServiceNode({ data }: { data: ServiceNodeData }) { valueClassName={errorRateClass(errorRate)} /> - + {/* Pods badge — empty placeholder when no infra so widths stay stable */}
@@ -406,7 +427,11 @@ function NamespaceAggregateNode({ data }: { data: ServiceNodeData }) { value={`${(errorRate * 100).toFixed(1)}%`} valueClassName={errorRateClass(errorRate)} /> - +
@@ -428,7 +453,5 @@ export const ServiceMapNode = memo(function ServiceMapNode({ data }: ServiceMapN ) // Focus dim-mode: fade non-neighbors without moving them. - return ( -
{card}
- ) + return
{card}
}) diff --git a/apps/web/src/components/service-map/service-map-view.tsx b/apps/web/src/components/service-map/service-map-view.tsx index 4f2664419..1f37d1a6e 100644 --- a/apps/web/src/components/service-map/service-map-view.tsx +++ b/apps/web/src/components/service-map/service-map-view.tsx @@ -25,6 +25,7 @@ import { Bar, BarChart, CartesianGrid, Line, XAxis, YAxis } from "recharts" import { cn } from "@maple/ui/utils" import { getServiceColor, getValueHue } from "@maple/ui/colors" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { ChartContainer, ChartTooltip, @@ -326,7 +327,12 @@ function ServiceDetailPanel({
Avg Latency -

+

{formatLatency(avgLatencyMs)}

@@ -335,9 +341,12 @@ function ServiceDetailPanel({

avgLatencyMs * 3 ? "text-severity-warn" - : "text-foreground", + : latencyToneClass(p95LatencyMs, "p95"), )} > {formatLatency(p95LatencyMs)} @@ -387,7 +396,12 @@ function ServiceDetailPanel({

CPU p99 -

+

{formatLatency(cloudflare.cpuP99Ms ?? 0)}

@@ -395,7 +409,12 @@ function ServiceDetailPanel({ Duration p99 -

+

{formatLatency(cloudflare.latencyP99Ms)}

@@ -1349,7 +1368,12 @@ function DatabaseDetailPanel({
P50 Latency -

+

{formatLatency(metricP50LatencyMs)}

@@ -1358,9 +1382,11 @@ function DatabaseDetailPanel({

metricP50LatencyMs * 3 ? "text-severity-warn" - : "text-foreground", + : latencyToneClass(metricP95LatencyMs, "p95"), )} > {formatLatency(metricP95LatencyMs)} @@ -1368,7 +1394,12 @@ function DatabaseDetailPanel({

Avg Latency -

+

{formatLatency(metricAvgLatencyMs)}

@@ -1444,10 +1475,20 @@ function DatabaseDetailPanel({ {formatCompactCount(query.estimatedQueryCount)} calls - p50 {formatLatency(query.p50DurationMs)} + p50{" "} + + {formatLatency(query.p50DurationMs)} + - p95 {formatLatency(query.p95DurationMs)} + p95{" "} + + {formatLatency(query.p95DurationMs)} + {query.serviceCount > 1 diff --git a/apps/web/src/components/services/dependency-table.tsx b/apps/web/src/components/services/dependency-table.tsx index bad8466ce..867e21c94 100644 --- a/apps/web/src/components/services/dependency-table.tsx +++ b/apps/web/src/components/services/dependency-table.tsx @@ -4,9 +4,9 @@ import { cn } from "@maple/ui/utils" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Tooltip, TooltipContent, TooltipTrigger } from "@maple/ui/components/ui/tooltip" import { ChevronDownIcon, ChevronUpIcon, ChevronExpandYIcon } from "@/components/icons" -import { formatLatency } from "@/lib/format" import { DependencyTypeBadge, type DependencyKind } from "./dependency-type-badge" import { ServiceDot } from "@maple/ui/components/service-dot" +import { LatencyValue } from "@maple/ui/components/latency-value" export interface DependencyRow { id: string @@ -167,7 +167,10 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre
{row.kind === "service" && ( - + )} {row.name} @@ -225,9 +228,11 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre - - {formatLatency(row.avgDurationMs)} - + - - {formatLatency(row.p95DurationMs)} - + ) @@ -336,9 +343,7 @@ export function DependencyTable({ serviceName, rows, startTime, endTime, timePre p95 - - {formatLatency(row.p95DurationMs)} - +
diff --git a/apps/web/src/components/services/service-dependencies-tab.tsx b/apps/web/src/components/services/service-dependencies-tab.tsx index 8ad7e321d..72a30a4e0 100644 --- a/apps/web/src/components/services/service-dependencies-tab.tsx +++ b/apps/web/src/components/services/service-dependencies-tab.tsx @@ -4,6 +4,7 @@ import { Result } from "@/lib/effect-atom" import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { getServiceDependenciesBundleResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" import { toSingleDeploymentEnv } from "@/lib/services/environments" +import { latencyToneClass } from "@maple/ui/lib/latency-tone" import { formatLatency } from "@/lib/format" import { normalizeTimestampInput } from "@/lib/timezone-format" import { DependencyTable, type DependencyRow } from "./dependency-table" @@ -82,6 +83,10 @@ export function ServiceDependenciesTab({ const e = new Date(normalizeTimestampInput(effectiveEndTime)).getTime() return s > 0 && e > 0 ? Math.max((e - s) / 1000, 1) : 3600 }, [effectiveStartTime, effectiveEndTime]) + const traceDetailLimited = durationSeconds > 30 * 24 * 60 * 60 + const traceDetailStartTime = traceDetailLimited + ? new Date(Date.parse(effectiveEndTime) - 30 * 24 * 60 * 60 * 1000).toISOString() + : startTime const rows = useMemo(() => { const out: DependencyRow[] = [] @@ -300,17 +305,24 @@ export function ServiceDependenciesTab({ label="Slowest p95" name={summary.topByLatency.name} value={formatLatency(summary.topByLatency.p95DurationMs)} + valueClassName={latencyToneClass(summary.topByLatency.p95DurationMs, "p95")} /> ) : null} + {traceDetailLimited && ( +

+ Dependency summaries cover the selected range; individual trace drill-downs show the + latest 30 days. +

+ )} ) @@ -321,9 +333,11 @@ interface HeadlineFactProps { name: string value: string tone?: "error" + /** Overrides the default value color — e.g. a magnitude tone for latency. */ + valueClassName?: string } -function HeadlineFact({ label, name, value, tone }: HeadlineFactProps) { +function HeadlineFact({ label, name, value, tone, valueClassName }: HeadlineFactProps) { return ( @@ -334,6 +348,7 @@ function HeadlineFact({ label, name, value, tone }: HeadlineFactProps) { className={cn( "shrink-0 tabular-nums font-mono", tone === "error" ? "text-severity-error" : "text-foreground", + valueClassName, )} > {value} diff --git a/apps/web/src/components/services/service-operations-tab.tsx b/apps/web/src/components/services/service-operations-tab.tsx index 39ab1dbeb..49a961745 100644 --- a/apps/web/src/components/services/service-operations-tab.tsx +++ b/apps/web/src/components/services/service-operations-tab.tsx @@ -4,8 +4,8 @@ import { cn } from "@maple/ui/utils" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@maple/ui/components/ui/table" import { Skeleton } from "@maple/ui/components/ui/skeleton" import { Sparkline } from "@maple/ui/components/ui/gradient-chart" +import { LatencyValue } from "@maple/ui/components/latency-value" import { ChevronDownIcon, ChevronUpIcon, ChevronExpandYIcon } from "@/components/icons" -import { formatLatency } from "@/lib/format" import { Result } from "@/lib/effect-atom" import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { getServiceOperationsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" @@ -88,6 +88,10 @@ export function ServiceOperationsTab({ ) const seconds = windowSeconds(effectiveStartTime, effectiveEndTime) + const traceDetailLimited = seconds > 30 * 24 * 60 * 60 + const traceDetailStartTime = traceDetailLimited + ? new Date(Date.parse(effectiveEndTime) - 30 * 24 * 60 * 60 * 1000).toISOString() + : startTime const operations = useMemo( () => @@ -134,9 +138,9 @@ export function ServiceOperationsTab({ serviceName, spanName: op.spanName, environments, - startTime, - endTime, - timePreset, + startTime: traceDetailStartTime, + endTime: traceDetailLimited ? effectiveEndTime : endTime, + timePreset: traceDetailLimited ? undefined : timePreset, }), }) } @@ -151,6 +155,12 @@ export function ServiceOperationsTab({ return (
+ {traceDetailLimited && ( +

+ Operation summaries cover the selected range; individual trace drill-downs show the latest + 30 days. +

+ )} {/* Desktop: dense sortable table with inline distribution bars. */}
@@ -195,7 +205,10 @@ export function ServiceOperationsTab({ {sorted.length === 0 ? ( - + No operations recorded in this window. @@ -216,7 +229,11 @@ export function ServiceOperationsTab({ {op.spanName} - + {op.estimatedSpanCount > op.spanCount ? "~" : ""} {formatRate(callsPerSecond(op.estimatedSpanCount, seconds))} @@ -241,14 +258,18 @@ export function ServiceOperationsTab({ - - {formatLatency(op.p50DurationMs)} - + - - {formatLatency(op.p95DurationMs)} - + {label} - + ) })} @@ -314,7 +338,9 @@ export function ServiceOperationsTab({ onClick={() => handleRowClick(op)} className="flex w-full flex-col gap-1 border-b px-3 py-2.5 text-left last:border-b-0 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring" > - {op.spanName} + + {op.spanName} +
calls @@ -337,7 +363,7 @@ export function ServiceOperationsTab({ p95 - {formatLatency(op.p95DurationMs)} +
diff --git a/apps/web/src/components/services/service-top-operations-panel.tsx b/apps/web/src/components/services/service-top-operations-panel.tsx index 5d5391014..b9e6a7bd5 100644 --- a/apps/web/src/components/services/service-top-operations-panel.tsx +++ b/apps/web/src/components/services/service-top-operations-panel.tsx @@ -4,7 +4,7 @@ import { Sparkline } from "@maple/ui/components/ui/gradient-chart" import { Result } from "@/lib/effect-atom" import { useRetainedRefreshableResultValue } from "@/hooks/use-retained-refreshable-result-value" import { getServiceOperationsResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" -import { formatLatency } from "@/lib/format" +import { LatencyValue } from "@maple/ui/components/latency-value" import type { ServiceOperation } from "@/api/warehouse/service-operations" import { SectionCard } from "./section-card" import { callsPerSecond, serviceOperationsQueryInput, windowSeconds } from "./service-operations" @@ -119,7 +119,7 @@ export function ServiceTopOperationsPanel({ > {formatErrorRate(op.errorRate)} - {formatLatency(op.p95DurationMs)} + ({ value: point.count }))} diff --git a/apps/web/src/components/services/services-table.tsx b/apps/web/src/components/services/services-table.tsx index 1516a108f..501fa6160 100644 --- a/apps/web/src/components/services/services-table.tsx +++ b/apps/web/src/components/services/services-table.tsx @@ -44,6 +44,7 @@ import { openAnomalyIncidentsAtom } from "@/lib/services/atoms/anomaly-atoms" import { MapleApiV2AtomClient } from "@/lib/services/common/v2-atom-client" import type { ServicesSearchParams } from "@/routes/services/index" import { ServiceDot } from "@maple/ui/components/service-dot" +import { LatencyValue } from "@maple/ui/components/latency-value" // One fleet-level call: open (actionable) error-issue counts grouped by // service name. Progressive enrichment — the table renders without it. @@ -52,19 +53,6 @@ const openIssueCountsAtom = MapleApiV2AtomClient.query("errorIssues", "serviceCo timeToLive: 60_000, }) -function formatLatency(ms: number): string { - if (ms == null || Number.isNaN(ms)) { - return "-" - } - if (ms < 1) { - return `${(ms * 1000).toFixed(0)}μs` - } - if (ms < 1000) { - return `${ms.toFixed(1)}ms` - } - return `${(ms / 1000).toFixed(2)}s` -} - function formatThroughput(rate: number): string { if (rate == null || Number.isNaN(rate) || rate === 0) { return "0/s" @@ -166,7 +154,10 @@ interface BaselineDelta { className: string } -function baselineDelta(p95LatencyMs: number, baseline: LatencyBaselineSignal | undefined): BaselineDelta | undefined { +function baselineDelta( + p95LatencyMs: number, + baseline: LatencyBaselineSignal | undefined, +): BaselineDelta | undefined { if (baseline === undefined || baseline.spanCount < MIN_BASELINE_SPANS || baseline.p95LatencyMs <= 0) { return undefined } @@ -175,7 +166,11 @@ function baselineDelta(p95LatencyMs: number, baseline: LatencyBaselineSignal | u return { label: `${pct > 0 ? "+" : ""}${pct}% vs 7d`, className: - delta >= 1 ? "text-severity-error" : delta >= 0.25 ? "text-severity-warn" : "text-muted-foreground", + delta >= 1 + ? "text-severity-error" + : delta >= 0.25 + ? "text-severity-warn" + : "text-muted-foreground", } } @@ -222,7 +217,9 @@ function deriveDeployInfo(commits: CommitBreakdown[]): DeployCellInfo | undefine meaningful.length > 1 ? { percentage: dominant.percentage, - others: real.filter((c) => c !== dominant).toSorted((a, b) => b.percentage - a.percentage), + others: real + .filter((c) => c !== dominant) + .toSorted((a, b) => b.percentage - a.percentage), } : undefined @@ -418,17 +415,19 @@ const ServiceRow = React.memo(function ServiceRow({
{service.serviceNamespace}
) : null}
- - {formatLatency(service.p50LatencyMs)} + + - -
{formatLatency(service.p95LatencyMs)}
+ +
+ +
{delta !== undefined && (
{delta.label}
)}
- - {formatLatency(service.p99LatencyMs)} + +

- Estimated from {((1 / service.samplingWeight) * 100).toFixed(0)}% sampled traces - (x{service.samplingWeight.toFixed(0)} extrapolation) + Estimated from {((1 / service.samplingWeight) * 100).toFixed(0)}% sampled + traces (x{service.samplingWeight.toFixed(0)} extrapolation)

)} @@ -685,9 +684,7 @@ export function ServicesTable({ filters }: ServicesTableProps) { } return ( -
+
{/* Desktop: full metrics table. Below md the fixed-width columns and in-cell sparklines force horizontal scroll, so we swap to a list. */}
@@ -706,9 +703,7 @@ export function ServicesTable({ filters }: ServicesTableProps) { P95 P99 Error Rate - - Throughput - + Throughput Issues @@ -793,9 +788,10 @@ export function ServicesTable({ filters }: ServicesTableProps) { P99{" "} - - {formatLatency(service.p99LatencyMs)} - + diff --git a/apps/web/src/lib/latency-tone.test.ts b/apps/web/src/lib/latency-tone.test.ts new file mode 100644 index 000000000..5716ca996 --- /dev/null +++ b/apps/web/src/lib/latency-tone.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest" + +import { + LATENCY_TEXT_TONE, + latencyLevel, + latencyLevelLabel, + latencyToneClass, +} from "@maple/ui/lib/latency-tone" + +describe("latencyLevel", () => { + it("walks the p95 ramp at 150ms / 500ms / 1s / 3s", () => { + expect(latencyLevel(149, "p95")).toBe("quiet") + expect(latencyLevel(150, "p95")).toBe("nominal") + expect(latencyLevel(499, "p95")).toBe("nominal") + expect(latencyLevel(500, "p95")).toBe("elevated") + expect(latencyLevel(999, "p95")).toBe("elevated") + expect(latencyLevel(1_000, "p95")).toBe("slow") + expect(latencyLevel(2_999, "p95")).toBe("slow") + expect(latencyLevel(3_000, "p95")).toBe("critical") + }) + + // The p95 slow/critical boundaries must stay pinned to the absolute health + // thresholds in components/dashboard/service-health.ts, so a toned cell never + // disagrees with the health badge on the same row. + it("agrees with the service-health absolute p95 thresholds", () => { + const P95_DEGRADED_MS = 1_000 + const P95_UNHEALTHY_MS = 3_000 + expect(latencyLevel(P95_DEGRADED_MS - 1, "p95")).toBe("elevated") + expect(latencyLevel(P95_DEGRADED_MS, "p95")).toBe("slow") + expect(latencyLevel(P95_UNHEALTHY_MS, "p95")).toBe("critical") + }) + + it("scales the ramp per percentile so a healthy service reads flat", () => { + // A healthy service: none of these should advance past "nominal". + expect(latencyLevel(42, "p50")).toBe("quiet") + expect(latencyLevel(180, "p95")).toBe("nominal") + expect(latencyLevel(410, "p99")).toBe("nominal") + + // A slow one: the same absolute values now tone by their own budget. + expect(latencyLevel(310, "p50")).toBe("slow") + expect(latencyLevel(2_400, "p95")).toBe("slow") + expect(latencyLevel(7_100, "p99")).toBe("critical") + }) + + it("gives avg its own budget between p50 and p95", () => { + expect(latencyLevel(74, "avg")).toBe("quiet") + expect(latencyLevel(250, "avg")).toBe("elevated") + expect(latencyLevel(500, "avg")).toBe("slow") + expect(latencyLevel(1_500, "avg")).toBe("critical") + }) + + // Worker CPU time is ~20x tighter than wall-clock latency; on the p99 budget + // every worker would collapse to "quiet". + it("uses a tighter budget for cpu time", () => { + expect(latencyLevel(12, "cpu")).toBe("nominal") + expect(latencyLevel(12, "p99")).toBe("quiet") + expect(latencyLevel(50, "cpu")).toBe("slow") + expect(latencyLevel(150, "cpu")).toBe("critical") + }) + + it("defaults to the p95 scale", () => { + expect(latencyLevel(2_400)).toBe(latencyLevel(2_400, "p95")) + }) + + it("honours a budgetMs override", () => { + expect(latencyLevel(12, "p99", 50)).toBe("nominal") + expect(latencyLevel(60, "p50", 50)).toBe("slow") + }) + + it("treats missing and non-finite values as quiet rather than fast", () => { + expect(latencyLevel(0, "p95")).toBe("quiet") + expect(latencyLevel(-1, "p95")).toBe("quiet") + expect(latencyLevel(Number.NaN, "p95")).toBe("quiet") + expect(latencyLevel(Number.POSITIVE_INFINITY, "p95")).toBe("quiet") + expect(latencyLevel(500, "p95", 0)).toBe("quiet") + }) +}) + +describe("latencyToneClass", () => { + it("resolves to the text tone for the level", () => { + expect(latencyToneClass(42, "p50")).toBe(LATENCY_TEXT_TONE.quiet) + expect(latencyToneClass(2_400, "p95")).toBe(LATENCY_TEXT_TONE.slow) + expect(latencyToneClass(7_100, "p99")).toBe(LATENCY_TEXT_TONE.critical) + }) +}) + +describe("latencyLevelLabel", () => { + it("gives every level a plain word, so tone is never color-only", () => { + expect(latencyLevelLabel("quiet")).toBe("fast") + expect(latencyLevelLabel("nominal")).toBe("normal") + expect(latencyLevelLabel("elevated")).toBe("elevated") + expect(latencyLevelLabel("slow")).toBe("slow") + expect(latencyLevelLabel("critical")).toBe("very slow") + }) +}) diff --git a/packages/ui/src/components/latency-value.tsx b/packages/ui/src/components/latency-value.tsx new file mode 100644 index 000000000..d7c48f656 --- /dev/null +++ b/packages/ui/src/components/latency-value.tsx @@ -0,0 +1,37 @@ +import { formatLatency } from "../lib/format" +import { LATENCY_TEXT_TONE, latencyLevel, latencyLevelLabel, type LatencyScale } from "../lib/latency-tone" +import { cn } from "../lib/utils" + +interface LatencyValueProps { + ms: number + /** Which budget the value is measured against. Defaults to "p95". */ + scale?: LatencyScale + /** Override the scale preset, in ms — for units without a preset. */ + budgetMs?: number + /** Override formatting, e.g. rendering "—" for a 0 that means "unavailable". */ + format?: (ms: number) => string + className?: string +} + +/** + * A latency number, formatted and toned by magnitude. Fast values recede and + * slow ones advance, so a percentile column can be scanned instead of read. + * See lib/latency-tone.ts for the budgets behind each scale. + */ +export function LatencyValue({ + ms, + scale = "p95", + budgetMs, + format = formatLatency, + className, +}: LatencyValueProps) { + const level = latencyLevel(ms, scale, budgetMs) + return ( + + {format(ms)} + + ) +} diff --git a/packages/ui/src/lib/latency-tone.ts b/packages/ui/src/lib/latency-tone.ts new file mode 100644 index 000000000..0d3c4754c --- /dev/null +++ b/packages/ui/src/lib/latency-tone.ts @@ -0,0 +1,107 @@ +// Magnitude → tone scale for latency percentiles. Sibling of the severity +// vocabulary in apps/web's infra/severity-tokens.ts: a `Record` +// per rendering surface, so a percentile cell tones identically wherever it is +// rendered (services table, infra tables, service map, chat renderers). + +/** Emphasis steps, low → high. Warm-only: nothing is colored for being fine. */ +export type LatencyLevel = "quiet" | "nominal" | "elevated" | "slow" | "critical" + +/** + * Which budget a value is measured against. p99 is *expected* to be higher than + * p50, so a single absolute threshold would paint every p99 column red; each + * percentile gets its own budget instead. + */ +export type LatencyScale = "p50" | "avg" | "p95" | "p99" | "cpu" + +/** + * Budgets in ms. p95 is anchored on the absolute health thresholds already in + * apps/web/src/components/dashboard/service-health.ts (P95_DEGRADED_MS = 1000, + * P95_UNHEALTHY_MS = 3000), so a cell's tone agrees with the health badge on + * the same row. `cpu` exists because Cloudflare Worker CPU-time lives on a + * different scale entirely — under the p99 budget every worker reads "quiet". + */ +const BUDGET_MS: Record = { + p50: 300, + avg: 500, + p95: 1_000, + p99: 2_000, + cpu: 50, +} + +/** + * Level boundaries as ratios of the budget. At the p95 budget these land on + * 150ms / 500ms / 1s / 3s — the last two being exactly the degraded/unhealthy + * thresholds the health rollup already uses. + */ +const BREAKS = [0.15, 0.5, 1, 3] as const + +const LEVELS: readonly LatencyLevel[] = ["quiet", "nominal", "elevated", "slow", "critical"] + +/** + * Resolve a latency value to an emphasis level. + * + * `budgetMs` overrides the scale preset for one-off units. Missing values + * (0, negative, NaN, Infinity) resolve to "quiet" so "no data" recedes rather + * than reading as fast. + */ +export function latencyLevel(ms: number, scale: LatencyScale = "p95", budgetMs?: number): LatencyLevel { + if (!Number.isFinite(ms) || ms <= 0) return "quiet" + const budget = budgetMs ?? BUDGET_MS[scale] + if (!Number.isFinite(budget) || budget <= 0) return "quiet" + const ratio = ms / budget + for (const [i, brk] of BREAKS.entries()) { + if (ratio < brk) return LEVELS[i] + } + return "critical" +} + +/** + * Value text tone — table cells, stat cards, metric lines. + * + * The floor is `text-muted-foreground`, not a dimmer opacity variant: eyebrow + * labels next to these values already sit at `text-muted-foreground/60`, and a + * value fainter than its own label reads as the label rather than the number. + */ +export const LATENCY_TEXT_TONE: Record = { + quiet: "text-muted-foreground", + nominal: "text-foreground/70", + elevated: "text-foreground", + slow: "text-severity-warn", + critical: "text-severity-error", +} + +/** Solid fill for inline meters and distribution bars behind a value. */ +export const LATENCY_BAR_TONE: Record = { + quiet: "bg-muted-foreground/15", + nominal: "bg-muted-foreground/25", + elevated: "bg-foreground/20", + slow: "bg-severity-warn/25", + critical: "bg-severity-error/25", +} + +/** Raw color strings for SVG fills and inline styles (no Tailwind class). */ +export const LATENCY_COLOR: Record = { + quiet: "var(--color-muted-foreground)", + nominal: "var(--color-muted-foreground)", + elevated: "var(--color-foreground)", + slow: "var(--color-severity-warn)", + critical: "var(--color-severity-error)", +} + +/** Level → text class in one call. Mirrors the shape of `errorRateClass`. */ +export function latencyToneClass(ms: number, scale: LatencyScale = "p95", budgetMs?: number): string { + return LATENCY_TEXT_TONE[latencyLevel(ms, scale, budgetMs)] +} + +const LEVEL_LABEL: Record = { + quiet: "fast", + nominal: "normal", + elevated: "elevated", + slow: "slow", + critical: "very slow", +} + +/** Plain-word label for `title`/`aria-label`, so tone is never color-only. */ +export function latencyLevelLabel(level: LatencyLevel): string { + return LEVEL_LABEL[level] +} From 4d21f641f07341bb879dba57f76c30ce36014fe6 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 24 Jul 2026 16:08:11 +0200 Subject: [PATCH 2/2] fix: avoid JSC DFG crash in latencyLevel hot loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `latencyLevel` ran its break-point scan as `for (const [i, brk] of BREAKS.entries())`. On the service map that function is called three times per node per frame (120 calls/frame at the bench's 40 nodes), so it tiers up quickly — and when JavaScriptCore's DFG tier compiles the iterator-plus-array-destructuring form it hits an assertion in VirtualRegisterAllocationPhase and SIGTRAPs the whole WebKit content process. That took out the webkit-smoke leg of the cross-browser perf gate: `/service-map-bench` died with "Target page, context or browser has been closed" on every attempt, while chromium and firefox passed the same segment. Reproduced locally and confirmed from the macOS crash report (EXC_BREAKPOINT in JSC::DFG::VirtualRegisterAllocationPhase::run). Swapping in a plain indexed loop sidesteps it and does less work per call. Behaviour is unchanged — the 10 boundary tests still pass — and webkit/chromium/firefox smoke legs are all green locally. Co-Authored-By: Claude Opus 4.8 --- packages/ui/src/lib/latency-tone.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/lib/latency-tone.ts b/packages/ui/src/lib/latency-tone.ts index 0d3c4754c..4033507a7 100644 --- a/packages/ui/src/lib/latency-tone.ts +++ b/packages/ui/src/lib/latency-tone.ts @@ -49,8 +49,14 @@ export function latencyLevel(ms: number, scale: LatencyScale = "p95", budgetMs?: const budget = budgetMs ?? BUDGET_MS[scale] if (!Number.isFinite(budget) || budget <= 0) return "quiet" const ratio = ms / budget - for (const [i, brk] of BREAKS.entries()) { - if (ratio < brk) return LEVELS[i] + // Indexed loop, not `BREAKS.entries()`: this runs per metric cell per frame + // (3 calls x 40 nodes on the service map), so it goes hot fast, and the + // iterator-plus-array-destructuring form crashes JavaScriptCore's DFG tier + // when it does — EXC_BREAKPOINT in VirtualRegisterAllocationPhase, which + // takes the whole WebKit content process down. Plain indexing is also less + // work per call. + for (let i = 0; i < BREAKS.length; i++) { + if (ratio < BREAKS[i]) return LEVELS[i] } return "critical" }