From 2cb3e08b55ee8a316f13dcdcb681692a17e7e769 Mon Sep 17 00:00:00 2001 From: DarkSky Date: Tue, 17 Feb 2026 00:56:03 +0800 Subject: [PATCH] feat: improve admin dashboard & settings --- .github/workflows/release.yml | 2 +- .../src/core/workspaces/resolvers/admin.ts | 73 ++- .../server/src/models/workspace-analytics.ts | 120 ++--- .../backend/server/src/plugins/indexer/job.ts | 4 + packages/frontend/admin/package.json | 4 +- packages/frontend/admin/src/app.tsx | 7 +- .../admin/src/modules/dashboard/index.tsx | 431 +++++++++++++----- .../frontend/admin/src/modules/layout.tsx | 54 +-- .../src/modules/nav/collapsible-item.tsx | 44 -- .../frontend/admin/src/modules/nav/context.ts | 21 - .../admin/src/modules/nav/settings-item.tsx | 220 ++------- .../settings/config-input-row.spec.tsx | 107 +++++ .../src/modules/settings/config-input-row.tsx | 84 +++- .../admin/src/modules/settings/index.spec.tsx | 215 +++++++++ .../admin/src/modules/settings/index.tsx | 281 +++++++++--- .../modules/settings/use-app-config.spec.ts | 193 ++++++++ .../src/modules/settings/use-app-config.ts | 217 ++++++++- yarn.lock | 22 + 18 files changed, 1516 insertions(+), 583 deletions(-) delete mode 100644 packages/frontend/admin/src/modules/nav/collapsible-item.tsx delete mode 100644 packages/frontend/admin/src/modules/nav/context.ts create mode 100644 packages/frontend/admin/src/modules/settings/config-input-row.spec.tsx create mode 100644 packages/frontend/admin/src/modules/settings/index.spec.tsx create mode 100644 packages/frontend/admin/src/modules/settings/use-app-config.spec.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 822c50c828..98c0db20f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index d5b76d2df9..1a59780545 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -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 +): 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}` + ), + })) + : [], }; } diff --git a/packages/backend/server/src/models/workspace-analytics.ts b/packages/backend/server/src/models/workspace-analytics.ts index 253d165344..8041346797 100644 --- a/packages/backend/server/src/models/workspace-analytics.ts +++ b/packages/backend/server/src/models/workspace-analytics.ts @@ -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 => ({ diff --git a/packages/backend/server/src/plugins/indexer/job.ts b/packages/backend/server/src/plugins/indexer/job.ts index ff21ecba2c..9d1be70fbc 100644 --- a/packages/backend/server/src/plugins/indexer/job.ts +++ b/packages/backend/server/src/plugins/indexer/job.ts @@ -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` ); diff --git a/packages/frontend/admin/package.json b/packages/frontend/admin/package.json index 0cf2bb693c..df71544e5d 100644 --- a/packages/frontend/admin/package.json +++ b/packages/frontend/admin/package.json @@ -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", diff --git a/packages/frontend/admin/src/app.tsx b/packages/frontend/admin/src/app.tsx index c9d487f02e..ba62b8235a 100644 --- a/packages/frontend/admin/src/app.tsx +++ b/packages/frontend/admin/src/app.tsx @@ -134,12 +134,7 @@ export const App = () => { } - > - } - /> - + /> diff --git a/packages/frontend/admin/src/modules/dashboard/index.tsx b/packages/frontend/admin/src/modules/dashboard/index.tsx index 32e616fa73..acaaa30f56 100644 --- a/packages/frontend/admin/src/modules/dashboard/index.tsx +++ b/packages/frontend/admin/src/modules/dashboard/index.tsx @@ -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 ( - +