diff --git a/auth-server/package.json b/auth-server/package.json index 72e2816e..785d2c96 100644 --- a/auth-server/package.json +++ b/auth-server/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/auth-server", - "version": "0.46.1", + "version": "0.46.2", "private": true, "repository": { "type": "git", @@ -21,8 +21,8 @@ "@schemavaults/openapi-docs-ui": "workspace:*", "@schemavaults/openapi-operations": "workspace:*", "@schemavaults/send-email": "0.3.1", - "@schemavaults/theme": "0.26.1", - "@schemavaults/ui": "0.101.0", + "@schemavaults/theme": "0.29.0", + "@schemavaults/ui": "0.110.1", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "cookies-next": "6.1.1", diff --git a/auth-server/src/components/ServerTracesDashboard/ServerTracesDashboard.tsx b/auth-server/src/components/ServerTracesDashboard/ServerTracesDashboard.tsx index 437341ad..4fc7e8bb 100644 --- a/auth-server/src/components/ServerTracesDashboard/ServerTracesDashboard.tsx +++ b/auth-server/src/components/ServerTracesDashboard/ServerTracesDashboard.tsx @@ -15,7 +15,6 @@ import { CardHeader, CardTitle, MultiSelect, - cn, type MultiSelectOption, } from "@schemavaults/ui"; import { AlertTriangle } from "lucide-react"; @@ -34,7 +33,6 @@ import { } from "./trace-analytics"; import { LocalDateTime } from "@schemavaults/auth-ui"; import { formatCount } from "./format"; -import { TRACE_CHART_COLOR_VARIABLES } from "./trace-categories"; import { TRACE_TILE_IDS, TRACE_TILES, @@ -216,7 +214,7 @@ export function ServerTracesDashboard({ return (
diff --git a/auth-server/src/components/ServerTracesDashboard/charts/DurationHistogram.tsx b/auth-server/src/components/ServerTracesDashboard/charts/DurationHistogram.tsx deleted file mode 100644 index 9c713800..00000000 --- a/auth-server/src/components/ServerTracesDashboard/charts/DurationHistogram.tsx +++ /dev/null @@ -1,274 +0,0 @@ -"use client"; - -import { - useMemo, - useState, - type KeyboardEvent, - type ReactElement, -} from "react"; -import type { DurationBucket } from "../trace-analytics"; -import { formatCount, formatDuration, formatDurationTick, formatShare } from "../format"; -import { - ChartTooltip, - HorizontalGrid, - TooltipRow, - linearScale, - niceAxis, - roundedTopBarPath, - thinIndices, -} from "./chart-primitives"; - -export interface DurationHistogramProps { - buckets: readonly DurationBucket[]; - /** Traces in the sample, for each bucket's share. */ - total: number; - width: number; - height?: number; - /** Bar color (a CSS color, e.g. a `--trace-series-*` variable). */ - color: string; - /** Color of the open-ended overflow bucket, set apart from the regular bins. */ - overflowColor: string; - /** Accessible name of the chart. */ - label: string; -} - -const MARGIN = { top: 16, right: 8, bottom: 24, left: 44 } as const; -/** The surface gap between touching bars. */ -const BAR_GAP: number = 2; -/** Room each x-axis label needs before labels start thinning out. */ -const X_LABEL_SPACING: number = 52; - -export function describeBucket(bucket: DurationBucket): 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 duration histogram: one column per bucket on a shared count axis, with - * bucket boundaries labelled along the x-axis. Hover or arrow keys read out - * a bucket; the tallest bucket is labelled directly. - */ -export function DurationHistogram({ - buckets, - total, - width, - height = 240, - color, - overflowColor, - label, -}: DurationHistogramProps): ReactElement { - const [active, setActive] = useState(null); - - const plotX0: number = MARGIN.left; - const plotX1: number = Math.max(plotX0 + 1, width - MARGIN.right); - const plotY0: number = MARGIN.top; - const plotY1: number = Math.max(plotY0 + 1, height - MARGIN.bottom); - const slot: number = buckets.length > 0 ? (plotX1 - plotX0) / buckets.length : 0; - - const layout = useMemo(() => { - const max_count: number = Math.max(0, ...buckets.map((b) => b.count)); - const axis = niceAxis(max_count, 4, 1); - const y = linearScale([0, axis.top], [plotY1, plotY0]); - const mode_index: number = buckets.findIndex((b) => b.count === max_count); - - // Boundaries: the lower edge of every bucket plus the closing upper edge - // (the open-ended overflow bucket has none). - const boundaries: { x: number; value: number }[] = buckets.map( - (bucket: DurationBucket, index: number) => ({ - x: plotX0 + index * slot, - value: bucket.lower, - }), - ); - const last: DurationBucket | undefined = buckets[buckets.length - 1]; - if (last && last.upper !== null) { - boundaries.push({ x: plotX0 + buckets.length * slot, value: last.upper }); - } - const max_labels: number = Math.max(2, Math.floor((plotX1 - plotX0) / X_LABEL_SPACING)); - const labelled = thinIndices(boundaries.length, max_labels).map( - (index: number) => boundaries[index]!, - ); - - return { axis, y, mode_index, labelled }; - }, [buckets, plotX0, plotX1, plotY0, plotY1, slot]); - - const onKeyDown = (event: KeyboardEvent): void => { - if (buckets.length === 0) return; - if (event.key === "ArrowRight" || event.key === "ArrowLeft") { - event.preventDefault(); - const step: number = event.key === "ArrowRight" ? 1 : -1; - setActive((current: number | null): number => - current === null - ? 0 - : Math.min(buckets.length - 1, Math.max(0, current + step)), - ); - } else if (event.key === "Escape") { - setActive(null); - } - }; - - const active_bucket: DurationBucket | undefined = - active === null ? undefined : buckets[active]; - const last_bucket: DurationBucket | undefined = buckets[buckets.length - 1]; - const overflow: DurationBucket | undefined = - last_bucket && last_bucket.upper === null ? last_bucket : undefined; - - return ( -
-
- 0 ? 0 : -1} - onKeyDown={onKeyDown} - onBlur={(): void => setActive(null)} - onPointerLeave={(): void => setActive(null)} - className="block overflow-visible text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" - > - - - {buckets.map((bucket: DurationBucket, index: number): ReactElement => { - const top: number = layout.y(bucket.count); - const x: number = plotX0 + index * slot + BAR_GAP / 2; - const dimmed: boolean = active !== null && active !== index; - return ( - - ); - })} - - {buckets.length > 0 && layout.mode_index >= 0 ? ( - - {formatCount(buckets[layout.mode_index]!.count)} - - ) : null} - - - - - {/* Hit areas: the whole column, not just the painted bar. */} - {buckets.map((bucket: DurationBucket, index: number): ReactElement => ( - setActive(index)} - /> - ))} - - {buckets.length === 0 ? ( - - No traces match the filters - - ) : null} - - - {active_bucket && active !== null ? ( - - 0 ? active_bucket.count / total : 0)} - /> -
{describeBucket(active_bucket)}
-
- ) : null} -
- - {overflow ? ( -

-

- ) : null} - - - - - - - - - - - {buckets.map((bucket: DurationBucket): ReactElement => ( - - - - - ))} - -
{label}
DurationTraces
{describeBucket(bucket)}{bucket.count}
-
- ); -} - -export default DurationHistogram; diff --git a/auth-server/src/components/ServerTracesDashboard/charts/DurationScatter.tsx b/auth-server/src/components/ServerTracesDashboard/charts/DurationScatter.tsx deleted file mode 100644 index a0763c82..00000000 --- a/auth-server/src/components/ServerTracesDashboard/charts/DurationScatter.tsx +++ /dev/null @@ -1,342 +0,0 @@ -"use client"; - -import { - memo, - useMemo, - useState, - type KeyboardEvent, - type PointerEvent, - type ReactElement, -} from "react"; -import { - timeTicks, - traceDurationMs, - type HistogramScaleId, - type TraceSample, -} from "../trace-analytics"; -import { - formatDuration, - formatDurationTick, - formatInstant, - formatTimeTick, -} from "../format"; -import { getTraceCategoryLabel } from "../trace-categories"; -import { - ChartTooltip, - HorizontalGrid, - TooltipRow, - linearScale, - niceAxis, -} from "./chart-primitives"; - -export interface DurationScatterReference { - label: string; - value: number; -} - -export interface DurationScatterProps { - traces: readonly TraceSample[]; - /** Time window of the x-axis (Unix epoch ms). */ - timeWindow: { from: number; to: number }; - scale: HistogramScaleId; - width: number; - height?: number; - /** Marker color (a CSS color, e.g. a `--trace-series-*` variable). */ - color: string; - /** Horizontal reference lines, e.g. the sample's p50 and p95. */ - references?: readonly DurationScatterReference[]; - /** Called when a point is clicked, with its operation name. */ - onSelectOperation?: (op_name: string) => void; - label: string; -} - -const MARGIN = { top: 12, right: 12, bottom: 24, left: 48 } as const; -/** How close (px) the pointer must be to a point to read it out. */ -const HIT_RADIUS: number = 24; -const POINT_RADIUS: number = 4; - -interface PlacedPoint { - trace: TraceSample; - duration: number; - x: number; - y: number; -} - -/** Log axis floor: 0 ms durations sit on the 1 ms line. */ -function log10Duration(ms: number): number { - return Math.log10(Math.max(1, ms)); -} - -interface PointsLayerProps { - points: readonly PlacedPoint[]; - color: string; - /** Operation whose points stay opaque while the others fade. */ - emphasized_op: string | null; -} - -/** The markers, memoized so moving the pointer only re-renders the readout. */ -const PointsLayer = memo(function PointsLayer({ - points, - color, - emphasized_op, -}: PointsLayerProps): ReactElement { - return ( - - ); -}); - -/** - * Every trace as a point: start time across, duration up. The nearest point - * within reach of the pointer is read out and its operation's other traces - * stay emphasized while the rest fade; clicking selects that operation. - */ -export function DurationScatter({ - traces, - timeWindow, - scale, - width, - height = 260, - color, - references = [], - onSelectOperation, - label, -}: DurationScatterProps): ReactElement { - const [active, setActive] = useState(null); - - const plotX0: number = MARGIN.left; - const plotX1: number = Math.max(plotX0 + 1, width - MARGIN.right); - const plotY0: number = MARGIN.top; - const plotY1: number = Math.max(plotY0 + 1, height - MARGIN.bottom); - - const layout = useMemo(() => { - const durations: number[] = traces.map(traceDurationMs); - const max_duration: number = Math.max(0, ...durations); - - // Linear: 0 up to a nice ceiling. Log: whole decades from 1 ms. - const y_ticks: number[] = - scale === "log" - ? Array.from( - { length: Math.max(1, Math.ceil(log10Duration(max_duration))) + 1 }, - (_, decade: number): number => 10 ** decade, - ) - : niceAxis(max_duration, 4, 1).ticks; - const y_top: number = y_ticks[y_ticks.length - 1]!; - const y = - scale === "log" - ? ((): ((ms: number) => number) => { - const project = linearScale([0, log10Duration(y_top)], [plotY1, plotY0]); - return (ms: number): number => project(log10Duration(ms)); - })() - : linearScale([0, y_top], [plotY1, plotY0]); - - 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_domain: [number, number] = to > from ? [from, to] : [from - 1_000, to + 1_000]; - const x = linearScale(x_domain, [plotX0, plotX1]); - const x_ticks: number[] = timeTicks(x_domain[0], x_domain[1], Math.max(2, Math.floor((plotX1 - plotX0) / 110))); - - const points: PlacedPoint[] = traces.map( - (trace: TraceSample, index: number): PlacedPoint => ({ - trace, - duration: durations[index]!, - x: x(trace.start_time), - y: y(durations[index]!), - }), - ); - // Keyboard order: left to right. - const by_time: number[] = points - .map((_, index: number): number => index) - .sort((a: number, b: number): number => points[a]!.x - points[b]!.x); - - return { x, y, y_ticks, x_ticks, x_domain, points, by_time }; - }, [traces, scale, timeWindow.from, timeWindow.to, plotX0, plotX1, plotY0, plotY1]); - - const operations_count: number = useMemo( - (): number => new Set(traces.map((trace) => trace.op_name)).size, - [traces], - ); - - const active_point: PlacedPoint | undefined = - active === null ? undefined : layout.points[active]; - const emphasized_op: string | null = - active_point && operations_count > 1 ? active_point.trace.op_name : null; - - const onPointerMove = (event: PointerEvent): void => { - const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds) return; - const px: number = event.clientX - bounds.left; - const py: number = event.clientY - bounds.top; - let nearest: number | null = null; - let nearest_distance: number = HIT_RADIUS * HIT_RADIUS; - layout.points.forEach((point: PlacedPoint, index: number): void => { - const distance: number = (point.x - px) ** 2 + (point.y - py) ** 2; - if (distance <= nearest_distance) { - nearest = index; - nearest_distance = distance; - } - }); - setActive(nearest); - }; - - const onKeyDown = (event: KeyboardEvent): void => { - const order: number[] = layout.by_time; - if (order.length === 0) return; - if (event.key === "ArrowRight" || event.key === "ArrowLeft") { - event.preventDefault(); - const position: number = active === null ? -1 : order.indexOf(active); - const next: number = - event.key === "ArrowRight" - ? Math.min(order.length - 1, position + 1) - : Math.max(0, position - 1); - setActive(order[next]!); - } else if (event.key === "Enter" && active_point && onSelectOperation) { - event.preventDefault(); - onSelectOperation(active_point.trace.op_name); - } else if (event.key === "Escape") { - setActive(null); - } - }; - - const span: number = layout.x_domain[1] - layout.x_domain[0]; - - return ( -
- 0 ? 0 : -1} - onKeyDown={onKeyDown} - onBlur={(): void => setActive(null)} - className="block overflow-visible text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" - > - - - - - - {traces.length > 0 - ? references.map((reference: DurationScatterReference): ReactElement => { - const y: number = layout.y(reference.value); - return ( - - ); - }) - : null} - - {active_point ? ( - - ) : null} - - setActive(null)} - onClick={(): void => { - if (active_point && onSelectOperation) { - onSelectOperation(active_point.trace.op_name); - } - }} - className={active_point && onSelectOperation ? "cursor-pointer" : undefined} - /> - - {traces.length === 0 ? ( - - No traces match the filters - - ) : null} - - - {active_point ? ( - - -
{active_point.trace.op_name}
-
- {getTraceCategoryLabel(active_point.trace.op_category)} · {formatInstant(active_point.trace.start_time)} -
- {onSelectOperation ? ( -
Click to filter by this operation
- ) : null} -
- ) : null} -
- ); -} - -export default DurationScatter; diff --git a/auth-server/src/components/ServerTracesDashboard/charts/RankedBarList.tsx b/auth-server/src/components/ServerTracesDashboard/charts/RankedBarList.tsx deleted file mode 100644 index fbcf733d..00000000 --- a/auth-server/src/components/ServerTracesDashboard/charts/RankedBarList.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import type { ReactElement, ReactNode } from "react"; -import { cn } from "@schemavaults/ui"; - -export interface RankedBarItem { - id: string; - label: string; - value: number; - /** Formatted value shown at the end of the row. */ - valueLabel: ReactNode; - /** Secondary detail under the label (e.g. the trace count). */ - detail?: ReactNode; -} - -export interface RankedBarListProps { - items: readonly RankedBarItem[]; - /** Bar color (a CSS color, e.g. a `--trace-series-*` variable). */ - color: string; - /** Row click handler; rows render as buttons when set. */ - onSelect?: (item: RankedBarItem) => void; - /** Ids rendered as selected (e.g. operations already in the filter). */ - selectedIds?: readonly string[]; - emptyMessage?: string; - label: string; -} - -/** - * Horizontal bars on one shared scale, one row per item, for rankings whose - * labels are too long for an SVG axis gutter (operation names). The value - * sits at the end of each row; bars stay thin. - */ -export function RankedBarList({ - items, - color, - onSelect, - selectedIds = [], - emptyMessage = "No traces match the filters", - label, -}: RankedBarListProps): ReactElement { - const max: number = Math.max(0, ...items.map((item) => item.value)); - - if (items.length === 0) { - return ( -

- {emptyMessage} -

- ); - } - - return ( -
    - {items.map((item: RankedBarItem): ReactElement => { - const fraction: number = max > 0 ? item.value / max : 0; - const selected: boolean = selectedIds.includes(item.id); - const content: ReactElement = ( - <> -
    - - {item.label} - - - {item.valueLabel} - -
    -
    -
    -
    0 ? 1 : 0)}%`, - backgroundColor: color, - }} - /> -
    - {item.detail ? ( - - {item.detail} - - ) : null} -
    - - ); - return ( -
  1. - {onSelect ? ( - - ) : ( -
    {content}
    - )} -
  2. - ); - })} -
- ); -} - -export default RankedBarList; diff --git a/auth-server/src/components/ServerTracesDashboard/charts/TimeSeriesChart.tsx b/auth-server/src/components/ServerTracesDashboard/charts/TimeSeriesChart.tsx deleted file mode 100644 index fd445817..00000000 --- a/auth-server/src/components/ServerTracesDashboard/charts/TimeSeriesChart.tsx +++ /dev/null @@ -1,363 +0,0 @@ -"use client"; - -import { - useMemo, - useState, - type KeyboardEvent, - type PointerEvent, - type ReactElement, -} from "react"; -import { timeTicks, type TimeBucket } from "../trace-analytics"; -import { formatInstant, formatTimeTick } from "../format"; -import { - ChartTooltip, - HorizontalGrid, - TooltipRow, - linearScale, - niceAxis, -} from "./chart-primitives"; - -export interface TimeSeriesDefinition { - id: string; - label: string; - /** Stroke color (a CSS color, e.g. a `--trace-series-*` variable). */ - color: string; - /** The series value of a bucket; non-finite values leave a gap. */ - value: (bucket: TimeBucket) => number; - /** Wash the area under the line (single-series charts). */ - area?: boolean; -} - -export interface TimeSeriesChartProps { - buckets: readonly TimeBucket[]; - series: readonly TimeSeriesDefinition[]; - width: number; - height?: number; - /** Tooltip value format. */ - formatValue: (value: number) => string; - /** Y-axis tick format. */ - formatTick: (value: number) => string; - /** Smallest y-axis tick step (1 for whole counts and milliseconds). */ - minTickStep?: number; - label: string; -} - -const MARGIN = { top: 12, right: 12, bottom: 24, left: 48 } as const; - -interface Segment { - points: { x: number; y: number }[]; -} - -/** Splits a series into contiguous runs of finite values. */ -function toSegments( - buckets: readonly TimeBucket[], - value: (bucket: TimeBucket) => number, - x: (index: number) => number, - y: (value: number) => number, -): Segment[] { - const segments: Segment[] = []; - let current: Segment | null = null; - buckets.forEach((bucket: TimeBucket, index: number): void => { - const v: number = value(bucket); - if (!Number.isFinite(v)) { - current = null; - return; - } - if (!current) { - current = { points: [] }; - segments.push(current); - } - current.points.push({ x: x(index), y: y(v) }); - }); - return segments; -} - -function linePath(segment: Segment): string { - return segment.points - .map((point, index: number): string => `${index === 0 ? "M" : "L"}${point.x},${point.y}`) - .join(" "); -} - -function areaPath(segment: Segment, baseline: number): string { - const first = segment.points[0]!; - const last = segment.points[segment.points.length - 1]!; - return `${linePath(segment)} L${last.x},${baseline} L${first.x},${baseline} Z`; -} - -/** - * Bucketed values over time as lines on one shared axis. A crosshair snaps - * to the nearest bucket and one readout lists every series there. - */ -export function TimeSeriesChart({ - buckets, - series, - width, - height = 220, - formatValue, - formatTick, - minTickStep = 1, - label, -}: TimeSeriesChartProps): ReactElement { - const [active, setActive] = useState(null); - - const plotX0: number = MARGIN.left; - const plotX1: number = Math.max(plotX0 + 1, width - MARGIN.right); - const plotY0: number = MARGIN.top; - const plotY1: number = Math.max(plotY0 + 1, height - MARGIN.bottom); - - const layout = useMemo(() => { - const max_value: number = Math.max( - 0, - ...buckets.flatMap((bucket: TimeBucket): number[] => - series.map((s) => s.value(bucket)).filter(Number.isFinite), - ), - ); - const axis = niceAxis(max_value, 4, minTickStep); - const y = linearScale([0, axis.top], [plotY1, plotY0]); - - const first: TimeBucket | undefined = buckets[0]; - const last: TimeBucket | undefined = buckets[buckets.length - 1]; - const time_domain: [number, number] = first && last ? [first.start, last.end] : [0, 1]; - const time_x = linearScale(time_domain, [plotX0, plotX1]); - // Each bucket's value sits at the bucket's midpoint. - const bucket_x = (index: number): number => - time_x((buckets[index]!.start + buckets[index]!.end) / 2); - const x_ticks: number[] = timeTicks( - time_domain[0], - time_domain[1], - Math.max(2, Math.floor((plotX1 - plotX0) / 110)), - ); - - const lines = series.map((definition: TimeSeriesDefinition) => ({ - definition, - segments: toSegments(buckets, definition.value, bucket_x, y), - })); - - return { axis, y, time_x, bucket_x, x_ticks, time_domain, lines }; - }, [buckets, series, minTickStep, plotX0, plotX1, plotY0, plotY1]); - - const onPointerMove = (event: PointerEvent): void => { - const bounds = event.currentTarget.ownerSVGElement?.getBoundingClientRect(); - if (!bounds || buckets.length === 0) return; - const px: number = event.clientX - bounds.left; - let nearest: number = 0; - let nearest_distance: number = Infinity; - for (let index = 0; index < buckets.length; index += 1) { - const distance: number = Math.abs(layout.bucket_x(index) - px); - if (distance < nearest_distance) { - nearest = index; - nearest_distance = distance; - } - } - setActive(nearest); - }; - - const onKeyDown = (event: KeyboardEvent): void => { - if (buckets.length === 0) return; - if (event.key === "ArrowRight" || event.key === "ArrowLeft") { - event.preventDefault(); - const step: number = event.key === "ArrowRight" ? 1 : -1; - setActive((current: number | null): number => - current === null - ? buckets.length - 1 - : Math.min(buckets.length - 1, Math.max(0, current + step)), - ); - } else if (event.key === "Escape") { - setActive(null); - } - }; - - const active_bucket: TimeBucket | undefined = - active === null ? undefined : buckets[active]; - const span: number = layout.time_domain[1] - layout.time_domain[0]; - const active_values = active_bucket - ? series.map((definition: TimeSeriesDefinition) => ({ - definition, - value: definition.value(active_bucket), - })) - : []; - const tooltip_y: number = Math.min( - ...active_values - .filter(({ value }) => Number.isFinite(value)) - .map(({ value }) => layout.y(value)), - plotY1, - ); - - return ( -
- {series.length > 1 ? ( -
    - {series.map((definition: TimeSeriesDefinition): ReactElement => ( -
  • -
  • - ))} -
- ) : null} -
- 0 ? 0 : -1} - onKeyDown={onKeyDown} - onBlur={(): void => setActive(null)} - className="block overflow-visible text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" - > - - - - {layout.lines.map(({ definition, segments }): ReactElement => ( - - ))} - - {active !== null && active_bucket ? ( - - ) : null} - - - - setActive(null)} - /> - - - {active !== null && active_bucket ? ( - - {active_values.map(({ definition, value }): ReactElement => ( - 1 ? definition.color : undefined} - value={Number.isFinite(value) ? formatValue(value) : "—"} - label={definition.label} - /> - ))} -
- from {formatInstant(active_bucket.start, false)} -
-
- ) : null} - - - - - - - {series.map((definition: TimeSeriesDefinition): ReactElement => ( - - ))} - - - - {buckets.map((bucket: TimeBucket): ReactElement => ( - - - {series.map((definition: TimeSeriesDefinition): ReactElement => { - const value: number = definition.value(bucket); - return ; - })} - - ))} - -
{label}
Bucket start - {definition.label} -
{formatInstant(bucket.start)}{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 ( - - ); -} - -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 ? ( -
- ); -} - -/** 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 ? ( +
+
{trace.op_name}
+
+ {getTraceCategoryLabel(trace.op_category)} · {formatInstant(trace.start_time)} +
+
+ ) : null} + + ); + }, + [traces_by_id], + ); + return ( } refreshing={refreshing} > - - {(width: number): ReactElement => ( - - )} - + ); } diff --git a/auth-server/src/components/ServerTracesDashboard/tiles/TimeTiles.tsx b/auth-server/src/components/ServerTracesDashboard/tiles/TimeTiles.tsx index 4d124a60..39761655 100644 --- a/auth-server/src/components/ServerTracesDashboard/tiles/TimeTiles.tsx +++ b/auth-server/src/components/ServerTracesDashboard/tiles/TimeTiles.tsx @@ -1,47 +1,137 @@ "use client"; -import { useMemo, type ReactElement } from "react"; +import { useCallback, useMemo, type ReactElement } from "react"; +import { LineChart, type LineChartPoint, type LineChartSeries } from "@schemavaults/ui"; import { TRACE_TILES } from "../tiles"; -import { buildTimeSeries, type TimeBucket, type TimeSeries } from "../trace-analytics"; +import { + buildTimeSeries, + niceAxisTop, + timeTicks, + type TimeBucket, + type TimeSeries, +} from "../trace-analytics"; import { formatCount, formatDuration, formatDurationTick, + formatInstant, formatSpan, + formatTimeTick, } from "../format"; -import { MeasuredChart, TraceTileCard } from "./TraceTileCard"; -import { TimeSeriesChart, type TimeSeriesDefinition } from "../charts/TimeSeriesChart"; +import { TraceTileCard } from "./TraceTileCard"; import type { TraceTileProps } from "./tile-props"; const CHART_HEIGHT: number = 220; -/** The legend row above a multi-series chart. */ -const LEGEND_HEIGHT: number = 24; -const LATENCY_SERIES: readonly TimeSeriesDefinition[] = [ - { - id: "p50", - label: "Median (p50)", - color: "var(--trace-series-1)", - value: (bucket: TimeBucket): number => bucket.p50, - }, - { - id: "p95", - label: "p95", - color: "var(--trace-series-2)", - value: (bucket: TimeBucket): number => bucket.p95, - }, +interface TimeLineDefinition { + id: string; + label: string; + /** The line's value in a bucket; non-finite values leave a gap. */ + value: (bucket: TimeBucket) => number; + /** Wash the area under the line (single-series charts). */ + area?: boolean; +} + +const LATENCY_LINES: readonly TimeLineDefinition[] = [ + { id: "p50", label: "Median (p50)", value: (bucket: TimeBucket): number => bucket.p50 }, + { id: "p95", label: "p95", value: (bucket: TimeBucket): number => bucket.p95 }, ]; -const THROUGHPUT_SERIES: readonly TimeSeriesDefinition[] = [ - { - id: "count", - label: "traces", - color: "var(--trace-series-1)", - value: (bucket: TimeBucket): number => bucket.count, - area: true, - }, +const THROUGHPUT_LINES: readonly TimeLineDefinition[] = [ + { id: "count", label: "traces", value: (bucket: TimeBucket): number => bucket.count, area: true }, ]; +/** A value with no value on either side draws no line segment: only a point marker shows it. */ +function hasIsolatedPoints(points: readonly LineChartPoint[]): boolean { + return points.some( + (point: LineChartPoint, index: number): boolean => + Number.isFinite(point.y) && + !Number.isFinite(points[index - 1]?.y ?? NaN) && + !Number.isFinite(points[index + 1]?.y ?? NaN), + ); +} + +interface TimeLineChartProps { + series: TimeSeries; + lines: readonly TimeLineDefinition[]; + /** Tooltip value format. */ + formatValue: (value: number) => string; + /** Y-axis tick format. */ + formatTick: (value: number) => string; + label: string; +} + +/** Bucketed values over time as lines on one zero-based axis, spanning the whole time window. */ +function TimeLineChart({ + series, + lines, + formatValue, + formatTick, + label, +}: TimeLineChartProps): ReactElement { + const { buckets } = series; + + const chart_series: LineChartSeries[] = useMemo( + (): LineChartSeries[] => + lines.map((line: TimeLineDefinition): LineChartSeries => { + const points: LineChartPoint[] = buckets.map( + (bucket: TimeBucket): LineChartPoint => ({ + id: String(bucket.start), + // Each bucket's value sits at the bucket's midpoint. + x: (bucket.start + bucket.end) / 2, + y: line.value(bucket), + label: `from ${formatInstant(bucket.start, false)}`, + }), + ); + return { + id: line.id, + label: line.label, + points, + area: line.area, + showPoints: hasIsolatedPoints(points), + }; + }), + [buckets, lines], + ); + + const x_min: number = buckets[0]?.start ?? 0; + const x_max: number = buckets[buckets.length - 1]?.end ?? 1; + 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 max_value: number = Math.max( + 0, + ...chart_series.flatMap((line: LineChartSeries): number[] => + line.points.map((point: LineChartPoint): number => point.y).filter(Number.isFinite), + ), + ); + + return ( + + ); +} + function useTimeSeries(props: TraceTileProps): TimeSeries { const { traces, timeWindow } = props; return useMemo( @@ -59,19 +149,13 @@ export function LatencyOverTimeTile(props: TraceTileProps): ReactElement { description={`Duration percentiles per ${formatSpan(series.bucket_ms)} bucket; gaps are buckets without traces.`} refreshing={props.refreshing} > - - {(width: number): ReactElement => ( - - )} - + ); } @@ -93,19 +177,13 @@ export function ThroughputTile(props: TraceTileProps): ReactElement { } refreshing={props.refreshing} > - - {(width: number): ReactElement => ( - - )} - + ); } diff --git a/auth-server/src/components/ServerTracesDashboard/tiles/TraceTileCard.tsx b/auth-server/src/components/ServerTracesDashboard/tiles/TraceTileCard.tsx index 2735fa13..e904cdaf 100644 --- a/auth-server/src/components/ServerTracesDashboard/tiles/TraceTileCard.tsx +++ b/auth-server/src/components/ServerTracesDashboard/tiles/TraceTileCard.tsx @@ -10,10 +10,9 @@ import { ToggleGroup, ToggleGroupItem, cn, + type HistogramScaleId, } from "@schemavaults/ui"; -import { useMeasuredWidth } from "@schemavaults/auth-ui"; import type { TraceTileDescriptor } from "../tiles"; -import type { HistogramScaleId } from "../trace-analytics"; export interface TraceTileCardProps { tile: TraceTileDescriptor; @@ -93,26 +92,3 @@ export function DurationScaleToggle({ ); } - -/** - * Renders a pixel-sized chart at the current width of its container. Until - * the container is measured (on the client, right after mount) a placeholder - * holds the chart's height: the server render never guesses a width, so the - * chart neither jumps in size nor renders locale-dependent tick labels in - * the server's time zone. - */ -export function MeasuredChart({ - height, - children, -}: { - /** Height of the placeholder shown until the width is known. */ - height: number; - children: (width: number) => ReactNode; -}): ReactElement { - const { ref: container_ref, width } = useMeasuredWidth(0); - return ( -
- {width > 0 ? children(width) : - ); -} diff --git a/auth-server/src/components/ServerTracesDashboard/trace-analytics.test.ts b/auth-server/src/components/ServerTracesDashboard/trace-analytics.test.ts index fef06af2..57b4128f 100644 --- a/auth-server/src/components/ServerTracesDashboard/trace-analytics.test.ts +++ b/auth-server/src/components/ServerTracesDashboard/trace-analytics.test.ts @@ -1,16 +1,14 @@ import { describe, expect, test } from "bun:test"; import { - buildDurationHistogram, buildTimeSeries, countByCategory, + niceAxisTop, niceStep, - niceTicks, percentile, summarizeDurations, summarizeOperations, summarizeTraces, timeTicks, - type DurationBucket, type TraceSample, } from "./trace-analytics"; @@ -29,10 +27,6 @@ function trace( }; } -function totalCount(buckets: readonly DurationBucket[]): number { - return buckets.reduce((sum, bucket) => sum + bucket.count, 0); -} - describe("percentile", () => { test("interpolates between the closest ranks like percentile_cont", () => { const sorted = [10, 20, 30, 40]; @@ -138,7 +132,7 @@ describe("countByCategory", () => { }); }); -describe("niceStep / niceTicks", () => { +describe("niceStep / niceAxisTop", () => { test("rounds steps up to 1, 2 or 5 × 10^k", () => { expect(niceStep(0.7)).toBe(1); expect(niceStep(1.5)).toBe(2); @@ -148,66 +142,14 @@ describe("niceStep / niceTicks", () => { expect(niceStep(0.3, 1)).toBe(1); }); - test("produces clean ticks within the domain", () => { - expect(niceTicks(0, 100, 4)).toEqual([0, 50, 100]); - expect(niceTicks(0, 0.3, 3)).toEqual([0, 0.1, 0.2, 0.3]); - expect(niceTicks(3, 3)).toEqual([3]); - }); -}); - -describe("buildDurationHistogram", () => { - test("is empty for no durations", () => { - expect(buildDurationHistogram([], "linear")).toEqual([]); - expect(buildDurationHistogram([], "log")).toEqual([]); - }); - - test("bins linearly with nice widths and keeps every duration", () => { - const durations = Array.from({ length: 100 }, (_, i) => 100 + i); - const buckets = buildDurationHistogram(durations, "linear"); - expect(totalCount(buckets)).toBe(100); - const widths = new Set(buckets.map((b) => (b.upper ?? 0) - b.lower)); - expect(widths.size).toBe(1); - expect(buckets[0]!.lower).toBeLessThanOrEqual(100); - expect(buckets.at(-1)!.upper!).toBeGreaterThan(199); - }); - - test("puts identical durations in a single 1 ms bin", () => { - expect(buildDurationHistogram([5, 5, 5], "linear")).toEqual([ - { lower: 5, upper: 6, count: 3 }, - ]); - }); - - test("folds a long tail past the p95 into an open-ended overflow bucket", () => { - // 96 fast traces and a burst of 4 slow ones. - const durations = [...Array.from({ length: 96 }, (_, i) => i % 50), 3_000, 3_500, 3_900, 10_000]; - const buckets = buildDurationHistogram(durations, "linear"); - const overflow = buckets.at(-1)!; - expect(overflow.upper).toBeNull(); - expect(overflow.count).toBe(4); - expect(totalCount(buckets)).toBe(100); - // The bulk still gets fine bins instead of one bin squashed by the outlier. - expect(buckets.length).toBeGreaterThan(5); - }); - - test("bins logarithmically on the 1-2-5 series and trims empty edges", () => { - const buckets = buildDurationHistogram([3, 4, 60, 1500], "log"); - expect(buckets.map((b) => [b.lower, b.upper, b.count])).toEqual([ - [2, 5, 2], - [5, 10, 0], - [10, 20, 0], - [20, 50, 0], - [50, 100, 1], - [100, 200, 0], - [200, 500, 0], - [500, 1000, 0], - [1000, 2000, 1], - ]); - }); - - test("puts sub-millisecond (zero) durations in the first log bucket", () => { - expect(buildDurationHistogram([0, 0], "log")).toEqual([ - { lower: 0, upper: 1, count: 2 }, - ]); + test("tops a zero-based axis at the first nice tick at or above the maximum", () => { + expect(niceAxisTop(100, 4)).toBe(100); + expect(niceAxisTop(130, 4)).toBe(150); + expect(niceAxisTop(3, 4)).toBe(3); + // Whole-number axes never get fractional steps, and an empty axis still has height. + expect(niceAxisTop(0.4, 4, 1)).toBe(1); + expect(niceAxisTop(0, 4, 1)).toBe(1); + expect(niceAxisTop(NaN, 4, 1)).toBe(1); }); }); diff --git a/auth-server/src/components/ServerTracesDashboard/trace-analytics.ts b/auth-server/src/components/ServerTracesDashboard/trace-analytics.ts index 632b390d..842127e6 100644 --- a/auth-server/src/components/ServerTracesDashboard/trace-analytics.ts +++ b/auth-server/src/components/ServerTracesDashboard/trace-analytics.ts @@ -211,132 +211,19 @@ export function niceStep(raw_step: number, min_step: number = 0): number { return Math.max(min_step, nice * magnitude); } -/** Evenly spaced "nice" tick values covering [min, max] with about `target` intervals. */ -export function niceTicks(min: number, max: number, target: number = 4): number[] { - if (!Number.isFinite(min) || !Number.isFinite(max)) { - return []; - } - if (max <= min) { - return [min]; - } - const step: number = niceStep((max - min) / Math.max(1, target)); - const first: number = Math.ceil(min / step) * step; - const ticks: number[] = []; - for (let value = first; value <= max + step * 1e-9; value += step) { - // Round away float drift (0.1 + 0.2) so labels stay clean. - ticks.push(Number(value.toPrecision(12))); - } - return ticks; -} - -export const histogramScaleIds = ["linear", "log"] as const; -export type HistogramScaleId = (typeof histogramScaleIds)[number]; - -export interface DurationBucket { - /** Inclusive lower bound (ms). */ - lower: number; - /** Exclusive upper bound (ms); `null` for the open-ended overflow bucket. */ - upper: number | null; - count: number; -} - -/** Most bins a linear histogram draws before widening its bins. */ -const MAX_LINEAR_BINS: number = 40; -const MIN_LINEAR_BINS: number = 8; - /** - * Linear histogram bounds: bins of a nice width from the smallest duration. - * When a long tail would squash the bulk of the distribution (max beyond - * twice the p95), durations past the p95 fold into one overflow bucket; the - * log scale shows that tail spread out. + * Top of a zero-based value axis: the first "nice" tick at or above `max` + * when the axis is split into about `target` intervals of at least + * `min_step` (1 for whole counts and milliseconds). */ -function linearBucketBounds(sorted: readonly number[]): { - bounds: number[]; - overflow_from: number | null; -} { - const min: number = sorted[0]!; - const max: number = sorted[sorted.length - 1]!; - const p95: number = percentile(sorted, 0.95); - const has_long_tail: boolean = sorted.length >= 20 && max > 2 * p95 && p95 > min; - const top: number = has_long_tail ? p95 : max; - const bins: number = Math.min( - MAX_LINEAR_BINS, - Math.max(MIN_LINEAR_BINS, Math.ceil(Math.sqrt(sorted.length))), - ); - // Durations are whole milliseconds, so bins narrower than 1 ms are empty. - const width: number = niceStep((top - min) / bins, 1); - const start: number = Math.floor(min / width) * width; - const bounds: number[] = [start]; - while (bounds[bounds.length - 1]! <= top) { - bounds.push(bounds[bounds.length - 1]! + width); - } - return { - bounds, - overflow_from: has_long_tail ? bounds[bounds.length - 1]! : null, - }; -} - -/** Log histogram bounds: 0, 1, 2, 5, 10, 20, 50, … ms up past the largest duration. */ -function logBucketBounds(sorted: readonly number[]): number[] { - const max: number = sorted[sorted.length - 1]!; - const bounds: number[] = [0, 1]; - let magnitude: number = 1; - while (bounds[bounds.length - 1]! <= max) { - for (const multiple of [2, 5, 10]) { - bounds.push(multiple * magnitude); - if (bounds[bounds.length - 1]! > max) { - break; - } - } - magnitude *= 10; - } - return bounds; -} - -/** - * Bins `durations` for a histogram. Leading and trailing empty buckets are - * trimmed; empty buckets inside the range are kept so gaps stay visible. - */ -export function buildDurationHistogram( - durations: readonly number[], - scale: HistogramScaleId, -): DurationBucket[] { - if (durations.length === 0) { - return []; - } - const sorted: number[] = [...durations].sort(ascending); - const { bounds, overflow_from } = - scale === "log" - ? { bounds: logBucketBounds(sorted), overflow_from: null } - : linearBucketBounds(sorted); - - const buckets: DurationBucket[] = []; - for (let i = 0; i < bounds.length - 1; i += 1) { - buckets.push({ lower: bounds[i]!, upper: bounds[i + 1]!, count: 0 }); - } - if (overflow_from !== null) { - buckets.push({ lower: overflow_from, upper: null, count: 0 }); - } - - let index: number = 0; - for (const value of sorted) { - while ( - index < buckets.length - 1 && - buckets[index]!.upper !== null && - value >= buckets[index]!.upper! - ) { - index += 1; - } - buckets[index]!.count += 1; - } - - const first: number = buckets.findIndex( - (bucket: DurationBucket): boolean => bucket.count > 0, - ); - const last: number = buckets.findLastIndex( - (bucket: DurationBucket): boolean => bucket.count > 0, - ); - return buckets.slice(first, last + 1); +export function niceAxisTop( + max: number, + target: number = 4, + min_step: number = 1, +): number { + const clamped: number = Number.isFinite(max) ? Math.max(0, max) : 0; + const step: number = niceStep(clamped / Math.max(1, target), min_step); + return Math.max(step, Math.ceil(clamped / step) * step); } /** Candidate time-bucket widths, smallest first. */ diff --git a/auth-server/src/components/ServerTracesDashboard/trace-categories.ts b/auth-server/src/components/ServerTracesDashboard/trace-categories.ts index 161993ed..109d0c87 100644 --- a/auth-server/src/components/ServerTracesDashboard/trace-categories.ts +++ b/auth-server/src/components/ServerTracesDashboard/trace-categories.ts @@ -1,34 +1,12 @@ +import { CHART_OTHER_COLOR, CHART_SERIES_COLORS } from "@schemavaults/ui"; import type { ServerTraceOpCategory } from "@/lib/server-trace-schema"; /** - * Chart colors of the trace dashboard, set as CSS custom properties on its - * root so light and dark mode each get their own validated steps. - * - * Slots 1–4 of the data-viz reference palette (blue, orange, aqua, yellow), - * validated in this order against the card surfaces (`#ffffff` light, - * `#020817` dark): worst adjacent CVD ΔE 9.1 light / 8.4 dark, normal-vision - * ΔE ≥ 19.8. Aqua and yellow sit below 3:1 on the light surface, so every - * colored mark ships with a legend or labels and a table view. "Other" is the - * de-emphasis gray for categories this build does not know. - */ -export const TRACE_CHART_COLOR_VARIABLES: string = [ - "[--trace-series-1:#2a78d6] dark:[--trace-series-1:#3987e5]", - "[--trace-series-2:#eb6834] dark:[--trace-series-2:#d95926]", - "[--trace-series-3:#1baf7a] dark:[--trace-series-3:#199e70]", - "[--trace-series-4:#eda100] dark:[--trace-series-4:#c98500]", - "[--trace-series-other:#898781]", -].join(" "); - -/** The single-series mark color (slot 1). */ -export const TRACE_ACCENT_COLOR: string = "var(--trace-series-1)"; - -/** The de-emphasis gray: folded tails and unknown categories. */ -export const TRACE_OTHER_COLOR: string = "var(--trace-series-other)"; - -/** - * Categories in color-slot order. The mapping is fixed per category (color - * follows the entity), so filtering never repaints the survivors; - * `subroutine`, the category most traces carry, takes the leading slot. + * Categories in color-slot order of the `@schemavaults/theme` chart palette + * (`--chart-1` …). The mapping is fixed per category (color follows the + * entity), so filtering never repaints the survivors; `subroutine`, the + * category most traces carry, takes the leading slot. Categories this build + * does not know get the de-emphasis grey (`--chart-other`). */ export const TRACE_CATEGORY_ORDER: readonly ServerTraceOpCategory[] = [ "subroutine", @@ -37,13 +15,6 @@ export const TRACE_CATEGORY_ORDER: readonly ServerTraceOpCategory[] = [ "http_response", ]; -const CATEGORY_COLORS: Record = { - subroutine: "var(--trace-series-1)", - database_query: "var(--trace-series-2)", - http_request: "var(--trace-series-3)", - http_response: "var(--trace-series-4)", -}; - const CATEGORY_LABELS: Record = { subroutine: "Subroutine", database_query: "Database query", @@ -55,10 +26,10 @@ function isKnownCategory(category: string): category is ServerTraceOpCategory { return (TRACE_CATEGORY_ORDER as readonly string[]).includes(category); } +/** The category's chart color as a CSS value (segment fills and legend swatches). */ export function getTraceCategoryColor(category: string): string { - return isKnownCategory(category) - ? CATEGORY_COLORS[category] - : TRACE_OTHER_COLOR; + const slot: number = (TRACE_CATEGORY_ORDER as readonly string[]).indexOf(category); + return slot >= 0 ? (CHART_SERIES_COLORS[slot] ?? CHART_OTHER_COLOR) : CHART_OTHER_COLOR; } export function getTraceCategoryLabel(category: string): string { diff --git a/bun.lock b/bun.lock index 6859f194..d7131238 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "auth-server": { "name": "@schemavaults/auth-server", - "version": "0.46.1", + "version": "0.46.2", "dependencies": { "@hookform/resolvers": "5.9.1", "@schemavaults/app-definitions": "workspace:*", @@ -25,8 +25,8 @@ "@schemavaults/openapi-docs-ui": "workspace:*", "@schemavaults/openapi-operations": "workspace:*", "@schemavaults/send-email": "0.3.1", - "@schemavaults/theme": "0.26.1", - "@schemavaults/ui": "0.101.0", + "@schemavaults/theme": "0.29.0", + "@schemavaults/ui": "0.110.1", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", "cookies-next": "6.1.1", @@ -155,14 +155,14 @@ }, "packages/auth-resource-server-codegen-templates": { "name": "@schemavaults/auth-resource-server-codegen-templates", - "version": "0.0.150", + "version": "0.0.151", "devDependencies": { "@eslint/js": "9.39.1", "@schemavaults/auth-client-sdk": "workspace:*", "@schemavaults/auth-common": "workspace:*", "@schemavaults/auth-react-provider": "workspace:*", "@schemavaults/jwt": "workspace:*", - "@schemavaults/ui": "^0.101.0", + "@schemavaults/ui": "^0.110.1", "@types/react": "19.2.14", "@typescript-eslint/eslint-plugin": "8.48.1", "@typescript-eslint/parser": "8.48.1", @@ -180,7 +180,7 @@ }, "packages/auth-server-sdk": { "name": "@schemavaults/auth-server-sdk", - "version": "0.31.0", + "version": "0.31.1", "bin": { "auth-server-sdk": "./dist/cli.cjs", }, @@ -213,7 +213,7 @@ }, "packages/auth-ui": { "name": "@schemavaults/auth-ui", - "version": "0.17.0", + "version": "0.17.1", "dependencies": { "@hookform/resolvers": "5.9.1", }, @@ -236,7 +236,7 @@ "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", "@schemavaults/auth-react-provider": "workspace:*", - "@schemavaults/ui": "^0.101.0", + "@schemavaults/ui": "^0.110.1", "lucide-react": "0.575.0", "next": "^16.3.5", "postcss": "8.5.28", @@ -268,7 +268,7 @@ }, "packages/openapi-docs-ui": { "name": "@schemavaults/openapi-docs-ui", - "version": "0.1.7", + "version": "0.1.8", "devDependencies": { "@eslint/js": "9.39.1", "@types/react": "19.2.14", @@ -285,7 +285,7 @@ "typescript": "6.0.3", }, "peerDependencies": { - "@schemavaults/ui": "^0.101.0", + "@schemavaults/ui": "^0.110.1", "next": "^16.3.5", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -314,7 +314,7 @@ }, "packages/trpc-backend-init": { "name": "@schemavaults/trpc-backend-init", - "version": "0.9.66", + "version": "0.9.67", "devDependencies": { "bun-types": "1.4.2", "tsc-alias": "1.9.0", @@ -361,7 +361,7 @@ }, "tests/example-nextjs-resource-server": { "name": "@schemavaults/example-nextjs-resource-server", - "version": "0.5.8", + "version": "0.5.9", "dependencies": { "@schemavaults/auth-client-sdk": "workspace:*", "@schemavaults/auth-common": "workspace:*", @@ -369,8 +369,8 @@ "@schemavaults/auth-server-sdk": "workspace:*", "@schemavaults/openapi-docs-ui": "workspace:*", "@schemavaults/openapi-operations": "workspace:*", - "@schemavaults/theme": "0.26.1", - "@schemavaults/ui": "0.101.0", + "@schemavaults/theme": "0.29.0", + "@schemavaults/ui": "0.110.1", "next": "16.3.5", "openid-client": "6.8.4", "react": "19.2.8", @@ -812,11 +812,11 @@ "@schemavaults/send-email": ["@schemavaults/send-email@0.3.1", "", { "dependencies": { "commander": "14.0.1", "zod": "4.4.3" }, "bin": { "schemavaults-send-email": "dist/cli.js" } }, "sha512-SR6pEXaKD5annFKQRRKqJJFU80tGsAi97dV8XA5G+07pkUKWCahL/hNHqgxw87dPGyXhjtJ8VNpVDp75tUBl7A=="], - "@schemavaults/theme": ["@schemavaults/theme@0.26.1", "", { "dependencies": { "resolve": "1.22.11", "tailwindcss-animate": "1.0.7" } }, "sha512-jVVa0eRMV9i+xu8wuzJaui57W/37Pd0zBFqVa7PTmtq//1xuZ91c3tm8FNA80DAgKbrmfs4gH74UW0c/QSuEtA=="], + "@schemavaults/theme": ["@schemavaults/theme@0.29.0", "", { "dependencies": { "resolve": "1.22.11", "tailwindcss-animate": "1.0.7" } }, "sha512-aaVojqvyDIRg7PCXlKL3RG+6K8RvFV5vHlnO52BBpwAhYPWk50yQHon050xWpwrTSHX7oy/shAm0jSj5XPkfEw=="], "@schemavaults/trpc-backend-init": ["@schemavaults/trpc-backend-init@workspace:packages/trpc-backend-init"], - "@schemavaults/ui": ["@schemavaults/ui@0.101.0", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@hookform/resolvers": "3.9.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-navigation-menu": "1.2.13", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-visually-hidden": "1.2.3", "@schemavaults/theme": "0.26.1", "@tanstack/react-table": "8.20.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "framer-motion": "13.0.0", "lucide-react": "0.575.0", "next-themes": "0.4.6", "react-hook-form": "7.76.1", "tailwind-merge": "3.0.2" }, "peerDependencies": { "react": "19.2.8", "react-dom": "19.2.8" } }, "sha512-fkgA7hbQR5CZM/6ygzh0kPnsdSkGWyXeo+jVsfF6qnwuUF1iRS+1mW/X1zxLYzBoc5MSE6KGtL4b2sjg0xJ4SA=="], + "@schemavaults/ui": ["@schemavaults/ui@0.110.1", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@hookform/resolvers": "3.9.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-navigation-menu": "1.2.13", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-visually-hidden": "1.2.3", "@schemavaults/theme": "0.29.0", "@tanstack/react-table": "8.20.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "framer-motion": "13.0.0", "lucide-react": "0.575.0", "next-themes": "0.4.6", "react-hook-form": "7.76.1", "tailwind-merge": "3.0.2" }, "peerDependencies": { "react": "19.2.8", "react-dom": "19.2.8" } }, "sha512-5NuPsqudIHwAfZbDpjSpBRYyok3lpzrkhfVYoTvxnZ6aXoMMMd0lPYfMwFf3XSR4irb8YBDMs+R/9Q1DyoN5VQ=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], diff --git a/packages/auth-resource-server-codegen-templates/package.json b/packages/auth-resource-server-codegen-templates/package.json index 5cd1dba9..d9a41f3b 100644 --- a/packages/auth-resource-server-codegen-templates/package.json +++ b/packages/auth-resource-server-codegen-templates/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/auth-resource-server-codegen-templates", "description": "Templates used for building your own app that logs in via SchemaVaults Auth", - "version": "0.0.150", + "version": "0.0.151", "license": "UNLICENSED", "private": false, "repository": { @@ -22,7 +22,7 @@ "@schemavaults/auth-common": "workspace:*", "@schemavaults/auth-client-sdk": "workspace:*", "@schemavaults/auth-react-provider": "workspace:*", - "@schemavaults/ui": "^0.101.0", + "@schemavaults/ui": "^0.110.1", "typescript": "6.0.3", "bun-types": "1.4.2", "react": "^19.2.8", diff --git a/packages/auth-server-sdk/package.json b/packages/auth-server-sdk/package.json index f1e58356..ec7cf35a 100644 --- a/packages/auth-server-sdk/package.json +++ b/packages/auth-server-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/auth-server-sdk", "description": "TypeScript SDK for building authenticated endpoints/middlewares for the Auth Server and Resource Servers", - "version": "0.31.0", + "version": "0.31.1", "license": "UNLICENSED", "private": false, "repository": { diff --git a/packages/auth-ui/package.json b/packages/auth-ui/package.json index 9e1c0013..d03c81f5 100644 --- a/packages/auth-ui/package.json +++ b/packages/auth-ui/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/auth-ui", - "version": "0.17.0", + "version": "0.17.1", "private": false, "license": "UNLICENSED", "description": "React.js UI components for authentication/authorization shared between different SchemaVaults apps", @@ -16,7 +16,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "next": "^16.3.5", - "@schemavaults/ui": "^0.101.0", + "@schemavaults/ui": "^0.110.1", "@schemavaults/auth-react-provider": "workspace:*", "@schemavaults/app-definitions": "workspace:*", "@schemavaults/auth-common": "workspace:*", diff --git a/packages/auth-ui/src/components/UsersChartsRow/UsersBreakdownCard.tsx b/packages/auth-ui/src/components/UsersChartsRow/UsersBreakdownCard.tsx index f62b9acc..710d897f 100644 --- a/packages/auth-ui/src/components/UsersChartsRow/UsersBreakdownCard.tsx +++ b/packages/auth-ui/src/components/UsersChartsRow/UsersBreakdownCard.tsx @@ -9,6 +9,7 @@ import { CardTitle, PieChart, cn, + getChartColorClasses, type PieChartSegment, type PieChartSegmentColorId, } from "@schemavaults/ui"; @@ -30,10 +31,8 @@ export interface UsersBreakdownCardProps { interface CategoryDescriptor { id: UserCategoryId; label: string; - /** Preset from the `@schemavaults/ui` chart palette. */ + /** Preset from the `@schemavaults/ui` chart palette (also paints the legend swatch). */ color: PieChartSegmentColorId; - /** Matching Tailwind background for the legend swatch. */ - swatchClassName: string; Icon: typeof UserRound; /** Hover text; the legend itself stays a single line per category. */ title: string; @@ -48,8 +47,7 @@ const CATEGORIES: readonly CategoryDescriptor[] = [ { id: "normal", label: "Standard", - color: "default", - swatchClassName: "bg-schemavaults-brand-blue", + color: "chart-1", Icon: UserRound, title: "Active accounts without administrator privileges.", }, @@ -57,7 +55,6 @@ const CATEGORIES: readonly CategoryDescriptor[] = [ id: "admin", label: "Admins", color: "warning", - swatchClassName: "bg-warning", Icon: ShieldCheck, title: "Active accounts with administrator privileges. Disabled administrators are counted under Disabled, so this can be lower than the Admins stat card.", @@ -66,7 +63,6 @@ const CATEGORIES: readonly CategoryDescriptor[] = [ id: "disabled", label: "Disabled", color: "destructive", - swatchClassName: "bg-destructive", Icon: UserX, title: "Accounts blocked from signing in, administrator or not.", }, @@ -168,7 +164,7 @@ export function UsersBreakdownCard( aria-hidden="true" className={cn( "size-2.5 shrink-0 rounded-full", - category.swatchClassName, + getChartColorClasses(category.color).bg, )} /> = { "7d": "7 days", "30d": "30 days", @@ -97,11 +92,6 @@ export function UsersGrowthCard(props: UsersGrowthCardProps): ReactElement { [series.buckets], ); - const signups_chart = useMeasuredWidth( - SIGNUPS_FALLBACK_WIDTH, - ); - const total_chart = useMeasuredWidth(TOTAL_FALLBACK_WIDTH); - const loading: boolean = !users.data; const first_bucket: UserGrowthBucket | undefined = series.buckets[0]; const last_bucket: UserGrowthBucket | undefined = @@ -169,19 +159,17 @@ export function UsersGrowthCard(props: UsersGrowthCardProps): ReactElement {
-
- -
+ {first_bucket && last_bucket ? (
{formatUtcDay(first_bucket.start)} @@ -205,15 +193,15 @@ export function UsersGrowthCard(props: UsersGrowthCardProps): ReactElement { Total users -
+
diff --git a/packages/auth-ui/src/lib/useMeasuredWidth.ts b/packages/auth-ui/src/lib/useMeasuredWidth.ts index 6669b558..7790fb96 100644 --- a/packages/auth-ui/src/lib/useMeasuredWidth.ts +++ b/packages/auth-ui/src/lib/useMeasuredWidth.ts @@ -1,53 +1,5 @@ -"use client"; - -import { useEffect, useRef, useState, type RefObject } from "react"; - -export interface UseMeasuredWidthResult { - /** Attach to the element whose width the chart should match. */ - ref: RefObject; - /** The measured width in whole pixels, or `fallback_width` before measuring. */ - width: number; -} - -/** - * @description Tracks an element's rendered width. The chart primitives in - * `@schemavaults/ui` size their SVG canvas in pixels, so a chart that should - * span its card has to be told how wide the card currently is. - * - * The fallback width is used for the server render and the first client - * render — the observer only reports after mount — so the two agree and - * hydration stays clean. - */ -export function useMeasuredWidth( - fallback_width: number, -): UseMeasuredWidthResult { - const ref = useRef(null); - const [width, setWidth] = useState(fallback_width); - - useEffect((): (() => void) | undefined => { - const element: T | null = ref.current; - if (!element || typeof ResizeObserver === "undefined") { - return undefined; - } - - const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { - const entry: ResizeObserverEntry | undefined = entries[0]; - if (!entry) { - return; - } - const measured: number = Math.round(entry.contentRect.width); - if (measured > 0) { - setWidth(measured); - } - }); - observer.observe(element); - - return (): void => { - observer.disconnect(); - }; - }, []); - - return { ref, width }; -} - -export default useMeasuredWidth; +// The width hook behind the `@schemavaults/ui` charts' `width="auto"`, +// re-exported so existing `@schemavaults/auth-ui` imports keep working +// (auth-ui 0.17.0 shipped its own copy). +export { useMeasuredWidth, useMeasuredWidth as default } from "@schemavaults/ui"; +export type { UseMeasuredWidthResult } from "@schemavaults/ui"; diff --git a/packages/openapi-docs-ui/package.json b/packages/openapi-docs-ui/package.json index 3adcc669..617c5096 100644 --- a/packages/openapi-docs-ui/package.json +++ b/packages/openapi-docs-ui/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/openapi-docs-ui", "description": "React.js components (built on @schemavaults/ui) and Next.js page factories for browsing an OpenAPI document: routes, permissions, and auth details", - "version": "0.1.7", + "version": "0.1.8", "license": "UNLICENSED", "private": false, "repository": { @@ -18,7 +18,7 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "next": "^16.3.5", - "@schemavaults/ui": "^0.101.0" + "@schemavaults/ui": "^0.110.1" }, "devDependencies": { "typescript": "6.0.3", diff --git a/packages/trpc-backend-init/package.json b/packages/trpc-backend-init/package.json index d6d16332..84727173 100644 --- a/packages/trpc-backend-init/package.json +++ b/packages/trpc-backend-init/package.json @@ -1,7 +1,7 @@ { "name": "@schemavaults/trpc-backend-init", "description": "tRPC Server-side Router Factory", - "version": "0.9.66", + "version": "0.9.67", "private": false, "license": "UNLICENSED", "repository": { diff --git a/tests/example-nextjs-resource-server/package.json b/tests/example-nextjs-resource-server/package.json index cb84be89..5915158b 100644 --- a/tests/example-nextjs-resource-server/package.json +++ b/tests/example-nextjs-resource-server/package.json @@ -1,6 +1,6 @@ { "name": "@schemavaults/example-nextjs-resource-server", - "version": "0.5.8", + "version": "0.5.9", "license": "UNLICENSED", "private": false, "repository": { @@ -15,8 +15,8 @@ "@schemavaults/auth-server-sdk": "workspace:*", "@schemavaults/openapi-docs-ui": "workspace:*", "@schemavaults/openapi-operations": "workspace:*", - "@schemavaults/theme": "0.26.1", - "@schemavaults/ui": "0.101.0", + "@schemavaults/theme": "0.29.0", + "@schemavaults/ui": "0.110.1", "next": "16.3.5", "openid-client": "6.8.4", "react": "19.2.8",