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 -->
This commit is contained in:
DarkSky
2026-02-13 01:01:29 +08:00
committed by GitHub
parent b46bf91575
commit b4be9118ad
44 changed files with 5701 additions and 86 deletions
+1
View File
@@ -53,6 +53,7 @@
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^7.12.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"swr": "^2.3.7",
"vaul": "^1.1.2",
+22 -1
View File
@@ -23,6 +23,9 @@ export const Setup = lazy(
export const Accounts = lazy(
() => import(/* webpackChunkName: "accounts" */ './modules/accounts')
);
export const Dashboard = lazy(
() => import(/* webpackChunkName: "dashboard" */ './modules/dashboard')
);
export const Workspaces = lazy(
() => import(/* webpackChunkName: "workspaces" */ './modules/workspaces')
);
@@ -75,7 +78,15 @@ function RootRoutes() {
}
if (/^\/admin\/?$/.test(location.pathname)) {
return <Navigate to="/admin/accounts" />;
return (
<Navigate
to={
environment.isSelfHosted
? ROUTES.admin.accounts
: ROUTES.admin.dashboard
}
/>
);
}
return <Outlet />;
@@ -96,6 +107,16 @@ export const App = () => {
<Route path={ROUTES.admin.auth} element={<Auth />} />
<Route path={ROUTES.admin.setup} element={<Setup />} />
<Route element={<AuthenticatedRoutes />}>
<Route
path={ROUTES.admin.dashboard}
element={
environment.isSelfHosted ? (
<Navigate to={ROUTES.admin.accounts} replace />
) : (
<Dashboard />
)
}
/>
<Route path={ROUTES.admin.accounts} element={<Accounts />} />
<Route
path={ROUTES.admin.workspaces}
@@ -0,0 +1,173 @@
import { cn } from '@affine/admin/utils';
import * as React from 'react';
import type { TooltipProps } from 'recharts';
import { ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
const THEMES = { light: '', dark: '.dark' } as const;
export type ChartConfig = Record<
string,
{
label?: React.ReactNode;
color?: string;
theme?: Partial<Record<keyof typeof THEMES, string>>;
}
>;
type ChartContextValue = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextValue | null>(null);
function useChart() {
const value = React.useContext(ChartContext);
if (!value) {
throw new Error('useChart must be used within <ChartContainer />');
}
return value;
}
function ChartStyle({
chartId,
config,
}: {
chartId: string;
config: ChartConfig;
}) {
const colorEntries = Object.entries(config).filter(
([, item]) => item.color || item.theme
);
if (!colorEntries.length) {
return null;
}
const css = Object.entries(THEMES)
.map(([themeKey, prefix]) => {
const declarations = colorEntries
.map(([key, item]) => {
const color =
item.theme?.[themeKey as keyof typeof THEMES] ?? item.color;
return color ? ` --color-${key}: ${color};` : '';
})
.filter(Boolean)
.join('\n');
if (!declarations) {
return '';
}
return `${prefix} [data-chart="${chartId}"] {\n${declarations}\n}`;
})
.filter(Boolean)
.join('\n');
if (!css) {
return null;
}
return <style dangerouslySetInnerHTML={{ __html: css }} />;
}
type ChartContainerProps = React.ComponentProps<'div'> & {
config: ChartConfig;
children: React.ComponentProps<typeof ResponsiveContainer>['children'];
};
const ChartContainer = React.forwardRef<HTMLDivElement, ChartContainerProps>(
({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`;
return (
<ChartContext.Provider value={{ config }}>
<div
ref={ref}
data-chart={chartId}
className={cn(
'flex min-h-0 w-full items-center justify-center text-xs',
className
)}
{...props}
>
<ChartStyle chartId={chartId} config={config} />
<ResponsiveContainer>{children}</ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
);
ChartContainer.displayName = 'ChartContainer';
const ChartTooltip = RechartsTooltip;
type TooltipContentProps = {
active?: boolean;
payload?: TooltipProps<number, string>['payload'];
label?: string | number;
labelFormatter?: (
label: string | number,
payload: TooltipProps<number, string>['payload']
) => React.ReactNode;
valueFormatter?: (value: number, key: string) => React.ReactNode;
};
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(({ active, payload, label, labelFormatter, valueFormatter }, ref) => {
const { config } = useChart();
if (!active || !payload?.length) {
return null;
}
const title = labelFormatter ? labelFormatter(label ?? '', payload) : label;
return (
<div
ref={ref}
className="min-w-44 rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"
>
{title ? (
<div className="mb-2 font-medium text-foreground/90">{title}</div>
) : null}
<div className="space-y-1">
{payload.map((item, index) => {
const dataKey = String(item.dataKey ?? item.name ?? index);
const itemConfig = config[dataKey];
const labelText = itemConfig?.label ?? item.name ?? dataKey;
const numericValue =
typeof item.value === 'number'
? item.value
: Number(item.value ?? 0);
const valueText = valueFormatter
? valueFormatter(numericValue, dataKey)
: numericValue;
const color = item.color ?? `var(--color-${dataKey})`;
return (
<div
key={`${dataKey}-${index}`}
className="flex items-center gap-2"
>
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<span className="text-muted-foreground">{labelText}</span>
<span className="ml-auto font-medium tabular-nums">
{valueText}
</span>
</div>
);
})}
</div>
</div>
);
});
ChartTooltipContent.displayName = 'ChartTooltipContent';
export { ChartContainer, ChartTooltip, ChartTooltipContent };
@@ -0,0 +1,645 @@
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 };
@@ -1,8 +1,13 @@
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { ROUTES } from '@affine/routes';
import { AccountIcon, SelfhostIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { LayoutDashboardIcon, ListChecksIcon } from 'lucide-react';
import {
BarChart3Icon,
LayoutDashboardIcon,
ListChecksIcon,
} from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { ServerVersion } from './server-version';
@@ -85,22 +90,30 @@ export function Nav({ isCollapsed = false }: NavProps) {
isCollapsed && 'items-center px-0 gap-1 overflow-visible'
)}
>
{environment.isSelfHosted ? null : (
<NavItem
to={ROUTES.admin.dashboard}
icon={<BarChart3Icon size={18} />}
label="Dashboard"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/accounts"
to={ROUTES.admin.accounts}
icon={<AccountIcon fontSize={20} />}
label="Accounts"
isCollapsed={isCollapsed}
/>
{environment.isSelfHosted ? null : (
<NavItem
to="/admin/workspaces"
to={ROUTES.admin.workspaces}
icon={<LayoutDashboardIcon size={18} />}
label="Workspaces"
isCollapsed={isCollapsed}
/>
)}
<NavItem
to="/admin/queue"
to={ROUTES.admin.queue}
icon={<ListChecksIcon size={18} />}
label="Queue"
isCollapsed={isCollapsed}
@@ -113,7 +126,7 @@ export function Nav({ isCollapsed = false }: NavProps) {
/> */}
<SettingsItem isCollapsed={isCollapsed} />
<NavItem
to="/admin/about"
to={ROUTES.admin.about}
icon={<SelfhostIcon fontSize={20} />}
label="About"
isCollapsed={isCollapsed}
+1
View File
@@ -87,6 +87,7 @@
"react-router-dom": "^6.30.3",
"react-transition-state": "^2.2.0",
"react-virtuoso": "^4.12.3",
"recharts": "^2.15.4",
"rxjs": "^7.8.2",
"semver": "^7.7.3",
"ses": "^1.14.0",
@@ -43,6 +43,7 @@ import { focusBlockEnd } from '@blocksuite/affine/shared/commands';
import { getLastNoteBlock } from '@blocksuite/affine/shared/utils';
import {
AiIcon,
ChartPanelIcon,
CommentIcon,
ExportIcon,
FrameIcon,
@@ -67,6 +68,7 @@ import * as styles from './detail-page.css';
import { DetailPageHeader } from './detail-page-header';
import { DetailPageWrapper } from './detail-page-wrapper';
import { EditorAdapterPanel } from './tabs/adapter';
import { EditorAnalyticsPanel } from './tabs/analytics';
import { EditorChatPanel } from './tabs/chat';
import { EditorFramePanel } from './tabs/frame';
import { EditorJournalPanel } from './tabs/journal';
@@ -433,6 +435,17 @@ const DetailPageImpl = memo(function DetailPageImpl() {
</ViewSidebarTab>
)}
{workspace.flavour === 'affine-cloud' && (
<ViewSidebarTab tabId="analytics" icon={<ChartPanelIcon />}>
<Scrollable.Root className={styles.sidebarScrollArea}>
<Scrollable.Viewport>
<EditorAnalyticsPanel workspaceId={workspace.id} docId={doc.id} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</ViewSidebarTab>
)}
<GlobalPageHistoryModal />
{/* FIXME: wait for better ai, <PageAIOnboarding /> */}
</FrameworkScope>
@@ -0,0 +1,229 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const root = style({
display: 'flex',
flexDirection: 'column',
minHeight: '100%',
padding: '16px',
gap: '20px',
});
export const section = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const sectionHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
});
export const sectionTitle = style({
display: 'flex',
alignItems: 'baseline',
gap: '8px',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
});
export const sectionSubtitle = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const windowButton = style({
minWidth: '124px',
justifyContent: 'space-between',
});
export const lockButton = style({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
background: 'transparent',
padding: 0,
margin: 0,
color: cssVar('textSecondaryColor'),
cursor: 'pointer',
selectors: {
'&:hover': {
color: cssVar('textPrimaryColor'),
},
},
});
export const metrics = style({
display: 'flex',
alignItems: 'stretch',
gap: '8px',
});
export const metricCard = style({
minWidth: '0',
flex: 1,
borderRadius: '10px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '8px 10px',
display: 'flex',
flexDirection: 'column',
gap: '4px',
});
export const metricLabel = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.2,
});
export const metricValue = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontBase'),
fontWeight: 600,
lineHeight: 1.2,
fontVariantNumeric: 'tabular-nums',
});
export const chartContainer = style({
height: '228px',
borderRadius: '12px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
padding: '10px 10px 8px 10px',
});
export const axisLabels = style({
display: 'flex',
justifyContent: 'space-between',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
fontVariantNumeric: 'tabular-nums',
marginTop: '4px',
});
export const chartLegend = style({
display: 'flex',
flexWrap: 'wrap',
gap: '12px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
});
export const legendItem = style({
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
});
export const legendDot = style({
width: '8px',
height: '8px',
borderRadius: '50%',
});
export const tooltip = style({
minWidth: '160px',
borderRadius: '8px',
border: `1px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('backgroundPrimaryColor'),
boxShadow: cssVar('shadow2'),
padding: '8px 10px',
});
export const tooltipTitle = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
marginBottom: '6px',
});
export const tooltipRow = style({
display: 'flex',
alignItems: 'center',
gap: '6px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
lineHeight: 1.4,
});
export const tooltipValue = style({
marginLeft: 'auto',
color: cssVar('textPrimaryColor'),
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
});
export const emptyState = style({
borderRadius: '10px',
border: `1px dashed ${cssVar('borderColor')}`,
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
minHeight: '96px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
padding: '0 16px',
});
export const viewersList = style({
display: 'flex',
flexDirection: 'column',
gap: '6px',
});
export const viewerRow = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
borderRadius: '8px',
padding: '6px 8px',
selectors: {
'&:hover': {
backgroundColor: cssVar('hoverColor'),
},
},
});
export const viewerUser = style({
minWidth: 0,
display: 'flex',
alignItems: 'center',
gap: '8px',
});
export const viewerName = style({
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontSm'),
fontWeight: 500,
});
export const viewerTime = style({
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontSm'),
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
});
export const loadMoreButton = style({
alignSelf: 'flex-start',
});
export const loading = style({
minHeight: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
@@ -0,0 +1,511 @@
import {
Avatar,
Button,
Loading,
Menu,
MenuItem,
toast,
} from '@affine/component';
import { useQuery } from '@affine/core/components/hooks/use-query';
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import {
getDocLastAccessedMembersQuery,
getDocPageAnalyticsQuery,
} from '@affine/graphql';
import { i18nTime, useI18n } from '@affine/i18n';
import {
ArrowDownSmallIcon,
CalendarPanelIcon,
LockIcon,
} from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Area,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip as RechartsTooltip,
type TooltipProps,
XAxis,
YAxis,
} from 'recharts';
import * as styles from './analytics.css';
import {
type AnalyticsChartPoint,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
INITIAL_MEMBERS_PAGE_SIZE,
isLockedAnalyticsWindowOption,
MAX_MEMBERS_PAGE_SIZE,
} from './analytics.utils';
const intFormatter = new Intl.NumberFormat('en-US');
const totalViewsColor = cssVar('primaryColor');
const uniqueViewsColor = cssVar('processingColor');
function formatChartDate(value: string) {
return i18nTime(value, { absolute: { accuracy: 'day' } });
}
function AnalyticsChartTooltip({
active,
payload,
}: TooltipProps<number, string>) {
const t = useI18n();
if (!active || !payload?.length) {
return null;
}
const point = payload[0]?.payload as AnalyticsChartPoint | undefined;
if (!point) {
return null;
}
const valueByKey = payload.reduce<Record<string, number>>((acc, item) => {
if (!item.dataKey) {
return acc;
}
acc[String(item.dataKey)] =
typeof item.value === 'number' ? item.value : Number(item.value ?? 0);
return acc;
}, {});
return (
<div className={styles.tooltip}>
<div className={styles.tooltipTitle}>{formatChartDate(point.date)}</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.totalViews ?? point.totalViews)}
</span>
</div>
<div className={styles.tooltipRow}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
<span className={styles.tooltipValue}>
{intFormatter.format(valueByKey.uniqueViews ?? point.uniqueViews)}
</span>
</div>
</div>
);
}
export const EditorAnalyticsPanel = ({
workspaceId,
docId,
}: {
workspaceId: string;
docId: string;
}) => {
const t = useI18n();
const permission = useService(WorkspacePermissionService).permission;
const workspaceDialogService = useService(WorkspaceDialogService);
const isTeam = useLiveData(permission.isTeam$);
const isTeamWorkspace = isTeam ?? false;
const [windowDays, setWindowDays] = useState(DEFAULT_ANALYTICS_WINDOW_DAYS);
const [membersPageSize, setMembersPageSize] = useState(
INITIAL_MEMBERS_PAGE_SIZE
);
const allowedWindowOptions = useMemo(
() => getAvailableAnalyticsWindowOptions(),
[]
);
const effectiveWindowDays = useMemo(
() => clampAnalyticsWindowDays(windowDays, isTeamWorkspace),
[isTeamWorkspace, windowDays]
);
const timezone = useMemo(
() => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
[]
);
useEffect(() => {
setMembersPageSize(INITIAL_MEMBERS_PAGE_SIZE);
}, [docId]);
useEffect(() => {
permission.revalidate();
}, [permission, workspaceId]);
useEffect(() => {
if (windowDays !== effectiveWindowDays) {
setWindowDays(effectiveWindowDays);
}
}, [effectiveWindowDays, windowDays]);
const {
data: analyticsData,
isLoading: analyticsLoading,
error: analyticsError,
} = useQuery(
{
query: getDocPageAnalyticsQuery,
variables: {
workspaceId,
docId,
input: {
windowDays: effectiveWindowDays,
timezone,
},
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const {
data: membersData,
isLoading: membersLoading,
error: membersError,
} = useQuery(
{
query: getDocLastAccessedMembersQuery,
variables: {
workspaceId,
docId,
pagination: {
first: membersPageSize,
offset: 0,
},
includeTotal: true,
},
},
{
suspense: false,
keepPreviousData: true,
revalidateOnFocus: false,
shouldRetryOnError: false,
}
);
const analytics = analyticsData?.workspace.doc.analytics;
const summary = analytics?.summary;
const chartPoints = useMemo(
() =>
ensureMinimumChartPoints(
buildAnalyticsChartPoints(analytics?.series ?? [])
),
[analytics?.series]
);
const membersConnection = membersData?.workspace.doc.lastAccessedMembers;
const members = useMemo(
() => membersConnection?.edges.map(edge => edge.node) ?? [],
[membersConnection?.edges]
);
const totalMembers = membersConnection?.totalCount ?? members.length;
const hasMoreMembers =
Boolean(membersConnection?.pageInfo.hasNextPage) &&
membersPageSize < MAX_MEMBERS_PAGE_SIZE;
const openTeamPricing = useCallback(() => {
workspaceDialogService.open('setting', {
activeTab: 'plans',
scrollAnchor: 'cloudPricingPlan',
});
}, [workspaceDialogService]);
const showTeamPlanToast = useCallback(() => {
toast(t['com.affine.doc.analytics.paywall.toast']());
}, [t]);
return (
<div className={styles.root}>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.title']()}</span>
<span className={styles.sectionSubtitle}>
{summary
? t.t('com.affine.doc.analytics.summary.total', {
count: intFormatter.format(summary.totalViews),
})
: ''}
</span>
</div>
<Menu
contentOptions={{ align: 'end' }}
items={
<>
{allowedWindowOptions.map(option => {
const isLocked = isLockedAnalyticsWindowOption(
option,
isTeamWorkspace
);
return (
<MenuItem
key={option}
selected={effectiveWindowDays === option}
suffixIcon={
isLocked ? (
<button
type="button"
className={styles.lockButton}
aria-label={t[
'com.affine.doc.analytics.paywall.open-pricing'
]()}
onClick={event => {
event.preventDefault();
event.stopPropagation();
openTeamPricing();
}}
>
<LockIcon />
</button>
) : undefined
}
onSelect={() => {
if (isLocked) {
showTeamPlanToast();
return;
}
setWindowDays(option);
}}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: option,
})}
{isLocked
? ` (${t['com.affine.payment.cloud.team-workspace.name']()})`
: ''}
</MenuItem>
);
})}
</>
}
>
<Button
variant="secondary"
size="default"
className={styles.windowButton}
prefix={<CalendarPanelIcon />}
suffix={<ArrowDownSmallIcon />}
>
{t.t('com.affine.doc.analytics.window.last-days', {
days: effectiveWindowDays,
})}
</Button>
</Menu>
</div>
<div className={styles.metrics}>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.total']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.totalViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.unique']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.uniqueViews ?? 0)}
</div>
</div>
<div className={styles.metricCard}>
<div className={styles.metricLabel}>
{t['com.affine.doc.analytics.metric.guest']()}
</div>
<div className={styles.metricValue}>
{intFormatter.format(summary?.guestViews ?? 0)}
</div>
</div>
</div>
{analyticsLoading && !analytics ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : analyticsError && !analytics ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-analytics']()}
</div>
) : chartPoints.length ? (
<>
<div className={styles.chartContainer}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartPoints}
margin={{ top: 10, right: 6, bottom: 6, left: 6 }}
>
<CartesianGrid
vertical={false}
stroke={cssVar('borderColor')}
strokeDasharray="3 3"
/>
<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);
},
]}
/>
<RechartsTooltip
cursor={{
stroke: cssVar('borderColor'),
strokeDasharray: '4 4',
}}
content={<AnalyticsChartTooltip />}
/>
<Area
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
fill={totalViewsColor}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Area
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
fill={uniqueViewsColor}
fillOpacity={0.1}
isAnimationActive={false}
/>
<Line
dataKey="totalViews"
type="monotone"
stroke={totalViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
<Line
dataKey="uniqueViews"
type="monotone"
stroke={uniqueViewsColor}
strokeWidth={2}
dot={false}
activeDot={{ r: 3 }}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className={styles.axisLabels}>
<span>{formatChartDate(chartPoints[0].date)}</span>
<span>
{formatChartDate(chartPoints[chartPoints.length - 1].date)}
</span>
</div>
<div className={styles.chartLegend}>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: totalViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.total-views']()}
</span>
<span className={styles.legendItem}>
<span
className={styles.legendDot}
style={{ backgroundColor: uniqueViewsColor }}
aria-hidden="true"
/>
{t['com.affine.doc.analytics.chart.unique-views']()}
</span>
</div>
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-page-views']()}
</div>
)}
</section>
<section className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionTitle}>
<span>{t['com.affine.doc.analytics.viewers.title']()}</span>
<span className={styles.sectionSubtitle}>
({intFormatter.format(totalMembers)})
</span>
</div>
</div>
{membersLoading && !membersConnection ? (
<div className={styles.loading}>
<Loading size={20} />
</div>
) : membersError && !membersConnection ? (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.error.load-viewers']()}
</div>
) : members.length ? (
<>
<div className={styles.viewersList}>
{members.map(member => (
<div className={styles.viewerRow} key={member.user.id}>
<div className={styles.viewerUser}>
<Avatar
size={24}
url={member.user.avatarUrl || ''}
name={member.user.name}
/>
<span className={styles.viewerName}>
{member.user.name}
</span>
</div>
<span className={styles.viewerTime}>
{i18nTime(member.lastAccessedAt, { relative: true })}
</span>
</div>
))}
</div>
{hasMoreMembers ? (
<Button
variant="plain"
className={styles.loadMoreButton}
onClick={() => setMembersPageSize(MAX_MEMBERS_PAGE_SIZE)}
>
{t['com.affine.doc.analytics.viewers.show-all']()}
</Button>
) : null}
</>
) : (
<div className={styles.emptyState}>
{t['com.affine.doc.analytics.empty.no-viewers']()}
</div>
)}
</section>
</div>
);
};
@@ -0,0 +1,107 @@
import { describe, expect, test } from 'vitest';
import {
ANALYTICS_WINDOW_OPTIONS,
buildAnalyticsChartPoints,
clampAnalyticsWindowDays,
DEFAULT_ANALYTICS_WINDOW_DAYS,
ensureMinimumChartPoints,
getAvailableAnalyticsWindowOptions,
isLockedAnalyticsWindowOption,
NON_TEAM_ANALYTICS_WINDOW_DAYS,
} from './analytics.utils';
describe('analytics.utils', () => {
test('clampAnalyticsWindowDays returns default for unsupported values', () => {
expect(clampAnalyticsWindowDays(28, true)).toBe(28);
expect(clampAnalyticsWindowDays(15, true)).toBe(
DEFAULT_ANALYTICS_WINDOW_DAYS
);
});
test('clampAnalyticsWindowDays returns only 7 days for non-team workspaces', () => {
expect(clampAnalyticsWindowDays(7, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
expect(clampAnalyticsWindowDays(28, false)).toBe(
NON_TEAM_ANALYTICS_WINDOW_DAYS
);
});
test('getAvailableAnalyticsWindowOptions keeps all options visible', () => {
expect(getAvailableAnalyticsWindowOptions()).toEqual([
...ANALYTICS_WINDOW_OPTIONS,
]);
});
test('isLockedAnalyticsWindowOption locks windows over 7 days for non-team workspaces', () => {
expect(
isLockedAnalyticsWindowOption(NON_TEAM_ANALYTICS_WINDOW_DAYS, false)
).toBe(false);
expect(isLockedAnalyticsWindowOption(14, false)).toBe(true);
expect(isLockedAnalyticsWindowOption(14, true)).toBe(false);
});
test('buildAnalyticsChartPoints sorts series by date and maps values', () => {
const points = buildAnalyticsChartPoints([
{
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-10',
totalViews: 9,
uniqueViews: 3,
guestViews: 0,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
test('ensureMinimumChartPoints duplicates the only data point', () => {
const points = ensureMinimumChartPoints([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
expect(points).toEqual([
{
x: 0,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
{
x: 1,
date: '2026-02-12',
totalViews: 4,
uniqueViews: 2,
guestViews: 1,
},
]);
});
});
@@ -0,0 +1,72 @@
import type { GetDocPageAnalyticsQuery } from '@affine/graphql';
export const ANALYTICS_WINDOW_OPTIONS = [7, 14, 28, 60, 90] as const;
export const DEFAULT_ANALYTICS_WINDOW_DAYS = 28;
export const NON_TEAM_ANALYTICS_WINDOW_DAYS = 7;
export const INITIAL_MEMBERS_PAGE_SIZE = 5;
export const MAX_MEMBERS_PAGE_SIZE = 50;
export type AnalyticsSeriesPoint =
GetDocPageAnalyticsQuery['workspace']['doc']['analytics']['series'][number];
export type AnalyticsChartPoint = {
x: number;
date: string;
totalViews: number;
uniqueViews: number;
guestViews: number;
};
export function getAvailableAnalyticsWindowOptions() {
return [...ANALYTICS_WINDOW_OPTIONS];
}
export function isLockedAnalyticsWindowOption(
value: number,
isTeamWorkspace: boolean
) {
return !isTeamWorkspace && value > NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
export function clampAnalyticsWindowDays(
value: number,
isTeamWorkspace: boolean
) {
if (!isTeamWorkspace) {
return NON_TEAM_ANALYTICS_WINDOW_DAYS;
}
return ANALYTICS_WINDOW_OPTIONS.includes(
value as (typeof ANALYTICS_WINDOW_OPTIONS)[number]
)
? value
: DEFAULT_ANALYTICS_WINDOW_DAYS;
}
export function buildAnalyticsChartPoints(series: AnalyticsSeriesPoint[]) {
const sorted = [...series].sort(
(left, right) =>
new Date(left.date).getTime() - new Date(right.date).getTime()
);
return sorted.map((point, index) => ({
x: index,
date: point.date,
totalViews: point.totalViews,
uniqueViews: point.uniqueViews,
guestViews: point.guestViews,
})) satisfies AnalyticsChartPoint[];
}
export function ensureMinimumChartPoints(points: AnalyticsChartPoint[]) {
if (points.length !== 1) {
return points;
}
return [
points[0],
{
...points[0],
x: points[0].x + 1,
},
] satisfies AnalyticsChartPoint[];
}
@@ -1,27 +1,27 @@
{
"ar": 97,
"ar": 96,
"ca": 98,
"da": 4,
"de": 98,
"el-GR": 97,
"de": 97,
"el-GR": 96,
"en": 100,
"es-AR": 97,
"es-AR": 96,
"es-CL": 98,
"es": 97,
"fa": 97,
"es": 96,
"fa": 96,
"fr": 98,
"hi": 2,
"hi": 1,
"it-IT": 98,
"it": 1,
"ja": 97,
"ko": 98,
"nb-NO": 48,
"ja": 96,
"ko": 97,
"nb-NO": 47,
"pl": 98,
"pt-BR": 97,
"pt-BR": 96,
"ru": 98,
"sv-SE": 97,
"uk": 97,
"uk": 96,
"ur": 2,
"zh-Hans": 99,
"zh-Hans": 98,
"zh-Hant": 97
}
+68
View File
@@ -4574,6 +4574,74 @@ export function useAFFiNEI18N(): {
* `Copied key to clipboard`
*/
["com.affine.payment.license-success.copy"](): string;
/**
* `View analytics`
*/
["com.affine.doc.analytics.title"](): string;
/**
* `({{count}} total)`
*/
["com.affine.doc.analytics.summary.total"](options: {
readonly count: string;
}): string;
/**
* `Last {{days}} days`
*/
["com.affine.doc.analytics.window.last-days"](options: {
readonly days: string;
}): string;
/**
* `Total`
*/
["com.affine.doc.analytics.metric.total"](): string;
/**
* `Unique`
*/
["com.affine.doc.analytics.metric.unique"](): string;
/**
* `Guest`
*/
["com.affine.doc.analytics.metric.guest"](): string;
/**
* `Total views`
*/
["com.affine.doc.analytics.chart.total-views"](): string;
/**
* `Unique views`
*/
["com.affine.doc.analytics.chart.unique-views"](): string;
/**
* `Unable to load analytics.`
*/
["com.affine.doc.analytics.error.load-analytics"](): string;
/**
* `Unable to load viewers.`
*/
["com.affine.doc.analytics.error.load-viewers"](): string;
/**
* `No page views in this window.`
*/
["com.affine.doc.analytics.empty.no-page-views"](): string;
/**
* `No viewers in this window.`
*/
["com.affine.doc.analytics.empty.no-viewers"](): string;
/**
* `Viewers`
*/
["com.affine.doc.analytics.viewers.title"](): string;
/**
* `Show all viewers`
*/
["com.affine.doc.analytics.viewers.show-all"](): string;
/**
* `Open pricing plans`
*/
["com.affine.doc.analytics.paywall.open-pricing"](): string;
/**
* `Doc analytics over 7 days require an AFFiNE Team subscription.`
*/
["com.affine.doc.analytics.paywall.toast"](): string;
/**
* `Close`
*/
@@ -1134,6 +1134,22 @@
"com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > License > Use purchased key",
"com.affine.payment.license-success.open-affine": "Open AFFiNE",
"com.affine.payment.license-success.copy": "Copied key to clipboard",
"com.affine.doc.analytics.title": "View analytics",
"com.affine.doc.analytics.summary.total": "({{count}} total)",
"com.affine.doc.analytics.window.last-days": "Last {{days}} days",
"com.affine.doc.analytics.metric.total": "Total",
"com.affine.doc.analytics.metric.unique": "Unique",
"com.affine.doc.analytics.metric.guest": "Guest",
"com.affine.doc.analytics.chart.total-views": "Total views",
"com.affine.doc.analytics.chart.unique-views": "Unique views",
"com.affine.doc.analytics.error.load-analytics": "Unable to load analytics.",
"com.affine.doc.analytics.error.load-viewers": "Unable to load viewers.",
"com.affine.doc.analytics.empty.no-page-views": "No page views in this window.",
"com.affine.doc.analytics.empty.no-viewers": "No viewers in this window.",
"com.affine.doc.analytics.viewers.title": "Viewers",
"com.affine.doc.analytics.viewers.show-all": "Show all viewers",
"com.affine.doc.analytics.paywall.open-pricing": "Open pricing plans",
"com.affine.doc.analytics.paywall.toast": "Doc analytics over 7 days require an AFFiNE Team subscription.",
"com.affine.peek-view-controls.close": "Close",
"com.affine.peek-view-controls.open-doc": "Open this doc",
"com.affine.peek-view-controls.open-doc-in-edgeless": "Open in edgeless",
+3
View File
@@ -7,7 +7,10 @@
"children": {
"auth": "auth",
"setup": "setup",
"dashboard": "dashboard",
"accounts": "accounts",
"workspaces": "workspaces",
"queue": "queue",
"ai": "ai",
"settings": {
"route": "settings",
+3
View File
@@ -11,6 +11,7 @@ export const ROUTES = {
index: '/admin',
auth: '/admin/auth',
setup: '/admin/setup',
dashboard: '/admin/dashboard',
accounts: '/admin/accounts',
workspaces: '/admin/workspaces',
queue: '/admin/queue',
@@ -29,6 +30,7 @@ export const RELATIVE_ROUTES = {
index: 'admin',
auth: 'auth',
setup: 'setup',
dashboard: 'dashboard',
accounts: 'accounts',
workspaces: 'workspaces',
queue: 'queue',
@@ -45,6 +47,7 @@ const home = () => '/';
const admin = () => '/admin';
admin.auth = () => '/admin/auth';
admin.setup = () => '/admin/setup';
admin.dashboard = () => '/admin/dashboard';
admin.accounts = () => '/admin/accounts';
admin.workspaces = () => '/admin/workspaces';
admin.queue = () => '/admin/queue';