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..4033507a7 --- /dev/null +++ b/packages/ui/src/lib/latency-tone.ts @@ -0,0 +1,113 @@ +// 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 + // 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" +} + +/** + * 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] +}