-
- The last bar gathers the {formatCount(overflow.count)} slowest{" "}
- {overflow.count === 1 ? "trace" : "traces"} ({describeBucket(overflow)}); the log scale spreads
- them out.
-
{Number.isFinite(value) ? formatValue(value) : "no traces"}
;
- })}
-
- ))}
-
-
-
-
- );
-}
-
-export default TimeSeriesChart;
diff --git a/auth-server/src/components/ServerTracesDashboard/charts/chart-primitives.tsx b/auth-server/src/components/ServerTracesDashboard/charts/chart-primitives.tsx
deleted file mode 100644
index 9d2ff3ba..00000000
--- a/auth-server/src/components/ServerTracesDashboard/charts/chart-primitives.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-"use client";
-
-import type { ReactElement, ReactNode } from "react";
-import { cn } from "@schemavaults/ui";
-import { niceStep } from "../trace-analytics";
-
-/**
- * Small SVG building blocks shared by the trace dashboard charts. Marks and
- * text use theme tokens (`stroke-border`, `fill-muted-foreground`, …) so the
- * charts follow the light/dark theme; data colors come from the
- * `--trace-series-*` custom properties.
- */
-
-export interface Scale {
- (value: number): number;
- domain: readonly [number, number];
-}
-
-/** Maps [d0, d1] onto [r0, r1]; a zero-width domain maps to the range start. */
-export function linearScale(
- domain: readonly [number, number],
- range: readonly [number, number],
-): Scale {
- const [d0, d1] = domain;
- const [r0, r1] = range;
- const span: number = d1 - d0;
- const scale = ((value: number): number =>
- span === 0 ? r0 : r0 + ((value - d0) / span) * (r1 - r0)) as Scale;
- scale.domain = domain;
- return scale;
-}
-
-/** SVG path of a bar with rounded top corners and a square base. */
-export function roundedTopBarPath(
- x: number,
- y: number,
- width: number,
- height: number,
- radius: number = 4,
-): string {
- if (width <= 0 || height <= 0) {
- return "";
- }
- const r: number = Math.max(0, Math.min(radius, width / 2, height));
- const bottom: number = y + height;
- return [
- `M${x},${bottom}`,
- `V${y + r}`,
- `Q${x},${y} ${x + r},${y}`,
- `H${x + width - r}`,
- `Q${x + width},${y} ${x + width},${y + r}`,
- `V${bottom}`,
- "Z",
- ].join(" ");
-}
-
-export interface HorizontalGridProps {
- ticks: readonly number[];
- y: (value: number) => number;
- x0: number;
- x1: number;
- format: (value: number) => string;
-}
-
-/** Hairline gridlines with right-aligned tick labels in the left gutter. */
-export function HorizontalGrid({
- ticks,
- y,
- x0,
- x1,
- format,
-}: HorizontalGridProps): ReactElement {
- return (
-
- {ticks.map(
- (tick: number): ReactElement => (
-
-
-
- {format(tick)}
-
-
- ),
- )}
-
- );
-}
-
-export interface ChartTooltipProps {
- /** Anchor point in container pixels (the tooltip sits above it). */
- x: number;
- y: number;
- /** Width of the chart container, to keep the tooltip inside it. */
- containerWidth: number;
- children: ReactNode;
- className?: string;
-}
-
-const TOOLTIP_HALF_WIDTH: number = 128;
-
-/**
- * A floating readout above a chart position: the value leads, labels follow.
- * Flips below the anchor near the top edge and clamps horizontally.
- */
-export function ChartTooltip({
- x,
- y,
- containerWidth,
- children,
- className,
-}: ChartTooltipProps): ReactElement {
- const left: number = Math.min(
- Math.max(x, TOOLTIP_HALF_WIDTH),
- Math.max(TOOLTIP_HALF_WIDTH, containerWidth - TOOLTIP_HALF_WIDTH),
- );
- const below: boolean = y < 72;
- return (
-
- {children}
-
- );
-}
-
-export interface TooltipRowProps {
- /** Short line key in the series color; omitted for single-series charts. */
- color?: string;
- label: ReactNode;
- value: ReactNode;
-}
-
-/** One `value · label` line of a tooltip, keyed by a short stroke of the series color. */
-export function TooltipRow({ color, label, value }: TooltipRowProps): ReactElement {
- return (
-
- {color ? (
-
- ) : null}
- {value}
- {label}
-
- );
-}
-
-/** Evenly spaced indices (always including the first and last) so at most `max_labels` labels render. */
-export function thinIndices(count: number, max_labels: number): number[] {
- if (count <= 0) {
- return [];
- }
- if (count <= max_labels) {
- return Array.from({ length: count }, (_, index: number): number => index);
- }
- const step: number = Math.ceil((count - 1) / Math.max(1, max_labels - 1));
- const indices: number[] = [];
- for (let index = 0; index < count - 1; index += step) {
- indices.push(index);
- }
- if (count - 1 - indices[indices.length - 1]! < step / 2 && indices.length > 1) {
- indices.pop();
- }
- indices.push(count - 1);
- return indices;
-}
-
-/**
- * A zero-based value axis: nice ticks from 0 up to the first tick at or
- * above `max` (at least `min_step`, e.g. 1 for whole counts).
- */
-export function niceAxis(
- max: number,
- target: number = 4,
- min_step: number = 1,
-): { ticks: number[]; top: number } {
- const step: number = niceStep(Math.max(0, max) / Math.max(1, target), min_step);
- const top: number = Math.max(step, Math.ceil(Math.max(0, max) / step) * step);
- const ticks: number[] = [];
- for (let value = 0; value <= top + step * 1e-9; value += step) {
- ticks.push(Number(value.toPrecision(12)));
- }
- return { ticks, top };
-}
diff --git a/auth-server/src/components/ServerTracesDashboard/format.ts b/auth-server/src/components/ServerTracesDashboard/format.ts
index 14494494..6a642b27 100644
--- a/auth-server/src/components/ServerTracesDashboard/format.ts
+++ b/auth-server/src/components/ServerTracesDashboard/format.ts
@@ -1,5 +1,7 @@
/** Display formatting shared by the trace dashboard tiles. */
+import type { HistogramBucket } from "@schemavaults/ui";
+
const integer = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
const compact = new Intl.NumberFormat("en-US", {
notation: "compact",
@@ -52,6 +54,20 @@ export function formatDurationTick(ms: number): string {
return `${trimZeros((ms / 60_000).toPrecision(3))}m`;
}
+/** A duration histogram bucket's range: `under 1 ms`, `12 ms`, `10 ms – 20 ms`, `2.5 s or more`. */
+export function describeDurationBucket(bucket: HistogramBucket): string {
+ if (bucket.upper === null) {
+ return `${formatDuration(bucket.lower)} or more`;
+ }
+ if (bucket.lower === 0 && bucket.upper === 1) {
+ return "under 1 ms";
+ }
+ if (bucket.upper - bucket.lower === 1) {
+ return formatDuration(bucket.lower);
+ }
+ return `${formatDuration(bucket.lower)} – ${formatDuration(bucket.upper)}`;
+}
+
/** A span of time: `45 s`, `12 min`, `3 h 12 min`, `2 d 4 h`. */
export function formatSpan(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) {
diff --git a/auth-server/src/components/ServerTracesDashboard/tiles/BreakdownTiles.tsx b/auth-server/src/components/ServerTracesDashboard/tiles/BreakdownTiles.tsx
index 01275166..e9122d14 100644
--- a/auth-server/src/components/ServerTracesDashboard/tiles/BreakdownTiles.tsx
+++ b/auth-server/src/components/ServerTracesDashboard/tiles/BreakdownTiles.tsx
@@ -1,7 +1,13 @@
"use client";
import { useMemo, type ReactElement } from "react";
-import { SegmentedBar, cn, type SegmentedBarSegment } from "@schemavaults/ui";
+import {
+ BarList,
+ SegmentedBar,
+ cn,
+ type BarListItem,
+ type SegmentedBarSegment,
+} from "@schemavaults/ui";
import { TRACE_TILES } from "../tiles";
import {
countByCategory,
@@ -13,12 +19,10 @@ import {
} from "../trace-analytics";
import { formatCount, formatDuration, formatShare } from "../format";
import {
- TRACE_ACCENT_COLOR,
TRACE_CATEGORY_ORDER,
getTraceCategoryColor,
getTraceCategoryLabel,
} from "../trace-categories";
-import { RankedBarList, type RankedBarItem } from "../charts/RankedBarList";
import { TraceTileCard } from "./TraceTileCard";
import type { TraceTileProps } from "./tile-props";
@@ -127,21 +131,23 @@ export function CategoryBreakdownTile({
function rankingProps(props: TraceTileProps) {
return {
- color: TRACE_ACCENT_COLOR,
selectedIds: props.selectedOperations,
- onSelect: (item: RankedBarItem): void => props.toggleOperation(item.id),
+ onSelect: (item: BarListItem): void => props.toggleOperation(item.id),
+ // Operation names are code identifiers.
+ labelClassName: "font-mono text-xs",
+ emptyMessage: "No traces match the filters",
};
}
/** Operations ranked by p95 duration: where the time goes. */
export function SlowestOperationsTile(props: TraceTileProps): ReactElement {
- const items: RankedBarItem[] = useMemo(
- (): RankedBarItem[] =>
+ const items: BarListItem[] = useMemo(
+ (): BarListItem[] =>
[...props.operations]
.sort((a: OperationStats, b: OperationStats): number => b.p95 - a.p95)
.slice(0, RANKING_SIZE)
.map(
- (op: OperationStats): RankedBarItem => ({
+ (op: OperationStats): BarListItem => ({
id: op.op_name,
label: op.op_name,
value: op.p95,
@@ -157,17 +163,17 @@ export function SlowestOperationsTile(props: TraceTileProps): ReactElement {
description={`Top ${RANKING_SIZE} by p95 duration. Click one to filter by it.`}
refreshing={props.refreshing}
>
-
+
);
}
/** Operations ranked by how many traces they recorded. */
export function BusiestOperationsTile(props: TraceTileProps): ReactElement {
- const items: RankedBarItem[] = useMemo(
- (): RankedBarItem[] =>
+ const items: BarListItem[] = useMemo(
+ (): BarListItem[] =>
props.operations.slice(0, RANKING_SIZE).map(
- (op: OperationStats): RankedBarItem => ({
+ (op: OperationStats): BarListItem => ({
id: op.op_name,
label: op.op_name,
value: op.count,
@@ -183,7 +189,7 @@ export function BusiestOperationsTile(props: TraceTileProps): ReactElement {
description={`Top ${RANKING_SIZE} by trace count. Click one to filter by it.`}
refreshing={props.refreshing}
>
-
+
);
}
diff --git a/auth-server/src/components/ServerTracesDashboard/tiles/DistributionTiles.tsx b/auth-server/src/components/ServerTracesDashboard/tiles/DistributionTiles.tsx
index c72ff10b..f7448691 100644
--- a/auth-server/src/components/ServerTracesDashboard/tiles/DistributionTiles.tsx
+++ b/auth-server/src/components/ServerTracesDashboard/tiles/DistributionTiles.tsx
@@ -1,22 +1,45 @@
"use client";
-import { useMemo, useState, type ReactElement } from "react";
-import { TRACE_TILES } from "../tiles";
+import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react";
import {
- buildDurationHistogram,
- traceDurationMs,
- type DurationBucket,
+ ChartTooltipRow,
+ Histogram,
+ ScatterPlot,
+ binValues,
+ type HistogramBucket,
+ type HistogramCountNoun,
type HistogramScaleId,
-} from "../trace-analytics";
-import { formatCount, formatDuration } from "../format";
-import { TRACE_ACCENT_COLOR, TRACE_OTHER_COLOR } from "../trace-categories";
-import { DurationHistogram } from "../charts/DurationHistogram";
-import { DurationScatter } from "../charts/DurationScatter";
-import { DurationScaleToggle, MeasuredChart, TraceTileCard } from "./TraceTileCard";
+ type ScatterPlotReferenceLine,
+ type ScatterPlotSeries,
+ type ScatterPlotTooltipContext,
+} from "@schemavaults/ui";
+import type { ServerTraceRow } from "@/lib/auth-db/server-traces";
+import { TRACE_TILES } from "../tiles";
+import { niceAxisTop, timeTicks, traceDurationMs } from "../trace-analytics";
+import {
+ describeDurationBucket,
+ formatCount,
+ formatDuration,
+ formatDurationTick,
+ formatInstant,
+ formatTimeTick,
+} from "../format";
+import { getTraceCategoryLabel } from "../trace-categories";
+import { DurationScaleToggle, TraceTileCard } from "./TraceTileCard";
import type { TraceTileProps } from "./tile-props";
const HISTOGRAM_HEIGHT: number = 240;
const SCATTER_HEIGHT: number = 260;
+const EMPTY_MESSAGE: string = "No traces match the filters";
+const TRACE_NOUN: HistogramCountNoun = { singular: "trace", plural: "traces" };
+
+/**
+ * The scatter plot's x readout. Module-level (like `formatDuration`) so the
+ * plot's memoized points layer never re-renders while the pointer moves.
+ */
+function formatTraceStart(ms: number): string {
+ return formatInstant(ms);
+}
/** How trace durations are distributed, on linear or logarithmic buckets. */
export function DurationHistogramTile({
@@ -25,10 +48,15 @@ export function DurationHistogramTile({
refreshing,
}: TraceTileProps): ReactElement {
const [scale, setScale] = useState("linear");
- const buckets: DurationBucket[] = useMemo(
- (): DurationBucket[] => buildDurationHistogram(traces.map(traceDurationMs), scale),
+ // Durations are whole milliseconds, so no bin is narrower than 1 ms.
+ const buckets: HistogramBucket[] = useMemo(
+ (): HistogramBucket[] =>
+ binValues(traces.map(traceDurationMs), { scale, minBinWidth: 1 }),
[traces, scale],
);
+ const last: HistogramBucket | undefined = buckets[buckets.length - 1];
+ const overflow: HistogramBucket | undefined =
+ last && last.upper === null ? last : undefined;
return (
-
- {(width: number): ReactElement => (
-
- )}
-
+
);
}
-/** Every trace by start time and duration; click a point to filter by its operation. */
+/** Every trace by start time and duration; hovering one highlights its operation's other traces. */
export function DurationScatterTile({
traces,
summary,
timeWindow,
refreshing,
- toggleOperation,
}: TraceTileProps): ReactElement {
const [scale, setScale] = useState("linear");
- const references = useMemo(
- () =>
+
+ const traces_by_id: Map = useMemo(
+ (): Map =>
+ new Map(traces.map((trace: ServerTraceRow): [string, ServerTraceRow] => [trace.event_id, trace])),
+ [traces],
+ );
+
+ const series: ScatterPlotSeries[] = useMemo(
+ (): ScatterPlotSeries[] => [
+ {
+ id: "traces",
+ label: "Traces",
+ points: traces.map((trace: ServerTraceRow) => ({
+ id: trace.event_id,
+ x: trace.start_time,
+ y: traceDurationMs(trace),
+ label: trace.op_name,
+ // Hovering a trace keeps its operation's other traces opaque.
+ group: trace.op_name,
+ })),
+ },
+ ],
+ [traces],
+ );
+
+ const from: number = Math.min(timeWindow.from, timeWindow.to);
+ const to: number = Math.max(timeWindow.from, timeWindow.to);
+ // A single instant still gets a readable axis: pad it by a second.
+ const x_min: number = to > from ? from : from - 1_000;
+ const x_max: number = to > from ? to : to + 1_000;
+ const x_ticks: number[] = useMemo(
+ (): number[] => timeTicks(x_min, x_max, 5),
+ [x_min, x_max],
+ );
+ const formatXTick = useCallback(
+ (x: number): string => formatTimeTick(x, x_max - x_min),
+ [x_min, x_max],
+ );
+
+ const references: ScatterPlotReferenceLine[] = useMemo(
+ (): ScatterPlotReferenceLine[] =>
summary.count > 0
? [
- { label: "p50", value: summary.p50 },
- { label: "p95", value: summary.p95 },
+ { value: summary.p50, label: `p50 · ${formatDuration(summary.p50)}` },
+ { value: summary.p95, label: `p95 · ${formatDuration(summary.p95)}` },
]
: [],
[summary.count, summary.p50, summary.p95],
);
+ const formatTooltip = useCallback(
+ ({ point }: ScatterPlotTooltipContext): ReactNode => {
+ const trace: ServerTraceRow | undefined =
+ point.id === undefined ? undefined : traces_by_id.get(point.id);
+ return (
+ <>
+
+ {trace ? (
+