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..bccaa432 100644 --- a/src/app/(app)/admin/analytics/page.tsx +++ b/src/app/(app)/admin/analytics/page.tsx @@ -1,186 +1,17 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; -import Link from "next/link"; +import { Flex, Heading } from "@radix-ui/themes"; 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 +24,17 @@ 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 ( + // The account view titles itself with the account name; only the admin + // tool needs a heading naming the page. 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..168a40cf --- /dev/null +++ b/src/components/features/analytics/BreakdownExplorer.tsx @@ -0,0 +1,613 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + Box, + Button, + Callout, + Card, + Flex, + 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>; + /** + * 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; +} + +/** + * 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, + showSql = false, + notice, +}: BreakdownExplorerProps) { + const state = parseState(searchParams, dimensions); + + if (!isAnalyticsConfigured()) { + return ( + + {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 ( + + {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();