mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
users-page-sync-performance-issues
This commit is contained in:
+58
-13
@@ -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>
|
||||
|
||||
+5
-1
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user