From eb91f2bb12b385ec2f8d1c45f3aacf217139a0f7 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 4 Aug 2026 10:35:41 -0700 Subject: [PATCH 1/3] feat(analytics): give account owners the traffic explorer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin traffic explorer becomes a shared BreakdownExplorer: the admin page keeps every dimension, while an account-scoped tab pins its account via hidden scope filters and drops "account" from the filter/group-by chips so the query string can't widen the view. Reached through a PROFILE | ANALYTICS tab on the account profile, mirroring the product tab — the same ?tab=analytics middleware rewrite, now matching one-segment account roots too. Carries the product page's preview callout. Co-Authored-By: Claude Opus 5 (1M context) --- .../(app)/[account_id]/-/analytics/page.tsx | 80 +++ src/app/(app)/[account_id]/page.tsx | 11 + src/app/(app)/admin/analytics/page.tsx | 563 +--------------- .../analytics/BreakdownExplorer.test.ts | 28 + .../features/analytics/BreakdownExplorer.tsx | 608 ++++++++++++++++++ .../analytics/{ProductTabs.tsx => Tabs.tsx} | 83 ++- src/components/features/analytics/index.ts | 2 +- src/lib/urls.ts | 10 +- src/middleware.test.ts | 16 +- src/middleware.ts | 19 +- 10 files changed, 826 insertions(+), 594 deletions(-) create mode 100644 src/app/(app)/[account_id]/-/analytics/page.tsx create mode 100644 src/components/features/analytics/BreakdownExplorer.test.ts create mode 100644 src/components/features/analytics/BreakdownExplorer.tsx rename src/components/features/analytics/{ProductTabs.tsx => Tabs.tsx} (50%) diff --git a/src/app/(app)/[account_id]/-/analytics/page.tsx b/src/app/(app)/[account_id]/-/analytics/page.tsx new file mode 100644 index 00000000..5abc62b8 --- /dev/null +++ b/src/app/(app)/[account_id]/-/analytics/page.tsx @@ -0,0 +1,80 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { Box, Callout, Heading, Link as RadixLink } from "@radix-ui/themes"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; +import { getPageSession } from "@/lib/api/utils"; +import { canManageAccount } from "@/lib/api/authz"; +import { accountsTable } from "@/lib/clients/database"; +import { ADMIN_DIMENSIONS, type AdminDimension } from "@/lib/clients/analytics"; +import { AccountTabs } from "@/components/features/analytics"; +import { BreakdownExplorer } from "@/components/features/analytics/BreakdownExplorer"; +import { accountAnalyticsUrl } from "@/lib/urls"; + +interface PageProps { + params: Promise<{ account_id: string }>; + searchParams: Promise>; +} + +/** Every dimension except the one the URL already pins. */ +const DIMENSIONS = (Object.keys(ADMIN_DIMENSIONS) as AdminDimension[]).filter( + (dim) => dim !== "account", +); + +/** Owners/maintainers/admins only; everyone else gets the same 404 an + * unknown path would — including from generateMetadata, which streams + * independently of the page body. */ +async function authorizedAccount(account_id: string) { + const account = await accountsTable.fetchById(account_id); + if (!account || !canManageAccount(await getPageSession(), account)) { + notFound(); + } + return account; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { account_id } = await params; + const account = await authorizedAccount(account_id); + return { title: `${account.name || account_id} — Analytics` }; +} + +export default async function AccountAnalyticsPage({ + params, + searchParams, +}: PageProps) { + const { account_id } = await params; + const account = await authorizedAccount(account_id); + + return ( + + + + {account.name || account_id} + + + + + + + + Analytics is a preview feature. The metrics shown here and who can + access them may change in the near future. Let us know what you + think at{" "} + + hello@source.coop + + . + + + } + /> + + ); +} diff --git a/src/app/(app)/[account_id]/page.tsx b/src/app/(app)/[account_id]/page.tsx index a88c8b37..c8a300a3 100644 --- a/src/app/(app)/[account_id]/page.tsx +++ b/src/app/(app)/[account_id]/page.tsx @@ -12,9 +12,13 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; +import { Box } from "@radix-ui/themes"; import { OrganizationProfilePage } from "@/app/(app)/[account_id]/OrganizationProfilePage"; import { accountsTable, isOrganizationalAccount } from "@/lib/clients/database"; import { IndividualProfilePage } from "./IndividualProfilePage"; +import { AccountTabs } from "@/components/features/analytics"; +import { getPageSession } from "@/lib/api/utils"; +import { canManageAccount } from "@/lib/api/authz"; import { generateNotFoundMetadata, generateAccountMetadata, @@ -49,6 +53,13 @@ export default async function AccountPage({ params, searchParams }: PageProps) { return ( <> + {/* Tab strip only for people who can manage the account — the + analytics route 404s everyone else. */} + {canManageAccount(await getPageSession(), account) && ( + + + + )} {isOrganizationalAccount(account) ? ( ) : ( diff --git a/src/app/(app)/admin/analytics/page.tsx b/src/app/(app)/admin/analytics/page.tsx index 24d1241f..019fbf39 100644 --- a/src/app/(app)/admin/analytics/page.tsx +++ b/src/app/(app)/admin/analytics/page.tsx @@ -1,186 +1,16 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; -import Link from "next/link"; import { getPageSession } from "@/lib"; import { isAdmin } from "@/lib/api/authz"; -import { - Box, - Button, - Callout, - Card, - Flex, - Heading, - Table, - Text, - Tooltip, -} from "@radix-ui/themes"; -import { - ExclamationTriangleIcon, - InfoCircledIcon, -} from "@radix-ui/react-icons"; import { ADMIN_DIMENSIONS, - BUCKET_INTERVALS, - MAX_CHART_BUCKETS, - OTHER_KEY, - RETENTION_DAYS, - getAdminBreakdown, - isAnalyticsConfigured, - type AdminBreakdown, type AdminDimension, } from "@/lib/clients/analytics"; -import { - AdminBreakdownChart, - seriesColor, -} from "@/components/features/analytics"; -// Components come from the client module; HELP/mono must come from the -// plain style module — client-module exports can't be called on the server. -import { MonoLabel } from "@/components/features/analytics/panels"; -import { HELP, mono } from "@/components/features/analytics/style"; -import { AdminFiltersForm } from "@/components/features/analytics/AdminFiltersForm"; -import { GroupByChips } from "@/components/features/analytics/GroupByChips"; -import { adminAnalyticsUrl, formatBytes } from "@/lib"; -import { accountUrl } from "@/lib/urls"; +import { BreakdownExplorer } from "@/components/features/analytics/BreakdownExplorer"; +import { adminAnalyticsUrl } from "@/lib"; export const metadata: Metadata = { title: "Admin — Analytics" }; -interface PageState { - /** - * UTC day "YYYY-MM-DD" (inclusive) or UTC instant "YYYY-MM-DDTHH:MM" - * (as `to`: exclusive) from the datetime filters and chart drill-downs; - * empty string = default - */ - from: string; - to: string; - /** Sum interval in minutes (a BUCKET_INTERVALS value); undefined = auto */ - bucketMinutes?: number; - /** Chart/ranking metric; "requests" is the default and stays out of URLs */ - metric: "bytes" | "requests"; - groupBy: AdminDimension[]; - /** Per-dimension value filters, one URL param per dimension key */ - filters: Partial>; -} - -const first = (v: string | string[] | undefined) => - Array.isArray(v) ? v[0] : v; - -const numberFormat = new Intl.NumberFormat("en-US"); - -const DAY_MS = 86_400_000; -const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); -const todayUtc = () => new Date().setUTCHours(0, 0, 0, 0); - -const dateParam = (v: string | string[] | undefined): string => { - const value = first(v) ?? ""; - return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/.test(value) ? value : ""; -}; - -/** Parse a range param (day or instant) to ms; day-only = UTC day start. */ -const paramMs = (value: string): number => - Date.parse(value.length === 10 ? `${value}T00:00:00Z` : `${value}:00Z`); - -/** "15-minute" / "6-hour" / "3-day" for any ladder value. */ -const bucketName = (minutes: number): string => - minutes < 60 - ? `${minutes}-minute` - : minutes < 1440 - ? `${minutes / 60}-hour` - : `${minutes / 1440}-day`; - -/** "6 hours" / "16 days" — the longest range an interval can draw. */ -const spanName = (minutes: number): string => - minutes < 1440 - ? `${Math.floor(minutes / 60)} hours` - : `${Math.floor(minutes / 1440)} days`; - -// Whole weeks (plus "Today"), to avoid aliasing day-of-week patterns. -const PRESETS = [ - { label: "Today", days: 1 }, - { label: "7d", days: 7 }, - { label: "28d", days: 28 }, - { label: "91d", days: 91 }, -]; - -function parseState(params: Record): PageState { - const groupByParam = first(params.groupBy); - const interval = Number(first(params.interval)); - return { - from: dateParam(params.from), - to: dateParam(params.to), - bucketMinutes: BUCKET_INTERVALS.some((b) => b.minutes === interval) - ? interval - : undefined, - metric: first(params.metric) === "bytes" ? "bytes" : "requests", - // Absent → the default grouping; present but empty → no grouping at all. - groupBy: - groupByParam === undefined - ? ["product"] - : [ - ...new Set( - groupByParam - .split(",") - // Object.hasOwn, not `in`: ?groupBy=constructor must not - // match prototype keys. - .filter((d): d is AdminDimension => - Object.hasOwn(ADMIN_DIMENSIONS, d), - ), - ), - ], - filters: Object.fromEntries( - (Object.keys(ADMIN_DIMENSIONS) as AdminDimension[]) - .map((dim) => [dim, first(params[dim])?.trim()]) - .filter(([, value]) => value), - ), - }; -} - -function pageUrl(state: PageState): string { - const params = new URLSearchParams({ groupBy: state.groupBy.join(",") }); - if (state.from) params.set("from", state.from); - if (state.to) params.set("to", state.to); - if (state.bucketMinutes) params.set("interval", String(state.bucketMinutes)); - if (state.metric === "bytes") params.set("metric", state.metric); - for (const [dim, value] of Object.entries(state.filters)) { - params.set(dim, value); - } - return `${adminAnalyticsUrl()}?${params}`; -} - -/** Range-shift arrow: a tooltipped link button, inert when at a boundary. */ -function ShiftButton({ - label, - help, - href, - disabled, -}: { - label: string; - help: string; - href: string; - disabled: boolean; -}) { - const button = disabled ? ( - - ) : ( - - ); - return {button}; -} - -/** Product/account group keys double as site paths ("acct" or "acct/prod"). */ -function groupHref(key: string, groupBy: AdminDimension[]): string | null { - if (key === OTHER_KEY || groupBy.length !== 1) return null; - if (groupBy[0] === "account" || groupBy[0] === "product") { - return accountUrl(key); // "/{key}" — product keys already include the slash - } - return null; -} - interface PageProps { searchParams: Promise>; } @@ -193,390 +23,11 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { notFound(); } - const state = parseState(await searchParams); - - if (!isAnalyticsConfigured()) { - return ( - - Analytics - - - - - - Analytics is not configured. Set CF_ANALYTICS_ACCOUNT_ID, - CF_ANALYTICS_API_TOKEN, and CF_ANALYTICS_DATASET. - - - - ); - } - - let breakdown: AdminBreakdown | null = null; - let queryError: string | null = null; - try { - breakdown = await getAdminBreakdown(state); - } catch (error) { - queryError = error instanceof Error ? error.message : String(error); - } - - const seriesColors = new Map( - breakdown?.series.map((key, i) => [key, seriesColor(key, i, OTHER_KEY)]), - ); - - // The resolved (clamped) range drives the presets, shift arrows, and the - // date inputs' defaults; fall back to the same default the client uses. - // A drilled range may be time-grained ("…THH:MM", exclusive `to`); the - // day-oriented controls operate on the days it touches. - const today = todayUtc(); - const range = breakdown?.range ?? { from: isoDay(today - 6 * DAY_MS), to: isoDay(today) }; - const fromMs = paramMs(range.from); - const endMs = - range.to.length === 10 ? paramMs(range.to) + DAY_MS : paramMs(range.to); - const fromDayMs = Math.floor(fromMs / DAY_MS) * DAY_MS; - const lastDayMs = Math.floor((endMs - 1) / DAY_MS) * DAY_MS; - const retentionEdge = today - RETENTION_DAYS * DAY_MS; - const rangeDays = Math.round((lastDayMs - fromDayMs) / DAY_MS) + 1; - const rangeMinutes = (endMs - fromMs) / 60_000; - const rangeLabel = `${rangeDays} day${rangeDays === 1 ? "" : "s"}`; - // Shift the whole range by N days, clamped so its length is preserved at - // the edges (today forward, ~retention backward). Shifting a drilled - // sub-day range deliberately widens it back to whole days. - const shiftUrl = (days: number) => { - const deltaMs = - days > 0 - ? Math.min(days * DAY_MS, today - lastDayMs) - : Math.max(days * DAY_MS, retentionEdge - fromDayMs); - return pageUrl({ - ...state, - from: isoDay(fromDayMs + deltaMs), - to: isoDay(lastDayMs + deltaMs), - }); - }; - const atToday = lastDayMs >= today; - const atRetention = fromDayMs <= retentionEdge; - // Bandwidth denominator: elapsed wall-clock within the range — a range - // that includes today only counts the part that has happened. - const elapsedSeconds = Math.max( - 1, - (Math.min(Date.now(), endMs) - fromMs) / 1000, - ); - return ( - - Analytics - - {/* Two zones: what data (dates + entity filters) | how it's drawn - (group by + interval), split by the stats-row hairline. */} - - - {/* flexBasis 0: zones split the row by ratio instead of claiming - their content width, so GROUP BY/INTERVAL stay to the right - (inner chip rows wrap within the zone); minWidth only forces - stacking on truly narrow screens. */} - - - - Date range (UTC) - - - - - {PRESETS.map((preset) => { - const from = isoDay(today - (preset.days - 1) * DAY_MS); - const to = isoDay(today); - const active = range.from === from && range.to === to; - return ( - - ); - })} - - - - - ({ - key: dim, - label: ADMIN_DIMENSIONS[dim].label, - }))} - defaults={{ - // datetime-local values over the resolved [from, end) — - // midnight-aligned submissions collapse back to day grain - // in the data layer. - from: new Date(fromMs).toISOString().slice(0, 16), - to: new Date(endMs).toISOString().slice(0, 16), - filters: state.filters, - }} - hidden={{ - groupBy: state.groupBy.join(","), - ...(state.bucketMinutes && { - interval: String(state.bucketMinutes), - }), - ...(state.metric === "bytes" && { metric: state.metric }), - }} - /> - - - - - - - - Group by - - ({ - key: dim, - label: ADMIN_DIMENSIONS[dim].label, - }))} - selected={state.groupBy} - /> - - - - Interval - - - - {BUCKET_INTERVALS.map((bucket) => { - // An interval that would draw more bars than the chart can - // hold is disabled rather than silently coarsened. - const fits = - rangeMinutes <= MAX_CHART_BUCKETS * bucket.minutes; - if (!fits) { - return ( - - - - ); - } - return ( - - ); - })} - - - - - - - {queryError ? ( - - - - - {queryError} - - ) : !breakdown || - (breakdown.totals.bytes === 0 && breakdown.totals.requests === 0) ? ( - - - - - - No traffic recorded for this selection between {range.from} and{" "} - {range.to}. - - - ) : ( - <> - - {state.bucketMinutes !== undefined && - breakdown.bucketMinutes !== state.bucketMinutes && ( - - Showing {bucketName(breakdown.bucketMinutes)} buckets — the - requested interval would draw more than {MAX_CHART_BUCKETS}{" "} - bars over this range. - - )} - - - - - - - - # - - - - {state.groupBy.length - ? state.groupBy - .map((d) => ADMIN_DIMENSIONS[d].label) - .join(" · ") - : "Scope"} - - - - Data served - - - Requests - - - Share - - - - - {breakdown.groups.map((group, index) => { - // Share follows the active metric, like the row order. - const share = breakdown.totals[state.metric] - ? (group[state.metric] / breakdown.totals[state.metric]) * 100 - : 0; - const color = seriesColors.get(group.key); - const href = groupHref(group.key, state.groupBy); - return ( - // align="center": the swatch/meter cells hold block-level - // Flexes that otherwise top-align against the text cells. - - - - {/* The Other remainder isn't a ranked group */} - {group.key === OTHER_KEY ? "" : index + 1} - - - - - - - {href ? ( - {group.key} - ) : ( - group.key - )} - - - - - - {formatBytes(group.bytes)} - - - - - {numberFormat.format(Math.round(group.requests))} - - - - - - - - - {share.toFixed(1)}% - - - - - ); - })} - - - - )} - + ); } diff --git a/src/components/features/analytics/BreakdownExplorer.test.ts b/src/components/features/analytics/BreakdownExplorer.test.ts new file mode 100644 index 00000000..9a4388ee --- /dev/null +++ b/src/components/features/analytics/BreakdownExplorer.test.ts @@ -0,0 +1,28 @@ +import { parseState } from "./BreakdownExplorer"; +import type { AdminDimension } from "@/lib/clients/analytics"; + +// The account explorer pins its account via scopeFilters and drops the +// dimension; nothing from the query string may put it back. +const SCOPED: AdminDimension[] = ["product", "country", "client"]; + +describe("parseState", () => { + it("ignores filters and group-bys on dimensions the view doesn't offer", () => { + const state = parseState( + { account: "other-account", groupBy: "account,country" }, + SCOPED, + ); + expect(state.filters).toEqual({}); + expect(state.groupBy).toEqual(["country"]); + }); + + it("keeps offered filters and group-bys", () => { + const state = parseState({ country: " US ", groupBy: "product" }, SCOPED); + expect(state.filters).toEqual({ country: "US" }); + expect(state.groupBy).toEqual(["product"]); + }); + + it("defaults to grouping by product; an empty param means no grouping", () => { + expect(parseState({}, SCOPED).groupBy).toEqual(["product"]); + expect(parseState({ groupBy: "" }, SCOPED).groupBy).toEqual([]); + }); +}); diff --git a/src/components/features/analytics/BreakdownExplorer.tsx b/src/components/features/analytics/BreakdownExplorer.tsx new file mode 100644 index 00000000..a95d0e05 --- /dev/null +++ b/src/components/features/analytics/BreakdownExplorer.tsx @@ -0,0 +1,608 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + Box, + Button, + Callout, + Card, + Flex, + Heading, + Table, + Text, + Tooltip, +} from "@radix-ui/themes"; +import { + ExclamationTriangleIcon, + InfoCircledIcon, +} from "@radix-ui/react-icons"; +import { + ADMIN_DIMENSIONS, + BUCKET_INTERVALS, + MAX_CHART_BUCKETS, + OTHER_KEY, + RETENTION_DAYS, + getAdminBreakdown, + isAnalyticsConfigured, + type AdminBreakdown, + type AdminDimension, +} from "@/lib/clients/analytics"; +import { AdminBreakdownChart, seriesColor } from "@/components/features/analytics"; +// Components come from the client module; HELP/mono must come from the +// plain style module — client-module exports can't be called on the server. +import { MonoLabel } from "@/components/features/analytics/panels"; +import { HELP, mono } from "@/components/features/analytics/style"; +import { AdminFiltersForm } from "@/components/features/analytics/AdminFiltersForm"; +import { GroupByChips } from "@/components/features/analytics/GroupByChips"; +import { formatBytes } from "@/lib"; +import { accountUrl } from "@/lib/urls"; + +interface PageState { + /** + * UTC day "YYYY-MM-DD" (inclusive) or UTC instant "YYYY-MM-DDTHH:MM" + * (as `to`: exclusive) from the datetime filters and chart drill-downs; + * empty string = default + */ + from: string; + to: string; + /** Sum interval in minutes (a BUCKET_INTERVALS value); undefined = auto */ + bucketMinutes?: number; + /** Chart/ranking metric; "requests" is the default and stays out of URLs */ + metric: "bytes" | "requests"; + groupBy: AdminDimension[]; + /** Per-dimension value filters, one URL param per dimension key */ + filters: Partial>; +} + +const first = (v: string | string[] | undefined) => + Array.isArray(v) ? v[0] : v; + +const numberFormat = new Intl.NumberFormat("en-US"); + +const DAY_MS = 86_400_000; +const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); +const todayUtc = () => new Date().setUTCHours(0, 0, 0, 0); + +const dateParam = (v: string | string[] | undefined): string => { + const value = first(v) ?? ""; + return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/.test(value) ? value : ""; +}; + +/** Parse a range param (day or instant) to ms; day-only = UTC day start. */ +const paramMs = (value: string): number => + Date.parse(value.length === 10 ? `${value}T00:00:00Z` : `${value}:00Z`); + +/** "15-minute" / "6-hour" / "3-day" for any ladder value. */ +const bucketName = (minutes: number): string => + minutes < 60 + ? `${minutes}-minute` + : minutes < 1440 + ? `${minutes / 60}-hour` + : `${minutes / 1440}-day`; + +/** "6 hours" / "16 days" — the longest range an interval can draw. */ +const spanName = (minutes: number): string => + minutes < 1440 + ? `${Math.floor(minutes / 60)} hours` + : `${Math.floor(minutes / 1440)} days`; + +// Whole weeks (plus "Today"), to avoid aliasing day-of-week patterns. +const PRESETS = [ + { label: "Today", days: 1 }, + { label: "7d", days: 7 }, + { label: "28d", days: 28 }, + { label: "91d", days: 91 }, +]; + +/** + * URL params → view state, restricted to the offered dimensions. Exported + * for tests: a scoped view leaks other accounts' traffic if a dropped + * dimension can be filtered or grouped back in from the query string. + */ +export function parseState( + params: Record, + dimensions: AdminDimension[], +): PageState { + const groupByParam = first(params.groupBy); + const interval = Number(first(params.interval)); + // Scoped views drop dimensions (an account explorer has no "account" + // axis); params naming one are ignored rather than silently widening. + const offered = new Set(dimensions); + const defaultGroupBy = dimensions.filter((d) => d === "product"); + return { + from: dateParam(params.from), + to: dateParam(params.to), + bucketMinutes: BUCKET_INTERVALS.some((b) => b.minutes === interval) + ? interval + : undefined, + metric: first(params.metric) === "bytes" ? "bytes" : "requests", + // Absent → the default grouping; present but empty → no grouping at all. + groupBy: + groupByParam === undefined + ? defaultGroupBy + : [ + ...new Set( + groupByParam + .split(",") + .filter((d): d is AdminDimension => offered.has(d)), + ), + ], + filters: Object.fromEntries( + dimensions + .map((dim) => [dim, first(params[dim])?.trim()]) + .filter(([, value]) => value), + ), + }; +} + +/** Range-shift arrow: a tooltipped link button, inert when at a boundary. */ +function ShiftButton({ + label, + help, + href, + disabled, +}: { + label: string; + help: string; + href: string; + disabled: boolean; +}) { + const button = disabled ? ( + + ) : ( + + ); + return {button}; +} + +/** Product/account group keys double as site paths ("acct" or "acct/prod"). */ +function groupHref(key: string, groupBy: AdminDimension[]): string | null { + if (key === OTHER_KEY || groupBy.length !== 1) return null; + if (groupBy[0] === "account" || groupBy[0] === "product") { + return accountUrl(key); // "/{key}" — product keys already include the slash + } + return null; +} + +interface BreakdownExplorerProps { + /** + * Page the controls link back to. Any query it carries (the `?tab=analytics` + * the middleware rewrites) is preserved — a GET form's action drops its own + * query string, so those params are re-emitted as hidden inputs. + */ + baseUrl: string; + searchParams: Record; + /** Dimensions offered as filters and group-by chips, in display order */ + dimensions: AdminDimension[]; + /** + * Filters applied to every query but hidden from the UI and URLs — how a + * scoped view (e.g. one account's) pins its dimension. + */ + scopeFilters?: Partial>; + /** Rendered under the heading, above the controls (e.g. a preview notice) */ + notice?: ReactNode; +} + +/** + * The traffic explorer: range/filter controls, a stacked-series chart, and a + * ranked totals table over data-proxy request analytics. Admin sees every + * dimension; a scoped view pins one via `scopeFilters` and drops it from + * `dimensions` so it can't be filtered or grouped away. + */ +export async function BreakdownExplorer({ + baseUrl, + searchParams, + dimensions, + scopeFilters, + notice, +}: BreakdownExplorerProps) { + const state = parseState(searchParams, dimensions); + + if (!isAnalyticsConfigured()) { + return ( + + Analytics + {notice} + + + + + + Analytics is not configured. Set CF_ANALYTICS_ACCOUNT_ID, + CF_ANALYTICS_API_TOKEN, and CF_ANALYTICS_DATASET. + + + + ); + } + + let breakdown: AdminBreakdown | null = null; + let queryError: string | null = null; + try { + breakdown = await getAdminBreakdown({ + ...state, + filters: { ...state.filters, ...scopeFilters }, + }); + } catch (error) { + queryError = error instanceof Error ? error.message : String(error); + } + + const seriesColors = new Map( + breakdown?.series.map((key, i) => [key, seriesColor(key, i, OTHER_KEY)]), + ); + + const dimensionChips = dimensions.map((dim) => ({ + key: dim, + label: ADMIN_DIMENSIONS[dim].label, + })); + + const base = new URL(baseUrl, "http://relative.invalid"); + const baseParams = Object.fromEntries(base.searchParams); + + const pageUrl = (next: PageState): string => { + const params = new URLSearchParams(baseParams); + params.set("groupBy", next.groupBy.join(",")); + if (next.from) params.set("from", next.from); + if (next.to) params.set("to", next.to); + if (next.bucketMinutes) params.set("interval", String(next.bucketMinutes)); + if (next.metric === "bytes") params.set("metric", next.metric); + for (const [dim, value] of Object.entries(next.filters)) { + params.set(dim, value); + } + return `${base.pathname}?${params}`; + }; + + // The resolved (clamped) range drives the presets, shift arrows, and the + // date inputs' defaults; fall back to the same default the client uses. + // A drilled range may be time-grained ("…THH:MM", exclusive `to`); the + // day-oriented controls operate on the days it touches. + const today = todayUtc(); + const range = breakdown?.range ?? { from: isoDay(today - 6 * DAY_MS), to: isoDay(today) }; + const fromMs = paramMs(range.from); + const endMs = + range.to.length === 10 ? paramMs(range.to) + DAY_MS : paramMs(range.to); + const fromDayMs = Math.floor(fromMs / DAY_MS) * DAY_MS; + const lastDayMs = Math.floor((endMs - 1) / DAY_MS) * DAY_MS; + const retentionEdge = today - RETENTION_DAYS * DAY_MS; + const rangeDays = Math.round((lastDayMs - fromDayMs) / DAY_MS) + 1; + const rangeMinutes = (endMs - fromMs) / 60_000; + const rangeLabel = `${rangeDays} day${rangeDays === 1 ? "" : "s"}`; + // Shift the whole range by N days, clamped so its length is preserved at + // the edges (today forward, ~retention backward). Shifting a drilled + // sub-day range deliberately widens it back to whole days. + const shiftUrl = (days: number) => { + const deltaMs = + days > 0 + ? Math.min(days * DAY_MS, today - lastDayMs) + : Math.max(days * DAY_MS, retentionEdge - fromDayMs); + return pageUrl({ + ...state, + from: isoDay(fromDayMs + deltaMs), + to: isoDay(lastDayMs + deltaMs), + }); + }; + const atToday = lastDayMs >= today; + const atRetention = fromDayMs <= retentionEdge; + // Bandwidth denominator: elapsed wall-clock within the range — a range + // that includes today only counts the part that has happened. + const elapsedSeconds = Math.max( + 1, + (Math.min(Date.now(), endMs) - fromMs) / 1000, + ); + + return ( + + Analytics + + {notice} + + {/* Two zones: what data (dates + entity filters) | how it's drawn + (group by + interval), split by the stats-row hairline. */} + + + {/* flexBasis 0: zones split the row by ratio instead of claiming + their content width, so GROUP BY/INTERVAL stay to the right + (inner chip rows wrap within the zone); minWidth only forces + stacking on truly narrow screens. */} + + + + Date range (UTC) + + + + + {PRESETS.map((preset) => { + const from = isoDay(today - (preset.days - 1) * DAY_MS); + const to = isoDay(today); + const active = range.from === from && range.to === to; + return ( + + ); + })} + + + + + + + + + + + + Group by + + + + + + Interval + + + + {BUCKET_INTERVALS.map((bucket) => { + // An interval that would draw more bars than the chart can + // hold is disabled rather than silently coarsened. + const fits = + rangeMinutes <= MAX_CHART_BUCKETS * bucket.minutes; + if (!fits) { + return ( + + + + ); + } + return ( + + ); + })} + + + + + + + {queryError ? ( + + + + + {queryError} + + ) : !breakdown || + (breakdown.totals.bytes === 0 && breakdown.totals.requests === 0) ? ( + + + + + + No traffic recorded for this selection between {range.from} and{" "} + {range.to}. + + + ) : ( + <> + + {state.bucketMinutes !== undefined && + breakdown.bucketMinutes !== state.bucketMinutes && ( + + Showing {bucketName(breakdown.bucketMinutes)} buckets — the + requested interval would draw more than {MAX_CHART_BUCKETS}{" "} + bars over this range. + + )} + + + + + + + + # + + + + {state.groupBy.length + ? state.groupBy + .map((d) => ADMIN_DIMENSIONS[d].label) + .join(" · ") + : "Scope"} + + + + Data served + + + Requests + + + Share + + + + + {breakdown.groups.map((group, index) => { + // Share follows the active metric, like the row order. + const share = breakdown.totals[state.metric] + ? (group[state.metric] / breakdown.totals[state.metric]) * 100 + : 0; + const color = seriesColors.get(group.key); + const href = groupHref(group.key, state.groupBy); + return ( + // align="center": the swatch/meter cells hold block-level + // Flexes that otherwise top-align against the text cells. + + + + {/* The Other remainder isn't a ranked group */} + {group.key === OTHER_KEY ? "" : index + 1} + + + + + + + {href ? ( + {group.key} + ) : ( + group.key + )} + + + + + + {formatBytes(group.bytes)} + + + + + {numberFormat.format(Math.round(group.requests))} + + + + + + + + + {share.toFixed(1)}% + + + + + ); + })} + + + + )} + + ); +} diff --git a/src/components/features/analytics/ProductTabs.tsx b/src/components/features/analytics/Tabs.tsx similarity index 50% rename from src/components/features/analytics/ProductTabs.tsx rename to src/components/features/analytics/Tabs.tsx index c3d4db43..aeca5e5a 100644 --- a/src/components/features/analytics/ProductTabs.tsx +++ b/src/components/features/analytics/Tabs.tsx @@ -1,13 +1,12 @@ import Link from "next/link"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { LockClosedIcon } from "@radix-ui/react-icons"; -import { productAnalyticsUrl, productUrl } from "@/lib/urls"; - -interface ProductTabsProps { - accountId: string; - productId: string; - active: "product" | "analytics"; -} +import { + accountAnalyticsUrl, + accountUrl, + productAnalyticsUrl, + productUrl, +} from "@/lib/urls"; const label = (active: boolean): React.CSSProperties => ({ fontFamily: "var(--code-font-family)", @@ -15,18 +14,20 @@ const label = (active: boolean): React.CSSProperties => ({ color: active ? "var(--gray-12)" : "var(--gray-10)", }); -/** - * PRODUCT | ANALYTICS strip shown at the product root to viewers who can - * manage the product (the analytics route 404s everyone else). - */ -export function ProductTabs({ accountId, productId, active }: ProductTabsProps) { +interface TabsProps { + /** The public view's tab: its label and href */ + scope: { text: string; href: string }; + analyticsHref: string; + /** Tooltip on the padlock: who the analytics tab is visible to */ + restrictedTo: string; + active: "scope" | "analytics"; +} + +/** SCOPE | ANALYTICS strip; the analytics route 404s anyone who can't see it. */ +function Tabs({ scope, analyticsHref, restrictedTo, active }: TabsProps) { const tabs = [ - { key: "product", text: "PRODUCT", href: productUrl(accountId, productId) }, - { - key: "analytics", - text: "ANALYTICS", - href: productAnalyticsUrl(accountId, productId), - }, + { key: "scope", text: scope.text, href: scope.href }, + { key: "analytics", text: "ANALYTICS", href: analyticsHref }, ] as const; return ( @@ -49,7 +50,7 @@ export function ProductTabs({ accountId, productId, active }: ProductTabsProps) > {tab.key === "analytics" && ( - + ); } + +/** + * PRODUCT | ANALYTICS strip shown at the product root to viewers who can + * manage the product. + */ +export function ProductTabs({ + accountId, + productId, + active, +}: { + accountId: string; + productId: string; + active: "product" | "analytics"; +}) { + return ( + + ); +} + +/** + * PROFILE | ANALYTICS strip shown on an account profile to viewers who can + * manage the account. + */ +export function AccountTabs({ + accountId, + active, +}: { + accountId: string; + active: "profile" | "analytics"; +}) { + return ( + + ); +} diff --git a/src/components/features/analytics/index.ts b/src/components/features/analytics/index.ts index f3930b9c..cbf21c14 100644 --- a/src/components/features/analytics/index.ts +++ b/src/components/features/analytics/index.ts @@ -2,6 +2,6 @@ export { UsageCard } from "./UsageCard"; export { UsageCardSkeleton } from "./UsageCardSkeleton"; export { UsagePanel } from "./UsagePanel"; export { ProductAnalyticsView } from "./ProductAnalyticsView"; -export { ProductTabs } from "./ProductTabs"; +export { ProductTabs, AccountTabs } from "./Tabs"; export { AdminBreakdownChart } from "./AdminBreakdownChart"; export { SERIES_COLORS, OTHER_COLOR, seriesColor } from "./palette"; diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 8ddff493..2cb59abb 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -44,12 +44,14 @@ export const loginUrl = (returnTo?: string) => { }; export const onboardingUrl = () => "/onboarding"; -// Product analytics (maintainers/owners/admins). The query param is -// rewritten by middleware to the internal /-/analytics route, so object -// paths can never be shadowed and layouts (which can't read search params) -// stay out of the loop. +// Analytics tabs (maintainers/owners/admins). The query param is rewritten +// by middleware to the internal /-/analytics route, so object paths can +// never be shadowed and layouts (which can't read search params) stay out +// of the loop. export const productAnalyticsUrl = (account_id: string, product_id: string) => `/${account_id}/${product_id}?tab=analytics`; +export const accountAnalyticsUrl = (account_id: string) => + `/${account_id}?tab=analytics`; // Object URLs export const objectUrl = ( diff --git a/src/middleware.test.ts b/src/middleware.test.ts index e9cdf1bf..b1206311 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -3,10 +3,10 @@ * failure mode is a silently dead ANALYTICS tab — pin its behavior. */ import { NextRequest } from "next/server"; -import { handleProductAnalyticsTab } from "./middleware"; +import { handleAnalyticsTab } from "./middleware"; const rewriteTarget = (url: string): string | null => - handleProductAnalyticsTab(new NextRequest(url))?.headers.get( + handleAnalyticsTab(new NextRequest(url))?.headers.get( "x-middleware-rewrite", ) ?? null; @@ -16,6 +16,12 @@ it("rewrites the product analytics tab URL to the internal route", () => { ); }); +it("rewrites the account analytics tab URL to the internal route", () => { + expect(rewriteTarget("https://source.coop/acct?tab=analytics")).toBe( + "https://source.coop/acct/-/analytics", + ); +}); + it("preserves other query params and drops tab", () => { expect( rewriteTarget("https://source.coop/acct/prod?tab=analytics&window=7"), @@ -26,16 +32,16 @@ it("ignores non-matching requests", () => { // No tab param / wrong value expect(rewriteTarget("https://source.coop/acct/prod")).toBeNull(); expect(rewriteTarget("https://source.coop/acct/prod?tab=other")).toBeNull(); - // Not a two-segment product path - expect(rewriteTarget("https://source.coop/acct?tab=analytics")).toBeNull(); + // Deeper than an account or product root expect( rewriteTarget("https://source.coop/acct/prod/file.txt?tab=analytics"), ).toBeNull(); - // Two-segment top-level app routes are not products + // Top-level app routes are neither accounts nor products expect( rewriteTarget("https://source.coop/admin/analytics?tab=analytics"), ).toBeNull(); expect( rewriteTarget("https://source.coop/products/new?tab=analytics"), ).toBeNull(); + expect(rewriteTarget("https://source.coop/products?tab=analytics")).toBeNull(); }); diff --git a/src/middleware.ts b/src/middleware.ts index e4ff51ba..5b23ddf7 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -63,7 +63,7 @@ const handleLegacyRedirects = (request: NextRequest): NextResponse | null => { return null; }; -// Top-level routes that share the /{segment}/{segment} shape but are not +// Top-level routes that share the /{segment}[/{segment}] shape but are not // account/product pages — the analytics rewrite must leave them alone. const NON_ACCOUNT_SEGMENTS = new Set([ "admin", @@ -78,19 +78,20 @@ const NON_ACCOUNT_SEGMENTS = new Set([ ]); /** - * Serve the maintainer analytics view on the product root via a query param - * (`/{account}/{product}?tab=analytics`). Layouts can't read search params, - * so the view lives at the internal `/-/analytics` route (which also keeps - * it from shadowing real object paths) and the query-param URL is rewritten - * to it here. Other params (e.g. `window`) pass through. + * Serve the maintainer analytics view on an account or product root via a + * query param (`/{account}?tab=analytics`, `/{account}/{product}?tab=analytics`). + * Layouts can't read search params, so the view lives at the internal + * `/-/analytics` route (which also keeps it from shadowing real object + * paths) and the query-param URL is rewritten to it here. Other params + * (e.g. `window`) pass through. * * Exported for tests: the failure mode is a silently dead ANALYTICS tab. */ -export const handleProductAnalyticsTab = ( +export const handleAnalyticsTab = ( request: NextRequest, ): NextResponse | null => { const { pathname, searchParams } = request.nextUrl; - const match = pathname.match(/^\/([^/]+)\/[^/]+$/); + const match = pathname.match(/^\/([^/]+)(\/[^/]+)?$/); if ( searchParams.get("tab") === "analytics" && match && @@ -135,7 +136,7 @@ export const middleware = async (request: NextRequest) => { return ory(request); } - const analyticsRewrite = handleProductAnalyticsTab(request); + const analyticsRewrite = handleAnalyticsTab(request); if (analyticsRewrite) return analyticsRewrite; return NextResponse.next(); From 04ffc7fe28b4c70f6606684183a83f2203531a42 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 4 Aug 2026 11:10:58 -0700 Subject: [PATCH 2/3] refactor(analytics): move the "Analytics" heading to the admin page The account view titles itself with the account name, so the shared explorer no longer owns a heading. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/(app)/admin/analytics/page.tsx | 16 +++++++++++----- .../features/analytics/BreakdownExplorer.tsx | 6 +----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/app/(app)/admin/analytics/page.tsx b/src/app/(app)/admin/analytics/page.tsx index 019fbf39..a3bc8831 100644 --- a/src/app/(app)/admin/analytics/page.tsx +++ b/src/app/(app)/admin/analytics/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; +import { Flex, Heading } from "@radix-ui/themes"; import { getPageSession } from "@/lib"; import { isAdmin } from "@/lib/api/authz"; import { @@ -24,10 +25,15 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { } return ( - + // The account view titles itself with the account name; only the admin + // tool needs a heading naming the page. + + Analytics + + ); } diff --git a/src/components/features/analytics/BreakdownExplorer.tsx b/src/components/features/analytics/BreakdownExplorer.tsx index a95d0e05..b3f66bb8 100644 --- a/src/components/features/analytics/BreakdownExplorer.tsx +++ b/src/components/features/analytics/BreakdownExplorer.tsx @@ -6,7 +6,6 @@ import { Callout, Card, Flex, - Heading, Table, Text, Tooltip, @@ -184,7 +183,7 @@ interface BreakdownExplorerProps { * scoped view (e.g. one account's) pins its dimension. */ scopeFilters?: Partial>; - /** Rendered under the heading, above the controls (e.g. a preview notice) */ + /** Rendered above the controls (e.g. a preview notice) */ notice?: ReactNode; } @@ -206,7 +205,6 @@ export async function BreakdownExplorer({ if (!isAnalyticsConfigured()) { return ( - Analytics {notice} @@ -297,8 +295,6 @@ export async function BreakdownExplorer({ return ( - Analytics - {notice} {/* Two zones: what data (dates + entity filters) | how it's drawn From ef90746994bb6449678d1838688f671ead97621c Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Tue, 4 Aug 2026 11:42:51 -0700 Subject: [PATCH 3/3] fix(analytics): keep the SQL debug dialog admin-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BreakdownExplorer forwarded breakdown.queries unconditionally, so the account view handed every owner and maintainer the Analytics Engine dataset name and blob1–blob9 layout. Gate it on an explicit showSql the caller opts into (admin page only) and withhold the prop entirely when closed — hiding the trigger would still ship the SQL to the client. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/(app)/admin/analytics/page.tsx | 1 + .../features/analytics/BreakdownExplorer.tsx | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/(app)/admin/analytics/page.tsx b/src/app/(app)/admin/analytics/page.tsx index a3bc8831..bccaa432 100644 --- a/src/app/(app)/admin/analytics/page.tsx +++ b/src/app/(app)/admin/analytics/page.tsx @@ -33,6 +33,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { baseUrl={adminAnalyticsUrl()} searchParams={await searchParams} dimensions={Object.keys(ADMIN_DIMENSIONS) as AdminDimension[]} + showSql /> ); diff --git a/src/components/features/analytics/BreakdownExplorer.tsx b/src/components/features/analytics/BreakdownExplorer.tsx index b3f66bb8..168a40cf 100644 --- a/src/components/features/analytics/BreakdownExplorer.tsx +++ b/src/components/features/analytics/BreakdownExplorer.tsx @@ -183,6 +183,14 @@ interface BreakdownExplorerProps { * scoped view (e.g. one account's) pins its dimension. */ scopeFilters?: Partial>; + /** + * Offer the "View SQL" debug dialog. Default closed: the statements name + * the Analytics Engine dataset and its blob1–blob9 layout, internal infra + * detail an account's owners have no business seeing. Withheld by not + * sending the prop at all — hiding the trigger would still ship the SQL + * in the client payload. + */ + showSql?: boolean; /** Rendered above the controls (e.g. a preview notice) */ notice?: ReactNode; } @@ -198,6 +206,7 @@ export async function BreakdownExplorer({ searchParams, dimensions, scopeFilters, + showSql = false, notice, }: BreakdownExplorerProps) { const state = parseState(searchParams, dimensions); @@ -489,7 +498,7 @@ export async function BreakdownExplorer({ buckets={breakdown.buckets} bucketMinutes={breakdown.bucketMinutes} initialMetric={state.metric} - queries={breakdown.queries} + queries={showSql ? breakdown.queries : undefined} series={breakdown.series} points={breakdown.points} totals={breakdown.totals}