feat: add audit log
This commit is contained in:
parent
bf1604d9ec
commit
d912c788c5
@ -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==}
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
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 { 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>
|
||||||
|
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 { 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;
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user