From e2529049b78338a9cd8e857d0cca1f95033fa26e Mon Sep 17 00:00:00 2001 From: actiontech-zihan Date: Fri, 19 Jun 2026 20:03:51 +0800 Subject: [PATCH] Add SQL audit rule exception review support --- .../sqle/service/audit_whitelist/index.d.ts | 47 +- .../api/sqle/service/audit_whitelist/index.ts | 75 ++- .../shared/lib/api/sqle/service/common.d.ts | 66 ++ .../lib/api/sqle/service/instance/index.d.ts | 5 + .../lib/api/sqle/service/instance/index.ts | 17 + .../ReportDrawer/RuleExceptionDrawer.tsx | 186 ++++++ .../__snapshots__/index.test.tsx.snap | 608 ++++++++++++++---- .../ReportDrawer/__tests__/index.test.tsx | 61 +- .../src/components/ReportDrawer/index.tsx | 178 ++++- .../src/components/ReportDrawer/index.type.ts | 34 +- .../sqle/src/components/ReportDrawer/style.ts | 35 +- .../sqle/src/locale/en-US/operationRecord.ts | 1 + packages/sqle/src/locale/en-US/whitelist.ts | 35 + .../sqle/src/locale/zh-CN/operationRecord.ts | 1 + packages/sqle/src/locale/zh-CN/whitelist.ts | 33 + .../src/page/OperationRecord/List/index.tsx | 53 +- .../src/page/OperationRecord/index.test.tsx | 10 +- .../sqle/src/page/OperationRecord/index.tsx | 14 +- .../Table/AuditResultDrawer.tsx | 22 + .../Common/AuditResultList/Table/index.tsx | 11 +- .../AuditResultList/Table/index.type.ts | 9 + .../Common/AuditResultList/index.tsx | 19 +- .../Common/AuditResultList/index.type.ts | 1 + .../List/__snapshots__/index.test.tsx.snap | 37 ++ .../sqle/src/page/Whitelist/List/columns.tsx | 138 +++- .../src/page/Whitelist/List/index.test.tsx | 69 ++ .../sqle/src/page/Whitelist/List/index.tsx | 324 ++++++++-- .../__snapshots__/index.test.tsx.snap | 37 ++ .../sqle/src/page/Whitelist/index.test.tsx | 13 + packages/sqle/src/page/Whitelist/index.tsx | 24 +- .../testUtils/mockApi/auditWhiteList/data.ts | 23 +- .../testUtils/mockApi/auditWhiteList/index.ts | 20 +- .../src/testUtils/mockApi/instance/index.ts | 12 + 33 files changed, 1993 insertions(+), 225 deletions(-) create mode 100644 packages/sqle/src/components/ReportDrawer/RuleExceptionDrawer.tsx diff --git a/packages/shared/lib/api/sqle/service/audit_whitelist/index.d.ts b/packages/shared/lib/api/sqle/service/audit_whitelist/index.d.ts index 732f28310..6e6e8fc96 100644 --- a/packages/shared/lib/api/sqle/service/audit_whitelist/index.d.ts +++ b/packages/shared/lib/api/sqle/service/audit_whitelist/index.d.ts @@ -2,7 +2,10 @@ import { IGetAuditWhitelistResV1, ICreateAuditWhitelistReqV1, IBaseRes, - IUpdateAuditWhitelistReqV1 + IUpdateAuditWhitelistReqV1, + ISQLRuleExceptionResV1, + ICreateSQLRuleExceptionReqV1, + IGetSQLRuleExceptionResV1 } from '../common.d'; export interface IGetAuditWhitelistV1Params { @@ -42,3 +45,45 @@ export interface IUpdateAuditWhitelistByIdV1Params } export interface IUpdateAuditWhitelistByIdV1Return extends IBaseRes {} + +export interface ICreateSQLRuleExceptionV1Params + extends ICreateSQLRuleExceptionReqV1 { + project_name: string; +} + +export interface ICreateSQLRuleExceptionV1Return extends IBaseRes { + data?: ISQLRuleExceptionResV1; +} + +export interface IGetSQLRuleExceptionV1Params { + project_name: string; + + fuzzy_search_value?: string; + + filter_instance_id?: string; + + filter_rule_name?: string; + + filter_created_by?: string; + + filter_created_time_from?: string; + + filter_created_time_to?: string; + + filter_sql_fingerprint?: string; + + page_index: string; + + page_size: string; +} + +export interface IGetSQLRuleExceptionV1Return + extends IGetSQLRuleExceptionResV1 {} + +export interface IDeleteSQLRuleExceptionV1Params { + project_name: string; + + sql_rule_exception_id: string; +} + +export interface IDeleteSQLRuleExceptionV1Return extends IBaseRes {} diff --git a/packages/shared/lib/api/sqle/service/audit_whitelist/index.ts b/packages/shared/lib/api/sqle/service/audit_whitelist/index.ts index df283dea6..475ed74d9 100644 --- a/packages/shared/lib/api/sqle/service/audit_whitelist/index.ts +++ b/packages/shared/lib/api/sqle/service/audit_whitelist/index.ts @@ -14,7 +14,13 @@ import { IDeleteAuditWhitelistByIdV1Params, IDeleteAuditWhitelistByIdV1Return, IUpdateAuditWhitelistByIdV1Params, - IUpdateAuditWhitelistByIdV1Return + IUpdateAuditWhitelistByIdV1Return, + ICreateSQLRuleExceptionV1Params, + ICreateSQLRuleExceptionV1Return, + IDeleteSQLRuleExceptionV1Params, + IDeleteSQLRuleExceptionV1Return, + IGetSQLRuleExceptionV1Params, + IGetSQLRuleExceptionV1Return } from './index.d'; class AuditWhitelistService extends ServiceBase { @@ -48,6 +54,73 @@ class AuditWhitelistService extends ServiceBase { ); } + public createSQLRuleExceptionV1( + params: ICreateSQLRuleExceptionV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + const project_name = paramsData.project_name; + delete paramsData.project_name; + + const config = options || {}; + const headers = config.headers ? config.headers : {}; + config.headers = { + ...headers, + 'Content-Type': 'application/json' + }; + config.transformRequest = [ + (data) => + `{"instance_id":${data.instance_id},"sql_fingerprint":${JSON.stringify( + data.sql_fingerprint ?? '' + )},"rule_name":${JSON.stringify( + data.rule_name ?? '' + )},"rule_desc":${JSON.stringify( + data.rule_desc ?? '' + )},"rule_level":${JSON.stringify( + data.rule_level ?? '' + )},"reason":${JSON.stringify(data.reason ?? '')}}` + ]; + + return this.post( + `/v1/projects/${project_name}/audit_whitelist/rule_exceptions`, + paramsData, + config + ); + } + + public getSQLRuleExceptionV1( + params: IGetSQLRuleExceptionV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + const project_name = paramsData.project_name; + delete paramsData.project_name; + + return this.get( + `/v1/projects/${project_name}/audit_whitelist/rule_exceptions`, + paramsData, + options + ); + } + + public deleteSQLRuleExceptionV1( + params: IDeleteSQLRuleExceptionV1Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + const project_name = paramsData.project_name; + delete paramsData.project_name; + + const sql_rule_exception_id = paramsData.sql_rule_exception_id; + delete paramsData.sql_rule_exception_id; + + return this.delete( + `/v1/projects/${project_name}/audit_whitelist/rule_exceptions/${sql_rule_exception_id}`, + paramsData, + options + ); + } + public deleteAuditWhitelistByIdV1( params: IDeleteAuditWhitelistByIdV1Params, options?: AxiosRequestConfig diff --git a/packages/shared/lib/api/sqle/service/common.d.ts b/packages/shared/lib/api/sqle/service/common.d.ts index f43283583..7f62d4664 100644 --- a/packages/shared/lib/api/sqle/service/common.d.ts +++ b/packages/shared/lib/api/sqle/service/common.d.ts @@ -529,6 +529,66 @@ export interface ICreateAuditWhitelistReqV1 { value?: string; } +export interface ICreateSQLRuleExceptionReqV1 { + instance_id?: string; + + sql_fingerprint?: string; + + rule_name?: string; + + rule_desc?: string; + + rule_level?: string; + + reason?: string; +} + +export interface ISQLRuleExceptionResV1 { + sql_rule_exception_id?: number; + + project_name?: string; + + project_id?: string; + + instance_id?: string; + + instance_name?: string; + + sql_fingerprint?: string; + + rule_name?: string; + + rule_desc?: string; + + rule_level?: string; + + reason?: string; + + created_by?: string; + + created_at?: string; + + hit_count?: number; + + last_match_time?: string; + + matched_count?: number; + + match_info?: string; + + hit_info?: string; +} + +export interface IGetSQLRuleExceptionResV1 { + code?: number; + + data?: ISQLRuleExceptionResV1[]; + + message?: string; + + total_nums?: number; +} + export interface ICreateBlacklistReqV1 { content?: string; @@ -3126,6 +3186,12 @@ export interface IAuditTaskSQLResV2 { audit_result?: IAuditResult[]; + skipped_audit_result?: (IAuditResult & Partial)[]; + + sql_fingerprint?: string; + + audit_fingerprint?: string; + audit_status?: string; description?: string; diff --git a/packages/shared/lib/api/sqle/service/instance/index.d.ts b/packages/shared/lib/api/sqle/service/instance/index.d.ts index 633536be3..fdd95838c 100644 --- a/packages/shared/lib/api/sqle/service/instance/index.d.ts +++ b/packages/shared/lib/api/sqle/service/instance/index.d.ts @@ -26,6 +26,11 @@ export interface IGetInstanceTipListV1Params { export interface IGetInstanceTipListV1Return extends IGetInstanceTipsResV1 {} +export interface IGetInstanceTipListV2Params + extends IGetInstanceTipListV1Params {} + +export interface IGetInstanceTipListV2Return extends IGetInstanceTipsResV1 {} + export interface IBatchCheckInstanceIsConnectableByNameParams extends IBatchCheckInstanceConnectionsReqV1 { project_name: string; diff --git a/packages/shared/lib/api/sqle/service/instance/index.ts b/packages/shared/lib/api/sqle/service/instance/index.ts index 5a1098780..e25a927b5 100644 --- a/packages/shared/lib/api/sqle/service/instance/index.ts +++ b/packages/shared/lib/api/sqle/service/instance/index.ts @@ -9,6 +9,8 @@ import { AxiosRequestConfig } from 'axios'; import { IGetInstanceTipListV1Params, IGetInstanceTipListV1Return, + IGetInstanceTipListV2Params, + IGetInstanceTipListV2Return, IBatchCheckInstanceIsConnectableByNameParams, IBatchCheckInstanceIsConnectableByNameReturn, ICheckInstanceIsConnectableByNameV1Params, @@ -41,6 +43,21 @@ class InstanceService extends ServiceBase { ); } + public getInstanceTipListV2( + params: IGetInstanceTipListV2Params, + options?: AxiosRequestConfig + ) { + const paramsData = this.cloneDeep(params); + const project_name = paramsData.project_name; + delete paramsData.project_name; + + return this.get( + `/v2/projects/${project_name}/instance_tips`, + paramsData, + options + ); + } + public batchCheckInstanceIsConnectableByName( params: IBatchCheckInstanceIsConnectableByNameParams, options?: AxiosRequestConfig diff --git a/packages/sqle/src/components/ReportDrawer/RuleExceptionDrawer.tsx b/packages/sqle/src/components/ReportDrawer/RuleExceptionDrawer.tsx new file mode 100644 index 000000000..6ac9fbb84 --- /dev/null +++ b/packages/sqle/src/components/ReportDrawer/RuleExceptionDrawer.tsx @@ -0,0 +1,186 @@ +import { BasicButton, BasicDrawer, BasicInput } from '@actiontech/shared'; +import { DrawerFormLayout } from '@actiontech/shared/lib/data/common'; +import { ResponseCode } from '@actiontech/shared/lib/enum'; +import audit_whitelist from '@actiontech/shared/lib/api/sqle/service/audit_whitelist'; +import instance from '@actiontech/shared/lib/api/sqle/service/instance'; +import { getInstanceTipListV1FunctionalModuleEnum } from '@actiontech/shared/lib/api/sqle/service/instance/index.enum'; +import DBService from '@actiontech/shared/lib/api/base/service/DBService'; +import { ListDBServiceTipsFunctionalModuleEnum } from '@actiontech/shared/lib/api/base/service/DBService/index.enum'; +import { Form, Space, message } from 'antd'; +import { useRequest } from 'ahooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + RuleExceptionDrawerProps, + RuleExceptionFormFields +} from './index.type'; + +const DUPLICATE_RULE_EXCEPTION_CODE = 4010; + +type RuleExceptionInstanceTip = { + instance_id?: string; + instance_name?: string; +}; + +const RuleExceptionDrawer: React.FC = ({ + open, + data, + context, + onClose, + onCreated +}) => { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [messageApi, messageContextHolder] = message.useMessage(); + + const { data: instanceTips, loading: instanceTipsLoading } = useRequest( + () => + context?.projectID + ? DBService.ListDBServiceTips({ + project_uid: context.projectID, + filter_db_type: context?.dbType, + functional_module: + ListDBServiceTipsFunctionalModuleEnum.create_workflow + }).then((res) => + (res.data.data ?? []).map((item) => ({ + instance_id: item.id, + instance_name: item.name + })) + ) + : instance + .getInstanceTipListV2({ + project_name: context?.projectName ?? '', + filter_db_type: context?.dbType, + functional_module: + getInstanceTipListV1FunctionalModuleEnum.create_workflow + }) + .then((res) => { + if (res.data.code === ResponseCode.SUCCESS) { + return res.data.data ?? []; + } + return []; + }) + .catch(() => []), + { + ready: open && !!context?.projectName && !context?.instanceId + } + ); + + const resolvedInstanceId = useMemo(() => { + if (context?.instanceId) { + return context.instanceId; + } + const matchedInstance = instanceTips?.find( + (item) => item.instance_name === context?.instanceName + ); + return matchedInstance?.instance_id; + }, [context?.instanceId, context?.instanceName, instanceTips]); + + const lackRequiredContext = + !context?.projectName || + !resolvedInstanceId || + !context?.sqlFingerprint || + !data?.rule_name; + + const { run: submit, loading: submitLoading } = useRequest( + () => form.validateFields(), + { + manual: true, + onSuccess(values) { + audit_whitelist + .createSQLRuleExceptionV1({ + project_name: context!.projectName, + instance_id: `${resolvedInstanceId}`, + sql_fingerprint: context!.sqlFingerprint, + rule_name: data!.rule_name, + rule_desc: data?.desc ?? data?.annotation ?? data?.message, + rule_level: data?.level, + reason: values.reason + }) + .then((res) => { + if (res.data.code === ResponseCode.SUCCESS) { + messageApi.success(t('whitelist.ruleException.addSuccess')); + form.resetFields(); + onCreated?.(); + onClose(); + } else if (res.data.code === DUPLICATE_RULE_EXCEPTION_CODE) { + messageApi.warning(t('whitelist.ruleException.duplicateTips')); + onClose(); + } + }); + } + } + ); + + return ( + <> + {messageContextHolder} + + + {t('common.close')} + + + {t('common.submit')} + + + } + > +
+ + + + + + + + + + + + + + + + + + + + + +
+
+ + ); +}; + +export default RuleExceptionDrawer; diff --git a/packages/sqle/src/components/ReportDrawer/__tests__/__snapshots__/index.test.tsx.snap b/packages/sqle/src/components/ReportDrawer/__tests__/__snapshots__/index.test.tsx.snap index 1144cef94..f59777cd7 100644 --- a/packages/sqle/src/components/ReportDrawer/__tests__/__snapshots__/index.test.tsx.snap +++ b/packages/sqle/src/components/ReportDrawer/__tests__/__snapshots__/index.test.tsx.snap @@ -69,7 +69,7 @@ exports[`sqle/components/ReportDrawer render snap is empty 1`] = `
+
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + + +
+
+ + 暂无已设例外的规则 + +
+ +
+
- - - message - + + + message + +
+
+ annotation +
+
+
+
+
+
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + +
- annotation - - 查看更多 - + 暂无已设例外的规则 +
+
@@ -442,7 +574,7 @@ exports[`sqle/components/ReportDrawer render snap when has delete rule 1`] = `
- - 该规则已删除 -
- - message1 + 该规则已删除 +
+ + + message1 + +
- - - message2 - + + + message2 + +
+
+ annotation +
+
+
+
+ +
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + +
- annotation - - 查看更多 - + 暂无已设例外的规则 +
+
@@ -720,7 +920,7 @@ exports[`sqle/components/ReportDrawer render snap when has extra 1`] = `
- - - message - + + + message + +
+
+ annotation +
+
+
+
+ +
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + +
- annotation - - 查看更多 - + 暂无已设例外的规则 +
+
@@ -906,7 +1170,7 @@ exports[`sqle/components/ReportDrawer render snap when loading is true 1`] = `
- - - message - + + + message + +
+
+ annotation +
+
+
+
+ +
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + +
- annotation - - 查看更多 - + 暂无已设例外的规则 +
+
@@ -1156,7 +1484,7 @@ exports[`sqle/components/ReportDrawer render snap when showSourceFile is true 1`
- - - message - + + + message + +
+
+ annotation +
+
+
+
+ +
+

+ 已设例外的规则 +

+
+
+
+ + + + + + + + + + + +
- annotation - - 查看更多 - + 暂无已设例外的规则 +
+
diff --git a/packages/sqle/src/components/ReportDrawer/__tests__/index.test.tsx b/packages/sqle/src/components/ReportDrawer/__tests__/index.test.tsx index 5c25010db..22a6b2570 100644 --- a/packages/sqle/src/components/ReportDrawer/__tests__/index.test.tsx +++ b/packages/sqle/src/components/ReportDrawer/__tests__/index.test.tsx @@ -2,7 +2,7 @@ import ReportDrawer from '..'; import { renderWithTheme } from '../../../testUtils/customRender'; import { DetailReportDrawerProps } from '../index.type'; -import { cleanup } from '@testing-library/react'; +import { cleanup, screen } from '@testing-library/react'; import { mockUseCurrentUser } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentUser'; describe('sqle/components/ReportDrawer', () => { @@ -174,4 +174,63 @@ describe('sqle/components/ReportDrawer', () => { }); expect(baseElement).toMatchSnapshot(); }); + + it('should hide rule exception entry without create permission', () => { + customRender({ + open: true, + title: 'this is a title', + showAnnotation: true, + data: { + sql: 'select 1', + auditResult: [ + { + rule_name: 'rule a', + message: 'message', + level: 'level', + annotation: 'annotation', + db_type: 'mysql' + } + ] + }, + ruleExceptionContext: { + projectName: 'default', + projectID: '700300', + instanceName: 'mysql_local_sqle', + sqlFingerprint: 'fp' + }, + onClose: jest.fn() + }); + + expect(screen.queryByText('添加为单规则例外')).not.toBeInTheDocument(); + }); + + it('should show rule exception entry with create permission', () => { + customRender({ + open: true, + title: 'this is a title', + showAnnotation: true, + data: { + sql: 'select 1', + auditResult: [ + { + rule_name: 'rule a', + message: 'message', + level: 'level', + annotation: 'annotation', + db_type: 'mysql' + } + ] + }, + ruleExceptionContext: { + projectName: 'default', + projectID: '700300', + instanceName: 'mysql_local_sqle', + sqlFingerprint: 'fp' + }, + canCreateRuleException: true, + onClose: jest.fn() + }); + + expect(screen.getByText('添加为单规则例外')).toBeInTheDocument(); + }); }); diff --git a/packages/sqle/src/components/ReportDrawer/index.tsx b/packages/sqle/src/components/ReportDrawer/index.tsx index 927017dd0..f1daf572b 100644 --- a/packages/sqle/src/components/ReportDrawer/index.tsx +++ b/packages/sqle/src/components/ReportDrawer/index.tsx @@ -1,20 +1,23 @@ import { useTranslation } from 'react-i18next'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { BasicDrawer, BasicTag, EmptyBox, BasicToolTips, SQLRenderer, - BasicTypographyEllipsis + BasicTypographyEllipsis, + BasicButton } from '@actiontech/shared'; import { DetailReportDrawerProps, IAuditResultItem } from './index.type'; import { AuditReportStyleWrapper } from './style'; import AuditResultMessage from '../AuditResultMessage'; -import { Typography, Space } from 'antd'; +import { Typography, Space, Descriptions } from 'antd'; import { ProfileSquareFilled, EnvironmentFilled } from '@actiontech/icons'; import useThemeStyleData from '../../hooks/useThemeStyleData'; import { Spin } from 'antd'; +import RuleExceptionDrawer from './RuleExceptionDrawer'; +import { formatTime } from '@actiontech/shared/lib/utils/Common'; const ReportDrawer = ({ open, @@ -24,16 +27,25 @@ const ReportDrawer = ({ showAnnotation, showSourceFile, loading, - extra + extra, + ruleExceptionContext, + canCreateRuleException = false, + onRuleExceptionCreated }: DetailReportDrawerProps) => { const { t } = useTranslation(); const { sqleTheme } = useThemeStyleData(); + const [selectedRuleException, setSelectedRuleException] = + useState(); const closeModal = () => { onClose(); }; + const closeRuleExceptionDrawer = () => { + setSelectedRuleException(undefined); + }; + const resultDataIsEmpty = useMemo(() => { return ( (Array.isArray(data?.auditResult) && !data?.auditResult.length) || @@ -41,6 +53,23 @@ const ReportDrawer = ({ ); }, [data?.auditResult]); + const skippedAuditResult = useMemo( + () => data?.skippedAuditResult ?? [], + [data?.skippedAuditResult] + ); + + const openOperationRecord = () => { + if (!ruleExceptionContext?.projectName) { + return; + } + + window.open( + `/sqle/project/${ + ruleExceptionContext.projectID ?? ruleExceptionContext.projectName + }/operation-record` + ); + }; + return ( <> { if (!showAnnotation || item.isRuleDeleted) { return ( - + > + + ); } return ( - + > + + +
+ setSelectedRuleException(item)} + > + {t('whitelist.ruleException.addAction')} + +
+
+ ); } ) )} + +
+ + {t('whitelist.ruleException.skippedModule.title')} + +
+ {skippedAuditResult.map((item, index) => ( +
+ + + {item.rule_name || '-'} + + + {item.rule_level || item.level || '-'} + + + {item.rule_desc || + item.message || + item.rule_name || + '-'} + + + {item.created_by || '-'} + + + {formatTime(item.created_at, '-')} + + + {item.reason || '-'} + + + + {t('whitelist.ruleException.viewAudit')} + + + +
+ ))} +
+
+
@@ -185,6 +304,13 @@ const ReportDrawer = ({
+ ); }; diff --git a/packages/sqle/src/components/ReportDrawer/index.type.ts b/packages/sqle/src/components/ReportDrawer/index.type.ts index 8cc0fef08..1f445e588 100644 --- a/packages/sqle/src/components/ReportDrawer/index.type.ts +++ b/packages/sqle/src/components/ReportDrawer/index.type.ts @@ -1,13 +1,30 @@ -import { IAuditResult } from '@actiontech/shared/lib/api/sqle/service/common'; +import { + IAuditResult, + ISQLRuleExceptionResV1 +} from '@actiontech/shared/lib/api/sqle/service/common'; import { ReactNode } from 'react'; export type IAuditResultItem = IAuditResult & { isRuleDeleted?: boolean; annotation?: string; + desc?: string; +}; + +export type SkippedAuditResultItem = IAuditResult & + Partial; + +export type RuleExceptionContext = { + projectName: string; + projectID?: string; + instanceName?: string; + instanceId?: string; + dbType?: string; + sqlFingerprint?: string; }; export type TypeData = { auditResult: Array; + skippedAuditResult?: Array; sql: string; sqlSourceFile?: string; sqlStartLine?: number; @@ -23,4 +40,19 @@ export interface DetailReportDrawerProps { showSourceFile?: boolean; loading?: boolean; extra?: ReactNode; + ruleExceptionContext?: RuleExceptionContext; + canCreateRuleException?: boolean; + onRuleExceptionCreated?: () => void; } + +export type RuleExceptionFormFields = { + reason: string; +}; + +export type RuleExceptionDrawerProps = { + open: boolean; + data?: IAuditResultItem; + context?: RuleExceptionContext; + onClose: () => void; + onCreated?: () => void; +}; diff --git a/packages/sqle/src/components/ReportDrawer/style.ts b/packages/sqle/src/components/ReportDrawer/style.ts index d611bdc21..cc5856e37 100644 --- a/packages/sqle/src/components/ReportDrawer/style.ts +++ b/packages/sqle/src/components/ReportDrawer/style.ts @@ -5,7 +5,15 @@ export const AuditReportStyleWrapper = styled('div')` height: 100%; .wrapper-item { - height: 50%; + height: 34%; + + &.skipped-rule-wrapper { + height: 28%; + + .wrapper-cont { + height: calc(100% - 68px); + } + } h3 { margin-bottom: 0; @@ -28,10 +36,35 @@ export const AuditReportStyleWrapper = styled('div')` padding: 8px 12px; margin-bottom: 4px; + .rule-exception-action { + display: flex; + justify-content: flex-end; + margin-top: 8px; + } + &:last-child { margin-bottom: 0; } } + + .skipped-rule-item { + background: ${({ theme }) => + theme.sharedTheme.uiToken.colorFillTertiary}; + border: 1px solid + ${({ theme }) => theme.sharedTheme.uiToken.colorBorderSecondary}; + border-radius: 4px; + padding: 12px; + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + + .ant-descriptions-item-label, + .ant-descriptions-item-content { + padding-bottom: 8px; + } + } } .title-wrap { diff --git a/packages/sqle/src/locale/en-US/operationRecord.ts b/packages/sqle/src/locale/en-US/operationRecord.ts index b4b630080..10a933333 100644 --- a/packages/sqle/src/locale/en-US/operationRecord.ts +++ b/packages/sqle/src/locale/en-US/operationRecord.ts @@ -7,6 +7,7 @@ export default { exportButtonText: 'Export', exporting: 'Exporting operation log list...', exportSuccessTips: 'Operation log list exported successfully', + auditContentKeywordsTips: 'Located by audit content: {{keywords}}', filterForm: { operatingTime: 'Operation time', projectName: 'Project name', diff --git a/packages/sqle/src/locale/en-US/whitelist.ts b/packages/sqle/src/locale/en-US/whitelist.ts index 9469d1139..56c9d9a2f 100644 --- a/packages/sqle/src/locale/en-US/whitelist.ts +++ b/packages/sqle/src/locale/en-US/whitelist.ts @@ -7,6 +7,10 @@ export default { ceTips: "If a user enables a rule but wants to temporarily bypass the triggering of certain rules in actual use, they can enable the platform'S audit whitelist feature.\nCurrently, it supports string matching or SQL fingerprint matching. Statements added to the SQL audit whitelist will not be subject to audit rules when creating a workflow request.", allWhitelist: 'All audit whitelist statements', + view: { + sql: 'Whole SQL exception', + rule: 'Rule exception' + }, table: { sql: 'SQL statement', desc: 'Audit whitelist description', @@ -35,5 +39,36 @@ export default { title: 'Update audit whitelist' }, sql: 'SQL' + }, + ruleException: { + addAction: 'Add as rule exception', + drawerTitle: 'Add rule exception', + addSuccess: 'Rule exception added successfully', + duplicateTips: + 'This rule exception already exists. No need to add it again.', + cancelAction: 'Cancel exception', + deleting: 'Canceling rule exception...', + deleteSuccess: 'Rule exception canceled successfully', + confirmCancel: + 'Confirm canceling this rule exception? The same tuple will trigger this rule again after cancellation.', + project: 'Project', + instance: 'Data source', + sqlFingerprint: 'SQL fingerprint', + ruleName: 'Rule name', + ruleDesc: 'Rule description', + ruleLevel: 'Original rule level', + reason: 'Reason', + createdBy: 'Added by', + createdAt: 'Added at', + matchInfo: 'Hit information', + matchInfoWithTime: 'Hit {{count}} times, last hit {{time}}', + audit: 'Action', + viewAudit: 'View audit', + missingSqlFingerprint: + 'The current audit result does not return a SQL fingerprint, so rule exception cannot be added yet.', + skippedModule: { + title: 'Excepted rules', + empty: 'No excepted rules in this audit result' + } } }; diff --git a/packages/sqle/src/locale/zh-CN/operationRecord.ts b/packages/sqle/src/locale/zh-CN/operationRecord.ts index d042f26e5..ea2e9b497 100644 --- a/packages/sqle/src/locale/zh-CN/operationRecord.ts +++ b/packages/sqle/src/locale/zh-CN/operationRecord.ts @@ -7,6 +7,7 @@ export default { exportButtonText: '导出', exporting: '正在导出操作记录列表...', exportSuccessTips: '操作记录列表导出成功', + auditContentKeywordsTips: '已按审计内容定位:{{keywords}}', filterForm: { operatingTime: '操作时间', projectName: '操作项目', diff --git a/packages/sqle/src/locale/zh-CN/whitelist.ts b/packages/sqle/src/locale/zh-CN/whitelist.ts index 401ccd8a3..30fa8f73b 100644 --- a/packages/sqle/src/locale/zh-CN/whitelist.ts +++ b/packages/sqle/src/locale/zh-CN/whitelist.ts @@ -7,6 +7,10 @@ export default { ceTips: '如果用户开启了某条规则,但在实际使用中又想临时规避某些规则的触发,可以启用平台的审核SQL例外功能。\n目前支持按字符串匹配或按照SQL指纹匹配,添加在SQL审核审核SQL例外中的语句,在提交工单申请时,将不受审核规则的约束。', allWhitelist: '所有审核SQL例外语句', + view: { + sql: '整条 SQL 例外', + rule: '单规则例外' + }, table: { sql: '内容', desc: '描述', @@ -39,5 +43,34 @@ export default { tips: '当修改匹配类型或匹配内容后,该条记录的匹配次数和最后匹配时间将被重置。' }, sql: 'SQL语句' + }, + ruleException: { + addAction: '添加为单规则例外', + drawerTitle: '添加单规则例外', + addSuccess: '添加单规则例外成功', + duplicateTips: '该规则例外已存在,无需重复添加', + cancelAction: '取消例外', + deleting: '正在取消单规则例外...', + deleteSuccess: '取消单规则例外成功', + confirmCancel: + '确认取消这条单规则例外么?取消后同四元组复审将重新触发该规则。', + project: '项目', + instance: '数据源', + sqlFingerprint: 'SQL指纹', + ruleName: '规则名', + ruleDesc: '规则描述', + ruleLevel: '规则原级别', + reason: '添加原因', + createdBy: '添加例外的人', + createdAt: '添加时间', + matchInfo: '命中信息', + matchInfoWithTime: '命中 {{count}} 次,最近命中 {{time}}', + audit: '操作', + viewAudit: '查看审计', + missingSqlFingerprint: '当前审核结果未返回 SQL 指纹,暂不能添加单规则例外', + skippedModule: { + title: '已设例外的规则', + empty: '暂无已设例外的规则' + } } }; diff --git a/packages/sqle/src/page/OperationRecord/List/index.tsx b/packages/sqle/src/page/OperationRecord/List/index.tsx index ac3851818..026981228 100644 --- a/packages/sqle/src/page/OperationRecord/List/index.tsx +++ b/packages/sqle/src/page/OperationRecord/List/index.tsx @@ -1,5 +1,6 @@ import { useMemo, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { Space, message } from 'antd'; import { useRequest, useBoolean } from 'ahooks'; import { BasicButton, PageHeader } from '@actiontech/shared'; @@ -30,6 +31,7 @@ import { DownArrowLineOutlined } from '@actiontech/icons'; const OperationRecordList: React.FC = () => { const { t } = useTranslation(); + const [searchParams] = useSearchParams(); const [messageApi, contextHolder] = message.useMessage(); @@ -49,6 +51,25 @@ const OperationRecordList: React.FC = () => { const { updateOperationActions, operationActionOptions } = useOperationActions(); + const defaultFilterInfo = useMemo(() => { + const filterOperateTypeName = searchParams.get('filter_operate_type_name'); + + if (!filterOperateTypeName) { + return {}; + } + + return { + filter_operate_type_name: filterOperateTypeName + }; + }, [searchParams]); + + const auditContentKeywords = useMemo( + () => + searchParams.get('audit_content_keywords')?.split('|').filter(Boolean) ?? + [], + [searchParams] + ); + const { tableFilterInfo, updateTableFilterInfo, @@ -60,7 +81,9 @@ const OperationRecordList: React.FC = () => { } = useTableRequestParams< IOperationRecordList, OperationRecordListFilterParamType - >(); + >({ + defaultFilterInfo + }); const { requestErrorMessage, handleTableRequestError } = useTableRequestError(); @@ -73,16 +96,33 @@ const OperationRecordList: React.FC = () => { () => { const params: IGetOperationRecordListV1Params = { ...pagination, + page_size: auditContentKeywords.length > 0 ? 100 : pagination.page_size, ...tableFilterInfo, filter_operate_project_name: projectName, fuzzy_search_operate_user_name: searchKeyword }; return handleTableRequestError( operationRecord.getOperationRecordListV1(params) - ); + ).then((res) => { + if (auditContentKeywords.length === 0) { + return res; + } + + const filteredData = res.list?.filter((item) => { + return auditContentKeywords.every((keyword) => + item.operation_content?.includes(keyword) + ); + }); + + return { + ...res, + list: filteredData, + total: filteredData?.length ?? 0 + }; + }); }, { - refreshDeps: [pagination, tableFilterInfo] + refreshDeps: [pagination, tableFilterInfo, auditContentKeywords] } ); @@ -168,6 +208,13 @@ const OperationRecordList: React.FC = () => { } /> + {auditContentKeywords.length > 0 && ( +
+ {t('operationRecord.list.auditContentKeywordsTips', { + keywords: auditContentKeywords.join(' / ') + })} +
+ )} { }); test('should render operation record list', async () => { - const { baseElement } = renderWithReduxAndTheme(); + const { baseElement } = renderWithReduxAndTheme( + + + + ); expect(baseElement).toMatchSnapshot(); expect(operationRecordListSpy).toHaveBeenCalledTimes(1); expect(screen.getByText('导出')).toBeInTheDocument(); + expect( + screen.queryByText('操作记录列表为企业版功能') + ).not.toBeInTheDocument(); }); }); diff --git a/packages/sqle/src/page/OperationRecord/index.tsx b/packages/sqle/src/page/OperationRecord/index.tsx index 8c9570555..5779ac997 100644 --- a/packages/sqle/src/page/OperationRecord/index.tsx +++ b/packages/sqle/src/page/OperationRecord/index.tsx @@ -1,6 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { EnterpriseFeatureDisplay, PageHeader } from '@actiontech/shared'; -import { Typography } from 'antd'; +import { PageHeader } from '@actiontech/shared'; import OperationRecordList from './List'; const OperationRecord = () => { @@ -12,16 +11,7 @@ const OperationRecord = () => { {/* #endif */} - - {t('operationRecord.ceTips')} - - } - > - - + ); }; diff --git a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/AuditResultDrawer.tsx b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/AuditResultDrawer.tsx index fc1b64fc7..d833d1286 100644 --- a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/AuditResultDrawer.tsx +++ b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/AuditResultDrawer.tsx @@ -10,6 +10,11 @@ const AuditResultDrawer: React.FC = ({ open, auditResultRecord, dbType, + projectID, + projectName, + instanceName, + canCreateRuleException, + onRuleExceptionCreated, clickAnalyze }) => { const { t } = useTranslation(); @@ -24,6 +29,7 @@ const AuditResultDrawer: React.FC = ({ onClose={onClose} data={{ auditResult: auditResultRuleInfo, + skippedAuditResult: auditResultRecord?.skipped_audit_result, sql: auditResultRecord?.exec_sql ?? '', sqlSourceFile: auditResultRecord?.sql_source_file ?? '', sqlStartLine: auditResultRecord?.sql_start_line, @@ -39,6 +45,22 @@ const AuditResultDrawer: React.FC = ({ } showAnnotation loading={loading} + ruleExceptionContext={ + projectName && canCreateRuleException + ? { + projectName, + projectID, + instanceName, + dbType, + sqlFingerprint: + auditResultRecord?.sql_fingerprint ?? + auditResultRecord?.audit_fingerprint ?? + auditResultRecord?.exec_sql + } + : undefined + } + canCreateRuleException={canCreateRuleException} + onRuleExceptionCreated={onRuleExceptionCreated} extra={ clickAnalyze(auditResultRecord?.number)}> {t('execWorkflow.audit.table.analyze')} diff --git a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.tsx b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.tsx index 0557bc3e5..be1ec6a7f 100644 --- a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.tsx +++ b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.tsx @@ -22,8 +22,12 @@ const AuditResultTable: React.FC = ({ taskID, auditLevelFilterValue, projectID, + projectName, updateTaskRecordCount, - dbType + dbType, + instanceName, + canCreateRuleException, + onRuleExceptionCreated }) => { const [currentAuditResultRecord, setCurrentAuditResultRecord] = useState(); @@ -144,6 +148,11 @@ const AuditResultTable: React.FC = ({ onClose={closeAuditResultDrawer} auditResultRecord={currentAuditResultRecord} dbType={dbType} + projectID={projectID} + projectName={projectName} + instanceName={instanceName} + canCreateRuleException={canCreateRuleException} + onRuleExceptionCreated={onRuleExceptionCreated ?? refresh} clickAnalyze={handleClickAnalyze} /> diff --git a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.type.ts b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.type.ts index 2f2d06a4a..7ec48b0fd 100644 --- a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.type.ts +++ b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/Table/index.type.ts @@ -8,6 +8,10 @@ export type AuditResultTableProps = { projectID: string; updateTaskRecordCount?: (taskId: string, sqlNumber: number) => void; dbType?: string; + projectName?: string; + instanceName?: string; + canCreateRuleException?: boolean; + onRuleExceptionCreated?: () => void; }; export type AuditResultDrawerProps = { @@ -15,5 +19,10 @@ export type AuditResultDrawerProps = { onClose: () => void; auditResultRecord?: IAuditTaskSQLResV2; dbType?: string; + projectID?: string; + projectName?: string; + instanceName?: string; + canCreateRuleException?: boolean; + onRuleExceptionCreated?: () => void; clickAnalyze: (sqlNum?: number) => void; }; diff --git a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.tsx b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.tsx index 3f5b8168e..a18222129 100644 --- a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.tsx +++ b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.tsx @@ -12,7 +12,10 @@ import DownloadRecord from '../DownloadRecord'; import AuditResultTable from './Table'; import AuditResultFilterContainer from '../AuditResultFilterContainer'; import { AuditTaskResV1AuditLevelEnum } from '@actiontech/shared/lib/api/sqle/service/common.enum'; -import { useCurrentProject } from '@actiontech/shared/lib/global'; +import { + useCurrentProject, + useCurrentUser +} from '@actiontech/shared/lib/global'; import useAuditResultFilterParams from '../AuditResultFilterContainer/useAuditResultFilterParams'; import { auditLevelDictionary, @@ -22,10 +25,12 @@ import { const AuditResultList: React.FC = ({ tasks, updateTaskRecordCount, - showTaskTab = true + showTaskTab = true, + onRuleExceptionCreated }) => { const { t } = useTranslation(); - const { projectID } = useCurrentProject(); + const { projectID, projectName, projectArchive } = useCurrentProject(); + const { isAdmin, isProjectManager } = useCurrentUser(); const { noDuplicate, setNoDuplicate, @@ -40,6 +45,10 @@ const AuditResultList: React.FC = ({ [currentTaskID, tasks] ); + const canCreateRuleException = useMemo(() => { + return (isAdmin || isProjectManager(projectName)) && !projectArchive; + }, [isAdmin, isProjectManager, projectName, projectArchive]); + const handleChangeCurrentTask = (taskID?: string) => { setCurrentTaskID(taskID); }; @@ -123,8 +132,12 @@ const AuditResultList: React.FC = ({ noDuplicate={noDuplicate} auditLevelFilterValue={auditLevelFilterValue} projectID={projectID} + projectName={projectName} updateTaskRecordCount={updateTaskRecordCount} dbType={currentTask?.instance_db_type} + instanceName={currentTask?.instance_name} + canCreateRuleException={canCreateRuleException} + onRuleExceptionCreated={onRuleExceptionCreated} /> ); diff --git a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.type.ts b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.type.ts index bedf27eb8..38a898d69 100644 --- a/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.type.ts +++ b/packages/sqle/src/page/SqlExecWorkflow/Common/AuditResultList/index.type.ts @@ -4,4 +4,5 @@ export type AuditResultListProps = { tasks: IAuditTaskResV1[]; updateTaskRecordCount?: (taskId: string, sqlNumber: number) => void; showTaskTab?: boolean; + onRuleExceptionCreated?: () => void; }; diff --git a/packages/sqle/src/page/Whitelist/List/__snapshots__/index.test.tsx.snap b/packages/sqle/src/page/Whitelist/List/__snapshots__/index.test.tsx.snap index 6c4af3c67..3f011f23f 100644 --- a/packages/sqle/src/page/Whitelist/List/__snapshots__/index.test.tsx.snap +++ b/packages/sqle/src/page/Whitelist/List/__snapshots__/index.test.tsx.snap @@ -43,6 +43,43 @@ exports[`slqe/Whitelist/WhitelistList should render whitelist list 1`] = ` +
+
+ + +
+
diff --git a/packages/sqle/src/page/Whitelist/List/columns.tsx b/packages/sqle/src/page/Whitelist/List/columns.tsx index 6149a095d..3ac76970a 100644 --- a/packages/sqle/src/page/Whitelist/List/columns.tsx +++ b/packages/sqle/src/page/Whitelist/List/columns.tsx @@ -4,10 +4,16 @@ import { PageInfoWithoutIndexAndSize } from '@actiontech/shared/lib/components/ActiontechTable'; import { WhitelistMatchTypeLabel } from '../index.data'; -import { IAuditWhitelistResV1 } from '@actiontech/shared/lib/api/sqle/service/common'; +import { + IAuditWhitelistResV1, + ISQLRuleExceptionResV1 +} from '@actiontech/shared/lib/api/sqle/service/common'; import { CreateAuditWhitelistReqV1MatchTypeEnum } from '@actiontech/shared/lib/api/sqle/service/common.enum'; import { SQLRenderer, BasicTypographyEllipsis } from '@actiontech/shared'; -import { IGetAuditWhitelistV1Params } from '@actiontech/shared/lib/api/sqle/service/audit_whitelist/index.d'; +import { + IGetAuditWhitelistV1Params, + IGetSQLRuleExceptionV1Params +} from '@actiontech/shared/lib/api/sqle/service/audit_whitelist/index.d'; import { formatTime } from '@actiontech/shared/lib/utils/Common'; export type WhitelistTableFilterParamType = PageInfoWithoutIndexAndSize< @@ -18,6 +24,14 @@ export type WhitelistTableFilterParamType = PageInfoWithoutIndexAndSize< 'project_name' >; +export type SQLRuleExceptionTableFilterParamType = PageInfoWithoutIndexAndSize< + IGetSQLRuleExceptionV1Params & { + page_index: number; + page_size: number; + }, + 'project_name' +>; + export const WhitelistColumn = (): ActiontechTableColumn< IAuditWhitelistResV1, WhitelistTableFilterParamType @@ -76,3 +90,123 @@ export const WhitelistColumn = (): ActiontechTableColumn< } ]; }; + +const renderEmptyValue = (value?: string | number) => { + if (value === 0) { + return value; + } + return value || '-'; +}; + +const getRuleExceptionMatchInfo = (record: ISQLRuleExceptionResV1) => { + return ( + record.match_info ?? + record.hit_info ?? + record.matched_count ?? + record.hit_count ?? + record.last_match_time + ); +}; + +export const SQLRuleExceptionColumn = (): ActiontechTableColumn< + ISQLRuleExceptionResV1, + SQLRuleExceptionTableFilterParamType +> => { + return [ + { + dataIndex: 'project_name', + title: () => t('whitelist.ruleException.project'), + render: (projectName, record) => { + return renderEmptyValue(projectName ?? record.project_id); + } + }, + { + dataIndex: 'instance_name', + title: () => t('whitelist.ruleException.instance'), + filterCustomType: 'select', + filterKey: 'filter_instance_id', + render: (instanceName, record) => { + return renderEmptyValue(instanceName ?? record.instance_id); + } + }, + { + dataIndex: 'sql_fingerprint', + title: () => t('whitelist.ruleException.sqlFingerprint'), + className: 'ellipsis-column-width', + filterCustomType: 'search-input', + filterKey: 'filter_sql_fingerprint', + render: (sqlFingerprint) => { + if (!!sqlFingerprint) { + return ( + + ); + } + return '-'; + } + }, + { + dataIndex: 'rule_name', + title: () => t('whitelist.ruleException.ruleName'), + className: 'ellipsis-column-width', + filterCustomType: 'input', + filterKey: 'filter_rule_name', + render: (ruleName) => { + return ruleName ? : '-'; + } + }, + { + dataIndex: 'rule_desc', + title: () => t('whitelist.ruleException.ruleDesc'), + className: 'ellipsis-column-width', + render: (ruleDesc) => { + return ruleDesc ? : '-'; + } + }, + { + dataIndex: 'rule_level', + title: () => t('whitelist.ruleException.ruleLevel'), + render: renderEmptyValue + }, + { + dataIndex: 'created_by', + title: () => t('whitelist.ruleException.createdBy'), + filterCustomType: 'select', + filterKey: 'filter_created_by', + render: renderEmptyValue + }, + { + dataIndex: 'created_at', + title: () => t('whitelist.ruleException.createdAt'), + filterCustomType: 'date-range', + filterKey: ['filter_created_time_from', 'filter_created_time_to'], + render: (createdAt) => formatTime(createdAt, '-') + }, + { + dataIndex: 'reason', + title: () => t('whitelist.ruleException.reason'), + className: 'ellipsis-column-width', + render: (reason) => { + return reason ? : '-'; + } + }, + { + dataIndex: 'match_info', + title: () => t('whitelist.ruleException.matchInfo'), + render: (_, record) => { + const matchInfo = getRuleExceptionMatchInfo(record); + if (record.last_match_time) { + return t('whitelist.ruleException.matchInfoWithTime', { + count: record.matched_count ?? record.hit_count ?? '-', + time: formatTime(record.last_match_time, '-') + }); + } + return renderEmptyValue(matchInfo); + } + } + ]; +}; diff --git a/packages/sqle/src/page/Whitelist/List/index.test.tsx b/packages/sqle/src/page/Whitelist/List/index.test.tsx index ebf091d95..6a1d73bf5 100644 --- a/packages/sqle/src/page/Whitelist/List/index.test.tsx +++ b/packages/sqle/src/page/Whitelist/List/index.test.tsx @@ -9,10 +9,13 @@ import { ModalName } from '../../../data/ModalName'; import { mockUseCurrentProject } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentProject'; import { mockUseCurrentUser } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentUser'; import { createSpySuccessResponse } from '@actiontech/shared/lib/testUtil/mockApi'; +import instance from '../../../testUtils/mockApi/instance'; +import user from '../../../testUtils/mockApi/user'; import { mockProjectInfo, mockCurrentUserReturn } from '@actiontech/shared/lib/testUtil/mockHook/data'; +import { driverMeta } from '../../../hooks/useDatabaseType/index.test.data'; jest.mock('react-redux', () => { return { @@ -22,6 +25,13 @@ jest.mock('react-redux', () => { }; }); +jest.mock('react-router-dom', () => { + return { + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn() + }; +}); + describe('slqe/Whitelist/WhitelistList', () => { let whitelistSpy: jest.SpyInstance; const dispatchSpy = jest.fn(); @@ -30,8 +40,11 @@ describe('slqe/Whitelist/WhitelistList', () => { beforeEach(() => { jest.useFakeTimers(); whitelistSpy = auditWhiteList.getAuditWhitelist(); + instance.getInstanceTipList(); + user.getUserTipList(); (useSelector as jest.Mock).mockImplementation((e) => e({ + database: { driverMeta }, whitelist: { modalStatus: { [ModalName.Add_Whitelist]: false } } }) ); @@ -71,6 +84,62 @@ describe('slqe/Whitelist/WhitelistList', () => { expect(whitelistSpy).toHaveBeenCalledTimes(2); }); + test('should render rule exception management view', async () => { + const ruleExceptionSpy = auditWhiteList.getSQLRuleException(); + renderWithReduxAndTheme(); + await act(async () => jest.advanceTimersByTime(3000)); + + fireEvent.click(screen.getByText('单规则例外')); + await act(async () => jest.advanceTimersByTime(3000)); + + expect(ruleExceptionSpy).toHaveBeenCalledTimes(1); + expect(screen.getByText('项目')).toBeInTheDocument(); + expect(screen.getByText('数据源')).toBeInTheDocument(); + expect(screen.getByText('SQL指纹')).toBeInTheDocument(); + expect(screen.getByText('规则名')).toBeInTheDocument(); + expect(screen.getByText('规则描述')).toBeInTheDocument(); + expect(screen.getByText('规则原级别')).toBeInTheDocument(); + expect(screen.getByText('添加例外的人')).toBeInTheDocument(); + expect(screen.getByText('添加时间')).toBeInTheDocument(); + expect(screen.getByText('添加原因')).toBeInTheDocument(); + expect(screen.getByText('命中信息')).toBeInTheDocument(); + expect(screen.getByText('mysql_local_sqle')).toBeInTheDocument(); + expect(screen.getByText('ddl_check_pk_not_exist')).toBeInTheDocument(); + expect(screen.getByText('建表语句必须包含主键')).toBeInTheDocument(); + expect(screen.getByText('标准管理页回归验证')).toBeInTheDocument(); + expect(screen.getByText('查看审计')).toBeInTheDocument(); + expect(screen.getByText('取消例外')).toBeInTheDocument(); + }); + + test('should submit sql fingerprint filter in rule exception view', async () => { + const ruleExceptionSpy = auditWhiteList.getSQLRuleException(); + const { baseElement } = renderWithReduxAndTheme(); + await act(async () => jest.advanceTimersByTime(3000)); + + fireEvent.click(screen.getByText('单规则例外')); + await act(async () => jest.advanceTimersByTime(3000)); + fireEvent.click(screen.getByText('筛选')); + await act(async () => jest.advanceTimersByTime(300)); + + const sqlFingerprintInput = getBySelector( + '.filter-search-input input.ant-input', + baseElement + ); + fireEvent.change(sqlFingerprintInput, { + target: { value: 'ac013_standard_filter_20260618191804' } + }); + fireEvent.click( + getBySelector('.filter-search-input .custom-icon-search', baseElement) + ); + await act(async () => jest.advanceTimersByTime(3000)); + + expect(ruleExceptionSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + filter_sql_fingerprint: 'ac013_standard_filter_20260618191804' + }) + ); + }); + it('should hide table actions', async () => { useCurrentUserSpy.mockImplementation(() => ({ ...mockCurrentUserReturn, diff --git a/packages/sqle/src/page/Whitelist/List/index.tsx b/packages/sqle/src/page/Whitelist/List/index.tsx index 797b1cf6b..636d0e370 100644 --- a/packages/sqle/src/page/Whitelist/List/index.tsx +++ b/packages/sqle/src/page/Whitelist/List/index.tsx @@ -1,18 +1,35 @@ -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useRequest } from 'ahooks'; import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { useCurrentProject } from '@actiontech/shared/lib/global'; -import { WhitelistColumn, WhitelistTableFilterParamType } from './columns'; +import { + SQLRuleExceptionColumn, + SQLRuleExceptionTableFilterParamType, + WhitelistColumn, + WhitelistTableFilterParamType +} from './columns'; import { ModalName } from '../../../data/ModalName'; import { message } from 'antd'; import { ResponseCode } from '@actiontech/shared/lib/enum'; import { updateWhitelistModalStatus } from '../../../store/whitelist'; import EventEmitter from '../../../utils/EventEmitter'; import EmitterKey from '../../../data/EmitterKey'; -import { BasicButton, EmptyBox, PageHeader } from '@actiontech/shared'; +import { + BasicButton, + BasicSegmented, + EmptyBox, + PageHeader +} from '@actiontech/shared'; import WhitelistDrawer from '../Drawer'; -import { IAuditWhitelistResV1 } from '@actiontech/shared/lib/api/sqle/service/common'; -import { IGetAuditWhitelistV1Params } from '@actiontech/shared/lib/api/sqle/service/audit_whitelist/index.d'; +import { + IAuditWhitelistResV1, + ISQLRuleExceptionResV1 +} from '@actiontech/shared/lib/api/sqle/service/common'; +import { + IGetAuditWhitelistV1Params, + IGetSQLRuleExceptionV1Params +} from '@actiontech/shared/lib/api/sqle/service/audit_whitelist/index.d'; import audit_whitelist from '@actiontech/shared/lib/api/sqle/service/audit_whitelist'; import { ActiontechTable, @@ -27,11 +44,24 @@ import { import { PlusOutlined } from '@actiontech/icons'; import { whitelistMatchTypeOptions } from '../index.data'; import useWhitelistRedux from '../hooks/useWhitelistRedux'; +import useInstance from '../../../hooks/useInstance'; +import useUsername from '../../../hooks/useUsername'; + +enum WhitelistManageView { + sql = 'sql', + rule = 'rule' +} const WhitelistList = () => { const { t } = useTranslation(); + const navigate = useNavigate(); const [messageApi, messageContextHolder] = message.useMessage(); - const { projectName } = useCurrentProject(); + const { projectName, projectID } = useCurrentProject(); + const { instanceIDOptions, updateInstanceList } = useInstance(); + const { usernameOptions, updateUsernameList } = useUsername(); + const [activeView, setActiveView] = useState( + WhitelistManageView.sql + ); const { dispatch, @@ -53,7 +83,21 @@ const WhitelistList = () => { WhitelistTableFilterParamType >(); + const { + tableFilterInfo: ruleExceptionTableFilterInfo, + updateTableFilterInfo: updateRuleExceptionTableFilterInfo, + tableChange: ruleExceptionTableChange, + pagination: ruleExceptionPagination, + searchKeyword: ruleExceptionSearchKeyword, + setSearchKeyword: setRuleExceptionSearchKeyword, + refreshBySearchKeyword: refreshRuleExceptionBySearchKeyword + } = useTableRequestParams< + ISQLRuleExceptionResV1, + SQLRuleExceptionTableFilterParamType + >(); + const columns = useMemo(() => WhitelistColumn(), []); + const ruleExceptionColumns = useMemo(() => SQLRuleExceptionColumn(), []); const { requestErrorMessage, handleTableRequestError } = useTableRequestError(); @@ -81,6 +125,30 @@ const WhitelistList = () => { } ); + const { + data: ruleExceptionList, + loading: ruleExceptionLoading, + refresh: refreshRuleException + } = useRequest( + () => { + const params: IGetSQLRuleExceptionV1Params = { + ...ruleExceptionTableFilterInfo, + page_index: String(ruleExceptionPagination.page_index), + page_size: String(ruleExceptionPagination.page_size), + project_name: projectName, + fuzzy_search_value: ruleExceptionSearchKeyword + }; + + return handleTableRequestError( + audit_whitelist.getSQLRuleExceptionV1(params) + ); + }, + { + manual: true, + refreshDeps: [ruleExceptionPagination, ruleExceptionTableFilterInfo] + } + ); + const openUpdateWhitelistModal = useCallback( (selectRow: IAuditWhitelistResV1) => { updateSelectWhitelistRecord(selectRow); @@ -115,6 +183,46 @@ const WhitelistList = () => { [messageApi, projectName, refresh, t] ); + const removeRuleException = useCallback( + (sqlRuleExceptionId: number) => { + const hide = messageApi.loading(t('whitelist.ruleException.deleting')); + audit_whitelist + .deleteSQLRuleExceptionV1({ + sql_rule_exception_id: `${sqlRuleExceptionId}`, + project_name: projectName + }) + .then((res) => { + if (res.data.code === ResponseCode.SUCCESS) { + messageApi.success(t('whitelist.ruleException.deleteSuccess')); + refreshRuleException(); + } + }) + .finally(() => { + hide(); + }); + }, + [messageApi, projectName, refreshRuleException, t] + ); + + const viewRuleExceptionAudit = useCallback( + (record?: ISQLRuleExceptionResV1) => { + const contentKeywords = [ + record?.sql_fingerprint, + record?.rule_name + ].filter(Boolean); + + const searchParams = new URLSearchParams({ + filter_operate_type_name: 'sql_rule_exception', + audit_content_keywords: contentKeywords.join('|') + }); + + navigate( + `/sqle/project/${projectID}/operation-record?${searchParams.toString()}` + ); + }, + [navigate, projectID] + ); + const whitelistActionsInTable: { buttons: ActiontechTableActionMeta[]; } = { @@ -138,15 +246,71 @@ const WhitelistList = () => { ] }; + const ruleExceptionActionsInTable: { + buttons: ActiontechTableActionMeta[]; + } = { + buttons: [ + { + key: 'view-rule-exception-audit', + text: t('whitelist.ruleException.viewAudit'), + buttonProps: (record) => ({ + onClick: viewRuleExceptionAudit.bind(null, record) + }) + }, + { + key: 'remove-rule-exception', + text: t('whitelist.ruleException.cancelAction'), + buttonProps: () => ({ danger: true }), + confirm: (record) => ({ + title: t('whitelist.ruleException.confirmCancel'), + onConfirm: removeRuleException.bind( + null, + record?.sql_rule_exception_id ?? 0 + ) + }) + } + ] + }; + const filterCustomProps = useMemo(() => { return new Map([ ['match_type', { options: whitelistMatchTypeOptions }] ]); }, []); + const ruleExceptionFilterCustomProps = useMemo(() => { + return new Map([ + ['instance_name', { options: instanceIDOptions }], + [ + 'created_by', + { + options: usernameOptions.map((item) => ({ + ...item, + value: item.text + })) + } + ], + ['created_at', { showTime: true }] + ]); + }, [instanceIDOptions, usernameOptions]); + const { filterButtonMeta, filterContainerMeta, updateAllSelectedFilterItem } = useTableFilterContainer(columns, updateTableFilterInfo); + const { + filterButtonMeta: ruleExceptionFilterButtonMeta, + filterContainerMeta: ruleExceptionFilterContainerMeta, + updateAllSelectedFilterItem: updateAllSelectedRuleExceptionFilterItem + } = useTableFilterContainer( + ruleExceptionColumns, + updateRuleExceptionTableFilterInfo + ); + + useEffect(() => { + updateInstanceList({ project_name: projectName }); + updateUsernameList({ filter_project: projectName }); + }, [projectName, updateInstanceList, updateUsernameList]); + useEffect(() => { const { unsubscribe } = EventEmitter.subscribe( EmitterKey.Refresh_Whitelist_List, @@ -155,6 +319,33 @@ const WhitelistList = () => { return unsubscribe; }, [refresh]); + const segmentedOptions = useMemo( + () => [ + { + label: t('whitelist.view.sql'), + value: WhitelistManageView.sql + }, + { + label: t('whitelist.view.rule'), + value: WhitelistManageView.rule + } + ], + [t] + ); + + const isSqlWhitelistView = activeView === WhitelistManageView.sql; + + useEffect(() => { + if (!isSqlWhitelistView) { + refreshRuleException(); + } + }, [ + isSqlWhitelistView, + refreshRuleException, + ruleExceptionPagination, + ruleExceptionTableFilterInfo + ]); + return ( <> {messageContextHolder} @@ -174,41 +365,96 @@ const WhitelistList = () => { ]} /> - { - refreshBySearchKeyword(); - } - }} - loading={loading} - /> - - { - return `${record?.audit_whitelist_id}`; - }} - pagination={{ - total: whitelistList?.total ?? 0 + { + setActiveView(value as WhitelistManageView); }} - loading={loading} - columns={columns} - actions={actionPermission ? whitelistActionsInTable : undefined} - errorMessage={requestErrorMessage} - onChange={tableChange} - scroll={{}} /> + {isSqlWhitelistView ? ( + <> + + refreshButton={{ + refresh, + disabled: loading + }} + filterButton={{ + filterButtonMeta, + updateAllSelectedFilterItem + }} + searchInput={{ + onChange: setSearchKeyword, + onSearch: refreshBySearchKeyword + }} + loading={loading} + /> + + { + return `${record?.audit_whitelist_id}`; + }} + pagination={{ + total: whitelistList?.total ?? 0 + }} + loading={loading} + columns={columns} + actions={actionPermission ? whitelistActionsInTable : undefined} + errorMessage={requestErrorMessage} + onChange={tableChange} + scroll={{}} + /> + + ) : ( + <> + + refreshButton={{ + refresh: refreshRuleException, + disabled: ruleExceptionLoading + }} + filterButton={{ + filterButtonMeta: ruleExceptionFilterButtonMeta, + updateAllSelectedFilterItem: + updateAllSelectedRuleExceptionFilterItem + }} + searchInput={{ + onChange: setRuleExceptionSearchKeyword, + onSearch: refreshRuleExceptionBySearchKeyword + }} + loading={ruleExceptionLoading} + /> + + { + return `${ + record?.sql_rule_exception_id ?? + `${record.project_id}-${record.instance_id}-${record.sql_fingerprint}-${record.rule_name}` + }`; + }} + pagination={{ + total: ruleExceptionList?.total ?? 0 + }} + loading={ruleExceptionLoading} + columns={ruleExceptionColumns} + actions={actionPermission ? ruleExceptionActionsInTable : undefined} + errorMessage={requestErrorMessage} + onChange={ruleExceptionTableChange} + scroll={{ x: 1600 }} + /> + + )} ); diff --git a/packages/sqle/src/page/Whitelist/__snapshots__/index.test.tsx.snap b/packages/sqle/src/page/Whitelist/__snapshots__/index.test.tsx.snap index 7799c085b..ef2d80254 100644 --- a/packages/sqle/src/page/Whitelist/__snapshots__/index.test.tsx.snap +++ b/packages/sqle/src/page/Whitelist/__snapshots__/index.test.tsx.snap @@ -43,6 +43,43 @@ exports[`slqe/Whitelist should render white list 1`] = `
+
+
+ + +
+
diff --git a/packages/sqle/src/page/Whitelist/index.test.tsx b/packages/sqle/src/page/Whitelist/index.test.tsx index f5914b155..ff205cbdd 100644 --- a/packages/sqle/src/page/Whitelist/index.test.tsx +++ b/packages/sqle/src/page/Whitelist/index.test.tsx @@ -2,11 +2,14 @@ import { screen, cleanup, act } from '@testing-library/react'; import WhiteList from '.'; import { renderWithReduxAndTheme } from '@actiontech/shared/lib/testUtil/customRender'; import auditWhiteList from '../../testUtils/mockApi/auditWhiteList'; +import instance from '../../testUtils/mockApi/instance'; +import user from '../../testUtils/mockApi/user'; import { getBySelector } from '@actiontech/shared/lib/testUtil/customQuery'; import { useSelector } from 'react-redux'; import { ModalName } from '../../data/ModalName'; import { mockUseCurrentProject } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentProject'; import { mockUseCurrentUser } from '@actiontech/shared/lib/testUtil/mockHook/mockUseCurrentUser'; +import { driverMeta } from '../../hooks/useDatabaseType/index.test.data'; jest.mock('react-redux', () => { return { @@ -15,13 +18,23 @@ jest.mock('react-redux', () => { }; }); +jest.mock('react-router-dom', () => { + return { + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn() + }; +}); + describe('slqe/Whitelist', () => { let whiteListSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); whiteListSpy = auditWhiteList.getAuditWhitelist(); + instance.getInstanceTipList(); + user.getUserTipList(); (useSelector as jest.Mock).mockImplementation((e) => e({ + database: { driverMeta }, whitelist: { modalStatus: { [ModalName.Add_Whitelist]: false } } }) ); diff --git a/packages/sqle/src/page/Whitelist/index.tsx b/packages/sqle/src/page/Whitelist/index.tsx index 5a7de585b..03a6b1724 100644 --- a/packages/sqle/src/page/Whitelist/index.tsx +++ b/packages/sqle/src/page/Whitelist/index.tsx @@ -1,29 +1,7 @@ -import { useTranslation } from 'react-i18next'; -import { EnterpriseFeatureDisplay, PageHeader } from '@actiontech/shared'; -import { Typography } from 'antd'; import WhitelistList from './List'; const Whitelist = () => { - const { t } = useTranslation(); - - return ( - <> - {/* #if [ce] */} - - {/* #endif */} - - - {t('whitelist.ceTips')} - - } - > - - - - ); + return ; }; export default Whitelist; diff --git a/packages/sqle/src/testUtils/mockApi/auditWhiteList/data.ts b/packages/sqle/src/testUtils/mockApi/auditWhiteList/data.ts index b6c39a998..778367c59 100644 --- a/packages/sqle/src/testUtils/mockApi/auditWhiteList/data.ts +++ b/packages/sqle/src/testUtils/mockApi/auditWhiteList/data.ts @@ -1,4 +1,7 @@ -import { IAuditWhitelistResV1 } from '@actiontech/shared/lib/api/sqle/service/common'; +import { + IAuditWhitelistResV1, + ISQLRuleExceptionResV1 +} from '@actiontech/shared/lib/api/sqle/service/common'; import { CreateAuditWhitelistReqV1MatchTypeEnum } from '@actiontech/shared/lib/api/sqle/service/common.enum'; export const auditWhiteListMockData: IAuditWhitelistResV1[] = [ @@ -30,3 +33,21 @@ export const auditWhiteListMockData: IAuditWhitelistResV1[] = [ desc: 'test4' } ]; + +export const sqlRuleExceptionMockData: ISQLRuleExceptionResV1[] = [ + { + sql_rule_exception_id: 11, + project_name: 'default', + instance_id: '1739531854064652288', + instance_name: 'mysql_local_sqle', + sql_fingerprint: 'create table rule_exc_management (id int)', + rule_name: 'ddl_check_pk_not_exist', + rule_desc: '建表语句必须包含主键', + rule_level: 'error', + reason: '标准管理页回归验证', + created_by: 'admin', + created_at: '2026-06-19T02:40:00+00:00', + matched_count: 2, + last_match_time: '2026-06-19T02:50:00+00:00' + } +]; diff --git a/packages/sqle/src/testUtils/mockApi/auditWhiteList/index.ts b/packages/sqle/src/testUtils/mockApi/auditWhiteList/index.ts index 81905050f..f53936c01 100644 --- a/packages/sqle/src/testUtils/mockApi/auditWhiteList/index.ts +++ b/packages/sqle/src/testUtils/mockApi/auditWhiteList/index.ts @@ -3,13 +3,15 @@ import { MockSpyApy, createSpySuccessResponse } from '@actiontech/shared/lib/testUtil/mockApi'; -import { auditWhiteListMockData } from './data'; +import { auditWhiteListMockData, sqlRuleExceptionMockData } from './data'; class AuditWhiteList implements MockSpyApy { public mockAllApi(): void { this.getAuditWhitelist(); this.deleteAuthWhitelist(); this.addAuthWhitelist(); + this.getSQLRuleException(); + this.deleteSQLRuleException(); } public getAuditWhitelist() { @@ -39,6 +41,22 @@ class AuditWhiteList implements MockSpyApy { spy.mockImplementation(() => createSpySuccessResponse({})); return spy; } + + public getSQLRuleException() { + const spy = jest.spyOn(audit_whitelist, 'getSQLRuleExceptionV1'); + spy.mockImplementation(() => + createSpySuccessResponse({ + data: sqlRuleExceptionMockData + }) + ); + return spy; + } + + public deleteSQLRuleException() { + const spy = jest.spyOn(audit_whitelist, 'deleteSQLRuleExceptionV1'); + spy.mockImplementation(() => createSpySuccessResponse({})); + return spy; + } } export default new AuditWhiteList(); diff --git a/packages/sqle/src/testUtils/mockApi/instance/index.ts b/packages/sqle/src/testUtils/mockApi/instance/index.ts index 8a7fa4c6b..937214cea 100644 --- a/packages/sqle/src/testUtils/mockApi/instance/index.ts +++ b/packages/sqle/src/testUtils/mockApi/instance/index.ts @@ -13,6 +13,7 @@ import { class MockInstanceApi implements MockSpyApy { public mockAllApi(): void { this.getInstanceTipList(); + this.getInstanceTipListV2(); this.getInstanceSchemas(); this.batchCheckInstanceIsConnectableByName(); this.getInstance(); @@ -29,6 +30,17 @@ class MockInstanceApi implements MockSpyApy { return spy; } + public getInstanceTipListV2() { + const spy = jest.spyOn(instance, 'getInstanceTipListV2'); + spy.mockImplementation(() => + createSpySuccessResponse({ + data: instanceTipsMockData, + total_nums: instanceTipsMockData.length + }) + ); + return spy; + } + public getInstance() { const spy = jest.spyOn(instance, 'getInstanceV2'); spy.mockImplementation(() =>