diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f4d88..f4f5391 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@tanstack/react-query': specifier: 4.33.0 version: 4.33.0(react-dom@18.2.0)(react@18.2.0) + '@tanstack/react-virtual': + specifier: ^3.0.2 + version: 3.0.2(react-dom@18.2.0)(react@18.2.0) '@tianji/shared': specifier: workspace:^ version: link:../shared @@ -6788,6 +6791,21 @@ packages: use-sync-external-store: 1.2.0(react@18.2.0) dev: false + /@tanstack/react-virtual@3.0.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-9XbRLPKgnhMwwmuQMnJMv+5a9sitGNCSEtf/AZXzmJdesYk7XsjYHaEDny+IrJzvPNwZliIIDwCRiaUqR3zzCA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@tanstack/virtual-core': 3.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tanstack/virtual-core@3.0.0: + resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} + dev: false + /@tootallnate/quickjs-emscripten@0.23.0: resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} diff --git a/src/client/package.json b/src/client/package.json index 67107fd..f2aa074 100644 --- a/src/client/package.json +++ b/src/client/package.json @@ -17,6 +17,7 @@ "@loadable/component": "^5.16.3", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "4.33.0", + "@tanstack/react-virtual": "^3.0.2", "@tianji/shared": "workspace:^", "@trpc/client": "^10.45.0", "@trpc/react-query": "^10.45.0", diff --git a/src/client/pages/Settings/AuditLog.tsx b/src/client/pages/Settings/AuditLog.tsx new file mode 100644 index 0000000..2b414bc --- /dev/null +++ b/src/client/pages/Settings/AuditLog.tsx @@ -0,0 +1,112 @@ +import { Card, List } from 'antd'; +import React, { useMemo, useRef } from 'react'; +import { useCurrentWorkspaceId } from '../../store/user'; +import { PageHeader } from '../../components/PageHeader'; +import { trpc } from '../../api/trpc'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { last } from 'lodash-es'; +import { useWatch } from '../../hooks/useWatch'; +import { ColorTag } from '../../components/ColorTag'; +import dayjs from 'dayjs'; + +export const AuditLog: React.FC = React.memo(() => { + const workspaceId = useCurrentWorkspaceId(); + const parentRef = useRef(null); + + const { data, hasNextPage, fetchNextPage, isFetchingNextPage } = + trpc.auditLog.fetchByCursor.useInfiniteQuery({ + workspaceId, + }); + + const allData = useMemo(() => { + if (!data) { + return []; + } + + return [...data.pages.flatMap((p) => p.items)]; + }, [data]); + + const rowVirtualizer = useVirtualizer({ + count: hasNextPage ? allData.length + 1 : allData.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 48, + overscan: 5, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + useWatch([virtualItems], () => { + const lastItem = last(virtualItems); + + if (!lastItem) { + return; + } + + if ( + lastItem.index >= allData.length - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }); + + return ( +
+ + + + +
+
+ {virtualItems.map((virtualRow) => { + const isLoaderRow = virtualRow.index > allData.length - 1; + const item = allData[virtualRow.index]; + + return ( + + {isLoaderRow ? ( + hasNextPage ? ( + 'Loading more...' + ) : ( + 'Nothing more to load' + ) + ) : ( +
+ {item.relatedType && ( + + )} +
+ {dayjs(item.createdAt).format('MM-DD HH:mm')} +
+
{item.content}
+
+ )} +
+ ); + })} +
+
+
+
+
+ ); +}); +AuditLog.displayName = 'AuditLog'; diff --git a/src/client/pages/Settings/index.tsx b/src/client/pages/Settings/index.tsx index 890d3b2..e9740ea 100644 --- a/src/client/pages/Settings/index.tsx +++ b/src/client/pages/Settings/index.tsx @@ -6,6 +6,7 @@ import { WebsiteList } from '../../components/website/WebsiteList'; import { useEvent } from '../../hooks/useEvent'; import { NotificationList } from './NotificationList'; import { Profile } from './Profile'; +import { AuditLog } from './AuditLog'; const items: MenuProps['items'] = [ { @@ -16,6 +17,10 @@ const items: MenuProps['items'] = [ key: 'notifications', label: 'Notifications', }, + { + key: 'auditLog', + label: 'Audit Log', + }, { key: 'profile', label: 'Profile', @@ -51,6 +56,7 @@ export const SettingsPage: React.FC = React.memo(() => { } /> } /> } /> + } /> } /> diff --git a/src/server/trpc/routers/auditLog.ts b/src/server/trpc/routers/auditLog.ts new file mode 100644 index 0000000..27642d2 --- /dev/null +++ b/src/server/trpc/routers/auditLog.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { router, workspaceProcedure } from '../trpc'; +import { OPENAPI_TAG } from '../../utils/const'; +import { WorkspaceAuditLogModelSchema } from '../../prisma/zod'; +import { prisma } from '../../model/_client'; +import { fetchDataByCursor } from '../../utils/prisma'; + +export const auditLogRouter = router({ + fetchByCursor: workspaceProcedure + .meta({ + openapi: { + method: 'GET', + path: '/fetchByCursor', + tags: [OPENAPI_TAG.WORKSPACE], + description: 'Fetch workspace audit log', + }, + }) + .input( + z.object({ + limit: z.number().min(1).max(100).default(50), + cursor: z.string().optional(), + }) + ) + .output( + z.object({ + items: z.array(WorkspaceAuditLogModelSchema), + nextCursor: z.string().optional(), + }) + ) + .query(async ({ input }) => { + const { workspaceId, cursor, limit } = input; + + const { items, nextCursor } = await fetchDataByCursor( + prisma.workspaceAuditLog, + { + where: { + workspaceId, + }, + limit, + cursor, + } + ); + + return { + items, + nextCursor, + }; + }), +}); diff --git a/src/server/trpc/routers/index.ts b/src/server/trpc/routers/index.ts index bd26437..33e04aa 100644 --- a/src/server/trpc/routers/index.ts +++ b/src/server/trpc/routers/index.ts @@ -6,6 +6,7 @@ import { userRouter } from './user'; import { workspaceRouter } from './workspace'; import { globalRouter } from './global'; import { serverStatusRouter } from './serverStatus'; +import { auditLogRouter } from './auditLog'; export const appRouter = router({ global: globalRouter, @@ -15,6 +16,7 @@ export const appRouter = router({ notification: notificationRouter, monitor: monitorRouter, serverStatus: serverStatusRouter, + auditLog: auditLogRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/server/utils/const.ts b/src/server/utils/const.ts index 75ade12..2d2119e 100644 --- a/src/server/utils/const.ts +++ b/src/server/utils/const.ts @@ -103,6 +103,7 @@ export const DEFAULT_RESET_DATE = '2000-01-01'; export enum OPENAPI_TAG { GLOBAL = 'Global', + WORKSPACE = 'Workspace', USER = 'User', WEBSITE = 'Website', MONITOR = 'Monitor', diff --git a/src/server/utils/prisma.ts b/src/server/utils/prisma.ts index 5e82466..463e2d5 100644 --- a/src/server/utils/prisma.ts +++ b/src/server/utils/prisma.ts @@ -151,3 +151,51 @@ export function getTimestampIntervalQuery(field: string) { `floor(extract(epoch from max(${field}) - min(${field})))`, ]); } + +type ExtractFindManyReturnType = T extends ( + args?: any +) => Prisma.PrismaPromise + ? R + : never; + +export async function fetchDataByCursor< + Model extends { + findMany: (args?: any) => Prisma.PrismaPromise; + }, + CursorType +>( + fetchModel: Model, + options: { + where: Record; + limit: number; + cursor: CursorType; + cursorName?: string; + order?: 'asc' | 'desc'; + } +) { + const { where, limit, cursor, cursorName = 'id', order = 'desc' } = options; + const items: ExtractFindManyReturnType = + await fetchModel.findMany({ + where, + take: limit + 1, + cursor: cursor + ? { + [cursorName]: cursor, + } + : undefined, + orderBy: { + [cursorName]: order, + }, + }); + + let nextCursor: CursorType | undefined = undefined; + if (items.length > limit) { + const nextItem = items.pop()!; + nextCursor = nextItem[cursorName]; + } + + return { + items, + nextCursor, + }; +}