mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into promptless/update-analytics-tables-documentation
This commit is contained in:
commit
d7bdc5a8f7
@ -1,193 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from "@/components/router";
|
||||
import { UserAvatar } from '@stackframe/stack';
|
||||
import { fromNow } from '@stackframe/stack-shared/dist/utils/dates';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Typography,
|
||||
} from "@/components/ui";
|
||||
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
|
||||
import { useAdminApp, useProjectId } from '../use-admin-app';
|
||||
import { DonutChartDisplay, LineChartDisplay, LineChartDisplayConfig } from './line-chart';
|
||||
|
||||
const dailySignUpsConfig = {
|
||||
name: 'Daily Sign-ups',
|
||||
description: 'User registration over the last 30 days',
|
||||
chart: {
|
||||
activity: {
|
||||
label: "Activity",
|
||||
theme: {
|
||||
light: "hsl(221, 83%, 53%)", // Bright blue for light mode
|
||||
dark: "hsl(217, 91%, 60%)", // Lighter blue for dark mode
|
||||
},
|
||||
},
|
||||
}
|
||||
} 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",
|
||||
theme: {
|
||||
light: "hsl(142, 76%, 36%)", // Bright green for light mode
|
||||
dark: "hsl(142, 71%, 45%)", // Lighter green for dark mode
|
||||
},
|
||||
},
|
||||
}
|
||||
} satisfies LineChartDisplayConfig;
|
||||
|
||||
export function ChartsSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const projectId = useProjectId();
|
||||
const router = useRouter();
|
||||
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Charts Grid */}
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<LineChartDisplay
|
||||
config={dailySignUpsConfig}
|
||||
datapoints={data.daily_users}
|
||||
timeRange="30d"
|
||||
/>
|
||||
<LineChartDisplay
|
||||
config={dauConfig}
|
||||
datapoints={data.daily_active_users}
|
||||
timeRange="30d"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity Grid */}
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
{/* Recent Sign Ups - 2/3 width */}
|
||||
<Card className="lg:col-span-2 transition-all">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="space-y-1">
|
||||
<Typography className="text-xs font-medium uppercase tracking-wide text-blue-700">
|
||||
Activity
|
||||
</Typography>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Recent Sign Ups
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Users who signed up most recently.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{data.recently_registered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Typography variant="secondary" className="text-sm">
|
||||
No recent sign ups
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[340px] overflow-y-auto pr-1">
|
||||
{data.recently_registered.map((user: any) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${encodeURIComponent(projectId)}/users/${encodeURIComponent(user.id)}`
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/80 px-3 py-2 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
profileImageUrl: user.profile_image_url,
|
||||
displayName: user.display_name,
|
||||
primaryEmail: user.primary_email,
|
||||
}}
|
||||
size={40}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<Typography className="truncate text-sm font-medium leading-snug">
|
||||
{user.display_name ?? user.primary_email}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
{fromNow(new Date(user.signed_up_at_millis))}
|
||||
</Typography>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auth Methods Donut */}
|
||||
<DonutChartDisplay
|
||||
datapoints={data.login_methods}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recently Active - Full Width Grid */}
|
||||
<Card className="transition-all">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="space-y-1">
|
||||
<Typography className="text-xs font-medium uppercase tracking-wide text-blue-700">
|
||||
Activity
|
||||
</Typography>
|
||||
<CardTitle className="text-base font-semibold">
|
||||
Recently Active
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Users who were active most recently.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{data.recently_active.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Typography variant="secondary" className="text-sm">
|
||||
No recent activity
|
||||
</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{data.recently_active.map((user: any) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/projects/${encodeURIComponent(projectId)}/users/${encodeURIComponent(user.id)}`
|
||||
)
|
||||
}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border/70 bg-background/80 px-3 py-2 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<UserAvatar
|
||||
user={{
|
||||
profileImageUrl: user.profile_image_url,
|
||||
displayName: user.display_name,
|
||||
primaryEmail: user.primary_email,
|
||||
}}
|
||||
size={40}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<Typography className="truncate text-sm font-medium leading-snug">
|
||||
{user.display_name ?? user.primary_email}
|
||||
</Typography>
|
||||
<Typography variant="secondary" className="text-xs truncate">
|
||||
{fromNow(new Date(user.last_active_at_millis))}
|
||||
</Typography>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ErrorBoundary } from '@sentry/nextjs';
|
||||
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
|
||||
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { useAdminApp } from '../use-admin-app';
|
||||
import { GlobeSection } from './globe';
|
||||
|
||||
export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
const capturedGlobeErrors = new WeakSet<Error>();
|
||||
|
||||
function captureGlobeErrorOnce(error: Error) {
|
||||
if (capturedGlobeErrors.has(error)) {
|
||||
return;
|
||||
}
|
||||
capturedGlobeErrors.add(error);
|
||||
captureError("metrics-globe-error-boundary", error);
|
||||
}
|
||||
|
||||
export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
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 errorComponent={GlobeErrorComponent}>
|
||||
<GlobeSectionWithMetrics includeAnonymous={includeAnonymous} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeErrorComponent(props: { error: Error }) {
|
||||
captureGlobeErrorOnce(props.error);
|
||||
return <div className='text-center text-sm text-red-500'>Error initializing globe visualization. Please try updating your browser or enabling WebGL.</div>;
|
||||
}
|
||||
|
||||
function GlobeSectionWithMetrics({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const data = (adminApp as any)[stackAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
|
||||
return <GlobeSection countryData={data.users_by_country} totalUsers={data.total_users} />;
|
||||
}
|
||||
|
||||
@ -1,7 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, cn } from '@/components/ui';
|
||||
import { CircleNotchIcon } from '@phosphor-icons/react';
|
||||
import { Button, Card, CardContent, cn } from '@/components/ui';
|
||||
import { captureError } from '@stackframe/stack-shared/dist/utils/errors';
|
||||
import { ArrowClockwiseIcon, CircleNotchIcon, WarningCircleIcon } from '@phosphor-icons/react';
|
||||
|
||||
const capturedMetricsErrors = new WeakSet<Error>();
|
||||
|
||||
function captureMetricsErrorOnce(error: Error) {
|
||||
if (capturedMetricsErrors.has(error)) {
|
||||
return;
|
||||
}
|
||||
capturedMetricsErrors.add(error);
|
||||
captureError("metrics-page-error-boundary", error);
|
||||
}
|
||||
|
||||
export function MetricsLoadingFallback({ className }: { className?: string }) {
|
||||
return (
|
||||
@ -16,3 +27,37 @@ export function MetricsLoadingFallback({ className }: { className?: string }) {
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricsErrorFallback({
|
||||
error,
|
||||
onRetryAction,
|
||||
className,
|
||||
}: {
|
||||
error: Error,
|
||||
onRetryAction?: () => void,
|
||||
className?: string,
|
||||
}) {
|
||||
captureMetricsErrorOnce(error);
|
||||
|
||||
const errorMessage = error.message.trim().length > 0
|
||||
? error.message
|
||||
: "An unexpected error occurred while loading project metrics.";
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full", className)}>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 space-y-4 text-center">
|
||||
<WarningCircleIcon className="h-8 w-8 text-destructive" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-lg font-medium">Failed to load metrics</p>
|
||||
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
||||
</div>
|
||||
{onRetryAction != null ? (
|
||||
<Button variant="secondary" size="sm" onClick={onRetryAction}>
|
||||
<ArrowClockwiseIcon className="mr-1.5 h-4 w-4" />
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ import { useUser } from "@stackframe/stack";
|
||||
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { Suspense, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ElementType } from "react";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp, useProjectId } from "../use-admin-app";
|
||||
@ -42,7 +43,7 @@ import {
|
||||
VisitorsHoverChart,
|
||||
VisitorsHoverDataPoint,
|
||||
} from "./line-chart";
|
||||
import { MetricsLoadingFallback } from "./metrics-loading";
|
||||
import { MetricsErrorFallback, MetricsLoadingFallback } from "./metrics-loading";
|
||||
|
||||
const dailySignUpsConfig: LineChartDisplayConfig = {
|
||||
name: 'Daily Sign-Ups',
|
||||
@ -932,13 +933,19 @@ export default function MetricsPage(props: { toSetup: () => void }) {
|
||||
fullBleed
|
||||
wrapHeaderInCard
|
||||
>
|
||||
<Suspense fallback={<MetricsLoadingFallback />}>
|
||||
<MetricsContent includeAnonymous={includeAnonymous} timeRange={timeRange} customDateRange={customDateRange} />
|
||||
</Suspense>
|
||||
<ErrorBoundary errorComponent={MetricsErrorComponent}>
|
||||
<Suspense fallback={<MetricsLoadingFallback />}>
|
||||
<MetricsContent includeAnonymous={includeAnonymous} timeRange={timeRange} customDateRange={customDateRange} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricsErrorComponent(props: { error: Error, reset?: () => void }) {
|
||||
return <MetricsErrorFallback error={props.error} onRetryAction={props.reset} />;
|
||||
}
|
||||
|
||||
function MetricsContent({
|
||||
includeAnonymous,
|
||||
timeRange,
|
||||
|
||||
@ -6,12 +6,24 @@ import { StyledLink } from "@/components/link";
|
||||
import { Alert, Button, SimpleTooltip, Skeleton } from "@/components/ui";
|
||||
import { UserDialog } from "@/components/user-dialog";
|
||||
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
|
||||
import { captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { ArrowsClockwiseIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { ErrorBoundary } from "next/dist/client/components/error-boundary";
|
||||
import { Suspense, useState } from "react";
|
||||
import { AppEnabledGuard } from "../app-enabled-guard";
|
||||
import { PageLayout } from "../page-layout";
|
||||
import { useAdminApp } from "../use-admin-app";
|
||||
|
||||
const capturedUsersMetricsErrors = new WeakSet<Error>();
|
||||
|
||||
function captureUsersMetricsErrorOnce(error: Error) {
|
||||
if (capturedUsersMetricsErrors.has(error)) {
|
||||
return;
|
||||
}
|
||||
capturedUsersMetricsErrors.add(error);
|
||||
captureError("users-total-metrics-error-boundary", error);
|
||||
}
|
||||
|
||||
function TotalUsersDisplay() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const metrics = (stackAdminApp as any)[stackAppInternalsSymbol].useMetrics(false);
|
||||
@ -38,6 +50,11 @@ function TotalUsersDisplay() {
|
||||
);
|
||||
}
|
||||
|
||||
function TotalUsersErrorComponent(props: { error: Error }) {
|
||||
captureUsersMetricsErrorOnce(props.error);
|
||||
return <>Unavailable</>;
|
||||
}
|
||||
|
||||
export default function PageClient() {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const firstUser = (stackAdminApp as any).useUsers({ limit: 1 });
|
||||
@ -60,9 +77,11 @@ export default function PageClient() {
|
||||
title="Users"
|
||||
description={<>
|
||||
Total:{" "}
|
||||
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
|
||||
<TotalUsersDisplay key={refreshKey} />
|
||||
</Suspense>
|
||||
<ErrorBoundary errorComponent={TotalUsersErrorComponent}>
|
||||
<Suspense fallback={<Skeleton className="inline"><span>Calculating</span></Skeleton>}>
|
||||
<TotalUsersDisplay key={refreshKey} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</>}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user