feat: add audit log
This commit is contained in:
parent
bf1604d9ec
commit
d912c788c5
@ -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==}
|
||||
|
||||
|
@ -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",
|
||||
|
112
src/client/pages/Settings/AuditLog.tsx
Normal file
112
src/client/pages/Settings/AuditLog.tsx
Normal 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';
|
@ -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(() => {
|
||||
<Route path="/websites" element={<WebsiteList />} />
|
||||
<Route path="/website/:websiteId" element={<WebsiteInfo />} />
|
||||
<Route path="/notifications" element={<NotificationList />} />
|
||||
<Route path="/auditLog" element={<AuditLog />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
49
src/server/trpc/routers/auditLog.ts
Normal file
49
src/server/trpc/routers/auditLog.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -151,3 +151,51 @@ export function getTimestampIntervalQuery(field: string) {
|
||||
`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,
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user