Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/overview-rejected-range.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"seamless-auth-admin-dashboard": patch
---

Overview no longer shows zeros for a custom range the API refuses. A range longer than the API's 366 day maximum is caught before it is sent, and the page says how long the range is and what the limit is. A range the API rejects anyway is reported as an error, with the range picker still in place to fix it. The fixed 24 hour copy now appears only when the API answers without range support, not while a request is pending or has failed. Queries no longer retry a 400 or 422, which fails the same way every time.
8 changes: 7 additions & 1 deletion src/hooks/useAuthTimeseries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { apiFetch } from "../lib/api";
import type { AuthEventTimeseriesResponse } from "@seamless-auth/types";

export function useAuthTimeseries(
params: { from?: string; to?: string; interval?: "hour" | "day" } = {},
params: {
from?: string;
to?: string;
interval?: "hour" | "day";
enabled?: boolean;
} = {},
) {
const interval = params.interval ?? "hour";
const query = new URLSearchParams({ interval });
Expand All @@ -20,6 +25,7 @@ export function useAuthTimeseries(

return useQuery({
queryKey: ["auth-timeseries", interval, params.from, params.to],
enabled: params.enabled ?? true,
queryFn: () =>
apiFetch<AuthEventTimeseriesResponse>(
`/internal/auth-events/timeseries?${query}`,
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { apiFetch } from "../lib/api";
import type { DashboardMetricsResponse } from "@seamless-auth/types";

export function useDashboard(params: { from?: string; to?: string } = {}) {
export function useDashboard(
params: { from?: string; to?: string; enabled?: boolean } = {},
) {
const query = new URLSearchParams();

if (params.from) query.set("from", params.from);
Expand All @@ -18,6 +20,7 @@ export function useDashboard(params: { from?: string; to?: string } = {}) {

return useQuery({
queryKey: ["dashboard", params.from, params.to],
enabled: params.enabled ?? true,
queryFn: () =>
apiFetch<DashboardMetricsResponse>(
`/internal/metrics/dashboard${search ? `?${search}` : ""}`,
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useFunnelMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export interface FunnelMetricsResponse {
timeToFirstPasskey: IntervalStats;
}

export function useFunnelMetrics(params: { from?: string; to?: string } = {}) {
export function useFunnelMetrics(
params: { from?: string; to?: string; enabled?: boolean } = {},
) {
const query = new URLSearchParams();

if (params.from) query.set("from", params.from);
Expand All @@ -39,6 +41,7 @@ export function useFunnelMetrics(params: { from?: string; to?: string } = {}) {

return useQuery({
queryKey: ["funnelMetrics", params.from, params.to],
enabled: params.enabled ?? true,
queryFn: () =>
apiFetch<FunnelMetricsResponse>(
`/internal/metrics/funnel${search ? `?${search}` : ""}`,
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useGroupedEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export interface GroupedEvents {
}[];
}

export function useGroupedEvents(params: { from?: string; to?: string } = {}) {
export function useGroupedEvents(
params: { from?: string; to?: string; enabled?: boolean } = {},
) {
const query = new URLSearchParams();

if (params.from) query.set("from", params.from);
Expand All @@ -27,6 +29,7 @@ export function useGroupedEvents(params: { from?: string; to?: string } = {}) {

return useQuery({
queryKey: ["grouped-events", params.from, params.to],
enabled: params.enabled ?? true,
queryFn: async (): Promise<GroupedEvents> => {
const data = await apiFetch<AuthEventSummaryResponse>(
`/internal/auth-events/summary${search ? `?${search}` : ""}`,
Expand Down
5 changes: 4 additions & 1 deletion src/hooks/useSignInMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export interface SignInMetricsResponse {
breakdown: SignInBreakdownRow[];
}

export function useSignInMetrics(params: { from?: string; to?: string } = {}) {
export function useSignInMetrics(
params: { from?: string; to?: string; enabled?: boolean } = {},
) {
const query = new URLSearchParams();

if (params.from) query.set("from", params.from);
Expand All @@ -53,6 +55,7 @@ export function useSignInMetrics(params: { from?: string; to?: string } = {}) {

return useQuery({
queryKey: ["signInMetrics", params.from, params.to],
enabled: params.enabled ?? true,
queryFn: () =>
apiFetch<SignInMetricsResponse>(
`/internal/metrics/sign-ins${search ? `?${search}` : ""}`,
Expand Down
29 changes: 29 additions & 0 deletions src/lib/queryRetry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

import { describe, expect, it } from "vitest";
import { ApiError } from "./api";
import { shouldRetryQuery } from "./queryRetry";

describe("shouldRetryQuery", () => {
it("retries transient failures up to three times", () => {
const error = new ApiError("The Seamless Auth API had a problem.", 503);

expect(shouldRetryQuery(0, error)).toBe(true);
expect(shouldRetryQuery(2, error)).toBe(true);
expect(shouldRetryQuery(3, error)).toBe(false);
expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true);
});

it("does not retry an expired session", () => {
expect(shouldRetryQuery(0, new ApiError("expired", 401))).toBe(false);
});

it("does not retry a request the API rejected as invalid", () => {
expect(shouldRetryQuery(0, new ApiError("invalid", 400))).toBe(false);
expect(shouldRetryQuery(0, new ApiError("invalid", 422))).toBe(false);
});
});
20 changes: 20 additions & 0 deletions src/lib/queryRetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the Apache License, Version 2.0
* See LICENSE file in the project root for full license information
*/

import { ApiError } from "./api";

// An expired session is not a transient failure, and retrying it delays the
// redirect while firing more requests that cannot succeed. A rejected request
// (a range over the API's maximum, say) fails the same way every time, and
// retrying it held the page in a loading state for seconds before the error.
const NON_RETRYABLE_STATUSES = new Set([400, 401, 422]);

export function shouldRetryQuery(failureCount: number, error: unknown) {
return (
!(error instanceof ApiError && NON_RETRYABLE_STATUSES.has(error.status)) &&
failureCount < 3
);
}
30 changes: 30 additions & 0 deletions src/lib/timeRange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
getRangeFromSearch,
intervalForRange,
isTimeRange,
rangeExceedsMaximum,
rangeSpanMs,
resolveRangeBounds,
toDateTimeLocalValue,
} from "./timeRange";
Expand Down Expand Up @@ -246,3 +248,31 @@ describe("describeRange", () => {
expect(describeRange({ range: "custom" })).toBe("the selected window");
});
});

describe("rangeExceedsMaximum", () => {
const day = 1000 * 60 * 60 * 24;
const from = "2026-01-01T00:00:00.000Z";
const start = new Date(from).getTime();

it("accepts exactly 366 days, the API's day-interval cap", () => {
const to = new Date(start + 366 * day).toISOString();

expect(rangeExceedsMaximum({ from, to }, 0)).toBe(false);
expect(
rangeExceedsMaximum(
{ from, to: new Date(start + 366 * day + 1).toISOString() },
0,
),
).toBe(true);
});

it("measures an open-ended window to the reference, as the API does", () => {
expect(rangeSpanMs({ from }, start + 5 * day)).toBe(5 * day);
expect(rangeExceedsMaximum({ from }, start + 400 * day)).toBe(true);
});

it("has nothing to measure without a start", () => {
expect(rangeExceedsMaximum({}, Date.now())).toBe(false);
expect(rangeExceedsMaximum({ to: from }, Date.now())).toBe(false);
});
});
29 changes: 29 additions & 0 deletions src/lib/timeRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,32 @@ export function describeRange(filter: RangeFilterValue): string {

return "the selected window";
}

// Mirrors MAX_METRICS_WINDOW_MS.day in seamless-auth-api's internal.query.ts,
// the cap on every ranged metrics endpoint Overview calls. The API caps hourly
// buckets at 31 days, but intervalForRange switches to daily long before that.
export const MAX_RANGE_DAYS = 366;
const MAX_RANGE_MS = 1000 * 60 * 60 * 24 * MAX_RANGE_DAYS;

/**
* How long the bounds span. A window with no end runs to `reference`, which is
* how the API measures an open-ended range.
*/
export function rangeSpanMs(
bounds: { from?: string; to?: string },
reference: number,
): number {
if (!bounds.from) return 0;

const end = bounds.to ? new Date(bounds.to).getTime() : reference;

return end - new Date(bounds.from).getTime();
}

/** True when the bounds span more than the API accepts. */
export function rangeExceedsMaximum(
bounds: { from?: string; to?: string },
reference: number,
): boolean {
return rangeSpanMs(bounds, reference) > MAX_RANGE_MS;
}
8 changes: 2 additions & 6 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,13 @@ import ThemeProvider from "./components/ThemeProvider";
import ToastProvider from "./components/ToastProvider";
import ConfirmProvider from "./components/ConfirmProvider";
import TotpPromptProvider from "./components/TotpPromptProvider";
import { ApiError } from "./lib/api";
import { shouldRetryQuery } from "./lib/queryRetry";
import "./index.css";

const queryClient = new QueryClient({
defaultOptions: {
queries: {
// An expired session is not a transient failure, and retrying it delays
// the redirect while firing more requests that cannot succeed.
retry: (failureCount, error) =>
!(error instanceof ApiError && error.status === 401) &&
failureCount < 3,
retry: shouldRetryQuery,
},
},
});
Expand Down
Loading
Loading