mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 08:47:10 +08:00
#### 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 -->
646 lines
20 KiB
TypeScript
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 };
|