Files
AFFiNE-Mirror/packages/frontend/admin/src/modules/dashboard/index.tsx
DarkSky b4be9118ad feat: doc status & share status (#14426)
#### PR Dependency Tree


* **PR #14426** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Admin dashboard: view workspace analytics (storage, sync activity, top
shared links) with charts and configurable windows.
* Document analytics tab: see total/unique/guest views and trends over
selectable time windows.
* Last-accessed members: view who last accessed a document, with
pagination.
* Shared links analytics: browse and paginate all shared links with
view/unique/guest metrics and share URLs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-13 01:01:29 +08:00

646 lines
20 KiB
TypeScript

import { Button } from '@affine/admin/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@affine/admin/components/ui/card';
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@affine/admin/components/ui/chart';
import { Label } from '@affine/admin/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@affine/admin/components/ui/select';
import { Separator } from '@affine/admin/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@affine/admin/components/ui/table';
import { useQuery } from '@affine/admin/use-query';
import { adminDashboardQuery } from '@affine/graphql';
import { ROUTES } from '@affine/routes';
import {
DatabaseIcon,
MessageSquareTextIcon,
RefreshCwIcon,
UsersIcon,
} from 'lucide-react';
import { type ReactNode, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
import { Header } from '../header';
import { formatBytes } from '../workspaces/utils';
const intFormatter = new Intl.NumberFormat('en-US');
const compactFormatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const utcDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'UTC',
year: 'numeric',
month: 'numeric',
day: 'numeric',
});
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
type DualNumberPoint = {
label: string;
primary: number;
secondary: number;
};
type TrendPoint = {
x: number;
label: string;
primary: number;
secondary?: number;
};
function formatDateTime(value: string) {
return utcDateTimeFormatter.format(new Date(value));
}
function formatDate(value: string) {
return utcDateFormatter.format(new Date(value));
}
function downsample<T>(items: T[], maxPoints: number) {
if (items.length <= maxPoints) {
return items;
}
const step = Math.ceil(items.length / maxPoints);
return items.filter(
(_, index) => index % step === 0 || index === items.length - 1
);
}
function toIndexedTrendPoints<T extends Omit<TrendPoint, 'x'>>(points: T[]) {
return points.map((point, index) => ({
...point,
x: index,
}));
}
function TrendChart({
ariaLabel,
points,
primaryLabel,
primaryFormatter,
secondaryLabel,
secondaryFormatter,
}: {
ariaLabel: string;
points: TrendPoint[];
primaryLabel: string;
primaryFormatter: (value: number) => string;
secondaryLabel?: string;
secondaryFormatter?: (value: number) => string;
}) {
if (points.length === 0) {
return <div className="text-sm text-muted-foreground">No data</div>;
}
const chartPoints =
points.length === 1
? [points[0], { ...points[0], x: points[0].x + 1 }]
: points;
const hasSecondary =
Boolean(secondaryLabel) &&
chartPoints.some(point => typeof point.secondary === 'number');
const config: ChartConfig = {
primary: {
label: primaryLabel,
color: 'hsl(var(--primary))',
},
...(hasSecondary
? {
secondary: {
label: secondaryLabel,
color: 'hsl(var(--foreground) / 0.6)',
},
}
: {}),
};
return (
<div className="space-y-3">
<ChartContainer
config={config}
className="h-44 w-full"
aria-label={ariaLabel}
role="img"
>
<LineChart
data={chartPoints}
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
>
<CartesianGrid
vertical={false}
stroke="hsl(var(--border) / 0.6)"
strokeDasharray="3 4"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<ChartTooltip
cursor={{
stroke: 'hsl(var(--border))',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const item = payload?.[0];
return item?.payload?.label ?? '';
}}
valueFormatter={(value, key) => {
if (key === 'secondary') {
return secondaryFormatter
? secondaryFormatter(value)
: intFormatter.format(value);
}
return primaryFormatter(value);
}}
/>
}
/>
<Area
dataKey="primary"
type="monotone"
fill="var(--color-primary)"
fillOpacity={0.16}
stroke="none"
isAnimationActive={false}
/>
<Line
dataKey="primary"
type="monotone"
stroke="var(--color-primary)"
strokeWidth={3}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
{hasSecondary ? (
<Line
dataKey="secondary"
type="monotone"
stroke="var(--color-secondary)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
strokeDasharray="6 4"
connectNulls
isAnimationActive={false}
/>
) : null}
</LineChart>
</ChartContainer>
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
<span>{points[0]?.label}</span>
<span>{points[points.length - 1]?.label}</span>
</div>
</div>
);
}
function PrimaryMetricCard({
value,
description,
}: {
value: string;
description: string;
}) {
return (
<Card className="lg:col-span-5 border-primary/30 bg-gradient-to-br from-primary/10 via-card to-card shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2 text-foreground/75">
<UsersIcon className="h-4 w-4" aria-hidden="true" />
Current Sync Active Users
</CardDescription>
</CardHeader>
<CardContent className="space-y-1">
<div className="text-4xl font-bold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground">{description}</p>
</CardContent>
</Card>
);
}
function SecondaryMetricCard({
title,
value,
description,
icon,
}: {
title: string;
value: string;
description: string;
icon: ReactNode;
}) {
return (
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<span aria-hidden="true">{icon}</span>
{title}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{value}
</div>
<p className="text-xs text-muted-foreground mt-1">{description}</p>
</CardContent>
</Card>
);
}
function WindowSelect({
id,
label,
value,
options,
unit,
onChange,
}: {
id: string;
label: string;
value: number;
options: readonly number[];
unit: string;
onChange: (value: number) => void;
}) {
return (
<div className="flex flex-col gap-2 min-w-40">
<Label
htmlFor={id}
className="text-xs uppercase tracking-wide text-muted-foreground"
>
{label}
</Label>
<Select
value={String(value)}
onValueChange={next => onChange(Number(next))}
>
<SelectTrigger id={id}>
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
</SelectTrigger>
<SelectContent>
{options.map(option => (
<SelectItem key={option} value={String(option)}>
{option} {unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
export function DashboardPage() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const variables = useMemo(
() => ({
input: {
storageHistoryDays,
syncHistoryHours,
sharedLinkWindowDays,
timezone: 'UTC',
},
}),
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
);
const { data, isValidating, mutate } = useQuery(
{
query: adminDashboardQuery,
variables,
},
{
keepPreviousData: true,
revalidateOnFocus: false,
revalidateIfStale: true,
revalidateOnReconnect: true,
}
);
const dashboard = data.adminDashboard;
const syncPoints = useMemo(
() =>
toIndexedTrendPoints(
downsample(
dashboard.syncActiveUsersTimeline.map(point => ({
label: formatDateTime(point.minute),
primary: point.activeUsers,
})),
96
)
),
[dashboard.syncActiveUsersTimeline]
);
const storagePoints = useMemo(() => {
const merged: DualNumberPoint[] = dashboard.workspaceStorageHistory.map(
(point, index) => ({
label: formatDate(point.date),
primary: point.value,
secondary: dashboard.blobStorageHistory[index]?.value ?? 0,
})
);
return toIndexedTrendPoints(downsample(merged, 60));
}, [dashboard.blobStorageHistory, dashboard.workspaceStorageHistory]);
const totalStorageBytes =
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
return (
<div className="h-screen flex-1 flex-col flex overflow-hidden">
<Header
title="Dashboard"
endFix={
<div className="flex flex-wrap items-center justify-end gap-3">
<span className="text-xs text-muted-foreground tabular-nums">
Updated at {formatDateTime(dashboard.generatedAt)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => {
mutate().catch(() => {});
}}
disabled={isValidating}
>
<RefreshCwIcon
className={`h-3.5 w-3.5 mr-1.5 ${isValidating ? 'animate-spin' : ''}`}
aria-hidden="true"
/>
Refresh
</Button>
</div>
}
/>
<div className="flex-1 overflow-auto p-6 space-y-6">
<Card className="border-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base">Window Controls</CardTitle>
<CardDescription>
Tune dashboard windows. Data is sampled in UTC and refreshes
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
<WindowSelect
id="storage-history-window"
label="Storage History"
value={storageHistoryDays}
options={STORAGE_DAY_OPTIONS}
unit="days"
onChange={setStorageHistoryDays}
/>
<WindowSelect
id="sync-history-window"
label="Sync History"
value={syncHistoryHours}
options={SYNC_HOUR_OPTIONS}
unit="hours"
onChange={setSyncHistoryHours}
/>
<WindowSelect
id="shared-link-window"
label="Shared Link Window"
value={sharedLinkWindowDays}
options={SHARED_DAY_OPTIONS}
unit="days"
onChange={setSharedLinkWindowDays}
/>
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 lg:grid-cols-12">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
<SecondaryMetricCard
title="Copilot Conversations"
value={intFormatter.format(dashboard.copilotConversations)}
description={`${dashboard.topSharedLinksWindow.effectiveSize}d aggregation`}
icon={
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
}
/>
<Card className="lg:col-span-4 border-border/70 bg-gradient-to-br from-card via-card to-muted/15 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4" aria-hidden="true" />
Managed Storage
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold tracking-tight tabular-nums">
{formatBytes(totalStorageBytes)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Workspace {formatBytes(dashboard.workspaceStorageBytes)} Blob{' '}
{formatBytes(dashboard.blobStorageBytes)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Card className="xl:col-span-1 border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Sync Active Users Trend
</CardTitle>
<CardDescription>
{dashboard.syncWindow.effectiveSize}h at minute bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<TrendChart
ariaLabel="Sync active users trend"
points={syncPoints}
primaryLabel="Sync Active Users"
primaryFormatter={value => intFormatter.format(value)}
/>
</CardContent>
</Card>
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Storage Trend (Workspace + Blob)
</CardTitle>
<CardDescription>
{dashboard.storageWindow.effectiveSize}d at day bucket
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<TrendChart
ariaLabel="Workspace and blob storage trend"
points={storagePoints}
primaryLabel="Workspace Storage"
primaryFormatter={value => formatBytes(value)}
secondaryLabel="Blob Storage"
secondaryFormatter={value => formatBytes(value)}
/>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary" />
Workspace: {formatBytes(dashboard.workspaceStorageBytes)}
</div>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-foreground/50" />
Blob: {formatBytes(dashboard.blobStorageBytes)}
</div>
</div>
</CardContent>
</Card>
</div>
<Card className="border-border/70 bg-card/95 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Top Shared Links</CardTitle>
<CardDescription>
Top {dashboard.topSharedLinks.length} links in the last{' '}
{dashboard.topSharedLinksWindow.effectiveSize} days
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dashboard.topSharedLinks.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center bg-muted/20">
<div className="text-sm font-medium">
No shared links in this window
</div>
<div className="text-xs text-muted-foreground mt-2">
Publish pages and collect traffic, then this table will rank
links by views.
</div>
<Button asChild variant="outline" size="sm" className="mt-4">
<Link to={ROUTES.admin.workspaces}>Go to Workspaces</Link>
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Document</TableHead>
<TableHead>Workspace</TableHead>
<TableHead className="text-right">Views</TableHead>
<TableHead className="text-right">Unique</TableHead>
<TableHead className="text-right">Guest</TableHead>
<TableHead>Last Accessed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dashboard.topSharedLinks.map(link => (
<TableRow
key={`${link.workspaceId}-${link.docId}`}
className="hover:bg-muted/40"
>
<TableCell className="max-w-80 min-w-0">
<a
href={link.shareUrl}
target="_blank"
rel="noreferrer"
className="font-medium underline-offset-4 hover:underline truncate block"
>
{link.title || link.docId}
</a>
</TableCell>
<TableCell className="font-mono text-xs tabular-nums">
{link.workspaceId}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.views)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.uniqueViews)}
</TableCell>
<TableCell className="text-right tabular-nums">
{compactFormatter.format(link.guestViews)}
</TableCell>
<TableCell className="tabular-nums">
{link.lastAccessedAt
? formatDateTime(link.lastAccessedAt)
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<Separator />
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{formatDate(dashboard.topSharedLinksWindow.from)}</span>
<span>{formatDate(dashboard.topSharedLinksWindow.to)}</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export { DashboardPage as Component };