From c00ee47fc177652f484f54ccb961c8649ee68ab6 Mon Sep 17 00:00:00 2001
From: Brandon Corbett
Date: Tue, 3 Nov 2026 10:01:29 -0500
Subject: [PATCH] fix(overview): report a refused range instead of showing
zeros
A custom range over the API's 366 day maximum drew a 400 from every metrics
endpoint. The page retried each one, then rendered the figures as zeros under
copy saying the API takes no date range.
Overview now checks the span before sending it and names the limit. A 400 from
any of its queries is shown as a rejected range, with the picker kept in place.
The fixed 24 hour copy is shown only for a response without a window. Queries
no longer retry 400 or 422 responses.
---
.changeset/overview-rejected-range.md | 5 +
src/hooks/useAuthTimeseries.ts | 8 +-
src/hooks/useDashboard.ts | 5 +-
src/hooks/useFunnelMetrics.ts | 5 +-
src/hooks/useGroupedEvents.ts | 5 +-
src/hooks/useSignInMetrics.ts | 5 +-
src/lib/queryRetry.test.ts | 29 ++
src/lib/queryRetry.ts | 20 +
src/lib/timeRange.test.ts | 30 ++
src/lib/timeRange.ts | 29 ++
src/main.tsx | 8 +-
src/pages/Overview.test.tsx | 130 ++++-
src/pages/Overview.tsx | 685 ++++++++++++++------------
13 files changed, 625 insertions(+), 339 deletions(-)
create mode 100644 .changeset/overview-rejected-range.md
create mode 100644 src/lib/queryRetry.test.ts
create mode 100644 src/lib/queryRetry.ts
diff --git a/.changeset/overview-rejected-range.md b/.changeset/overview-rejected-range.md
new file mode 100644
index 0000000..8daf2d5
--- /dev/null
+++ b/.changeset/overview-rejected-range.md
@@ -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.
diff --git a/src/hooks/useAuthTimeseries.ts b/src/hooks/useAuthTimeseries.ts
index 4a85fef..c311fd7 100644
--- a/src/hooks/useAuthTimeseries.ts
+++ b/src/hooks/useAuthTimeseries.ts
@@ -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 });
@@ -20,6 +25,7 @@ export function useAuthTimeseries(
return useQuery({
queryKey: ["auth-timeseries", interval, params.from, params.to],
+ enabled: params.enabled ?? true,
queryFn: () =>
apiFetch(
`/internal/auth-events/timeseries?${query}`,
diff --git a/src/hooks/useDashboard.ts b/src/hooks/useDashboard.ts
index bf08808..5a632ed 100644
--- a/src/hooks/useDashboard.ts
+++ b/src/hooks/useDashboard.ts
@@ -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);
@@ -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(
`/internal/metrics/dashboard${search ? `?${search}` : ""}`,
diff --git a/src/hooks/useFunnelMetrics.ts b/src/hooks/useFunnelMetrics.ts
index 3947cd2..f6a459d 100644
--- a/src/hooks/useFunnelMetrics.ts
+++ b/src/hooks/useFunnelMetrics.ts
@@ -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);
@@ -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(
`/internal/metrics/funnel${search ? `?${search}` : ""}`,
diff --git a/src/hooks/useGroupedEvents.ts b/src/hooks/useGroupedEvents.ts
index 35563ae..29c3a77 100644
--- a/src/hooks/useGroupedEvents.ts
+++ b/src/hooks/useGroupedEvents.ts
@@ -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);
@@ -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 => {
const data = await apiFetch(
`/internal/auth-events/summary${search ? `?${search}` : ""}`,
diff --git a/src/hooks/useSignInMetrics.ts b/src/hooks/useSignInMetrics.ts
index 6579522..ebb2e7b 100644
--- a/src/hooks/useSignInMetrics.ts
+++ b/src/hooks/useSignInMetrics.ts
@@ -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);
@@ -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(
`/internal/metrics/sign-ins${search ? `?${search}` : ""}`,
diff --git a/src/lib/queryRetry.test.ts b/src/lib/queryRetry.test.ts
new file mode 100644
index 0000000..e99854f
--- /dev/null
+++ b/src/lib/queryRetry.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/queryRetry.ts b/src/lib/queryRetry.ts
new file mode 100644
index 0000000..be8b801
--- /dev/null
+++ b/src/lib/queryRetry.ts
@@ -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
+ );
+}
diff --git a/src/lib/timeRange.test.ts b/src/lib/timeRange.test.ts
index 81f9d94..cf3788a 100644
--- a/src/lib/timeRange.test.ts
+++ b/src/lib/timeRange.test.ts
@@ -13,6 +13,8 @@ import {
getRangeFromSearch,
intervalForRange,
isTimeRange,
+ rangeExceedsMaximum,
+ rangeSpanMs,
resolveRangeBounds,
toDateTimeLocalValue,
} from "./timeRange";
@@ -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);
+ });
+});
diff --git a/src/lib/timeRange.ts b/src/lib/timeRange.ts
index 59c11f9..3abe5ee 100644
--- a/src/lib/timeRange.ts
+++ b/src/lib/timeRange.ts
@@ -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;
+}
diff --git a/src/main.tsx b/src/main.tsx
index b95d11d..e8c5919 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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,
},
},
});
diff --git a/src/pages/Overview.test.tsx b/src/pages/Overview.test.tsx
index a0ff4bb..d2b1044 100644
--- a/src/pages/Overview.test.tsx
+++ b/src/pages/Overview.test.tsx
@@ -7,6 +7,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import { ApiError } from "../lib/api";
import Overview from "./Overview";
const mocks = vi.hoisted(() => ({
@@ -155,18 +156,10 @@ describe("Overview", () => {
expect(
new Date(bounds.to).getTime() - new Date(bounds.from).getTime(),
).toBe(24 * 60 * 60 * 1000);
- expect(mocks.useGroupedEvents).toHaveBeenCalledWith({
- from: bounds.from,
- to: bounds.to,
- });
- expect(mocks.useFunnelMetrics).toHaveBeenCalledWith({
- from: bounds.from,
- to: bounds.to,
- });
- expect(mocks.useSignInMetrics).toHaveBeenCalledWith({
- from: bounds.from,
- to: bounds.to,
- });
+ const expected = { from: bounds.from, to: bounds.to, enabled: true };
+ expect(mocks.useGroupedEvents).toHaveBeenCalledWith(expected);
+ expect(mocks.useFunnelMetrics).toHaveBeenCalledWith(expected);
+ expect(mocks.useSignInMetrics).toHaveBeenCalledWith(expected);
});
it("reads the window from the URL so a narrowed view is linkable", () => {
@@ -231,7 +224,11 @@ describe("Overview", () => {
renderPage("/?range=7d");
const { from, to } = mocks.useAuthTimeseries.mock.lastCall![0];
- expect(mocks.useDashboard).toHaveBeenLastCalledWith({ from, to });
+ expect(mocks.useDashboard).toHaveBeenLastCalledWith({
+ from,
+ to,
+ enabled: true,
+ });
expect(
screen.queryByText("24h auth attempts (fixed window)"),
).not.toBeInTheDocument();
@@ -430,4 +427,111 @@ describe("Overview", () => {
screen.getByText("Could not load overview metrics"),
).toBeInTheDocument();
});
+
+ it("does not send a range longer than the API accepts, and says so", () => {
+ // The production report: an all-time range drew a 400 from every metrics
+ // endpoint and rendered as zeros under the legacy fixed-window copy.
+ renderPage("/?range=custom&from=2025-01-01T00:00&to=2026-11-03T23:59");
+
+ for (const hook of [
+ mocks.useDashboard,
+ mocks.useAuthTimeseries,
+ mocks.useGroupedEvents,
+ mocks.useFunnelMetrics,
+ mocks.useSignInMetrics,
+ ]) {
+ expect(hook).toHaveBeenLastCalledWith(
+ expect.objectContaining({ enabled: false }),
+ );
+ }
+
+ expect(
+ screen.getByText("This range is too long to report on"),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(/reports on at most 366 days at a time/),
+ ).toBeInTheDocument();
+ expect(screen.getByText(/this range spans \d+ days/)).toBeInTheDocument();
+ expect(screen.getByLabelText("Range start")).toBeInTheDocument();
+
+ expect(
+ screen.queryByText(/This API's metrics endpoint takes no date range/),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("Successful Logins")).not.toBeInTheDocument();
+ expect(screen.queryByText("Passwordless Funnel")).not.toBeInTheDocument();
+ expect(screen.queryByText(/Auth attempts in/)).not.toBeInTheDocument();
+ });
+
+ it("sends a custom range inside the maximum", () => {
+ renderPage("/?range=custom&from=2026-08-01T00:00&to=2026-11-03T23:59");
+
+ expect(mocks.useDashboard).toHaveBeenLastCalledWith(
+ expect.objectContaining({ enabled: true }),
+ );
+ expect(
+ screen.queryByText("This range is too long to report on"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("reports a range the API rejects instead of showing zeros", () => {
+ const rejected = new ApiError(
+ "Request failed schema validation.",
+ 400,
+ "validation_error",
+ );
+ mocks.useDashboard.mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ error: rejected,
+ refetch: mocks.refetch,
+ isFetching: false,
+ dataUpdatedAt: 0,
+ });
+ mocks.useFunnelMetrics.mockReturnValue({
+ data: undefined,
+ isError: true,
+ error: rejected,
+ refetch: mocks.refetch,
+ });
+
+ renderPage("/?range=custom&from=2026-01-01T00:00&to=2026-11-03T23:59");
+
+ expect(screen.getByText("The API rejected this range")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /Request failed schema validation\. .*spans at most 366 days/,
+ ),
+ ).toBeInTheDocument();
+ // The range stays editable, so the operator can fix what was refused.
+ expect(screen.getByLabelText("Range start")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Could not load overview metrics"),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/This API's metrics endpoint takes no date range/),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("Successful Logins")).not.toBeInTheDocument();
+ });
+
+ it("keeps the fixed-window copy for a response, not a pending request", () => {
+ mocks.useDashboard.mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ error: null,
+ refetch: mocks.refetch,
+ isFetching: true,
+ dataUpdatedAt: 0,
+ });
+
+ renderPage("/?range=7d");
+
+ expect(
+ screen.queryByText(/This API's metrics endpoint takes no date range/),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText("24h auth attempts (fixed window)"),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/src/pages/Overview.tsx b/src/pages/Overview.tsx
index c601467..d90a34b 100644
--- a/src/pages/Overview.tsx
+++ b/src/pages/Overview.tsx
@@ -25,6 +25,7 @@ import { Section } from "../components/Section";
import { QueryErrorState, StateMessage } from "../components/StateMessage";
import RangeFilter from "../components/RangeFilter";
import ReviewAccountsNotice from "../components/ReviewAccountsNotice";
+import { ApiError } from "../lib/api";
import { getErrorMessage } from "../lib/errorMessage";
import { formatBytes } from "../lib/formatBytes";
import { formatDuration } from "../lib/formatDuration";
@@ -34,6 +35,9 @@ import {
describeRange,
getRangeFromSearch,
intervalForRange,
+ MAX_RANGE_DAYS,
+ rangeExceedsMaximum,
+ rangeSpanMs,
resolveRangeBounds,
} from "../lib/timeRange";
@@ -49,6 +53,12 @@ function describeInterval(stats: IntervalStats, noun: string, range: string) {
return `p90 ${formatDuration(stats.p90Seconds)} across ${stats.count.toLocaleString()} ${noun} in ${range}`;
}
+const DAY_MS = 1000 * 60 * 60 * 24;
+
+function isRejectedRequest(error: unknown) {
+ return error instanceof ApiError && error.status === 400;
+}
+
export default function Overview() {
const navigate = useNavigate();
const location = useLocation();
@@ -75,6 +85,12 @@ export default function Overview() {
const rangeLabel = describeRange(range);
+ // A window the API would refuse is never sent. Every figure on the screen
+ // depends on it, and the refusal used to render as zeros under copy blaming
+ // an API that predates ranges.
+ const rangeTooLong = rangeExceedsMaximum(bounds, referenceNow);
+ const queryParams = { ...bounds, enabled: !rangeTooLong };
+
const handleRangeChange = (next: typeof range) =>
navigate({
pathname: "/",
@@ -89,31 +105,31 @@ export default function Overview() {
refetch,
isFetching,
dataUpdatedAt,
- } = useDashboard(bounds);
+ } = useDashboard(queryParams);
const {
data: timeseries,
isError: timeseriesError,
error: timeseriesErrorValue,
refetch: refetchTimeseries,
- } = useAuthTimeseries({ ...bounds, interval });
+ } = useAuthTimeseries({ ...queryParams, interval });
const {
data: grouped,
isError: groupedError,
error: groupedErrorValue,
refetch: refetchGrouped,
- } = useGroupedEvents(bounds);
+ } = useGroupedEvents(queryParams);
const {
data: funnel,
isError: funnelError,
error: funnelErrorValue,
refetch: refetchFunnel,
- } = useFunnelMetrics(bounds);
+ } = useFunnelMetrics(queryParams);
const {
data: signIns,
isError: signInsError,
error: signInsErrorValue,
refetch: refetchSignIns,
- } = useSignInMetrics(bounds);
+ } = useSignInMetrics(queryParams);
const dropOff = signIns ? describeDropOff(signIns.attempts) : undefined;
const byMethod = signIns ? pivotBreakdown(signIns.breakdown, "method") : [];
@@ -124,10 +140,25 @@ export default function Overview() {
? pivotBreakdown(signIns.breakdown, "mailProvider")
: [];
+ // The API answers 400 when it refuses the window, which can still happen if
+ // its cap is tighter than the one mirrored here.
+ const rejectedError = [
+ error,
+ timeseriesErrorValue,
+ groupedErrorValue,
+ funnelErrorValue,
+ signInsErrorValue,
+ ].find(isRejectedRequest);
+
+ const showFigures = !rangeTooLong && !rejectedError;
+
const figures = dashboardFigures(data);
+ // Only a response without a window marks an API that predates ranges. No
+ // response at all says nothing about the API, so it never earns that copy.
+ const fixedWindow = data !== undefined && !figures.ranged;
// Said wherever a figure appears, so a tile never claims to follow the range
// selector while an API that predates ranges is still giving it 24 hours.
- const figuresWindow = figures.ranged ? rangeLabel : "the last 24 hours";
+ const figuresWindow = fixedWindow ? "the last 24 hours" : rangeLabel;
const totalAttempts = figures.loginSuccess + figures.loginFailed;
// With no attempts in the window there is no rate to report. Deriving one
// anyway rendered "100%" next to "Elevated enough to merit review" on the
@@ -152,7 +183,7 @@ export default function Overview() {
const securitySignalCount =
grouped?.summary.find((item) => item.type === "security")?.count ?? 0;
- if (isError) {
+ if (isError && !isRejectedRequest(error)) {
return (
A live snapshot of authentication health, growth, and operator
attention areas across your Seamless Auth deployment.{" "}
- {figures.ranged
- ? "The range below drives every figure on this screen apart from the current totals."
- : "The range below drives the activity chart and the event distribution."}
+ {fixedWindow
+ ? "The range below drives the activity chart and the event distribution."
+ : "The range below drives every figure on this screen apart from the current totals."}
@@ -209,334 +240,358 @@ export default function Overview() {
label="Activity time range"
/>
-
-
-
+
+
+
+
+ ) : rangeTooLong ? (
+
-
-
+ ) : null}
-
-
navigate("/security")}
- actionLabel="Open Security"
- />
+ {showFigures && (
+
+ navigate("/security")}
+ actionLabel="Open Security"
+ />
- navigate("/users")}
- actionLabel="Review Users"
- />
+ navigate("/users")}
+ actionLabel="Review Users"
+ />
- navigate("/events")}
- actionLabel="Inspect Events"
- />
-
+ navigate("/events")}
+ actionLabel="Inspect Events"
+ />
+
+ )}
-
-
- {figures.ranged
- ? `Users, Database and Sessions are current totals. The login figures cover ${rangeLabel}.`
- : "Deployment metrics cover a fixed 24-hour window. This API's metrics endpoint takes no date range, so these figures do not follow the range selector."}
-
-
-
- {isLoading ? (
- Array.from({ length: 5 }).map((_, i) => (
-
- ))
- ) : (
- <>
-
-
-
-
+
+
+ {fixedWindow
+ ? "Deployment metrics cover a fixed 24-hour window. This API's metrics endpoint takes no date range, so these figures do not follow the range selector."
+ : `Users, Database and Sessions are current totals. The login figures cover ${rangeLabel}.`}
+
+
+
+ {isLoading ? (
+ Array.from({ length: 5 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+
+
+
+
+ 0.1
+ ? "Elevated enough to merit review"
+ : "Within a low-friction range"
+ }
+ />
+ >
+ )}
+
+
+
+
+ {funnelError ? (
+
- 0.1
- ? "Elevated enough to merit review"
- : "Within a low-friction range"
- }
+ ) : funnel ? (
+
+
+
+ 0
+ ? formatPercent(funnel.passkeyAdoption.rate)
+ : "n/a"
+ }
+ hint={
+ funnel.passkeyAdoption.users > 0
+ ? `${funnel.passkeyAdoption.withPasskey.toLocaleString()} of ${funnel.passkeyAdoption.users.toLocaleString()} accounts created in ${rangeLabel} hold a passkey`
+ : `No accounts created in ${rangeLabel}`
+ }
+ />
+
+
+ ) : (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+
+
+ {signInsError ? (
+
- >
- )}
-
-
+ ) : signIns && dropOff ? (
+ <>
+
+ 0
+ ? formatPercent(signIns.signIns.successRate)
+ : "n/a"
+ }
+ hint={
+ signIns.signIns.success + signIns.signIns.failed > 0
+ ? `${signIns.signIns.success.toLocaleString()} got in, ${signIns.signIns.failed.toLocaleString()} did not, in ${rangeLabel}`
+ : `No factors presented in ${rangeLabel}`
+ }
+ />
+
+
+
+
-
- {funnelError ? (
-
- ) : funnel ? (
-
-
-
-
0
- ? formatPercent(funnel.passkeyAdoption.rate)
- : "n/a"
+
+
+
+
+
+ >
+ ) : (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+
+
+
navigate("/events")}
+ className="btn btn-secondary"
+ >
+ View Events
+
}
- hint={
- funnel.passkeyAdoption.users > 0
- ? `${funnel.passkeyAdoption.withPasskey.toLocaleString()} of ${funnel.passkeyAdoption.users.toLocaleString()} accounts created in ${rangeLabel} hold a passkey`
- : `No accounts created in ${rangeLabel}`
+ >
+ {timeseriesError ? (
+
+ ) : timeseries ? (
+
+ ) : (
+
+ )}
+
+
+
navigate("/security")}
+ className="btn btn-secondary"
+ >
+ Review Security
+
}
- />
-
+ {groupedError ? (
+
+ ) : grouped ? (
+
+ ) : (
+
)}
- />
-
- ) : (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
+
- )}
-
-
- {signInsError ? (
-
- ) : signIns && dropOff ? (
- <>
-
-
0
- ? formatPercent(signIns.signIns.successRate)
- : "n/a"
- }
- hint={
- signIns.signIns.success + signIns.signIns.failed > 0
- ? `${signIns.signIns.success.toLocaleString()} got in, ${signIns.signIns.failed.toLocaleString()} did not, in ${rangeLabel}`
- : `No factors presented in ${rangeLabel}`
- }
- />
-
-
-
+
+
0 ? "danger" : "neutral"}
+ title="Failed logins"
+ value={`${figures.loginFailed}`}
+ description="Recent login failures are the fastest signal of friction or abuse."
+ actionLabel="Investigate security"
+ onClick={() => navigate("/security")}
/>
-
-
-
- navigate("/events")}
/>
- navigate("/users")}
/>
- >
- ) : (
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
- )}
-
-
-
-
navigate("/events")}
- className="btn btn-secondary"
- >
- View Events
-
- }
- >
- {timeseriesError ? (
-
- ) : timeseries ? (
-
- ) : (
-
- )}
-
-
-
navigate("/security")}
- className="btn btn-secondary"
- >
- Review Security
-
- }
- >
- {groupedError ? (
-
- ) : grouped ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
0 ? "danger" : "neutral"}
- title="Failed logins"
- value={`${figures.loginFailed}`}
- description="Recent login failures are the fastest signal of friction or abuse."
- actionLabel="Investigate security"
- onClick={() => navigate("/security")}
- />
-
- {/* passkeyUsage counts passkey sign-ins. Adoption, the share of
- accounts holding one, is the funnel figure above, and the two
- used to share a title. */}
- navigate("/events")}
- />
-
- navigate("/users")}
- />
-
-
+
+ >
+ )}
);
}