mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Overview page loading indicator (#1004)
This commit is contained in:
parent
d28261937e
commit
3b34e26f0b
@ -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.`);
|
||||
}
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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': {
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user