users-page-sync-performance-issues

This commit is contained in:
Vedanta-Gawande
2026-06-29 19:39:17 -07:00
parent e298cfc67b
commit 2d99242004
8 changed files with 252 additions and 97 deletions
@@ -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 { 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, 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 <TotalUsersText metrics={metrics} />;
}
function TotalUsersText(props: {
metrics: MetricsUserCounts,
}) {
const { metrics } = props;
const anonymousUsersCount = metrics.anonymous_users;
const nonAnonymousUsersCount = metrics.total_users - anonymousUsersCount;
@@ -55,15 +68,42 @@ 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<UsersMetricsSnapshot | null>(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]);
const handleTableReloadChange = useCallback((reload: () => void) => {
tableReloadRef.current = reload;
}, []);
const handleUserMutated = useCallback(() => {
tableReloadRef.current();
runAsynchronouslyWithAlert(refreshUsersMetrics);
}, [refreshUsersMetrics]);
const handleRefresh = useCallback(async () => {
tableReloadRef.current();
await refreshUsersMetrics();
}, [refreshUsersMetrics]);
const hasUsers = usersMetricsSnapshot != null
? usersMetricsSnapshot.userCounts.total_users - usersMetricsSnapshot.userCounts.anonymous_users > 0
: firstUserPage.length > 0;
return (
<AppEnabledGuard appId="authentication">
@@ -72,9 +112,13 @@ export default function PageClient() {
description={<>
Total:{" "}
<ErrorBoundary errorComponent={TotalUsersErrorComponent}>
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
<TotalUsersDisplay key={refreshKey} />
</Suspense>
{usersMetricsSnapshot != null ? (
<TotalUsersText metrics={usersMetricsSnapshot.userCounts} />
) : (
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
<TotalUsersDisplay />
</Suspense>
)}
</ErrorBoundary>
</>}
actions={
@@ -87,20 +131,21 @@ export default function PageClient() {
<UserDialog
type="create"
trigger={<Button>Create User</Button>}
onUserMutated={handleUserMutated}
/>
</div>
}
>
{firstUserPage.length > 0 ? null : (
{hasUsers ? null : (
<Alert variant='success'>
Congratulations on starting your project! Check the <StyledLink href="https://docs.hexclave.com">documentation</StyledLink> to add your first users.
</Alert>
)}
<UsersKpiCards />
<UsersKpiCards metrics={usersMetricsSnapshot?.metrics} />
<div data-walkthrough="users-table">
<UserTable key={refreshKey} />
<UserTable onUserMutated={handleUserMutated} onReloadChange={handleTableReloadChange} />
</div>
</PageLayout>
</AppEnabledGuard>
@@ -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 (
<EntityKpiCards
errorTag="users-kpi-cards-error-boundary"
metrics={props.metrics}
source={(metrics) => ({
dailyNew: metrics.daily_users.map((p) => p.activity),
splitTotal: metrics.auth_overview.daily_active_users_split.total.map((p) => p.activity),
@@ -132,70 +132,74 @@ function parseEmailDomains(input: string) {
// ─── Column definitions ──────────────────────────────────────────────
const USER_TABLE_COLUMNS: DataGridColumnDef<ExtendedServerUser>[] = [
{
id: "user",
header: "User",
width: 180,
flex: 1,
sortable: false,
renderCell: ({ row }) => <UserIdentityCell user={row} />,
},
{
id: "email",
header: "Email",
width: 180,
flex: 1,
sortable: false,
renderCell: ({ row }) => <UserEmailCell user={row} />,
},
{
id: "userId",
header: "User ID",
width: 130,
sortable: false,
renderCell: ({ row }) => <UserIdCell user={row} />,
},
{
id: "emailStatus",
header: "Email Verified",
width: 110,
sortable: false,
renderCell: ({ row }) => <EmailStatusCell user={row} />,
},
{
id: "lastActiveAt",
header: "Last active",
width: 110,
renderCell: ({ row }) => <DateMetaCell value={row.lastActiveAt} emptyLabel="Never" />,
},
{
id: "auth",
header: "Auth methods",
width: 150,
sortable: false,
cellOverflow: "wrap",
renderCell: ({ row }) => <AuthMethodsCell user={row} />,
},
{
id: "signedUpAt",
header: "Signed up",
width: 110,
renderCell: ({ row }) => <DateMetaCell value={row.signedUpAt} emptyLabel="Unknown" />,
},
{
id: "actions",
header: "",
width: 44,
minWidth: 44,
maxWidth: 44,
sortable: false,
hideable: false,
resizable: false,
align: "right",
renderCell: ({ row }) => <UserActions user={row} />,
},
];
function createUserTableColumns(
onUserMutated?: () => void | Promise<void>,
): DataGridColumnDef<ExtendedServerUser>[] {
return [
{
id: "user",
header: "User",
width: 180,
flex: 1,
sortable: false,
renderCell: ({ row }) => <UserIdentityCell user={row} />,
},
{
id: "email",
header: "Email",
width: 180,
flex: 1,
sortable: false,
renderCell: ({ row }) => <UserEmailCell user={row} />,
},
{
id: "userId",
header: "User ID",
width: 130,
sortable: false,
renderCell: ({ row }) => <UserIdCell user={row} />,
},
{
id: "emailStatus",
header: "Email Verified",
width: 110,
sortable: false,
renderCell: ({ row }) => <EmailStatusCell user={row} />,
},
{
id: "lastActiveAt",
header: "Last active",
width: 110,
renderCell: ({ row }) => <DateMetaCell value={row.lastActiveAt} emptyLabel="Never" />,
},
{
id: "auth",
header: "Auth methods",
width: 150,
sortable: false,
cellOverflow: "wrap",
renderCell: ({ row }) => <AuthMethodsCell user={row} />,
},
{
id: "signedUpAt",
header: "Signed up",
width: 110,
renderCell: ({ row }) => <DateMetaCell value={row.signedUpAt} emptyLabel="Unknown" />,
},
{
id: "actions",
header: "",
width: 44,
minWidth: 44,
maxWidth: 44,
sortable: false,
hideable: false,
resizable: false,
align: "right",
renderCell: ({ row }) => <UserActions user={row} onUserMutated={onUserMutated} />,
},
];
}
const USER_EXPORT_FIELDS: DataGridExportField<ExtendedServerUser>[] = [
{ key: "id", label: "User ID", enabled: true, getValue: (user) => user.id },
@@ -218,10 +222,13 @@ const USER_EXPORT_FIELDS: DataGridExportField<ExtendedServerUser>[] = [
// ─── UserTable ───────────────────────────────────────────────────────
export function UserTable() {
export function UserTable(props: {
onUserMutated?: () => void | Promise<void>,
onReloadChange?: (reload: () => void) => void,
}) {
const [filters, setFilters] = useState<FilterState>(DEFAULT_FILTERS);
return <UserTableBody filters={filters} setFilters={setFilters} />;
return <UserTableBody filters={filters} setFilters={setFilters} onUserMutated={props.onUserMutated} onReloadChange={props.onReloadChange} />;
}
// ─── Body (imperative fetching — no Suspense flash) ──────────────────
@@ -229,12 +236,15 @@ export function UserTable() {
function UserTableBody(props: {
filters: FilterState,
setFilters: React.Dispatch<React.SetStateAction<FilterState>>,
onUserMutated?: () => void | Promise<void>,
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 (
<DataGrid
columns={USER_TABLE_COLUMNS}
columns={columns}
rows={gridData.rows}
getRowId={getRowId}
isLoading={gridData.isLoading}
@@ -595,8 +610,11 @@ function EmailDomainFilter(props: {
// ─── Cell components ─────────────────────────────────────────────────
function UserActions(props: { user: ExtendedServerUser }) {
const { user } = props;
function UserActions(props: {
user: ExtendedServerUser,
onUserMutated?: () => void | Promise<void>,
}) {
const { user, onUserMutated } = props;
const hexclaveAdminApp = useAdminApp();
const router = useRouter();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
@@ -611,7 +629,7 @@ function UserActions(props: { user: ExtendedServerUser }) {
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
>
<DeleteUserDialog user={user} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} profileHref={profileUrl} />
<DeleteUserDialog user={user} open={isDeleteOpen} onOpenChange={setIsDeleteOpen} profileHref={profileUrl} onDeleted={onUserMutated} />
<ImpersonateUserDialog user={user} impersonateSnippet={impersonateSnippet} onClose={() => setImpersonateSnippet(null)} />
<CreateCheckoutDialog
open={isCheckoutOpen}
@@ -678,7 +696,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
return (
<div className="flex items-center gap-3">
<Link href={profileUrl} className="rounded-full shrink-0">
<Link href={profileUrl} className="rounded-full shrink-0" prefetch={false}>
<Avatar className="h-6 w-6">
<AvatarImage src={user.profileImageUrl ?? undefined} alt={user.displayName ?? user.primaryEmail ?? "User avatar"} />
<AvatarFallback>{fallback}</AvatarFallback>
@@ -687,6 +705,7 @@ function UserIdentityCell(props: { user: ExtendedServerUser }) {
<div className="min-w-0 flex-1">
<Link
href={profileUrl}
prefetch={false}
className="block truncate text-sm font-semibold text-foreground hover:text-foreground"
title={displayName}
>
@@ -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<Error, Set<string>>();
@@ -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 <KpiGrid metrics={metrics} source={source} labels={labels} />;
}
function KpiSkeletonGrid() {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -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 (
<ErrorBoundary errorComponent={ErrorComponent}>
<Suspense fallback={<KpiSkeletonGrid />}>
<KpiGridContent source={source} labels={labels} />
</Suspense>
{metrics != null ? (
<KpiGrid metrics={metrics} source={source} labels={labels} />
) : (
<Suspense fallback={<KpiSkeletonGrid />}>
<KpiGridContent source={source} labels={labels} />
</Suspense>
)}
</ErrorBoundary>
);
}
+3 -2
View File
@@ -19,13 +19,14 @@ type LinkProps = {
title?: string,
};
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, href, children, ...rest }, ref) => {
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, href, children, prefetch, ...rest }, ref) => {
const router = useRouter();
const { needConfirm } = useRouterConfirm();
return <NextLink
ref={ref}
href={href}
prefetch={prefetch}
{...rest}
onClick={(e: React.MouseEvent<HTMLAnchorElement>) => {
if (needConfirm) {
@@ -38,7 +39,7 @@ export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(({ onClick, h
}
}}
>
<UrlPrefetcher href={href} />
{prefetch !== false ? <UrlPrefetcher href={href} /> : null}
{children}
</NextLink>;
@@ -18,6 +18,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<void>,
trigger?: React.ReactNode,
} & ({
type: 'create',
@@ -129,6 +130,7 @@ export function UserDialog(props: {
} else {
await adminApp.createUser(userValues);
}
await props.onUserMutated?.();
} catch (error) {
if (KnownErrors.UserWithEmailAlreadyExists.isInstance(error)) {
setErrorDialog({
@@ -11,6 +11,7 @@ export function DeleteUserDialog(props: {
profileHref: string,
redirectTo?: string,
onOpenChange: (open: boolean) => void,
onDeleted?: () => void | Promise<void>,
}) {
const router = useRouter();
const userLabel = props.user.displayName ?? props.user.primaryEmail ?? props.user.id;
@@ -23,6 +24,7 @@ export function DeleteUserDialog(props: {
okButton={{
label: "Delete User", onClick: async () => {
await props.user.delete();
await props.onDeleted?.();
if (props.redirectTo) {
router.push(props.redirectTo);
}
@@ -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<Response>,
};
function getInternalsHookOrThrow<K extends keyof AdminAppInternalsHooks>(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<MetricsResponse> = body;
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = 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<unknown> {
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<MetricsResponse> {
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<MetricsUserCounts> {
return await MetricsUserCountsSchema.validate(await fetchJsonOrThrow(adminApp, "/internal/metrics/user-counts"));
}