From a5e884db91b558a8a5caaf018936079c1df82850 Mon Sep 17 00:00:00 2001 From: Vedanta-Gawande <30631624+Vedanta-Gawande@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:22:05 -0400 Subject: [PATCH] (fix): delete dialog on click behavior; fix user page syncing issues (#1684) ## Summary This PR fixes the Users dashboard delete-dialog click behavior and makes user mutations refresh the Users page without requiring a manual browser reload. The main user-facing changes are: 1. Opening the delete dialog from a Users table row no longer lets dialog clicks fall through into row navigation. 2. Creating, deleting, or updating a user from the Users page now refreshes the table, total-user count, and KPI cards automatically. 3. Users table profile links avoid expensive profile prefetching, so refreshes no longer fan out into many unnecessary per-user profile requests. --- ## Delete Dialog Click Behavior The Users page has two delete-user entry points: - the user profile action menu - the Users table row action menu Before this PR, the profile-page flow behaved correctly, but the table-row flow could accidentally trigger row navigation while the delete dialog was open. For example, clicking dialog content, footer whitespace, the confirmation label, or the overlay could navigate to the user's profile instead of simply interacting with or closing the dialog. This PR fixes that by treating the row action area as non-row-click territory and stopping click/double-click propagation from the action surface. Behavior after this change: - Clicking outside the dialog closes it and returns to the page that opened it. - Clicking inside the dialog no longer navigates through the underlying row. - The confirmation label still toggles the required delete acknowledgement. - The explicit user identifier inside the dialog is the only navigation target to that user's profile. - The delete confirmation copy is shorter and uses a non-empty display fallback: - display name - primary email - user ID --- ## Users Page Refresh Previously, creating or deleting a user through the Users page changed backend state, but the table and KPI surfaces could stay stale until the user manually refreshed the page or clicked the reload button. This PR adds an explicit Users-page mutation refresh path: - `UserDialog` can notify the page after create/edit succeeds. - `DeleteUserDialog` can notify the page after delete succeeds. - `UserTable` exposes its existing `useDataSource().reload()` function to the page. - The Users page refreshes the table and the metrics/count surfaces after successful user mutations. - The row action menu also refreshes after removing 2FA, so the row no longer keeps showing a stale 2FA action after the update succeeds. --- ## Refresh Performance The existing manual reload path used the broad `_refreshUsers()` SDK invalidation, which refreshes more than this page needs. In local testing, that broad path could combine with profile prefetching and trigger many per-user requests for profile details, contact channels, and OAuth providers. This PR narrows the automatic Users-page refresh: - table rows reload through the table data source - total-user counts refresh through the user-count metrics endpoint - KPI cards refresh through the metrics endpoint - table/profile links can opt out of dashboard `UrlPrefetcher` with `prefetch={false}` - dense Users table profile links use `prefetch={false}` - the delete-dialog profile link also opts out of prefetching This keeps the generic `DataGrid` component unchanged. The Users page owns the fact that a user mutation happened, and the table only exposes the reload function it already has. --- ## KPI / Navigation Consistency The Users page now keeps a page-local metrics snapshot for the total count and KPI cards. It refreshes that snapshot after mutations and on page restore, so navigating into a user profile and then returning with browser back does not leave the KPI cards showing old counts. Passive page-load/page-restore metrics refreshes use non-alert async handling, so a background metrics failure is reported normally instead of showing a blocking browser alert. User-triggered refreshes and mutation-triggered refreshes still use the dashboard's alert-backed async handling. --- ## Screenshots of the Delete User Dialog Before old-user-delete-dialog After new-user-delete-dialog --- ## Summary by cubic Fixes delete dialog click-through and makes the Users page auto-refresh the table and metrics without flicker. KPIs and total users render instantly from a snapshot and refresh in the background for smoother navigation. - **Bug Fixes** - Block row navigation from all row-action and delete-dialog clicks; add a non-prefetching profile link in `DeleteUserDialog` that closes the dialog on click. Dialog now accepts `profileHref`, fires `onDeleted`, and URL-encodes `projectId`/`user.id`; `redirectTo` still works. - Safer links: `Link` supports `prefetch={false}` and disables internal prefetch; applied to profile links in the table and dialogs. - **Refactors** - Instant metrics: preload a snapshot via `fetchMetricsOrThrow`/`fetchMetricsUserCountsOrThrow` and pass it to Total Users and KPI cards; auto-refresh on tab restore and after user mutations. - Targeted reloads: `UserTable` exposes `onReloadChange`; page, dialogs, and 2FA removal call `onUserMutated` to reload rows and refresh metrics. The refresh button uses the same path. - Internals: add `sendRequest` and schema-validated metrics fetchers with backward-compatible defaults. Written for commit 5e5f641bbeb4ba11e4a4a6bb736dc9b03d01190f. Summary will update on new commits. Review in cubic ## Summary by CodeRabbit * **New Features** * User pages now display KPI metrics and total-user counts immediately when available, with improved loading/skeleton behavior. * User actions (create/update/delete/2FA changes) now automatically refresh the table and KPI data. * Delete confirmation now includes a direct link to the user profile. * **Bug Fixes** * Improved navigation after user deletion using safer, encoded redirect paths. * Reduced duplicate error reporting for repeated metrics-loading failures. * Disabled unintended prefetching on user profile links for more predictable navigation. --- .../users/[userId]/page-client.tsx | 8 +- .../[projectId]/users/page-client.tsx | 83 ++++++-- .../[projectId]/users/users-kpi-cards.tsx | 6 +- .../src/components/data-table/user-table.tsx | 180 ++++++++++-------- .../src/components/entity-kpi-cards.tsx | 28 ++- apps/dashboard/src/components/link.tsx | 5 +- apps/dashboard/src/components/user-dialog.tsx | 5 + .../dashboard/src/components/user-dialogs.tsx | 21 +- .../src/lib/hexclave-app-internals.ts | 68 +++++++ 9 files changed, 301 insertions(+), 103 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 81fb3e3ef..9c2885bc1 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -181,7 +181,13 @@ function UserHeader({ user }: UserHeaderProps) { ]} /> - + setImpersonateSnippet(null)} /> diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx index e98862fba..6cecd771d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/page-client.tsx @@ -4,11 +4,18 @@ import { UserTable } from "@/components/data-table/user-table"; import { StyledLink } from "@/components/link"; import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui"; import { UserDialog } from "@/components/user-dialog"; -import { useMetricsUserCountsOrThrow } from "@/lib/hexclave-app-internals"; +import { + fetchMetricsOrThrow, + fetchMetricsUserCountsOrThrow, + type MetricsResponse, + type MetricsUserCounts, + useMetricsUserCountsOrThrow, +} from "@/lib/hexclave-app-internals"; import { captureError } from "@hexclave/shared/dist/utils/errors"; +import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { ArrowsClockwiseIcon } from "@phosphor-icons/react"; import { ErrorBoundary } from "next/dist/client/components/error-boundary"; -import { Suspense, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { AppEnabledGuard } from "../app-enabled-guard"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -27,7 +34,13 @@ function captureUsersMetricsErrorOnce(error: Error) { function TotalUsersDisplay() { const hexclaveAdminApp = useAdminApp(); const metrics = useMetricsUserCountsOrThrow(hexclaveAdminApp); + return ; +} +function TotalUsersText(props: { + metrics: MetricsUserCounts, +}) { + const { metrics } = props; const anonymousUsersCount = metrics.anonymous_users; const nonAnonymousUsersCount = metrics.total_users - anonymousUsersCount; @@ -55,15 +68,54 @@ function TotalUsersErrorComponent(props: { error: Error }) { return <>Unavailable; } +type UsersMetricsSnapshot = { + metrics: MetricsResponse, + userCounts: MetricsUserCounts, +}; + export default function PageClient() { const hexclaveAdminApp = useAdminApp(); const firstUserPage = hexclaveAdminApp.useUsers({ limit: 1 }); - const [refreshKey, setRefreshKey] = useState(0); + const tableReloadRef = useRef<() => void>(() => {}); + const [usersMetricsSnapshot, setUsersMetricsSnapshot] = useState(null); - const handleRefresh = async () => { - await (hexclaveAdminApp as any)._refreshUsers(); - setRefreshKey((k) => k + 1); - }; + const refreshUsersMetrics = useCallback(async () => { + const [metrics, userCounts] = await Promise.all([ + fetchMetricsOrThrow(hexclaveAdminApp, false), + fetchMetricsUserCountsOrThrow(hexclaveAdminApp), + ]); + setUsersMetricsSnapshot({ metrics, userCounts }); + }, [hexclaveAdminApp]); + + useEffect(() => { + const refresh = () => runAsynchronously(refreshUsersMetrics); + const refreshAfterPageRestore = (event: PageTransitionEvent) => { + if (event.persisted) { + refresh(); + } + }; + refresh(); + window.addEventListener("pageshow", refreshAfterPageRestore); + return () => window.removeEventListener("pageshow", refreshAfterPageRestore); + }, [refreshUsersMetrics]); + + const handleTableReloadChange = useCallback((reload: () => void) => { + tableReloadRef.current = reload; + }, []); + + const handleUserMutated = useCallback(() => { + tableReloadRef.current(); + runAsynchronouslyWithAlert(refreshUsersMetrics); + }, [refreshUsersMetrics]); + + const handleRefresh = useCallback(() => { + tableReloadRef.current(); + runAsynchronouslyWithAlert(refreshUsersMetrics); + }, [refreshUsersMetrics]); + + const hasUsers = usersMetricsSnapshot != null + ? usersMetricsSnapshot.userCounts.total_users - usersMetricsSnapshot.userCounts.anonymous_users > 0 + : firstUserPage.length > 0; return ( @@ -72,9 +124,13 @@ export default function PageClient() { description={<> Total:{" "} - Calculating}> - - + {usersMetricsSnapshot != null ? ( + + ) : ( + Calculating}> + + + )} } actions={ @@ -87,20 +143,21 @@ export default function PageClient() { Create User} + onUserMutated={handleUserMutated} /> } > - {firstUserPage.length > 0 ? null : ( + {hasUsers ? null : ( Congratulations on starting your project! Check the documentation to add your first users. )} - +
- +
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx index 3506e1f2d..d30ad6117 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/users-kpi-cards.tsx @@ -1,11 +1,15 @@ "use client"; import { EntityKpiCards } from "@/components/entity-kpi-cards"; +import type { MetricsResponse } from "@/lib/hexclave-app-internals"; -export function UsersKpiCards() { +export function UsersKpiCards(props: { + metrics?: MetricsResponse, +}) { return ( ({ dailyNew: metrics.daily_users.map((p) => p.activity), splitTotal: metrics.auth_overview.daily_active_users_split.total.map((p) => p.activity), diff --git a/apps/dashboard/src/components/data-table/user-table.tsx b/apps/dashboard/src/components/data-table/user-table.tsx index f0f44411c..1e83a94a3 100644 --- a/apps/dashboard/src/components/data-table/user-table.tsx +++ b/apps/dashboard/src/components/data-table/user-table.tsx @@ -132,70 +132,74 @@ function parseEmailDomains(input: string) { // ─── Column definitions ────────────────────────────────────────────── -const USER_TABLE_COLUMNS: DataGridColumnDef[] = [ - { - id: "user", - header: "User", - width: 180, - flex: 1, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "email", - header: "Email", - width: 180, - flex: 1, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "userId", - header: "User ID", - width: 130, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "emailStatus", - header: "Email Verified", - width: 110, - sortable: false, - renderCell: ({ row }) => , - }, - { - id: "lastActiveAt", - header: "Last active", - width: 110, - renderCell: ({ row }) => , - }, - { - id: "auth", - header: "Auth methods", - width: 150, - sortable: false, - cellOverflow: "wrap", - renderCell: ({ row }) => , - }, - { - id: "signedUpAt", - header: "Signed up", - width: 110, - renderCell: ({ row }) => , - }, - { - id: "actions", - header: "", - width: 44, - minWidth: 44, - maxWidth: 44, - sortable: false, - hideable: false, - resizable: false, - align: "right", - renderCell: ({ row }) => , - }, -]; +function createUserTableColumns( + onUserMutated: () => void | Promise, +): DataGridColumnDef[] { + return [ + { + id: "user", + header: "User", + width: 180, + flex: 1, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "email", + header: "Email", + width: 180, + flex: 1, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "userId", + header: "User ID", + width: 130, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "emailStatus", + header: "Email Verified", + width: 110, + sortable: false, + renderCell: ({ row }) => , + }, + { + id: "lastActiveAt", + header: "Last active", + width: 110, + renderCell: ({ row }) => , + }, + { + id: "auth", + header: "Auth methods", + width: 150, + sortable: false, + cellOverflow: "wrap", + renderCell: ({ row }) => , + }, + { + id: "signedUpAt", + header: "Signed up", + width: 110, + renderCell: ({ row }) => , + }, + { + id: "actions", + header: "", + width: 44, + minWidth: 44, + maxWidth: 44, + sortable: false, + hideable: false, + resizable: false, + align: "right", + renderCell: ({ row }) => , + }, + ]; +} const USER_EXPORT_FIELDS: DataGridExportField[] = [ { key: "id", label: "User ID", enabled: true, getValue: (user) => user.id }, @@ -218,10 +222,13 @@ const USER_EXPORT_FIELDS: DataGridExportField[] = [ // ─── UserTable ─────────────────────────────────────────────────────── -export function UserTable() { +export function UserTable(props: { + onUserMutated: () => void | Promise, + onReloadChange?: (reload: () => void) => void, +}) { const [filters, setFilters] = useState(DEFAULT_FILTERS); - return ; + return ; } // ─── Body (imperative fetching — no Suspense flash) ────────────────── @@ -229,12 +236,15 @@ export function UserTable() { function UserTableBody(props: { filters: FilterState, setFilters: React.Dispatch>, + onUserMutated: () => void | Promise, + onReloadChange?: (reload: () => void) => void, }) { - const { filters, setFilters } = props; + const { filters, setFilters, onUserMutated, onReloadChange } = props; const hexclaveAdminApp = useAdminApp(); const router = useRouter(); + const columns = useMemo(() => createUserTableColumns(onUserMutated), [onUserMutated]); - const [gridState, setGridState] = useDataGridUrlState(USER_TABLE_COLUMNS, { + const [gridState, setGridState] = useDataGridUrlState(columns, { paramPrefix: "users", initial: { sorting: [{ columnId: "signedUpAt", direction: DEFAULT_FILTERS.signedUpOrder }], @@ -314,7 +324,7 @@ function UserTableBody(props: { const gridData = useDataSource({ dataSource, - columns: USER_TABLE_COLUMNS, + columns, getRowId, sorting: gridState.sorting, quickSearch: debouncedQuickSearch, @@ -322,6 +332,11 @@ function UserTableBody(props: { paginationMode: "infinite", }); + useEffect(() => { + onReloadChange?.(gridData.reload); + return () => onReloadChange?.(() => {}); + }, [gridData.reload, onReloadChange]); + const handleResetFilters = useCallback(() => { setFilters(DEFAULT_FILTERS); setGridState((prev) => ({ @@ -400,7 +415,7 @@ function UserTableBody(props: { return ( void | Promise, +}) { + const { user, onUserMutated } = props; const hexclaveAdminApp = useAdminApp(); const router = useRouter(); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isCheckoutOpen, setIsCheckoutOpen] = useState(false); const [impersonateSnippet, setImpersonateSnippet] = useState(null); + const profileUrl = `/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`; return ( -
- +
event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + > + setImpersonateSnippet(null)} /> - router.push(`/projects/${encodeURIComponent(hexclaveAdminApp.projectId)}/users/${encodeURIComponent(user.id)}`) - } + onClick={() => router.push(profileUrl)} > View details @@ -649,6 +671,7 @@ function UserActions(props: { user: ExtendedServerUser }) { onClick={() => runAsynchronouslyWithAlert(async () => { await user.update({ totpMultiFactorSecret: null }); + runAsynchronouslyWithAlert(Promise.resolve().then(() => onUserMutated())); }) } > @@ -674,7 +697,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) { return (
- + {fallback} @@ -683,6 +706,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
diff --git a/apps/dashboard/src/components/entity-kpi-cards.tsx b/apps/dashboard/src/components/entity-kpi-cards.tsx index 85d954681..69f59b571 100644 --- a/apps/dashboard/src/components/entity-kpi-cards.tsx +++ b/apps/dashboard/src/components/entity-kpi-cards.tsx @@ -34,6 +34,7 @@ type EntityKpiCardsProps = { /** Pulls the four series out of the shared metrics response. */ source: (metrics: Metrics) => EntityKpiSeries, labels: EntityKpiLabels, + metrics?: Metrics, }; const capturedKpiErrors = new WeakMap>(); @@ -59,9 +60,12 @@ function formatCompact(n: number): string { return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(n); } -function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["source"], labels: EntityKpiLabels }) { - const hexclaveAdminApp = useAdminApp(); - const metrics = useMetricsOrThrow(hexclaveAdminApp, false); +function KpiGrid(props: { + metrics: Metrics, + source: EntityKpiCardsProps["source"], + labels: EntityKpiLabels, +}) { + const { metrics, source, labels } = props; const { dailyNew, splitTotal, splitNew, totalCount } = source(metrics); const new7 = sumLast(dailyNew, 7); @@ -145,6 +149,12 @@ function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["sourc ); } +function KpiGridContent({ source, labels }: { source: EntityKpiCardsProps["source"], labels: EntityKpiLabels }) { + const hexclaveAdminApp = useAdminApp(); + const metrics = useMetricsOrThrow(hexclaveAdminApp, false); + return ; +} + function KpiSkeletonGrid() { return (
@@ -155,16 +165,20 @@ function KpiSkeletonGrid() { ); } -export function EntityKpiCards({ errorTag, source, labels }: EntityKpiCardsProps) { +export function EntityKpiCards({ errorTag, source, labels, metrics }: EntityKpiCardsProps) { const ErrorComponent = ({ error }: { error: Error }) => { captureKpiErrorOnce(error, errorTag); return null; }; return ( - }> - - + {metrics != null ? ( + + ) : ( + }> + + + )} ); } diff --git a/apps/dashboard/src/components/link.tsx b/apps/dashboard/src/components/link.tsx index d2a72be7f..df8b08743 100644 --- a/apps/dashboard/src/components/link.tsx +++ b/apps/dashboard/src/components/link.tsx @@ -19,13 +19,14 @@ type LinkProps = { title?: string, }; -export const Link = React.forwardRef(({ onClick, href, children, ...rest }, ref) => { +export const Link = React.forwardRef(({ onClick, href, children, prefetch, ...rest }, ref) => { const router = useRouter(); const { needConfirm } = useRouterConfirm(); return ) => { if (needConfirm) { @@ -38,7 +39,7 @@ export const Link = React.forwardRef(({ onClick, h } }} > - + {prefetch !== false ? : null} {children} ; diff --git a/apps/dashboard/src/components/user-dialog.tsx b/apps/dashboard/src/components/user-dialog.tsx index 688562744..6660a5f91 100644 --- a/apps/dashboard/src/components/user-dialog.tsx +++ b/apps/dashboard/src/components/user-dialog.tsx @@ -2,6 +2,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a import { ServerUser } from "@hexclave/next"; import { KnownErrors } from "@hexclave/shared"; import { countryCodeSchema, emailSchema, jsonStringOrEmptySchema, passwordSchema } from "@hexclave/shared/dist/schema-fields"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, Typography } from "@/components/ui"; import { DesignButton, DesignDialog, DesignDialogClose } from "@/components/design-components"; import { WarningCircleIcon } from "@phosphor-icons/react"; @@ -18,6 +19,7 @@ const metadataDocsUrl = "https://docs.hexclave.com/guides/getting-started/user-f export function UserDialog(props: { open?: boolean, onOpenChange?: (open: boolean) => void, + onUserMutated?: () => void | Promise, trigger?: React.ReactNode, } & ({ type: 'create', @@ -146,6 +148,9 @@ export function UserDialog(props: { } throw error; } + if (props.onUserMutated) { + runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onUserMutated?.())); + } } return <> diff --git a/apps/dashboard/src/components/user-dialogs.tsx b/apps/dashboard/src/components/user-dialogs.tsx index 66cdcaa3d..4e8d20709 100644 --- a/apps/dashboard/src/components/user-dialogs.tsx +++ b/apps/dashboard/src/components/user-dialogs.tsx @@ -1,16 +1,21 @@ import { ServerUser } from '@hexclave/next'; import { ActionDialog, CopyField, Typography } from "@/components/ui"; +import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import { deindent } from "@hexclave/shared/dist/utils/strings"; +import { Link } from './link'; import { useRouter } from './router'; export function DeleteUserDialog(props: { user: ServerUser, open: boolean, + profileHref: string, redirectTo?: string, onOpenChange: (open: boolean) => void, + onDeleted?: () => void | Promise, }) { const router = useRouter(); + const userLabel = props.user.displayName?.trim() || props.user.primaryEmail?.trim() || props.user.id; return { await props.user.delete(); + if (props.onDeleted) { + runAsynchronouslyWithAlert(Promise.resolve().then(() => props.onDeleted?.())); + } if (props.redirectTo) { router.push(props.redirectTo); } @@ -27,7 +35,18 @@ export function DeleteUserDialog(props: { }} confirmText="I understand that this action cannot be undone." > - {`Are you sure you want to delete the user ${props.user.displayName ? '"' + props.user.displayName + '"' : ''} with ID ${props.user.id}?`} + + Are you sure you want to delete the user " { + props.onOpenChange(false); + }} + > + {userLabel} + "? + ; } diff --git a/apps/dashboard/src/lib/hexclave-app-internals.ts b/apps/dashboard/src/lib/hexclave-app-internals.ts index c1be230d2..45ab1985c 100644 --- a/apps/dashboard/src/lib/hexclave-app-internals.ts +++ b/apps/dashboard/src/lib/hexclave-app-internals.ts @@ -1,5 +1,7 @@ import { + MetricsResponseBodySchema, type MetricsResponse, + MetricsUserCountsSchema, type MetricsUserCounts, type UserActivityResponse, } from "@hexclave/shared/dist/interface/admin-metrics"; @@ -58,6 +60,7 @@ type AdminAppInternalsHooks = { useMetrics: (includeAnonymous: boolean, filters?: AnalyticsOverviewFilters) => MetricsResponse, useUserActivity: (userId: string) => UserActivityResponse, useMetricsUserCounts: () => MetricsUserCounts, + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, }; function getInternalsHookOrThrow(adminApp: object, hookName: K): AdminAppInternalsHooks[K] { @@ -95,3 +98,68 @@ export function useUserActivityOrThrow(adminApp: object, userId: string): UserAc export function useMetricsUserCountsOrThrow(adminApp: object): MetricsUserCounts { return getInternalsHookOrThrow(adminApp, "useMetricsUserCounts")(); } + +function getMetricsQueryString(includeAnonymous: boolean, filters?: AnalyticsOverviewFilters): string { + const params = new URLSearchParams(); + if (includeAnonymous) { + params.append("include_anonymous", "true"); + } + if (filters?.country_code) params.append("filter_country_code", filters.country_code); + if (filters?.referrer) params.append("filter_referrer", filters.referrer); + if (filters?.browser) params.append("filter_browser", filters.browser); + if (filters?.os) params.append("filter_os", filters.os); + if (filters?.device) params.append("filter_device", filters.device); + if (filters?.since) params.append("filter_since", filters.since); + if (filters?.until) params.append("filter_until", filters.until); + return params.toString(); +} + +function applyMetricsResponseDefaults(body: MetricsResponse): MetricsResponse { + // Keep this in sync with HexclaveAdminInterface.getMetrics(). These defaults + // preserve one-release-cycle tolerance for dashboards talking to older servers. + const rawBody: Partial = body; + const rawAnalytics: Partial = body.analytics_overview; + return { + ...body, + live_users: rawBody.live_users ?? 0, + hourly_users: rawBody.hourly_users ?? [], + hourly_active_users: rawBody.hourly_active_users ?? [], + analytics_overview: { + ...body.analytics_overview, + hourly_page_views: rawAnalytics.hourly_page_views ?? [], + hourly_active_users: rawAnalytics.hourly_active_users ?? [], + hourly_visitors: rawAnalytics.hourly_visitors ?? [], + daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [], + anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0, + top_regions: rawAnalytics.top_regions ?? [], + bounce_rate: rawAnalytics.bounce_rate ?? 0, + daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [], + daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [], + top_browsers: rawAnalytics.top_browsers ?? [], + top_operating_systems: rawAnalytics.top_operating_systems ?? [], + top_devices: rawAnalytics.top_devices ?? [], + }, + }; +} + +async function fetchJsonOrThrow(adminApp: object, path: string): Promise { + const response = await getInternalsHookOrThrow(adminApp, "sendRequest")(path, { method: "GET" }, "admin"); + if (!response.ok) { + throw new HexclaveAssertionError(`Admin app internals request failed: ${path}`); + } + return await response.json(); +} + +export async function fetchMetricsOrThrow( + adminApp: object, + includeAnonymous: boolean, + filters?: AnalyticsOverviewFilters, +): Promise { + const queryString = getMetricsQueryString(includeAnonymous, filters); + const path = `/internal/metrics${queryString ? `?${queryString}` : ""}`; + return applyMetricsResponseDefaults(await MetricsResponseBodySchema.validate(await fetchJsonOrThrow(adminApp, path))); +} + +export async function fetchMetricsUserCountsOrThrow(adminApp: object): Promise { + return await MetricsUserCountsSchema.validate(await fetchJsonOrThrow(adminApp, "/internal/metrics/user-counts")); +}