Overview page loading indicator (#1004)

This commit is contained in:
Konsti Wohlwend 2025-11-06 11:41:30 -08:00 committed by GitHub
parent d28261937e
commit 3b34e26f0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 189 additions and 127 deletions

View File

@ -49,10 +49,7 @@ export const POST = createSmartRouteHandler({
if (semver.lt(clientVersion, serverVersion)) {
return err(false, `You are running an outdated version of Stack Auth (v${clientVersion}; the current version is v${serverVersion}). Please update to the latest version as soon as possible to ensure that you get the latest feature and security updates.`);
}
if (semver.gt(clientVersion, serverVersion)) {
return err(false, `You are running a version of Stack Auth that is newer than the newest known version (v${clientVersion} > v${serverVersion}). This is weird. Are you running on a development branch?`);
}
if (clientVersion !== serverVersion) {
if (!semver.gt(clientVersion, serverVersion) && clientVersion !== serverVersion) {
return err(true, `You are running a version of Stack Auth that is not the same as the newest known version (v${clientVersion} !== v${serverVersion}). Please update to the latest version as soon as possible to ensure that you get the latest feature and security updates.`);
}

View File

@ -1,14 +1,13 @@
import { Tenancy } from "@/lib/tenancies";
import { getOrSetCacheValue } from "@/lib/cache";
import { Tenancy } from "@/lib/tenancies";
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, PrismaClientTransaction, sqlQuoteIdent } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { Prisma } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import yup from 'yup';
import { userFullInclude, userPrismaToCrud } from "../../users/crud";
import { usersCrudHandlers } from "../../users/crud";
import { Prisma } from "@prisma/client";
import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud";
type DataPoints = yup.InferType<typeof DataPointsSchema>;

View File

@ -0,0 +1,114 @@
'use client';
import { useRouter } from "@/components/router";
import { UserAvatar } from '@stackframe/stack';
import { fromNow } from '@stackframe/stack-shared/dist/utils/dates';
import { Card, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableRow, Typography } from '@stackframe/stack-ui';
import { useAdminApp } from '../use-admin-app';
import { DonutChartDisplay, LineChartDisplay, LineChartDisplayConfig } from './line-chart';
const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals");
const dailySignUpsConfig = {
name: 'Daily Sign-ups',
description: 'User registration over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#cc6ce7",
},
}
} satisfies LineChartDisplayConfig;
const dauConfig = {
name: 'Daily Active Users',
description: 'Number of unique users that were active over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#2563eb",
},
}
} satisfies LineChartDisplayConfig;
export function ChartsSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
const adminApp = useAdminApp();
const router = useRouter();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
return (
<div className='grid gap-4 lg:grid-cols-2'>
<LineChartDisplay
config={dailySignUpsConfig}
datapoints={data.daily_users}
/>
<LineChartDisplay
config={dauConfig}
datapoints={data.daily_active_users}
/>
<Card>
<CardHeader>
<CardTitle>Recent Sign Ups</CardTitle>
</CardHeader>
<CardContent>
{data.recently_registered.length === 0 && (
<Typography variant='secondary'>No recent sign ups</Typography>
)}
<Table>
<TableBody>
{data.recently_registered.map((user: any) => (
<TableRow
key={user.id}
onClick={() => router.push(`/projects/${encodeURIComponent(adminApp.projectId)}/users/${encodeURIComponent(user.id)}`)}
>
<TableCell className='w-10 h-10'>
<UserAvatar user={{ profileImageUrl: user.profile_image_url, displayName: user.display_name, primaryEmail: user.primary_email }} />
</TableCell>
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
signed up {fromNow(new Date(user.signed_up_at_millis))}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recently Active Users</CardTitle>
</CardHeader>
<CardContent>
{data.recently_active.length === 0 && (
<Typography variant='secondary'>No recent active users</Typography>
)}
<Table>
<TableBody>
{data.recently_active.map((user: any) => (
<TableRow
key={user.id}
onClick={() => router.push(`/projects/${encodeURIComponent(adminApp.projectId)}/users/${encodeURIComponent(user.id)}`)}
>
<TableCell className='w-10 h-10'>
<UserAvatar user={{ profileImageUrl: user.profile_image_url, displayName: user.display_name, primaryEmail: user.primary_email }} />
</TableCell>
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
last active {fromNow(new Date(user.last_active_at_millis))}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<DonutChartDisplay
datapoints={data.login_methods}
/>
</div>
);
}

View File

@ -0,0 +1,18 @@
'use client';
import { ErrorBoundary } from '@sentry/nextjs';
import { useAdminApp } from '../use-admin-app';
import { GlobeSection } from './globe';
const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals");
export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
const adminApp = useAdminApp();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
return (
<ErrorBoundary fallback={<div className='text-center text-sm text-red-500'>Error initializing globe visualization. Please try updating your browser or enabling WebGL.</div>}>
<GlobeSection countryData={data.users_by_country} totalUsers={data.total_users} />
</ErrorBoundary>
);
}

View File

@ -0,0 +1,18 @@
'use client';
import { Card, CardContent, cn } from '@stackframe/stack-ui';
import { Loader2 } from 'lucide-react';
export function MetricsLoadingFallback({ className }: { className?: string }) {
return (
<Card className={cn("w-full", className)}>
<CardContent className="flex flex-col items-center justify-center py-12 space-y-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center space-y-1">
<p className="text-lg font-medium">Recalculating metrics...</p>
<p className="text-sm text-muted-foreground">Please check back later</p>
</div>
</CardContent>
</Card>
);
}

View File

@ -2,55 +2,25 @@
import { AppSquare, appSquarePaddingExpression, appSquareWidthExpression } from "@/components/app-square";
import { Link } from "@/components/link";
import { useRouter } from "@/components/router";
import { ErrorBoundary } from '@sentry/nextjs';
import { UserAvatar } from '@stackframe/stack';
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { fromNow } from '@stackframe/stack-shared/dist/utils/dates';
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { urlString } from "@stackframe/stack-shared/dist/utils/urls";
import { Card, CardContent, CardHeader, CardTitle, Table, TableBody, TableCell, TableRow, Typography, cn } from '@stackframe/stack-ui';
import { cn } from '@stackframe/stack-ui';
import { ArrowRight } from "lucide-react";
import { useState } from 'react';
import { Suspense, useState } from 'react';
import { PageLayout } from "../page-layout";
import { useAdminApp } from '../use-admin-app';
import { GlobeSection } from './globe';
import { DonutChartDisplay, LineChartDisplay, LineChartDisplayConfig } from './line-chart';
import { ChartsSectionWithData } from './charts-section-with-data';
import { GlobeSectionWithData } from './globe-section-with-data';
import { MetricsLoadingFallback } from './metrics-loading';
const stackAppInternalsSymbol = Symbol.for("StackAuth--DO-NOT-USE-OR-YOU-WILL-BE-FIRED--StackAppInternals");
const dailySignUpsConfig = {
name: 'Daily Sign-ups',
description: 'User registration over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#cc6ce7",
},
}
} satisfies LineChartDisplayConfig;
const dauConfig = {
name: 'Daily Active Users',
description: 'Number of unique users that were active over the last 30 days',
chart: {
activity: {
label: "Activity",
color: "#2563eb",
},
}
} satisfies LineChartDisplayConfig;
export default function MetricsPage(props: { toSetup: () => void }) {
const adminApp = useAdminApp();
const project = adminApp.useProject();
const config = project.useConfig();
const router = useRouter();
const [includeAnonymous, setIncludeAnonymous] = useState(false);
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
//
const installedApps = Object.entries(config.apps.installed)
.filter(([_, appConfig]) => appConfig?.enabled)
.map(([appId]) => appId as AppId);
@ -62,9 +32,9 @@ export default function MetricsPage(props: { toSetup: () => void }) {
return (
<PageLayout>
<ErrorBoundary fallback={<div className='text-center text-sm text-red-500'>Error initializing globe visualization. Please try updating your browser or enabling WebGL.</div>}>
<GlobeSection countryData={data.users_by_country} totalUsers={data.total_users} />
</ErrorBoundary>
<Suspense fallback={<MetricsLoadingFallback />}>
<GlobeSectionWithData includeAnonymous={includeAnonymous} />
</Suspense>
{/* Apps */}
@ -115,79 +85,10 @@ export default function MetricsPage(props: { toSetup: () => void }) {
</div>
</section>
<div className='grid gap-4 lg:grid-cols-2'>
<LineChartDisplay
config={dailySignUpsConfig}
datapoints={data.daily_users}
/>
<LineChartDisplay
config={dauConfig}
datapoints={data.daily_active_users}
/>
<Card>
<CardHeader>
<CardTitle>Recent Sign Ups</CardTitle>
</CardHeader>
<CardContent>
{data.recently_registered.length === 0 && (
<Typography variant='secondary'>No recent sign ups</Typography>
)}
<Table>
<TableBody>
{data.recently_registered.map((user: any) => (
<TableRow
key={user.id}
onClick={() => router.push(`/projects/${encodeURIComponent(adminApp.projectId)}/users/${encodeURIComponent(user.id)}`)}
>
<TableCell className='w-10 h-10'>
<UserAvatar user={{ profileImageUrl: user.profile_image_url, displayName: user.display_name, primaryEmail: user.primary_email }} />
</TableCell>
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
signed up {fromNow(new Date(user.signed_up_at_millis))}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recently Active Users</CardTitle>
</CardHeader>
<CardContent>
{data.recently_active.length === 0 && (
<Typography variant='secondary'>No recent active users</Typography>
)}
<Table>
<TableBody>
{data.recently_active.map((user: any) => (
<TableRow
key={user.id}
onClick={() => router.push(`/projects/${encodeURIComponent(adminApp.projectId)}/users/${encodeURIComponent(user.id)}`)}
>
<TableCell className='w-10 h-10'>
<UserAvatar user={{ profileImageUrl: user.profile_image_url, displayName: user.display_name, primaryEmail: user.primary_email }} />
</TableCell>
<TableCell>
{user.display_name ?? user.primary_email}
<Typography variant='secondary'>
last active {fromNow(new Date(user.last_active_at_millis))}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<DonutChartDisplay
datapoints={data.login_methods}
/>
</div>
{/* Charts */}
<Suspense fallback={<MetricsLoadingFallback />}>
<ChartsSectionWithData includeAnonymous={includeAnonymous} />
</Suspense>
</PageLayout>
);
}

View File

@ -1,6 +1,5 @@
'use client';
import { stackAppInternalsSymbol } from "@/app/(main)/integrations/transfer-confirm-page";
import { useState } from "react";
import { useAdminApp } from "../use-admin-app";
import MetricsPage from "./metrics-page";
@ -8,8 +7,8 @@ import SetupPage from "./setup-page";
export default function PageClient() {
const adminApp = useAdminApp();
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(false);
const [page, setPage] = useState<'setup' | 'metrics'>(data.total_users === 0 ? 'setup' : 'metrics');
const users = adminApp.useUsers({ limit: 1 });
const [page, setPage] = useState<'setup' | 'metrics'>(users.length === 0 ? 'setup' : 'metrics');
switch (page) {
case 'setup': {

View File

@ -1,7 +1,7 @@
"use client";
import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useUser } from "@stackframe/stack";
import { stackAppInternalsSymbol, useUser } from "@stackframe/stack";
import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails";
import { createCachedRegex } from "@stackframe/stack-shared/dist/utils/regex";
import { useEffect, useState } from "react";
@ -11,8 +11,12 @@ import { HookPrefetcher, HookPrefetcherCallback } from "./hook-prefetcher";
// this is because we suspend the component
const urlPrefetchers: Record<string, ((match: RegExpMatchArray, query: URLSearchParams, hash: string) => void | HookPrefetcherCallback[])[]> = {
"/projects/*": [
// TODO: we currently don't prefetch metrics as they are pretty slow to fetch
// ([_, projectId]) => (useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(false),
([_, projectId]) => {
(useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(false);
},
([_, projectId]) => {
useAdminApp(projectId).useUsers({ limit: 1 });
},
],
"/projects/*/**": [
([_, projectId]) => {
@ -20,8 +24,12 @@ const urlPrefetchers: Record<string, ((match: RegExpMatchArray, query: URLSearch
},
],
"/projects/*/users": [
// TODO: we currently don't prefetch metrics as they are pretty slow to fetch
// ([_, projectId]) => (useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(),
([_, projectId]) => {
(useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(false);
},
([_, projectId]) => {
(useAdminApp(projectId) as any)[stackAppInternalsSymbol].useMetrics(true);
},
([_, projectId]) => {
useAdminApp(projectId).useUsers({ limit: 1 });
},

View File

@ -433,6 +433,14 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
await this._internalApiKeysCache.refresh([]);
}
protected override async _refreshUsers() {
await Promise.all([
super._refreshUsers(),
this._metricsCache.refresh([false]),
this._metricsCache.refresh([true]),
]);
}
get [stackAppInternalsSymbol]() {
return {
...super[stackAppInternalsSymbol],