feat: add audit log

This commit is contained in:
moonrailgun 2024-01-28 23:15:16 +08:00
parent bf1604d9ec
commit d912c788c5
8 changed files with 237 additions and 0 deletions

View File

@ -65,6 +65,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: 4.33.0 specifier: 4.33.0
version: 4.33.0(react-dom@18.2.0)(react@18.2.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': '@tianji/shared':
specifier: workspace:^ specifier: workspace:^
version: link:../shared version: link:../shared
@ -6788,6 +6791,21 @@ packages:
use-sync-external-store: 1.2.0(react@18.2.0) use-sync-external-store: 1.2.0(react@18.2.0)
dev: false 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: /@tootallnate/quickjs-emscripten@0.23.0:
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}

View File

@ -17,6 +17,7 @@
"@loadable/component": "^5.16.3", "@loadable/component": "^5.16.3",
"@monaco-editor/react": "^4.6.0", "@monaco-editor/react": "^4.6.0",
"@tanstack/react-query": "4.33.0", "@tanstack/react-query": "4.33.0",
"@tanstack/react-virtual": "^3.0.2",
"@tianji/shared": "workspace:^", "@tianji/shared": "workspace:^",
"@trpc/client": "^10.45.0", "@trpc/client": "^10.45.0",
"@trpc/react-query": "^10.45.0", "@trpc/react-query": "^10.45.0",

View File

@ -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<HTMLDivElement>(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 (
<div>
<PageHeader title="Audit Log" />
<Card>
<List>
<div ref={parentRef} className="h-[560px] overflow-auto w-full">
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualItems.map((virtualRow) => {
const isLoaderRow = virtualRow.index > allData.length - 1;
const item = allData[virtualRow.index];
return (
<List.Item
key={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
hasNextPage ? (
'Loading more...'
) : (
'Nothing more to load'
)
) : (
<div className="flex items-center">
{item.relatedType && (
<ColorTag label={item.relatedType} />
)}
<div
className="opacity-60 mr-2 text-xs"
title={dayjs(item.createdAt).format(
'YYYY-MM-DD HH:mm:ss'
)}
>
{dayjs(item.createdAt).format('MM-DD HH:mm')}
</div>
<div>{item.content}</div>
</div>
)}
</List.Item>
);
})}
</div>
</div>
</List>
</Card>
</div>
);
});
AuditLog.displayName = 'AuditLog';

View File

@ -6,6 +6,7 @@ import { WebsiteList } from '../../components/website/WebsiteList';
import { useEvent } from '../../hooks/useEvent'; import { useEvent } from '../../hooks/useEvent';
import { NotificationList } from './NotificationList'; import { NotificationList } from './NotificationList';
import { Profile } from './Profile'; import { Profile } from './Profile';
import { AuditLog } from './AuditLog';
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@ -16,6 +17,10 @@ const items: MenuProps['items'] = [
key: 'notifications', key: 'notifications',
label: 'Notifications', label: 'Notifications',
}, },
{
key: 'auditLog',
label: 'Audit Log',
},
{ {
key: 'profile', key: 'profile',
label: 'Profile', label: 'Profile',
@ -51,6 +56,7 @@ export const SettingsPage: React.FC = React.memo(() => {
<Route path="/websites" element={<WebsiteList />} /> <Route path="/websites" element={<WebsiteList />} />
<Route path="/website/:websiteId" element={<WebsiteInfo />} /> <Route path="/website/:websiteId" element={<WebsiteInfo />} />
<Route path="/notifications" element={<NotificationList />} /> <Route path="/notifications" element={<NotificationList />} />
<Route path="/auditLog" element={<AuditLog />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
</Routes> </Routes>
</div> </div>

View File

@ -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,
};
}),
});

View File

@ -6,6 +6,7 @@ import { userRouter } from './user';
import { workspaceRouter } from './workspace'; import { workspaceRouter } from './workspace';
import { globalRouter } from './global'; import { globalRouter } from './global';
import { serverStatusRouter } from './serverStatus'; import { serverStatusRouter } from './serverStatus';
import { auditLogRouter } from './auditLog';
export const appRouter = router({ export const appRouter = router({
global: globalRouter, global: globalRouter,
@ -15,6 +16,7 @@ export const appRouter = router({
notification: notificationRouter, notification: notificationRouter,
monitor: monitorRouter, monitor: monitorRouter,
serverStatus: serverStatusRouter, serverStatus: serverStatusRouter,
auditLog: auditLogRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -103,6 +103,7 @@ export const DEFAULT_RESET_DATE = '2000-01-01';
export enum OPENAPI_TAG { export enum OPENAPI_TAG {
GLOBAL = 'Global', GLOBAL = 'Global',
WORKSPACE = 'Workspace',
USER = 'User', USER = 'User',
WEBSITE = 'Website', WEBSITE = 'Website',
MONITOR = 'Monitor', MONITOR = 'Monitor',

View File

@ -151,3 +151,51 @@ export function getTimestampIntervalQuery(field: string) {
`floor(extract(epoch from max(${field}) - min(${field})))`, `floor(extract(epoch from max(${field}) - min(${field})))`,
]); ]);
} }
type ExtractFindManyReturnType<T> = T extends (
args?: any
) => Prisma.PrismaPromise<infer R>
? R
: never;
export async function fetchDataByCursor<
Model extends {
findMany: (args?: any) => Prisma.PrismaPromise<any>;
},
CursorType
>(
fetchModel: Model,
options: {
where: Record<string, string>;
limit: number;
cursor: CursorType;
cursorName?: string;
order?: 'asc' | 'desc';
}
) {
const { where, limit, cursor, cursorName = 'id', order = 'desc' } = options;
const items: ExtractFindManyReturnType<Model['findMany']> =
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,
};
}