feat: improve admin dashboard & settings

This commit is contained in:
DarkSky
2026-02-17 00:56:03 +08:00
parent 850e646ab9
commit 2cb3e08b55
18 changed files with 1516 additions and 583 deletions

View File

@@ -148,7 +148,7 @@ jobs:
name: Wait for approval
with:
secret: ${{ secrets.GITHUB_TOKEN }}
approvers: darkskygit,pengx17,L-Sun,EYHN
approvers: darkskygit
minimum-approvals: 1
fail-on-denial: true
issue-title: Please confirm to release docker image

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import {
Args,
Field,
Info,
InputType,
Int,
Mutation,
@@ -14,6 +15,12 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import {
type FragmentDefinitionNode,
type GraphQLResolveInfo,
Kind,
type SelectionNode,
} from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import { PaginationInput, URLHelper } from '../../../base';
@@ -53,6 +60,44 @@ registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder',
});
function hasSelectedField(
selections: readonly SelectionNode[],
fieldName: string,
fragments: Record<string, FragmentDefinitionNode>
): boolean {
for (const selection of selections) {
if (selection.kind === Kind.FIELD) {
if (selection.name.value === fieldName) {
return true;
}
continue;
}
if (selection.kind === Kind.INLINE_FRAGMENT) {
if (
hasSelectedField(
selection.selectionSet.selections,
fieldName,
fragments
)
) {
return true;
}
continue;
}
const fragment = fragments[selection.name.value];
if (
fragment &&
hasSelectedField(fragment.selectionSet.selections, fieldName, fragments)
) {
return true;
}
}
return false;
}
@InputType()
class ListWorkspaceInput {
@Field(() => Int, { defaultValue: 20 })
@@ -471,22 +516,40 @@ export class AdminWorkspaceResolver {
})
async adminDashboard(
@Args('input', { nullable: true, type: () => AdminDashboardInput })
input?: AdminDashboardInput
input?: AdminDashboardInput,
@Info() info?: GraphQLResolveInfo
) {
this.assertCloudOnly();
const includeTopSharedLinks = Boolean(
info?.fieldNodes.some(
node =>
node.selectionSet &&
hasSelectedField(
node.selectionSet.selections,
'topSharedLinks',
info.fragments
)
)
);
const dashboard = await this.models.workspaceAnalytics.adminGetDashboard({
timezone: input?.timezone,
storageHistoryDays: input?.storageHistoryDays,
syncHistoryHours: input?.syncHistoryHours,
sharedLinkWindowDays: input?.sharedLinkWindowDays,
includeTopSharedLinks,
});
return {
...dashboard,
topSharedLinks: dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(`/workspace/${link.workspaceId}/${link.docId}`),
})),
topSharedLinks: includeTopSharedLinks
? dashboard.topSharedLinks.map(link => ({
...link,
shareUrl: this.url.link(
`/workspace/${link.workspaceId}/${link.docId}`
),
}))
: [],
};
}

View File

@@ -51,6 +51,7 @@ export type AdminDashboardOptions = {
storageHistoryDays?: number;
syncHistoryHours?: number;
sharedLinkWindowDays?: number;
includeTopSharedLinks?: boolean;
};
export type AdminAllSharedLinksOptions = {
@@ -262,6 +263,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
90,
DEFAULT_SHARED_LINK_WINDOW_DAYS
);
const includeTopSharedLinks = options.includeTopSharedLinks ?? true;
const now = new Date();
@@ -274,6 +276,66 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const storageFrom = addUtcDays(currentDay, -(storageHistoryDays - 1));
const sharedFrom = addUtcDays(currentDay, -(sharedLinkWindowDays - 1));
const topSharedLinksPromise = includeTopSharedLinks
? this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`
: Promise.resolve([]);
const [
syncCurrent,
syncTimeline,
@@ -350,63 +412,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
AND created_at >= ${sharedFrom}
AND created_at <= ${now}
`,
this.db.$queryRaw<
{
workspaceId: string;
docId: string;
title: string | null;
publishedAt: Date | null;
docUpdatedAt: Date | null;
workspaceOwnerId: string | null;
lastUpdaterId: string | null;
views: bigint | number;
uniqueViews: bigint | number;
guestViews: bigint | number;
lastAccessedAt: Date | null;
}[]
>`
WITH view_agg AS (
SELECT
workspace_id,
doc_id,
COALESCE(SUM(total_views), 0) AS views,
COALESCE(SUM(unique_views), 0) AS unique_views,
COALESCE(SUM(guest_views), 0) AS guest_views,
MAX(last_accessed_at) AS last_accessed_at
FROM workspace_doc_view_daily
WHERE date BETWEEN ${sharedFrom}::date AND ${currentDay}::date
GROUP BY workspace_id, doc_id
)
SELECT
wp.workspace_id AS "workspaceId",
wp.page_id AS "docId",
wp.title AS title,
wp.published_at AS "publishedAt",
sn.updated_at AS "docUpdatedAt",
owner.user_id AS "workspaceOwnerId",
sn.updated_by AS "lastUpdaterId",
COALESCE(v.views, 0) AS views,
COALESCE(v.unique_views, 0) AS "uniqueViews",
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL (
SELECT user_id
FROM workspace_user_permissions
WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner}
AND status = 'Accepted'::"WorkspaceMemberStatus"
ORDER BY created_at ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`,
topSharedLinksPromise,
]);
const storageHistorySeries = storageHistory.map(row => ({

View File

@@ -132,6 +132,10 @@ export class IndexerJob {
indexed: true,
});
}
if (!missingDocIds.length && !deletedDocIds.length) {
this.logger.verbose(`workspace ${workspaceId} is already indexed`);
return;
}
this.logger.log(
`indexed workspace ${workspaceId} with ${missingDocIds.length} missing docs and ${deletedDocIds.length} deleted docs`
);

View File

@@ -60,6 +60,7 @@
"zod": "^3.25.76"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/lodash-es": "^4.17.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -67,7 +68,8 @@
"shadcn-ui": "^0.9.5",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7"
"tailwindcss-animate": "^1.0.7",
"vitest": "^3.2.4"
},
"scripts": {
"build": "affine bundle",

View File

@@ -134,12 +134,7 @@ export const App = () => {
<Route
path={ROUTES.admin.settings.index}
element={<Settings />}
>
<Route
path={ROUTES.admin.settings.module}
element={<Settings />}
/>
</Route>
/>
</Route>
</Route>
</Routes>

View File

@@ -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',
@@ -260,7 +330,7 @@ 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-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" />
@@ -289,7 +359,7 @@ 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/70 bg-card/95 shadow-sm">
<CardHeader className="pb-2">
<CardDescription className="flex items-center gap-2">
<span aria-hidden="true">{icon}</span>
@@ -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-screen 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-primary/20 bg-gradient-to-r from-primary/5 via-card to-card shadow-sm">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent className="grid gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
<div className="grid gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
<Skeleton className="h-28 w-full min-[1024px]:col-span-5" />
<Skeleton className="h-28 w-full min-[1024px]:col-span-3" />
<Skeleton className="h-28 w-full min-[1024px]:col-span-4" />
</div>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
<Skeleton className="h-72 w-full xl:col-span-1" />
<Skeleton className="h-72 w-full xl:col-span-2" />
</div>
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
function TopSharedLinksCardSkeleton() {
return (
<Card className="border-border/70 bg-card/95 shadow-sm">
<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/70 bg-card/95 shadow-sm">
<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-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>
{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,
},
{
@@ -421,7 +673,7 @@ export function DashboardPage() {
variant="outline"
size="sm"
onClick={() => {
mutate().catch(() => {});
revalidateQueryResource(adminDashboardQuery).catch(() => {});
}}
disabled={isValidating}
>
@@ -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 gap-3 grid-cols-1 min-[1024px]:grid-cols-3 items-end">
<WindowSelect
id="storage-history-window"
label="Storage History"
@@ -472,36 +724,42 @@ 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 gap-5 grid-cols-1 min-[1024px]:grid-cols-12">
<div className="min-w-0 h-full min-[1024px]:col-span-5">
<PrimaryMetricCard
value={intFormatter.format(dashboard.syncActiveUsers)}
description={`${dashboard.syncWindow.effectiveSize}h active window`}
/>
</div>
<div className="min-w-0 h-full min-[1024px]: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="min-w-0 h-full min-[1024px]:col-span-4">
<Card className="h-full 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>
<div className="grid gap-5 grid-cols-1 xl:grid-cols-3">
@@ -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 };

View File

@@ -23,7 +23,6 @@ import {
} from '../components/ui/sheet';
import { Logo } from './accounts/components/logo';
import { useMediaQuery } from './common';
import { NavContext } from './nav/context';
import { Nav } from './nav/nav';
import {
PanelContext,
@@ -43,10 +42,6 @@ export function Layout({ children }: PropsWithChildren) {
const leftPanelRef = useRef<ImperativePanelHandle>(null);
const location = useLocation();
const [activeTab, setActiveTab] = useState('');
const [activeSubTab, setActiveSubTab] = useState('server');
const [currentModule, setCurrentModule] = useState('server');
const handleLeftExpand = useCallback(() => {
if (leftPanelRef.current?.getSize() === 0) {
leftPanelRef.current?.resize(30);
@@ -151,36 +146,25 @@ export function Layout({ children }: PropsWithChildren) {
},
}}
>
<NavContext.Provider
value={{
activeTab,
activeSubTab,
currentModule,
setActiveTab,
setActiveSubTab,
setCurrentModule,
}}
>
<TooltipProvider delayDuration={0}>
<div className="flex h-screen w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleLeftExpand}
onCollapse={handleLeftCollapse}
/>
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
{children}
</ResizablePanel>
<RightPanel
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleRightExpand}
onCollapse={handleRightCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</NavContext.Provider>
<TooltipProvider delayDuration={0}>
<div className="flex h-screen w-full overflow-hidden">
<ResizablePanelGroup direction="horizontal">
<LeftPanel
panelRef={leftPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleLeftExpand}
onCollapse={handleLeftCollapse}
/>
<ResizablePanel id="1" order={1} minSize={50} defaultSize={50}>
{children}
</ResizablePanel>
<RightPanel
panelRef={rightPanelRef as RefObject<ImperativePanelHandle>}
onExpand={handleRightExpand}
onCollapse={handleRightCollapse}
/>
</ResizablePanelGroup>
</div>
</TooltipProvider>
</PanelContext.Provider>
);
}

View File

@@ -1,44 +0,0 @@
import { useCallback } from 'react';
import { NavLink } from 'react-router-dom';
import { buttonVariants } from '../../components/ui/button';
import { cn } from '../../utils';
export const NormalSubItem = ({
module,
title,
changeModule,
indent = 'normal',
}: {
module: string;
title: string;
changeModule?: (module: string) => void;
indent?: 'normal' | 'nested';
}) => {
const handleClick = useCallback(() => {
changeModule?.(module);
}, [changeModule, module]);
const indentClassName = indent === 'nested' ? 'ml-12' : 'ml-8';
return (
<div className="w-full flex">
<NavLink
to={`/admin/settings/${module}`}
onClick={handleClick}
className={({ isActive }) => {
return cn(
buttonVariants({
variant: 'ghost',
className: cn(
indentClassName,
'px-2 w-full justify-start',
isActive && 'bg-zinc-100'
),
})
);
}}
>
{title}
</NavLink>
</div>
);
};

View File

@@ -1,21 +0,0 @@
import { createContext, useContext } from 'react';
interface NavContextType {
activeTab: string;
activeSubTab: string;
currentModule: string;
setActiveTab: (tab: string) => void;
setActiveSubTab: (tab: string) => void;
setCurrentModule: (module: string) => void;
}
export const NavContext = createContext<NavContextType | undefined>(undefined);
export const useNav = () => {
const context = useContext(NavContext);
if (!context) {
throw new Error('useNav must be used within a NavProvider');
}
return context;
};

View File

@@ -1,198 +1,54 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { buttonVariants } from '@affine/admin/components/ui/button';
import { cn } from '@affine/admin/utils';
import { SettingsIcon } from '@blocksuite/icons/rc';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cssVarV2 } from '@toeverything/theme/v2';
import { NavLink } from 'react-router-dom';
import { KNOWN_CONFIG_GROUPS, UNKNOWN_CONFIG_GROUPS } from '../settings/config';
import { NormalSubItem } from './collapsible-item';
import { useNav } from './context';
export const SettingsItem = ({ isCollapsed }: { isCollapsed: boolean }) => {
const { setCurrentModule } = useNav();
if (isCollapsed) {
return (
<NavigationMenuPrimitive.Root
className="flex-none relative"
orientation="vertical"
<NavLink
to="/admin/settings"
className={cn(
buttonVariants({
variant: 'ghost',
className: 'w-10 h-10',
size: 'icon',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
>
<NavigationMenuPrimitive.List>
<NavigationMenuPrimitive.Item>
<NavigationMenuPrimitive.Trigger className="[&>svg]:hidden m-0 p-0">
<NavLink
to={'/admin/settings'}
className={cn(
buttonVariants({
variant: 'ghost',
className: 'w-10 h-10',
size: 'icon',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
>
<SettingsIcon fontSize={20} />
</NavLink>
</NavigationMenuPrimitive.Trigger>
<NavigationMenuPrimitive.Content>
<ul
className="border rounded-lg w-full flex flex-col p-1 min-w-[160px] max-h-[200px] overflow-y-auto"
style={{
backgroundColor: cssVarV2('layer/background/overlayPanel'),
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
}}
>
{KNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</NavLink>
</li>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<li className="flex px-2 pt-1 pb-0.5 text-xs font-medium opacity-70">
Experimental
</li>
) : null}
{UNKNOWN_CONFIG_GROUPS.map(group => (
<li key={group.module} className="flex">
<NavLink
to={`/admin/settings/${group.module}`}
className={cn(
buttonVariants({
variant: 'ghost',
className:
'p-2 pl-6 rounded-[6px] text-[14px] w-full justify-start font-normal',
})
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
onClick={() => setCurrentModule?.(group.module)}
>
{group.name}
</NavLink>
</li>
))}
</ul>
</NavigationMenuPrimitive.Content>
</NavigationMenuPrimitive.Item>
</NavigationMenuPrimitive.List>
<NavigationMenuPrimitive.Viewport className="absolute z-10 left-11 top-0" />
</NavigationMenuPrimitive.Root>
<SettingsIcon fontSize={20} />
</NavLink>
);
}
return (
<Accordion type="multiple" className="w-full overflow-hidden">
<AccordionItem
value="item-1"
className="border-b-0 h-full flex flex-col gap-1 w-full"
>
<NavLink
to={'/admin/settings'}
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none w-full px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
})}
>
<AccordionTrigger
className={
'flex items-center justify-between w-full [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360'
}
>
<div className="flex items-center">
<span className="flex items-center p-0.5 mr-2">
<SettingsIcon fontSize={20} />
</span>
<span>Settings</span>
</div>
</AccordionTrigger>
</NavLink>
<AccordionContent className="h-full overflow-hidden w-full pb-0">
<ScrollAreaPrimitive.Root
className={cn('relative overflow-hidden w-full h-full')}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit] [&>div]:!block">
{KNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
/>
))}
{UNKNOWN_CONFIG_GROUPS.length ? (
<Accordion type="multiple" className="w-full">
<AccordionItem value="item-1" className="border-b-0">
<AccordionTrigger className="ml-8 py-2 px-2 rounded [&[data-state=closed]>svg]:rotate-270 [&[data-state=open]>svg]:rotate-360">
Experimental
</AccordionTrigger>
<AccordionContent className="flex flex-col gap-1 py-1">
{UNKNOWN_CONFIG_GROUPS.map(group => (
<NormalSubItem
key={group.module}
module={group.module}
title={group.name}
changeModule={setCurrentModule}
indent="nested"
/>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
) : null}
</ScrollAreaPrimitive.Viewport>
<ScrollAreaPrimitive.ScrollAreaScrollbar
className={cn(
'flex touch-none select-none transition-colors',
'h-full w-2.5 border-l border-l-transparent p-[1px]'
)}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
</AccordionContent>
</AccordionItem>
</Accordion>
<NavLink
to="/admin/settings"
className={cn(
buttonVariants({
variant: 'ghost',
}),
'justify-start flex-none text-sm font-medium px-2'
)}
style={({ isActive }) => ({
backgroundColor: isActive
? cssVarV2('selfhost/button/sidebarButton/bg/select')
: undefined,
'&:hover': {
backgroundColor: cssVarV2('selfhost/button/sidebarButton/bg/hover'),
},
})}
>
<span className="flex items-center p-0.5 mr-2">
<SettingsIcon fontSize={20} />
</span>
Settings
</NavLink>
);
};

View File

@@ -0,0 +1,107 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { ConfigRow } from './config-input-row';
describe('ConfigRow', () => {
afterEach(() => {
cleanup();
});
beforeAll(() => {
if (!Element.prototype.hasPointerCapture) {
Object.defineProperty(Element.prototype, 'hasPointerCapture', {
value: () => false,
});
}
if (!Element.prototype.setPointerCapture) {
Object.defineProperty(Element.prototype, 'setPointerCapture', {
value: () => {},
});
}
if (!Element.prototype.releasePointerCapture) {
Object.defineProperty(Element.prototype, 'releasePointerCapture', {
value: () => {},
});
}
});
test('triggers onChange when enum option changes', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="storages/blob.storage/provider"
desc="Storage provider"
type="Enum"
options={['fs', 'aws-s3', 'cloudflare-r2']}
defaultValue="fs"
onChange={handleChange}
/>
);
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' });
fireEvent.click(screen.getByRole('option', { name: 'aws-s3' }));
expect(handleChange).toHaveBeenCalledWith(
'storages/blob.storage/provider',
'aws-s3'
);
});
test('triggers onChange when json text becomes invalid', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="server/hosts"
desc="Server hosts"
type="JSON"
defaultValue={[]}
onChange={handleChange}
/>
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '[]asdasdasd' },
});
expect(handleChange).toHaveBeenCalledWith('server/hosts', '[]asdasdasd');
});
test('shows json validation error and clears after input is fixed', () => {
const handleChange = vi.fn();
render(
<ConfigRow
field="server/hosts"
desc="Server hosts"
type="JSON"
defaultValue={[]}
onChange={handleChange}
/>
);
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, {
target: { value: '[]asdasdasd' },
});
expect(screen.queryByText('Invalid JSON format')).not.toBeNull();
expect(textarea.className).toContain('border-red-500');
fireEvent.change(textarea, {
target: { value: '["localhost"]' },
});
expect(screen.queryByText('Invalid JSON format')).toBeNull();
expect(textarea.className).not.toContain('border-red-500');
expect(handleChange).toHaveBeenLastCalledWith('server/hosts', [
'localhost',
]);
});
});

View File

@@ -7,7 +7,8 @@ import {
SelectValue,
} from '@affine/admin/components/ui/select';
import { Switch } from '@affine/admin/components/ui/switch';
import { useCallback } from 'react';
import { cn } from '@affine/admin/utils';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Textarea } from '../../components/ui/textarea';
@@ -17,6 +18,7 @@ export type ConfigInputProps = {
defaultValue: any;
onChange: (field: string, value: any) => void;
error?: string;
onErrorChange?: (field: string, error?: string) => void;
} & (
| {
type: 'String' | 'Number' | 'Boolean' | 'JSON';
@@ -34,6 +36,7 @@ const Inputs: Record<
onChange: (value?: any) => void;
options?: string[];
error?: string;
onValidationChange?: (error?: string) => void;
}>
> = {
Boolean: function SwitchInput({ defaultValue, onChange }) {
@@ -43,7 +46,7 @@ const Inputs: Record<
return (
<Switch
defaultChecked={defaultValue}
checked={Boolean(defaultValue)}
onCheckedChange={handleSwitchChange}
/>
);
@@ -57,43 +60,78 @@ const Inputs: Record<
<Input
type="text"
minLength={1}
defaultValue={defaultValue}
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
Number: function NumberInput({ defaultValue, onChange }) {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(parseInt(e.target.value));
const next = e.target.value;
onChange(next === '' ? undefined : parseInt(next, 10));
};
return (
<Input
type="number"
defaultValue={defaultValue}
value={defaultValue ?? ''}
onChange={handleInputChange}
/>
);
},
JSON: function ObjectInput({ defaultValue, onChange }) {
JSON: function ObjectInput({
defaultValue,
onChange,
error,
onValidationChange,
}) {
const fallbackText = useMemo(
() =>
typeof defaultValue === 'string'
? defaultValue
: JSON.stringify(defaultValue ?? null),
[defaultValue]
);
const [text, setText] = useState(fallbackText);
useEffect(() => {
setText(fallbackText);
}, [fallbackText]);
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextText = e.target.value;
setText(nextText);
try {
const value = JSON.parse(e.target.value);
const value = JSON.parse(nextText);
onValidationChange?.(undefined);
onChange(value);
} catch {}
} catch {
onValidationChange?.('Invalid JSON format');
// Keep the draft "dirty" even when JSON is temporarily invalid
// so Save/Cancel state can reflect real editing progress.
onChange(nextText);
}
};
return (
<Textarea
defaultValue={JSON.stringify(defaultValue)}
value={text}
onChange={handleInputChange}
className="w-full"
className={cn(
'w-full',
error
? 'border-red-500 hover:border-red-500 focus-visible:border-red-500 focus-visible:ring-red-500'
: undefined
)}
/>
);
},
Enum: function EnumInput({ defaultValue, onChange, options }) {
return (
<Select defaultValue={defaultValue} onValueChange={onChange}>
<Select
value={typeof defaultValue === 'string' ? defaultValue : undefined}
onValueChange={onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select an option" />
</SelectTrigger>
@@ -116,9 +154,11 @@ export const ConfigRow = ({
defaultValue,
onChange,
error,
onErrorChange,
...props
}: ConfigInputProps) => {
const Input = Inputs[type] ?? Inputs.JSON;
const [validationError, setValidationError] = useState<string>();
const onValueChange = useCallback(
(value?: any) => {
@@ -127,6 +167,19 @@ export const ConfigRow = ({
[field, onChange]
);
const onValidationChange = useCallback((nextError?: string) => {
setValidationError(nextError);
}, []);
const mergedError = error ?? validationError;
useEffect(() => {
onErrorChange?.(field, mergedError);
return () => {
onErrorChange?.(field, undefined);
};
}, [field, mergedError, onErrorChange]);
return (
<div
className={`flex justify-between flex-grow space-y-[10px]
@@ -140,12 +193,13 @@ export const ConfigRow = ({
<Input
defaultValue={defaultValue}
onChange={onValueChange}
error={error}
error={mergedError}
onValidationChange={onValidationChange}
{...props}
/>
{error && (
<div className="absolute bottom-[-25px] text-sm right-0 break-words text-red-500">
{error}
{mergedError && (
<div className="mt-1 w-full text-sm break-words text-red-500">
{mergedError}
</div>
)}
</div>

View File

@@ -0,0 +1,215 @@
/**
* @vitest-environment happy-dom
*/
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const useAppConfigMock = vi.fn();
vi.mock('./use-app-config', () => ({
useAppConfig: () => useAppConfigMock(),
}));
vi.mock('../header', () => ({
Header: ({ title }: { title: string }) => <div>{title}</div>,
}));
vi.mock('./config-input-row', () => ({
ConfigRow: ({
field,
onErrorChange,
}: {
field: string;
onErrorChange?: (field: string, error?: string) => void;
}) => (
<div data-testid={`field-${field}`}>
<div>{field}</div>
<button
type="button"
onClick={() => {
onErrorChange?.(field, 'Invalid JSON format');
}}
>
mark-error-{field}
</button>
<button
type="button"
onClick={() => {
onErrorChange?.(field, undefined);
}}
>
clear-error-{field}
</button>
</div>
),
}));
vi.mock('./config', () => ({
ALL_CONFIG_DESCRIPTORS: {
server: {
name: {
desc: 'Server Name',
type: 'String',
},
},
auth: {
allowSignup: {
desc: 'Allow Signup',
type: 'Boolean',
},
},
},
ALL_SETTING_GROUPS: [
{
name: 'Server',
module: 'server',
fields: ['name'],
},
{
name: 'Auth',
module: 'auth',
fields: ['allowSignup'],
},
],
}));
import { SettingsPage } from './index';
describe('SettingsPage', () => {
beforeEach(() => {
useAppConfigMock.mockReset();
useAppConfigMock.mockReturnValue({
appConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
patchedAppConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
update: vi.fn(),
saveGroup: vi.fn().mockResolvedValue(undefined),
resetGroup: vi.fn(),
isGroupDirty: vi.fn().mockReturnValue(false),
isGroupSaving: vi.fn().mockReturnValue(false),
getGroupVersion: vi.fn().mockReturnValue(0),
});
});
afterEach(() => {
cleanup();
});
test('keeps all groups collapsed by default', () => {
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
const serverItem = document.getElementById('config-module-server');
const authItem = document.getElementById('config-module-auth');
expect(serverItem?.dataset.state).toBe('closed');
expect(authItem?.dataset.state).toBe('closed');
});
test('keeps previous group open when another group is expanded', () => {
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
fireEvent.click(screen.getAllByRole('button', { name: /Auth/i })[0]);
const serverItem = document.getElementById('config-module-server');
const authItem = document.getElementById('config-module-auth');
expect(serverItem?.dataset.state).toBe('open');
expect(authItem?.dataset.state).toBe('open');
});
test('disables save when group has validation errors even if group is dirty', () => {
useAppConfigMock.mockReset();
useAppConfigMock.mockReturnValue({
appConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
patchedAppConfig: {
server: {
name: 'AFFiNE',
},
auth: {
allowSignup: true,
},
},
update: vi.fn(),
saveGroup: vi.fn().mockResolvedValue(undefined),
resetGroup: vi.fn(),
isGroupDirty: vi
.fn()
.mockImplementation((module: string) => module === 'server'),
isGroupSaving: vi.fn().mockReturnValue(false),
getGroupVersion: vi.fn().mockReturnValue(0),
});
render(
<MemoryRouter initialEntries={['/admin/settings']}>
<Routes>
<Route path="/admin/settings" element={<SettingsPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getAllByRole('button', { name: /Server/i })[0]);
const serverItem = document.getElementById('config-module-server');
expect(serverItem).not.toBeNull();
if (!serverItem) {
return;
}
const saveButton = within(serverItem).getByRole('button', { name: 'Save' });
expect(saveButton.hasAttribute('disabled')).toBe(false);
fireEvent.click(
within(serverItem).getByRole('button', {
name: 'mark-error-server/name',
})
);
expect(saveButton.hasAttribute('disabled')).toBe(true);
fireEvent.click(
within(serverItem).getByRole('button', {
name: 'clear-error-server/name',
})
);
expect(saveButton.hasAttribute('disabled')).toBe(false);
});
});

View File

@@ -1,11 +1,15 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@affine/admin/components/ui/accordion';
import { Button } from '@affine/admin/components/ui/button';
import { ScrollArea } from '@affine/admin/components/ui/scroll-area';
import { get } from 'lodash-es';
import { CheckIcon } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useState } from 'react';
import { Header } from '../header';
import { useNav } from '../nav/context';
import {
ALL_CONFIG_DESCRIPTORS,
ALL_SETTING_GROUPS,
@@ -15,104 +19,237 @@ import { type ConfigInputProps, ConfigRow } from './config-input-row';
import { useAppConfig } from './use-app-config';
export function SettingsPage() {
const { appConfig, update, save, patchedAppConfig, updates } = useAppConfig();
const disableSave = Object.keys(updates).length === 0;
const saveChanges = useCallback(() => {
if (disableSave) {
return;
}
save();
}, [save, disableSave]);
const {
appConfig,
update,
saveGroup,
resetGroup,
patchedAppConfig,
isGroupDirty,
isGroupSaving,
getGroupVersion,
} = useAppConfig();
const [expandedModules, setExpandedModules] = useState<string[]>([]);
return (
<div className="h-screen flex-1 flex-col flex">
<Header
title="Settings"
endFix={
<Button
type="submit"
size="icon"
className="w-7 h-7"
variant="ghost"
onClick={saveChanges}
disabled={disableSave}
>
<CheckIcon size={20} />
</Button>
}
/>
<Header title="Settings" />
<AdminPanel
expandedModules={expandedModules}
onExpandedModulesChange={setExpandedModules}
onUpdate={update}
appConfig={appConfig}
patchedAppConfig={patchedAppConfig}
onSaveGroup={saveGroup}
onResetGroup={resetGroup}
isGroupDirty={isGroupDirty}
isGroupSaving={isGroupSaving}
getGroupVersion={getGroupVersion}
/>
</div>
);
}
const AdminPanel = ({
expandedModules,
onExpandedModulesChange,
appConfig,
patchedAppConfig,
onUpdate,
onSaveGroup,
onResetGroup,
isGroupDirty,
isGroupSaving,
getGroupVersion,
}: {
expandedModules: string[];
onExpandedModulesChange: (modules: string[]) => void;
appConfig: AppConfig;
patchedAppConfig: AppConfig;
onUpdate: (path: string, value: any) => void;
onSaveGroup: (module: string) => Promise<void>;
onResetGroup: (module: string) => void;
isGroupDirty: (module: string) => boolean;
isGroupSaving: (module: string) => boolean;
getGroupVersion: (module: string) => number;
}) => {
const { currentModule } = useNav();
const group = ALL_SETTING_GROUPS.find(
group => group.module === currentModule
);
const [groupErrors, setGroupErrors] = useState<
Record<string, Record<string, string>>
>({});
if (!group) {
return null;
}
const onFieldErrorChange = useCallback((field: string, error?: string) => {
const [module] = field.split('/');
if (!module) {
return;
}
const { name, module, fields, operations } = group;
setGroupErrors(prev => {
const moduleErrors = prev[module] ?? {};
if (error) {
if (moduleErrors[field] === error) {
return prev;
}
return {
...prev,
[module]: {
...moduleErrors,
[field]: error,
},
};
}
if (!(field in moduleErrors)) {
return prev;
}
const nextModuleErrors = { ...moduleErrors };
delete nextModuleErrors[field];
if (Object.keys(nextModuleErrors).length === 0) {
const next = { ...prev };
delete next[module];
return next;
}
return {
...prev,
[module]: nextModuleErrors,
};
});
}, []);
const clearModuleErrors = useCallback((module: string) => {
setGroupErrors(prev => {
if (!prev[module]) {
return prev;
}
const next = { ...prev };
delete next[module];
return next;
});
}, []);
return (
<ScrollArea className="h-full">
<div className="flex flex-col h-full gap-5 py-5 px-6 w-full max-w-[800px] mx-auto">
<div className="text-2xl font-semibold">{name}</div>
<div className="flex flex-col gap-10" id={`config-module-${module}`}>
{fields.map(field => {
let desc: string;
let props: ConfigInputProps;
if (typeof field === 'string') {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field];
desc = descriptor.desc;
props = {
field: `${module}/${field}`,
desc,
type: descriptor.type,
options: [],
defaultValue: get(appConfig[module], field),
onChange: onUpdate,
};
} else {
const descriptor = ALL_CONFIG_DESCRIPTORS[module][field.key];
<div className="flex flex-col gap-4 py-5 px-6 w-full max-w-[900px] mx-auto">
<Accordion
type="multiple"
className="w-full"
value={expandedModules}
onValueChange={onExpandedModulesChange}
>
{ALL_SETTING_GROUPS.map(group => {
const { name, module, fields, operations } = group;
const dirty = isGroupDirty(module);
const saving = isGroupSaving(module);
const sourceConfig = patchedAppConfig[module] ?? appConfig[module];
const version = getGroupVersion(module);
const hasValidationError = Boolean(
groupErrors[module] &&
Object.keys(groupErrors[module] ?? {}).length > 0
);
props = {
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type,
// @ts-expect-error for enum type
options: field.options,
defaultValue: get(
appConfig[module],
field.key + (field.sub ? '.' + field.sub : '')
),
onChange: onUpdate,
};
}
return (
<AccordionItem
key={module}
value={module}
id={`config-module-${module}`}
className="border border-border rounded-xl px-5 mb-4"
>
<AccordionTrigger className="hover:no-underline py-4">
<div className="flex flex-col items-start text-left gap-1">
<div className="text-lg font-semibold">{name}</div>
<div className="text-sm text-muted-foreground">
Manage {name.toLowerCase()} settings
</div>
</div>
</AccordionTrigger>
return <ConfigRow key={props.field} {...props} />;
<AccordionContent className="pt-2 pb-2 px-1">
<div
className="flex flex-col gap-8"
key={`${module}-${version}`}
>
{fields.map(field => {
let props: ConfigInputProps;
if (typeof field === 'string') {
const descriptor =
ALL_CONFIG_DESCRIPTORS[module][field];
props = {
field: `${module}/${field}`,
desc: descriptor.desc,
type: descriptor.type,
options: [],
defaultValue: get(sourceConfig, field),
onChange: onUpdate,
};
} else {
const descriptor =
ALL_CONFIG_DESCRIPTORS[module][field.key];
props = {
field: `${module}/${field.key}${field.sub ? `/${field.sub}` : ''}`,
desc: field.desc ?? descriptor.desc,
type: field.type ?? descriptor.type,
// @ts-expect-error for enum type
options: field.options,
defaultValue: get(
sourceConfig,
field.key + (field.sub ? '.' + field.sub : '')
),
onChange: onUpdate,
};
}
return (
<ConfigRow
key={props.field}
{...props}
onErrorChange={onFieldErrorChange}
/>
);
})}
{operations?.map(Operation => (
<Operation
key={Operation.name}
appConfig={patchedAppConfig}
/>
))}
<div className="flex justify-end gap-2">
{dirty ? (
<Button
type="button"
variant="outline"
onClick={() => {
onResetGroup(module);
clearModuleErrors(module);
}}
disabled={saving}
>
Cancel
</Button>
) : null}
<Button
type="button"
onClick={() => {
onSaveGroup(module).catch(err => {
console.error(err);
});
}}
disabled={!dirty || saving || hasValidationError}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
{operations?.map(Operation => (
<Operation key={Operation.name} appConfig={patchedAppConfig} />
))}
</div>
</Accordion>
</div>
</ScrollArea>
);

View File

@@ -0,0 +1,193 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const mocked = vi.hoisted(() => {
let queryState: {
appConfig: {
server: {
name: string;
hosts: string[];
};
auth: {
allowSignup: boolean;
};
storages: {
blob: {
storage: {
provider: string;
};
};
};
};
} = {
appConfig: {
server: {
name: '',
hosts: [],
},
auth: {
allowSignup: true,
},
storages: {
blob: {
storage: {
provider: 'fs',
},
},
},
},
};
return {
getQueryState: () => queryState,
setQueryState: (next: typeof queryState) => {
queryState = next;
},
mutateMock: vi.fn(),
saveUpdatesMock: vi.fn(),
notifySuccessMock: vi.fn(),
notifyErrorMock: vi.fn(),
};
});
vi.mock('@affine/admin/use-query', () => ({
useQuery: () => ({
data: mocked.getQueryState(),
mutate: mocked.mutateMock,
}),
}));
vi.mock('@affine/admin/use-mutation', () => ({
useMutation: () => ({
trigger: mocked.saveUpdatesMock,
}),
}));
vi.mock('@affine/component', () => ({
notify: {
success: mocked.notifySuccessMock,
error: mocked.notifyErrorMock,
},
}));
import { useAppConfig } from './use-app-config';
describe('useAppConfig', () => {
beforeEach(() => {
mocked.setQueryState({
appConfig: {
server: {
name: 'AFFiNE',
hosts: ['localhost'],
},
auth: {
allowSignup: true,
},
storages: {
blob: {
storage: {
provider: 'fs',
},
},
},
},
});
mocked.mutateMock.mockReset();
mocked.saveUpdatesMock.mockReset();
mocked.notifySuccessMock.mockReset();
mocked.notifyErrorMock.mockReset();
mocked.mutateMock.mockImplementation(async updater => {
const currentState = mocked.getQueryState();
if (typeof updater === 'function') {
mocked.setQueryState(updater(currentState));
}
return mocked.getQueryState();
});
});
test('clears dirty state when value is changed back to original', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
});
expect(result.current.isGroupDirty('server')).toBe(true);
act(() => {
result.current.update('server/name', 'AFFiNE');
});
expect(result.current.isGroupDirty('server')).toBe(false);
});
test('resetGroup cancels only target group changes immediately', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
result.current.update('auth/allowSignup', false);
});
expect(result.current.isGroupDirty('server')).toBe(true);
expect(result.current.isGroupDirty('auth')).toBe(true);
act(() => {
result.current.resetGroup('server');
});
expect(result.current.isGroupDirty('server')).toBe(false);
expect(result.current.isGroupDirty('auth')).toBe(true);
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE');
expect(result.current.getGroupVersion('server')).toBe(1);
});
test('saveGroup submits only target group updates and keeps others dirty', async () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('server/name', 'AFFiNE Cloud');
result.current.update('auth/allowSignup', false);
});
mocked.saveUpdatesMock.mockResolvedValue({
updateAppConfig: {
server: {
name: 'AFFiNE Cloud',
},
},
});
await act(async () => {
await result.current.saveGroup('server');
});
expect(mocked.saveUpdatesMock).toHaveBeenCalledWith({
updates: [
{
module: 'server',
key: 'name',
value: 'AFFiNE Cloud',
},
],
});
expect(result.current.isGroupDirty('server')).toBe(false);
expect(result.current.isGroupDirty('auth')).toBe(true);
expect(result.current.patchedAppConfig.server.name).toBe('AFFiNE Cloud');
expect(result.current.getGroupVersion('server')).toBe(1);
expect(mocked.notifySuccessMock).toHaveBeenCalledTimes(1);
});
test('marks group dirty when nested enum-like option changes', () => {
const { result } = renderHook(() => useAppConfig());
act(() => {
result.current.update('storages/blob.storage/provider', 'aws-s3');
});
expect(result.current.isGroupDirty('storages')).toBe(true);
});
});

View File

@@ -1,7 +1,6 @@
import { useMutation } from '@affine/admin/use-mutation';
import { useQuery } from '@affine/admin/use-query';
import { notify } from '@affine/component';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { UserFriendlyError } from '@affine/error';
import {
appConfigQuery,
@@ -9,13 +8,40 @@ import {
updateAppConfigMutation,
} from '@affine/graphql';
import { cloneDeep, get, merge, set } from 'lodash-es';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { AppConfig } from './config';
import { isEqual } from './utils';
export { type UpdateAppConfigInput };
export type AppConfigUpdates = Record<string, { from: any; to: any }>;
type SaveResponse =
| { updateAppConfig?: Partial<AppConfig> }
| Partial<AppConfig>;
const getUpdateInputs = (
entries: Array<[string, { from: any; to: any }]>
): UpdateAppConfigInput[] => {
return entries.map(([key, value]) => {
const splitIndex = key.indexOf('.');
const module = key.slice(0, splitIndex);
const field = key.slice(splitIndex + 1);
return {
module,
key: field,
value: value.to,
};
});
};
const getSavedAppConfig = (response: SaveResponse): Partial<AppConfig> => {
if ('updateAppConfig' in response) {
return (response.updateAppConfig as Partial<AppConfig>) ?? {};
}
return response;
};
export const useAppConfig = () => {
const {
@@ -33,30 +59,70 @@ export const useAppConfig = () => {
const [patchedAppConfig, setPatchedAppConfig] = useState<AppConfig>(() =>
cloneDeep(appConfig)
);
const [savingModules, setSavingModules] = useState<Record<string, boolean>>(
{}
);
const [groupVersions, setGroupVersions] = useState<Record<string, number>>(
{}
);
const save = useAsyncCallback(async () => {
const updateInputs: UpdateAppConfigInput[] = Object.entries(updates).map(
([key, value]) => {
const splitIndex = key.indexOf('.');
const module = key.slice(0, splitIndex);
const field = key.slice(splitIndex + 1);
useEffect(() => {
if (Object.keys(updates).length === 0) {
setPatchedAppConfig(cloneDeep(appConfig));
}
}, [appConfig, updates]);
return {
module,
key: field,
value: value.to,
};
}
);
const getEntriesByModule = useCallback(
(module: string, source: AppConfigUpdates = updates) => {
return Object.entries(source).filter(([key]) =>
key.startsWith(`${module}.`)
);
},
[updates]
);
const clearModuleUpdates = useCallback(
(module: string) => {
setUpdates(prev => {
const next = { ...prev };
Object.keys(next).forEach(key => {
if (key.startsWith(`${module}.`)) {
delete next[key];
}
});
return next;
});
},
[setUpdates]
);
const bumpGroupVersion = useCallback((module: string) => {
setGroupVersions(prev => ({
...prev,
[module]: (prev[module] ?? 0) + 1,
}));
}, []);
const save = useCallback(async () => {
const allEntries = Object.entries(updates);
if (allEntries.length === 0) {
return;
}
try {
const savedUpdates = await saveUpdates({
updates: updateInputs,
});
const response = (await saveUpdates({
updates: getUpdateInputs(allEntries),
})) as SaveResponse;
const savedAppConfig = getSavedAppConfig(response);
await mutate(prev => {
return { appConfig: merge({}, prev, savedUpdates) };
return {
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
};
});
setUpdates({});
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
notify.success({
title: 'Saved',
message: 'Settings have been saved successfully.',
@@ -71,6 +137,60 @@ export const useAppConfig = () => {
}
}, [updates, mutate, saveUpdates]);
const saveGroup = useCallback(
async (module: string) => {
const moduleEntries = getEntriesByModule(module);
if (moduleEntries.length === 0) {
return;
}
setSavingModules(prev => ({
...prev,
[module]: true,
}));
try {
const response = (await saveUpdates({
updates: getUpdateInputs(moduleEntries),
})) as SaveResponse;
const savedAppConfig = getSavedAppConfig(response);
await mutate(prev => {
return {
appConfig: merge({}, prev?.appConfig ?? {}, savedAppConfig),
};
});
clearModuleUpdates(module);
setPatchedAppConfig(prev => merge({}, prev, savedAppConfig));
bumpGroupVersion(module);
notify.success({
title: 'Saved',
message: 'Settings have been saved successfully.',
});
} catch (e) {
const error = UserFriendlyError.fromAny(e);
notify.error({
title: 'Failed to save',
message: error.message,
});
console.error(e);
} finally {
setSavingModules(prev => ({
...prev,
[module]: false,
}));
}
},
[
bumpGroupVersion,
clearModuleUpdates,
getEntriesByModule,
mutate,
saveUpdates,
]
);
const update = useCallback(
(path: string, value: any) => {
const [module, field, subField] = path.split('/');
@@ -78,9 +198,15 @@ export const useAppConfig = () => {
const from = get(appConfig, key);
setUpdates(prev => {
const to = subField
? set(prev[key]?.to ?? { ...from }, subField, value)
? set(cloneDeep(prev[key]?.to ?? from ?? {}), subField, value)
: value;
if (isEqual(from, to)) {
const next = { ...prev };
delete next[key];
return next;
}
return {
...prev,
[key]: {
@@ -91,21 +217,62 @@ export const useAppConfig = () => {
});
setPatchedAppConfig(prev => {
return set(
prev,
`${module}.${field}${subField ? `.${subField}` : ''}`,
value
);
const next = cloneDeep(prev);
if (subField) {
const nextValue = set(
cloneDeep(get(next, `${module}.${field}`) ?? {}),
subField,
value
);
set(next, `${module}.${field}`, nextValue);
return next;
}
set(next, `${module}.${field}`, value);
return next;
});
},
[appConfig]
);
const resetGroup = useCallback(
(module: string) => {
clearModuleUpdates(module);
setPatchedAppConfig(prev => {
return {
...prev,
[module]: cloneDeep(appConfig[module]),
};
});
bumpGroupVersion(module);
},
[appConfig, bumpGroupVersion, clearModuleUpdates]
);
const isGroupDirty = useCallback(
(module: string) => getEntriesByModule(module).length > 0,
[getEntriesByModule]
);
const isGroupSaving = useCallback(
(module: string) => Boolean(savingModules[module]),
[savingModules]
);
const getGroupVersion = useCallback(
(module: string) => groupVersions[module] ?? 0,
[groupVersions]
);
return {
appConfig: appConfig as AppConfig,
patchedAppConfig,
update,
save,
saveGroup,
resetGroup,
isGroupDirty,
isGroupSaving,
getGroupVersion,
updates,
};
};

View File

@@ -215,6 +215,7 @@ __metadata:
"@radix-ui/react-tooltip": "npm:^1.1.5"
"@sentry/react": "npm:^9.47.1"
"@tanstack/react-table": "npm:^8.20.5"
"@testing-library/react": "npm:^16.3.2"
"@toeverything/infra": "workspace:*"
"@toeverything/theme": "npm:^1.1.23"
"@types/lodash-es": "npm:^4.17.12"
@@ -241,6 +242,7 @@ __metadata:
tailwindcss: "npm:^4.1.17"
tailwindcss-animate: "npm:^1.0.7"
vaul: "npm:^1.1.2"
vitest: "npm:^3.2.4"
zod: "npm:^3.25.76"
languageName: unknown
linkType: soft
@@ -16575,6 +16577,26 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^16.3.2":
version: 16.3.2
resolution: "@testing-library/react@npm:16.3.2"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
"@testing-library/dom": ^10.0.0
"@types/react": ^18.0.0 || ^19.0.0
"@types/react-dom": ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: 10/0ca88c6f672d00c2afd1bdedeff9b5382dd8157038efeb9762dc016731030075624be7106b92d2b5e5c52812faea85263e69272c14b6f8700eb48a4a8af6feef
languageName: node
linkType: hard
"@testing-library/user-event@npm:^14.6.1":
version: 14.6.1
resolution: "@testing-library/user-event@npm:14.6.1"