Merge branch 'dev' into promptless/update-analytics-tables-documentation

This commit is contained in:
promptless[bot] 2026-04-19 02:10:18 +00:00
commit d7bdc5a8f7
5 changed files with 106 additions and 208 deletions

View File

@ -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>
);
}

View File

@ -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} />;
}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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">