mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
feat: improve admin panel design (#14464)
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
const mutateQueryResourceMock = vi.fn();
|
||||
|
||||
vi.mock('@affine/admin/use-query', () => ({
|
||||
useQuery: (...args: unknown[]) => useQueryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../use-mutation', () => ({
|
||||
useMutateQueryResource: () => () => {
|
||||
mutateQueryResourceMock();
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../header', () => ({
|
||||
Header: ({ title, endFix }: { title: string; endFix?: ReactNode }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{endFix}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
ResponsiveContainer: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
Tooltip: ({ content }: { content?: ReactNode }) => (
|
||||
<div data-testid="chart-tooltip">{content}</div>
|
||||
),
|
||||
Area: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="area">{children}</div>
|
||||
),
|
||||
CartesianGrid: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="grid">{children}</div>
|
||||
),
|
||||
Line: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="line">{children}</div>
|
||||
),
|
||||
LineChart: ({ children }: { children?: ReactNode }) => (
|
||||
<div data-testid="line-chart">{children}</div>
|
||||
),
|
||||
XAxis: () => <div data-testid="x-axis" />,
|
||||
YAxis: () => <div data-testid="y-axis" />,
|
||||
}));
|
||||
|
||||
import { DashboardPage } from './index';
|
||||
|
||||
const dashboardData = {
|
||||
adminDashboard: {
|
||||
syncActiveUsers: 0,
|
||||
syncActiveUsersTimeline: [
|
||||
{ minute: '2026-02-16T10:30:00.000Z', activeUsers: 0 },
|
||||
],
|
||||
syncWindow: {
|
||||
from: '2026-02-14T20:30:00.000Z',
|
||||
to: '2026-02-16T19:30:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'minute',
|
||||
requestedSize: 48,
|
||||
effectiveSize: 48,
|
||||
},
|
||||
copilotConversations: 0,
|
||||
workspaceStorageBytes: 375,
|
||||
blobStorageBytes: 0,
|
||||
workspaceStorageHistory: [{ date: '2026-02-16', value: 375 }],
|
||||
blobStorageHistory: [{ date: '2026-02-16', value: 0 }],
|
||||
storageWindow: {
|
||||
from: '2026-01-18T00:00:00.000Z',
|
||||
to: '2026-02-16T00:00:00.000Z',
|
||||
timezone: 'UTC',
|
||||
bucket: 'day',
|
||||
requestedSize: 30,
|
||||
effectiveSize: 30,
|
||||
},
|
||||
generatedAt: '2026-02-16T19:30:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).environment = {
|
||||
isSelfHosted: true,
|
||||
};
|
||||
useQueryMock.mockReset();
|
||||
useQueryMock.mockReturnValue({
|
||||
data: dashboardData,
|
||||
isValidating: false,
|
||||
});
|
||||
mutateQueryResourceMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test('uses responsive tailwind breakpoints instead of hardcoded min-[1024px]', () => {
|
||||
const { container } = render(<DashboardPage />);
|
||||
const classes = Array.from(container.querySelectorAll('[class]'))
|
||||
.map(node => node.getAttribute('class') ?? '')
|
||||
.join(' ');
|
||||
|
||||
expect(classes).toContain('lg:grid-cols-12');
|
||||
expect(classes).toContain('lg:grid-cols-3');
|
||||
expect(classes).not.toContain('min-[1024px]');
|
||||
});
|
||||
|
||||
test('uses affine token color variables for trend chart lines', () => {
|
||||
render(<DashboardPage />);
|
||||
const styles = Array.from(document.querySelectorAll('style'))
|
||||
.map(node => node.textContent ?? '')
|
||||
.join('\n');
|
||||
|
||||
expect(styles).toContain('--color-primary: var(--primary);');
|
||||
expect(styles).toContain('--color-secondary: var(--muted-foreground);');
|
||||
expect(styles).not.toContain('hsl(var(--primary))');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@affine/admin/components/ui/select';
|
||||
import { Separator } from '@affine/admin/components/ui/separator';
|
||||
import { Skeleton } from '@affine/admin/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -38,13 +39,82 @@ import {
|
||||
RefreshCwIcon,
|
||||
UsersIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, Suspense, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Area, CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import { useMutateQueryResource } from '../../use-mutation';
|
||||
import { Header } from '../header';
|
||||
import { formatBytes } from '../workspaces/utils';
|
||||
|
||||
const adminDashboardOverviewQuery: typeof adminDashboardQuery = {
|
||||
...adminDashboardQuery,
|
||||
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
syncActiveUsers
|
||||
syncActiveUsersTimeline {
|
||||
minute
|
||||
activeUsers
|
||||
}
|
||||
syncWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
copilotConversations
|
||||
workspaceStorageBytes
|
||||
blobStorageBytes
|
||||
workspaceStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
blobStorageHistory {
|
||||
date
|
||||
value
|
||||
}
|
||||
storageWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
generatedAt
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
const adminDashboardTopSharedLinksQuery: typeof adminDashboardQuery = {
|
||||
...adminDashboardQuery,
|
||||
query: `query adminDashboard($input: AdminDashboardInput) {
|
||||
adminDashboard(input: $input) {
|
||||
topSharedLinks {
|
||||
workspaceId
|
||||
docId
|
||||
title
|
||||
shareUrl
|
||||
publishedAt
|
||||
views
|
||||
uniqueViews
|
||||
guestViews
|
||||
lastAccessedAt
|
||||
}
|
||||
topSharedLinksWindow {
|
||||
from
|
||||
to
|
||||
timezone
|
||||
bucket
|
||||
requestedSize
|
||||
effectiveSize
|
||||
}
|
||||
}
|
||||
}`,
|
||||
};
|
||||
|
||||
const intFormatter = new Intl.NumberFormat('en-US');
|
||||
const compactFormatter = new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
@@ -140,13 +210,13 @@ function TrendChart({
|
||||
const config: ChartConfig = {
|
||||
primary: {
|
||||
label: primaryLabel,
|
||||
color: 'hsl(var(--primary))',
|
||||
color: 'var(--primary)',
|
||||
},
|
||||
...(hasSecondary
|
||||
? {
|
||||
secondary: {
|
||||
label: secondaryLabel,
|
||||
color: 'hsl(var(--foreground) / 0.6)',
|
||||
color: 'var(--muted-foreground)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -166,7 +236,7 @@ function TrendChart({
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
stroke="hsl(var(--border) / 0.6)"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="3 4"
|
||||
/>
|
||||
<XAxis
|
||||
@@ -190,7 +260,7 @@ function TrendChart({
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: 'hsl(var(--border))',
|
||||
stroke: 'var(--border)',
|
||||
strokeDasharray: '4 4',
|
||||
strokeWidth: 1,
|
||||
}}
|
||||
@@ -244,7 +314,7 @@ function TrendChart({
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
<div className="flex justify-between text-[11px] text-muted-foreground tabular-nums">
|
||||
<div className="flex justify-between text-xxs text-muted-foreground tabular-nums">
|
||||
<span>{points[0]?.label}</span>
|
||||
<span>{points[points.length - 1]?.label}</span>
|
||||
</div>
|
||||
@@ -260,14 +330,14 @@ function PrimaryMetricCard({
|
||||
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">
|
||||
<Card className="h-full border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-foreground/75">
|
||||
<CardDescription className="flex items-center gap-2 text-sm">
|
||||
<UsersIcon className="h-4 w-4" aria-hidden="true" />
|
||||
Current Sync Active Users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<CardContent className="space-y-1.5">
|
||||
<div className="text-4xl font-bold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
@@ -289,9 +359,9 @@ function SecondaryMetricCard({
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card className="lg:col-span-3 border-border/70 bg-card/95 shadow-sm">
|
||||
<Card className="h-full border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<CardDescription className="flex items-center gap-2 text-sm">
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
{title}
|
||||
</CardDescription>
|
||||
@@ -300,7 +370,7 @@ function SecondaryMetricCard({
|
||||
<div className="text-2xl font-semibold tracking-tight tabular-nums">
|
||||
{value}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">{description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -322,7 +392,7 @@ function WindowSelect({
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 min-w-40">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground"
|
||||
@@ -348,10 +418,192 @@ function WindowSelect({
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
function DashboardPageSkeleton() {
|
||||
return (
|
||||
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
||||
<Header
|
||||
title="Dashboard"
|
||||
endFix={
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-3 w-44" />
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-6 space-y-6">
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-3">
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-4 w-80" />
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-12">
|
||||
<Skeleton className="h-28 w-full lg:col-span-5" />
|
||||
<Skeleton className="h-28 w-full lg:col-span-3" />
|
||||
<Skeleton className="h-28 w-full lg:col-span-4" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<Skeleton className="h-72 w-full lg:col-span-1" />
|
||||
<Skeleton className="h-72 w-full lg:col-span-2" />
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSharedLinksCardSkeleton() {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-36" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSharedLinksSection({
|
||||
sharedLinkWindowDays,
|
||||
}: {
|
||||
sharedLinkWindowDays: number;
|
||||
}) {
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
sharedLinkWindowDays,
|
||||
timezone: 'UTC',
|
||||
},
|
||||
}),
|
||||
[sharedLinkWindowDays]
|
||||
);
|
||||
|
||||
const { data } = useQuery(
|
||||
{
|
||||
query: adminDashboardTopSharedLinksQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
|
||||
const topSharedLinks = data.adminDashboard.topSharedLinks;
|
||||
const topSharedLinksWindow = data.adminDashboard.topSharedLinksWindow;
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Top Shared Links</CardTitle>
|
||||
<CardDescription>
|
||||
Top {topSharedLinks.length} links in the last{' '}
|
||||
{topSharedLinksWindow.effectiveSize} days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{topSharedLinks.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/60 p-8 text-center bg-muted/15">
|
||||
<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>
|
||||
{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(topSharedLinksWindow.from)}</span>
|
||||
<span>{formatDate(topSharedLinksWindow.to)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardPageContent() {
|
||||
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
|
||||
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
|
||||
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
|
||||
const shouldShowTopSharedLinks = !environment.isSelfHosted;
|
||||
const revalidateQueryResource = useMutateQueryResource();
|
||||
|
||||
const variables = useMemo(
|
||||
() => ({
|
||||
@@ -365,9 +617,9 @@ export function DashboardPage() {
|
||||
[sharedLinkWindowDays, storageHistoryDays, syncHistoryHours]
|
||||
);
|
||||
|
||||
const { data, isValidating, mutate } = useQuery(
|
||||
const { data, isValidating } = useQuery(
|
||||
{
|
||||
query: adminDashboardQuery,
|
||||
query: adminDashboardOverviewQuery,
|
||||
variables,
|
||||
},
|
||||
{
|
||||
@@ -409,7 +661,7 @@ export function DashboardPage() {
|
||||
dashboard.workspaceStorageBytes + dashboard.blobStorageBytes;
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 flex-col flex overflow-hidden">
|
||||
<div className="h-dvh flex-1 flex-col flex overflow-hidden">
|
||||
<Header
|
||||
title="Dashboard"
|
||||
endFix={
|
||||
@@ -421,7 +673,7 @@ export function DashboardPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
mutate().catch(() => {});
|
||||
revalidateQueryResource(adminDashboardQuery).catch(() => {});
|
||||
}}
|
||||
disabled={isValidating}
|
||||
>
|
||||
@@ -436,7 +688,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
|
||||
<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">
|
||||
<Card className="border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Window Controls</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -444,7 +696,7 @@ export function DashboardPage() {
|
||||
automatically.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 grid-cols-1 md:grid-cols-3 items-end">
|
||||
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
<WindowSelect
|
||||
id="storage-history-window"
|
||||
label="Storage History"
|
||||
@@ -472,40 +724,46 @@ export function DashboardPage() {
|
||||
</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 className="grid grid-cols-1 gap-5 lg:grid-cols-12">
|
||||
<div className="h-full min-w-0 lg:col-span-5">
|
||||
<PrimaryMetricCard
|
||||
value={intFormatter.format(dashboard.syncActiveUsers)}
|
||||
description={`${dashboard.syncWindow.effectiveSize}h active window`}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full min-w-0 lg:col-span-3">
|
||||
<SecondaryMetricCard
|
||||
title="Copilot Conversations"
|
||||
value={intFormatter.format(dashboard.copilotConversations)}
|
||||
description={`${sharedLinkWindowDays}d aggregation`}
|
||||
icon={
|
||||
<MessageSquareTextIcon className="h-4 w-4" aria-hidden="true" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full min-w-0 lg:col-span-4">
|
||||
<Card className="h-full border-border/60 bg-card shadow-1">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="flex items-center gap-2 text-sm">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-3">
|
||||
<Card className="border-border/60 bg-card shadow-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Sync Active Users Trend
|
||||
@@ -524,7 +782,7 @@ export function DashboardPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2 border-border/70 bg-gradient-to-br from-primary/5 via-card to-card shadow-sm">
|
||||
<Card className="border-border/60 bg-card shadow-1 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Storage Trend (Workspace + Blob)
|
||||
@@ -557,89 +815,24 @@ export function DashboardPage() {
|
||||
</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>
|
||||
{shouldShowTopSharedLinks ? (
|
||||
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
|
||||
<TopSharedLinksSection
|
||||
sharedLinkWindowDays={sharedLinkWindowDays}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<DashboardPageSkeleton />}>
|
||||
<DashboardPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export { DashboardPage as Component };
|
||||
|
||||
Reference in New Issue
Block a user