From adf283ca1d2dd12bc5d8e7f88dd59a1f98930e01 Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Wed, 12 Aug 2026 09:42:33 +0800
Subject: [PATCH 1/5] fix(history): add scoped operation log filtering
---
chat2db-community-client/package.json | 3 +-
.../components/OperationLogFilters/index.tsx | 97 +++++++
.../OperationLogFilters/model.test.ts | 84 ++++++
.../components/OperationLogFilters/model.ts | 94 +++++++
.../components/OperationLogFilters/style.ts | 20 ++
.../useDebouncedFilters.ts | 26 ++
.../src/components/Output/index.tsx | 120 ++++++---
.../src/components/Output/style.ts | 10 +-
.../selectDatabaseRequestLifecycle.test.ts | 199 ++++++++++++++
.../hooks/selectDatabaseRequestLifecycle.ts | 148 ++++++++++
.../src/hooks/useSelectDatabase.ts | 253 ++++++++++--------
.../components/SQLAudit/index.tsx | 159 ++++++-----
.../organization/components/SQLAudit/style.ts | 5 +-
.../storage/LocalWorkspaceStorage.java | 26 +-
.../LocalWorkspaceStoragePaginationTest.java | 67 +++++
15 files changed, 1091 insertions(+), 220 deletions(-)
create mode 100644 chat2db-community-client/src/components/OperationLogFilters/index.tsx
create mode 100644 chat2db-community-client/src/components/OperationLogFilters/model.test.ts
create mode 100644 chat2db-community-client/src/components/OperationLogFilters/model.ts
create mode 100644 chat2db-community-client/src/components/OperationLogFilters/style.ts
create mode 100644 chat2db-community-client/src/components/OperationLogFilters/useDebouncedFilters.ts
create mode 100644 chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts
create mode 100644 chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts
diff --git a/chat2db-community-client/package.json b/chat2db-community-client/package.json
index cbac2c87be..4b161e59cc 100644
--- a/chat2db-community-client/package.json
+++ b/chat2db-community-client/package.json
@@ -13,7 +13,7 @@
"build:desktop": "npm run build:web:desktop",
"build:prod": "npm run build:web:prod",
"build:web": "umi build",
- "prebuild:web:community": "yarn test:runtime-edition-storage && yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:data-source-authorization && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:console-tab-name && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:editor-close && yarn test:invoice-routing && yarn test:result-set-ui && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:result-set-editor",
+ "prebuild:web:community": "yarn test:runtime-edition-storage && yarn test:chat-answer-update && yarn test:tree-title-highlight && yarn test:tree-loading && yarn test:tree-node-lookup && yarn test:data-source-authorization && yarn test:data-source-mutation-refresh && yarn test:ai-model-config && yarn test:ai-model-select && yarn test:export-connections && yarn test:main-page-navigation && yarn test:console-tab-name && yarn test:file-manager-label && yarn test:local-file-encoding && yarn test:editor-close && yarn test:invoice-routing && yarn test:result-set-ui && yarn test:result-status && yarn test:data-source-identity && yarn test:data-source-execution-snapshot && yarn test:data-source-watermark && yarn test:hot-update && yarn test:task-center && yarn test:application-exit && yarn test:result-set-editor && yarn test:operation-log-filters",
"postbuild:web:community": "node ./scripts/verify-production-bundles.cjs",
"build:web:2java": "yarn run build:web:prod && rm -rf ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/* && cp -r dist/index.html ../chat2db-community-server/chat2db-community-start/src/main/resources/thymeleaf/",
"build:web:desktop": "cross-env UMI_ENV=desktop cross-env APP_NAME=chat2db-pro cross-env APP_VERSION=${npm_config_app_version} cross-env PRINT_LOGS=${npm_config_print_logs} cross-env APP_PORT=${npm_config_app_port} umi build",
@@ -69,6 +69,7 @@
"test:settings-layout": "tsx src/blocks/Setting/navigation.test.ts && tsx src/blocks/Setting/search.test.ts && tsx src/blocks/Setting/BaseSetting/model.test.ts && tsx src/edition-ui/settingMenus.test.ts && tsx src/blocks/Setting/settingsLayout.test.ts",
"test:shortcut": "tsx src/constants/shortcut.test.ts && tsx src/utils/appTitleBarAction.test.ts && tsx src/utils/jcefZoom.test.ts",
"test:sql-execution-log": "tsx src/service/sqlExecutionLog.test.ts",
+ "test:operation-log-filters": "tsx src/components/OperationLogFilters/model.test.ts && tsx src/hooks/selectDatabaseRequestLifecycle.test.ts",
"test:sql-execution-batch": "tsx src/service/sqlExecutionBatch.test.ts",
"test:data-source-execution-snapshot": "tsx src/service/dataSourceExecutionSnapshot.test.ts",
"test:sql-execution-request-tracker": "tsx src/service/sqlExecutionRequestTracker.test.ts",
diff --git a/chat2db-community-client/src/components/OperationLogFilters/index.tsx b/chat2db-community-client/src/components/OperationLogFilters/index.tsx
new file mode 100644
index 0000000000..94613ae3df
--- /dev/null
+++ b/chat2db-community-client/src/components/OperationLogFilters/index.tsx
@@ -0,0 +1,97 @@
+import i18n from '@/i18n';
+import useSelectDatabase from '@/hooks/useSelectDatabase';
+import { Input, Select } from 'antd';
+import classnames from 'classnames';
+import { memo } from 'react';
+import { OperationLogFilterValues, updateOperationLogFilters } from './model';
+import { useStyles } from './style';
+
+interface IProps {
+ className?: string;
+ value: OperationLogFilterValues;
+ onChange: (value: OperationLogFilterValues) => void;
+ size?: 'small' | 'middle' | 'large';
+}
+
+function OperationLogFilters({ className, value, onChange, size = 'middle' }: IProps) {
+ const { styles } = useStyles();
+ const { dataSourceList, databaseList, schemaList, selectDatabase, onChangeSelectDatabase } = useSelectDatabase({});
+ const hasDataSource = value.dataSourceId !== undefined;
+ const databaseEnabled = hasDataSource && selectDatabase?.supportDatabase !== false;
+ const schemaEnabled =
+ hasDataSource &&
+ selectDatabase?.supportSchema !== false &&
+ (selectDatabase?.supportDatabase === false || !!value.databaseName);
+
+ const handleDataSourceChange = (dataSourceId?: number) => {
+ onChangeSelectDatabase({ dataSourceId });
+ onChange(updateOperationLogFilters(value, { field: 'dataSourceId', value: dataSourceId }));
+ };
+
+ const handleDatabaseChange = (databaseName?: string) => {
+ onChangeSelectDatabase({ databaseName });
+ onChange(updateOperationLogFilters(value, { field: 'databaseName', value: databaseName }));
+ };
+
+ const handleSchemaChange = (schemaName?: string) => {
+ onChangeSelectDatabase({ schemaName });
+ onChange(updateOperationLogFilters(value, { field: 'schemaName', value: schemaName }));
+ };
+
+ return (
+
+
+
+
+
+ onChange(updateOperationLogFilters(value, { field: 'searchKey', value: event.target.value }))
+ }
+ />
+
+ );
+}
+
+export default memo(OperationLogFilters);
+export type { OperationLogFilterValues } from './model';
+export { useDebouncedOperationLogFilters } from './useDebouncedFilters';
diff --git a/chat2db-community-client/src/components/OperationLogFilters/model.test.ts b/chat2db-community-client/src/components/OperationLogFilters/model.test.ts
new file mode 100644
index 0000000000..c7ad07bebc
--- /dev/null
+++ b/chat2db-community-client/src/components/OperationLogFilters/model.test.ts
@@ -0,0 +1,84 @@
+import assert from 'node:assert/strict';
+import type { OperationTypeEnum } from '@/service/history';
+import {
+ areOperationLogFiltersEqual,
+ buildOperationLogListParams,
+ normalizeOperationLogFilters,
+ updateOperationLogFilters,
+} from './model';
+
+const sqlExecute = 'SQL_EXECUTE' as OperationTypeEnum;
+
+{
+ const filters = normalizeOperationLogFilters({
+ dataSourceId: 12,
+ databaseName: ' application ',
+ schemaName: ' public ',
+ searchKey: ' Orders ',
+ });
+
+ assert.deepEqual(filters, {
+ dataSourceId: 12,
+ databaseName: 'application',
+ schemaName: 'public',
+ searchKey: 'Orders',
+ });
+ assert.deepEqual(normalizeOperationLogFilters({ databaseName: ' ', searchKey: '\t' }), {});
+}
+
+{
+ const filters = {
+ dataSourceId: 12,
+ databaseName: 'application',
+ schemaName: 'public',
+ searchKey: 'orders',
+ };
+
+ assert.deepEqual(updateOperationLogFilters(filters, { field: 'databaseName', value: 'analytics' }), {
+ dataSourceId: 12,
+ databaseName: 'analytics',
+ schemaName: undefined,
+ searchKey: 'orders',
+ });
+ assert.deepEqual(updateOperationLogFilters(filters, { field: 'dataSourceId', value: undefined }), {
+ dataSourceId: undefined,
+ databaseName: undefined,
+ schemaName: undefined,
+ searchKey: 'orders',
+ });
+}
+
+{
+ assert.equal(
+ areOperationLogFiltersEqual(
+ { dataSourceId: 12, databaseName: ' application ', searchKey: 'orders' },
+ { dataSourceId: 12, databaseName: 'application', searchKey: ' orders ' },
+ ),
+ true,
+ );
+
+ assert.deepEqual(
+ buildOperationLogListParams(
+ {
+ dataSourceId: 12,
+ databaseName: ' application ',
+ schemaName: ' public ',
+ searchKey: ' orders ',
+ },
+ 1,
+ 40,
+ sqlExecute,
+ ),
+ {
+ pageNo: 1,
+ pageSize: 40,
+ operationType: sqlExecute,
+ dataSourceId: 12,
+ databaseName: 'application',
+ schemaName: 'public',
+ searchKey: 'orders',
+ },
+ );
+}
+
+console.log('Operation log filter tests passed');
diff --git a/chat2db-community-client/src/components/OperationLogFilters/model.ts b/chat2db-community-client/src/components/OperationLogFilters/model.ts
new file mode 100644
index 0000000000..46f034f608
--- /dev/null
+++ b/chat2db-community-client/src/components/OperationLogFilters/model.ts
@@ -0,0 +1,94 @@
+import type { IGetHistoryListParams, OperationTypeEnum } from '@/service/history';
+
+export interface OperationLogFilterValues {
+ dataSourceId?: number;
+ databaseName?: string;
+ schemaName?: string;
+ searchKey?: string;
+}
+
+export type OperationLogFilterChange =
+ | { field: 'dataSourceId'; value?: number }
+ | { field: 'databaseName' | 'schemaName' | 'searchKey'; value?: string };
+
+function normalizeText(value?: string) {
+ const normalizedValue = value?.trim();
+ return normalizedValue || undefined;
+}
+
+export function normalizeOperationLogFilters(filters: OperationLogFilterValues): OperationLogFilterValues {
+ const normalizedFilters: OperationLogFilterValues = {};
+
+ if (filters.dataSourceId !== undefined) {
+ normalizedFilters.dataSourceId = filters.dataSourceId;
+ }
+
+ const databaseName = normalizeText(filters.databaseName);
+ const schemaName = normalizeText(filters.schemaName);
+ const searchKey = normalizeText(filters.searchKey);
+
+ if (databaseName) {
+ normalizedFilters.databaseName = databaseName;
+ }
+ if (schemaName) {
+ normalizedFilters.schemaName = schemaName;
+ }
+ if (searchKey) {
+ normalizedFilters.searchKey = searchKey;
+ }
+
+ return normalizedFilters;
+}
+
+export function updateOperationLogFilters(
+ filters: OperationLogFilterValues,
+ change: OperationLogFilterChange,
+): OperationLogFilterValues {
+ if (change.field === 'dataSourceId') {
+ return {
+ ...filters,
+ dataSourceId: change.value,
+ databaseName: undefined,
+ schemaName: undefined,
+ };
+ }
+
+ if (change.field === 'databaseName') {
+ return {
+ ...filters,
+ databaseName: change.value,
+ schemaName: undefined,
+ };
+ }
+
+ return {
+ ...filters,
+ [change.field]: change.value,
+ };
+}
+
+export function areOperationLogFiltersEqual(left: OperationLogFilterValues, right: OperationLogFilterValues) {
+ const normalizedLeft = normalizeOperationLogFilters(left);
+ const normalizedRight = normalizeOperationLogFilters(right);
+
+ return (
+ normalizedLeft.dataSourceId === normalizedRight.dataSourceId &&
+ normalizedLeft.databaseName === normalizedRight.databaseName &&
+ normalizedLeft.schemaName === normalizedRight.schemaName &&
+ normalizedLeft.searchKey === normalizedRight.searchKey
+ );
+}
+
+export function buildOperationLogListParams(
+ filters: OperationLogFilterValues,
+ pageNo: number,
+ pageSize: number,
+ operationType: OperationTypeEnum,
+): IGetHistoryListParams {
+ return {
+ pageNo,
+ pageSize,
+ operationType,
+ ...normalizeOperationLogFilters(filters),
+ };
+}
diff --git a/chat2db-community-client/src/components/OperationLogFilters/style.ts b/chat2db-community-client/src/components/OperationLogFilters/style.ts
new file mode 100644
index 0000000000..5a17899443
--- /dev/null
+++ b/chat2db-community-client/src/components/OperationLogFilters/style.ts
@@ -0,0 +1,20 @@
+import { createStyles } from 'antd-style';
+
+export const useStyles = createStyles(({ css }) => ({
+ filters: css`
+ display: flex;
+ flex: 1;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ `,
+ scopeFilter: css`
+ min-width: 120px;
+ flex: 1 1 140px;
+ `,
+ searchFilter: css`
+ min-width: 160px;
+ flex: 2 1 220px;
+ `,
+}));
diff --git a/chat2db-community-client/src/components/OperationLogFilters/useDebouncedFilters.ts b/chat2db-community-client/src/components/OperationLogFilters/useDebouncedFilters.ts
new file mode 100644
index 0000000000..79148ba611
--- /dev/null
+++ b/chat2db-community-client/src/components/OperationLogFilters/useDebouncedFilters.ts
@@ -0,0 +1,26 @@
+import { useEffect, useMemo, useState } from 'react';
+import { normalizeOperationLogFilters, OperationLogFilterValues } from './model';
+
+export function useDebouncedOperationLogFilters(filters: OperationLogFilterValues, delay = 300) {
+ const { dataSourceId, databaseName, schemaName, searchKey } = filters;
+ const [debouncedSearchKey, setDebouncedSearchKey] = useState(normalizeOperationLogFilters({ searchKey }).searchKey);
+
+ useEffect(() => {
+ const timeoutId = window.setTimeout(() => {
+ setDebouncedSearchKey(normalizeOperationLogFilters({ searchKey }).searchKey);
+ }, delay);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [delay, searchKey]);
+
+ return useMemo(
+ () =>
+ normalizeOperationLogFilters({
+ dataSourceId,
+ databaseName,
+ schemaName,
+ searchKey: debouncedSearchKey,
+ }),
+ [dataSourceId, databaseName, debouncedSearchKey, schemaName],
+ );
+}
diff --git a/chat2db-community-client/src/components/Output/index.tsx b/chat2db-community-client/src/components/Output/index.tsx
index c564570e69..09c0d65297 100644
--- a/chat2db-community-client/src/components/Output/index.tsx
+++ b/chat2db-community-client/src/components/Output/index.tsx
@@ -1,6 +1,11 @@
-import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
+import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import ScrollLoading from '@/components/ScrollLoading';
+import OperationLogFilters, {
+ OperationLogFilterValues,
+ useDebouncedOperationLogFilters,
+} from '@/components/OperationLogFilters';
+import { buildOperationLogListParams } from '@/components/OperationLogFilters/model';
import historyService, { IHistoryRecord, OperationTypeEnum } from '@/service/history';
import i18n from '@/i18n';
import { useStyles } from './style';
@@ -53,17 +58,13 @@ function getHistoryDataSourceName(item: IDatasource, sourceInfo?: TreeNodeData,
}
function getHistoryTitle(item: IDatasource, sourceInfo?: TreeNodeData, cachedSourceName?: string) {
- const dataSourceName =
- getHistoryDataSourceName(item, sourceInfo, cachedSourceName);
+ const dataSourceName = getHistoryDataSourceName(item, sourceInfo, cachedSourceName);
const nameList = [dataSourceName, item.databaseName || item.schemaName].filter(Boolean);
return nameList.join(' / ');
}
function getHistoryPopover(item: IDatasource, sourceInfo?: TreeNodeData, cachedSourceName?: string) {
- return [
- getHistoryTitle(item, sourceInfo, cachedSourceName),
- item.gmtCreate,
- ].filter(Boolean).join('\n');
+ return [getHistoryTitle(item, sourceInfo, cachedSourceName), item.gmtCreate].filter(Boolean).join('\n');
}
export default memo((props) => {
@@ -77,11 +78,16 @@ export default memo((props) => {
const savedConsoleList = useWorkspaceStore((state) => state.savedConsoleList);
const dataSourceList = useTreeStore((state) => state.dataSourceList);
const [dataSource, setDataSource] = useState([]);
+ const [filters, setFilters] = useState({});
const [finished, setFinished] = useState(false);
+ const appliedFilters = useDebouncedOperationLogFilters(filters);
const outputContentRef = useRef(null);
const curPageRef = useRef(1);
- const loadingRef = useRef(false);
const finishedRef = useRef(false);
+ const initializedRef = useRef(false);
+ const mountedRef = useRef(true);
+ const requestGenerationRef = useRef(0);
+ const activeRequestRef = useRef<{ generation: number; pageNo: number } | null>(null);
const dataSourceInfoMap = useMemo(() => {
return (dataSourceList || []).reduce>((map, item) => {
@@ -123,42 +129,84 @@ export default memo((props) => {
return item;
}, []);
- const getHistoryList = useCallback(async () => {
- if (loadingRef.current || finishedRef.current) {
- return;
- }
- loadingRef.current = true;
- try {
- const res = await historyService.getHistoryList({
- pageNo: curPageRef.current,
- pageSize: 40,
- operationType: OperationTypeEnum.SQL_EXECUTE,
- });
+ const loadHistoryPage = useCallback(
+ async (pageNo: number, requestFilters: OperationLogFilterValues, generation: number, replace: boolean) => {
+ if (generation !== requestGenerationRef.current || (!replace && finishedRef.current)) {
+ return;
+ }
- curPageRef.current += 1;
- finishedRef.current = !res.hasNextPage;
- setFinished(finishedRef.current);
- setDataSource((prev) => [...prev, ...((res.data || []) as IDatasource[])]);
- } finally {
- loadingRef.current = false;
- }
- }, []);
+ if (activeRequestRef.current?.generation === generation) {
+ return;
+ }
- const refresh = useCallback(() => {
+ const activeRequest = { generation, pageNo };
+ activeRequestRef.current = activeRequest;
+
+ try {
+ const res = await historyService.getHistoryList(
+ buildOperationLogListParams(requestFilters, pageNo, 40, OperationTypeEnum.SQL_EXECUTE),
+ );
+
+ if (!mountedRef.current || generation !== requestGenerationRef.current) {
+ return;
+ }
+
+ const records = (res.data || []) as IDatasource[];
+ curPageRef.current = pageNo + 1;
+ finishedRef.current = !res.hasNextPage;
+ setFinished(finishedRef.current);
+ setDataSource((previousRecords) => (replace ? records : [...previousRecords, ...records]));
+ } catch {
+ // Request errors are surfaced by the shared request layer. Keep pagination retryable.
+ } finally {
+ if (activeRequestRef.current === activeRequest) {
+ activeRequestRef.current = null;
+ }
+ }
+ },
+ [],
+ );
+
+ const resetAndLoadHistory = useCallback(() => {
+ const generation = requestGenerationRef.current + 1;
+ requestGenerationRef.current = generation;
+ initializedRef.current = true;
curPageRef.current = 1;
- loadingRef.current = false;
finishedRef.current = false;
setFinished(false);
setDataSource([]);
- getHistoryList();
- }, [getHistoryList]);
+ return loadHistoryPage(1, appliedFilters, generation, true);
+ }, [appliedFilters, loadHistoryPage]);
+
+ useEffect(() => {
+ void resetAndLoadHistory();
+ }, [resetAndLoadHistory]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ requestGenerationRef.current += 1;
+ };
+ }, []);
+
+ const getHistoryList = useCallback(() => {
+ if (!initializedRef.current || finishedRef.current) {
+ return Promise.resolve();
+ }
+
+ return loadHistoryPage(curPageRef.current, appliedFilters, requestGenerationRef.current, false);
+ }, [appliedFilters, loadHistoryPage]);
+
+ const refresh = useCallback(() => {
+ void resetAndLoadHistory();
+ }, [resetAndLoadHistory]);
const openHistoryConsole = useCallback(
async (item: IDatasource, readOnly: boolean) => {
const detail = await getFullHistoryRecord(item);
const tabId = getTemporaryId(`${readOnly ? 'execution-log' : 'execution-log-copy'}-${item.id || Date.now()}`);
- const sourceInfo =
- dataSourceInfoMap[getHistorySourceKey(detail)] || dataSourceInfoMap[getHistorySourceKey(item)];
+ const sourceInfo = dataSourceInfoMap[getHistorySourceKey(detail)] || dataSourceInfoMap[getHistorySourceKey(item)];
const cachedSourceName =
dataSourceNameMap[getHistorySourceKey(detail)] || dataSourceNameMap[getHistorySourceKey(item)];
const dataSourceName = getHistoryDataSourceName(detail, sourceInfo, cachedSourceName);
@@ -187,10 +235,7 @@ export default memo((props) => {
[addWorkspaceTab, dataSourceInfoMap, dataSourceNameMap, getFullHistoryRecord],
);
- const openHistoryTab = useCallback(
- (item: IDatasource) => openHistoryConsole(item, true),
- [openHistoryConsole],
- );
+ const openHistoryTab = useCallback((item: IDatasource) => openHistoryConsole(item, true), [openHistoryConsole]);
const openEditableHistoryTab = useCallback(
(event: React.MouseEvent, item: IDatasource) => {
@@ -244,6 +289,7 @@ export default memo((props) => {
leading={headerLeading ?? {i18n('common.title.executiveLogging')}}
trailing={}
/>
+
{
flex: 1;
height: 0px;
`,
+ outputFilters: css`
+ flex: 0 0 auto;
+ padding: 8px 12px;
+ border-bottom: 1px solid ${token.colorBorderSecondary};
+ background: ${token.colorBgContainer};
+ `,
outputItem: css`
position: relative;
display: flex;
@@ -28,9 +34,7 @@ export const useStyles = createStyles(({ css, token }) => {
border-radius: 6px;
background: ${token.colorFillQuaternary};
cursor: pointer;
- transition:
- border-color 0.16s ease,
- background-color 0.16s ease;
+ transition: border-color 0.16s ease, background-color 0.16s ease;
&:hover {
border-color: ${token.colorPrimaryBorder};
diff --git a/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts
new file mode 100644
index 0000000000..402bd6bf03
--- /dev/null
+++ b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.test.ts
@@ -0,0 +1,199 @@
+import assert from 'node:assert/strict';
+import { TreeNodeType } from '@/constants/tree';
+import type { TreeNodeData } from '@/typings/tree';
+import {
+ createSelectDatabaseRequestLifecycle,
+ disposeSelectDatabaseRequests,
+ hasApplicableDatabaseNameChange,
+ invalidateDatabaseOptionRequests,
+ invalidateDataSourceOptionRequests,
+ normalizeDatabaseOptions,
+ normalizeSchemaOptions,
+ runDatabaseOptionRequest,
+ runSchemaOptionRequest,
+} from './selectDatabaseRequestLifecycle';
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((done, fail) => {
+ resolve = done;
+ reject = fail;
+ });
+ return { promise, reject, resolve };
+}
+
+function treeNode(
+ treeNodeType: TreeNodeType,
+ originalTitle: string,
+ extraParams: TreeNodeData['extraParams'],
+): TreeNodeData {
+ return {
+ key: `${treeNodeType}-${originalTitle}`,
+ originalTitle,
+ treeNodeType,
+ extraParams,
+ };
+}
+
+async function testLatestDataSourceDatabaseRequestWins() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const firstResponse = deferred();
+ const secondResponse = deferred();
+ const committedOptions: string[][] = [];
+ const firstRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => firstResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const secondRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => secondResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ secondResponse.resolve(['new-source-database']);
+ await secondRequest;
+ firstResponse.reject(new Error('stale source request failed'));
+ await firstRequest;
+
+ assert.deepEqual(committedOptions, [['new-source-database']]);
+}
+
+async function testLatestDatabaseSchemaRequestWins() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const firstResponse = deferred();
+ const secondResponse = deferred();
+ const committedOptions: string[][] = [];
+ const firstRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => firstResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ invalidateDatabaseOptionRequests(lifecycle);
+ const secondRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => secondResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ secondResponse.resolve(['new-database-schema']);
+ await secondRequest;
+ firstResponse.resolve(['stale-database-schema']);
+ await firstRequest;
+
+ assert.deepEqual(committedOptions, [['new-database-schema']]);
+}
+
+async function testParentClearInvalidatesBothDependentLevels() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const databaseResponse = deferred();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const databaseRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => databaseResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ invalidateDataSourceOptionRequests(lifecycle);
+ databaseResponse.resolve(['stale-database']);
+ schemaResponse.resolve(['stale-schema']);
+ await Promise.all([databaseRequest, schemaRequest]);
+
+ assert.deepEqual(committedOptions, []);
+}
+
+async function testUnmountInvalidatesBothDependentLevels() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const databaseResponse = deferred();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const databaseRequest = runDatabaseOptionRequest(
+ lifecycle,
+ () => databaseResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ disposeSelectDatabaseRequests(lifecycle);
+ databaseResponse.resolve(['unmounted-database']);
+ schemaResponse.resolve(['unmounted-schema']);
+ await Promise.all([databaseRequest, schemaRequest]);
+
+ assert.deepEqual(committedOptions, []);
+}
+
+async function testSchemaOnlyFullInitializationKeepsSchemaRequest() {
+ const lifecycle = createSelectDatabaseRequestLifecycle();
+ const schemaResponse = deferred();
+ const committedOptions: string[][] = [];
+ const initData = {
+ dataSourceId: 1,
+ databaseName: undefined,
+ schemaName: 'PUBLIC',
+ };
+
+ const schemaRequest = runSchemaOptionRequest(
+ lifecycle,
+ () => schemaResponse.promise,
+ (options) => committedOptions.push(options),
+ () => committedOptions.push([]),
+ );
+
+ if (hasApplicableDatabaseNameChange(initData, false)) {
+ invalidateDatabaseOptionRequests(lifecycle);
+ }
+
+ schemaResponse.resolve(['PUBLIC', 'AUDIT']);
+ await schemaRequest;
+
+ assert.deepEqual(committedOptions, [['PUBLIC', 'AUDIT']]);
+}
+
+function testOnlyRealNamedDatabaseAndSchemaNodesBecomeOptions() {
+ const databaseNodes = [
+ treeNode(TreeNodeType.DATABASE, 'orders', { databaseName: 'orders' }),
+ treeNode(TreeNodeType.DATABASE_ACCOUNTS, 'Database Accounts', { databaseName: 'inherited-name' }),
+ treeNode(TreeNodeType.AI_DATA_COLLECTIONS, 'AI data collections', {}),
+ treeNode(TreeNodeType.DATABASE, 'blank database', { databaseName: ' ' }),
+ treeNode(TreeNodeType.DATABASE, 'duplicate orders', { databaseName: 'orders' }),
+ ];
+ assert.deepEqual(normalizeDatabaseOptions(databaseNodes), [{ value: 'orders', label: 'orders' }]);
+
+ const schemaNodes = [
+ treeNode(TreeNodeType.SCHEMA, 'public', { schemaName: 'public' }),
+ treeNode(TreeNodeType.TABLES, 'Tables', {}),
+ treeNode(TreeNodeType.SCHEMA, 'blank schema', { schemaName: '' }),
+ ];
+ assert.deepEqual(normalizeSchemaOptions(schemaNodes), [{ value: 'public', label: 'public' }]);
+}
+
+async function run() {
+ await testLatestDataSourceDatabaseRequestWins();
+ await testLatestDatabaseSchemaRequestWins();
+ await testParentClearInvalidatesBothDependentLevels();
+ await testUnmountInvalidatesBothDependentLevels();
+ await testSchemaOnlyFullInitializationKeepsSchemaRequest();
+ testOnlyRealNamedDatabaseAndSchemaNodesBecomeOptions();
+ console.log('Select database request lifecycle tests passed');
+}
+
+void run();
diff --git a/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts
new file mode 100644
index 0000000000..00b8345a31
--- /dev/null
+++ b/chat2db-community-client/src/hooks/selectDatabaseRequestLifecycle.ts
@@ -0,0 +1,148 @@
+import { TreeNodeType } from '@/constants/tree';
+import type { TreeNodeData } from '@/typings/tree';
+import {
+ beginLatestRequest,
+ invalidateLatestRequest,
+ isLatestRequest,
+ type RequestGenerationRef,
+} from '@/utils/latestRequest';
+
+export interface SelectDatabaseOption {
+ value: string;
+ label: string;
+}
+
+interface LatestOptionRequest {
+ active: boolean;
+ generationRef: RequestGenerationRef;
+}
+
+export interface SelectDatabaseRequestLifecycle {
+ database: LatestOptionRequest;
+ schema: LatestOptionRequest;
+}
+
+export function hasApplicableDatabaseNameChange(changedValues: { databaseName?: string }, supportDatabase?: boolean) {
+ return supportDatabase !== false && 'databaseName' in changedValues;
+}
+
+function createLatestOptionRequest(): LatestOptionRequest {
+ return {
+ active: true,
+ generationRef: { current: 0 },
+ };
+}
+
+export function createSelectDatabaseRequestLifecycle(): SelectDatabaseRequestLifecycle {
+ return {
+ database: createLatestOptionRequest(),
+ schema: createLatestOptionRequest(),
+ };
+}
+
+function activateLatestOptionRequest(request: LatestOptionRequest) {
+ request.active = true;
+}
+
+function invalidateLatestOptionRequest(request: LatestOptionRequest) {
+ invalidateLatestRequest(request.generationRef);
+}
+
+function disposeLatestOptionRequest(request: LatestOptionRequest) {
+ request.active = false;
+ invalidateLatestOptionRequest(request);
+}
+
+export function activateSelectDatabaseRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ activateLatestOptionRequest(lifecycle.database);
+ activateLatestOptionRequest(lifecycle.schema);
+}
+
+export function invalidateDataSourceOptionRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ invalidateLatestOptionRequest(lifecycle.database);
+ invalidateLatestOptionRequest(lifecycle.schema);
+}
+
+export function invalidateDatabaseOptionRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ invalidateLatestOptionRequest(lifecycle.schema);
+}
+
+export function disposeSelectDatabaseRequests(lifecycle: SelectDatabaseRequestLifecycle) {
+ disposeLatestOptionRequest(lifecycle.database);
+ disposeLatestOptionRequest(lifecycle.schema);
+}
+
+async function runLatestOptionRequest(
+ request: LatestOptionRequest,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ if (!request.active) {
+ return false;
+ }
+
+ const generation = beginLatestRequest(request.generationRef);
+ try {
+ const value = await load();
+ if (!request.active || !isLatestRequest(request.generationRef, generation)) {
+ return false;
+ }
+ onSuccess(value);
+ return true;
+ } catch {
+ if (!request.active || !isLatestRequest(request.generationRef, generation)) {
+ return false;
+ }
+ onError();
+ return true;
+ }
+}
+
+export function runDatabaseOptionRequest(
+ lifecycle: SelectDatabaseRequestLifecycle,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ return runLatestOptionRequest(lifecycle.database, load, onSuccess, onError);
+}
+
+export function runSchemaOptionRequest(
+ lifecycle: SelectDatabaseRequestLifecycle,
+ load: () => Promise,
+ onSuccess: (value: T) => void,
+ onError: () => void,
+) {
+ return runLatestOptionRequest(lifecycle.schema, load, onSuccess, onError);
+}
+
+function normalizeNamedOptions(
+ nodes: TreeNodeData[],
+ expectedNodeType: TreeNodeType,
+ field: 'databaseName' | 'schemaName',
+): SelectDatabaseOption[] {
+ const seenValues = new Set();
+
+ return nodes.reduce((options, node) => {
+ const value = node.extraParams?.[field];
+ if (node.treeNodeType !== expectedNodeType || typeof value !== 'string' || !value.trim() || seenValues.has(value)) {
+ return options;
+ }
+
+ seenValues.add(value);
+ options.push({
+ value,
+ label: node.originalTitle?.trim() || value,
+ });
+ return options;
+ }, []);
+}
+
+export function normalizeDatabaseOptions(nodes: TreeNodeData[]) {
+ return normalizeNamedOptions(nodes, TreeNodeType.DATABASE, 'databaseName');
+}
+
+export function normalizeSchemaOptions(nodes: TreeNodeData[]) {
+ return normalizeNamedOptions(nodes, TreeNodeType.SCHEMA, 'schemaName');
+}
diff --git a/chat2db-community-client/src/hooks/useSelectDatabase.ts b/chat2db-community-client/src/hooks/useSelectDatabase.ts
index 72ec3cfeb3..e437cf8b2b 100644
--- a/chat2db-community-client/src/hooks/useSelectDatabase.ts
+++ b/chat2db-community-client/src/hooks/useSelectDatabase.ts
@@ -1,8 +1,21 @@
-import { useState, useMemo, useEffect } from 'react';
import { normalizeTreeNodeLoadResult, treeConfig } from '@/blocks/NewTree/treeConfig';
-import { DatabaseTypeCode } from '@/constants';
+import { DatabaseTypeCode, TreeNodeType } from '@/constants';
import { databaseMap } from '@/constants/database';
import { getDatabaseSupport } from '@/utils/database';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import {
+ activateSelectDatabaseRequests,
+ createSelectDatabaseRequestLifecycle,
+ disposeSelectDatabaseRequests,
+ hasApplicableDatabaseNameChange,
+ invalidateDatabaseOptionRequests,
+ invalidateDataSourceOptionRequests,
+ normalizeDatabaseOptions,
+ normalizeSchemaOptions,
+ runDatabaseOptionRequest,
+ runSchemaOptionRequest,
+ SelectDatabaseOption,
+} from './selectDatabaseRequestLifecycle';
export type ISelectDatabase = {
dataSourceId?: number;
@@ -24,36 +37,33 @@ interface IUseSelectDatabaseProps {
astrictDatabaseType?: DatabaseTypeCode;
}
+interface DataSourceOption {
+ value: number;
+ label: string;
+ databaseType: DatabaseTypeCode;
+}
+
const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
const { astrictDatabaseType } = props;
- const [dataSourceList, setDataSourceList] = useState<
- | {
- value: number;
- label: string;
- databaseType: DatabaseTypeCode;
- }[]
- | null
- >([]);
-
- const [databaseList, setDatabaseList] = useState<
- | {
- value: string;
- label: string;
- }[]
- | null
- >([]);
-
- const [schemaList, setSchemaList] = useState<
- | {
- value: string;
- label: string;
- }[]
- | null
- >([]);
-
+ const [dataSourceList, setDataSourceList] = useState([]);
+ const [databaseList, setDatabaseList] = useState([]);
+ const [schemaList, setSchemaList] = useState([]);
const [selectDatabase, setSelectDatabase] = useState();
+ const mountedRef = useRef(true);
+ const requestLifecycleRef = useRef(createSelectDatabaseRequestLifecycle());
+ const requestLifecycle = requestLifecycleRef.current;
useEffect(() => {
+ mountedRef.current = true;
+ activateSelectDatabaseRequests(requestLifecycle);
+ return () => {
+ mountedRef.current = false;
+ disposeSelectDatabaseRequests(requestLifecycle);
+ };
+ }, [requestLifecycle]);
+
+ useEffect(() => {
+ invalidateDataSourceOptionRequests(requestLifecycle);
setDatabaseList([]);
setSchemaList([]);
@@ -70,7 +80,7 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
return;
}
setSelectDatabase(null);
- }, [astrictDatabaseType]);
+ }, [astrictDatabaseType, requestLifecycle]);
const astrictDataSourceList = useMemo(() => {
if (astrictDatabaseType) {
@@ -79,117 +89,132 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
return dataSourceList;
}, [dataSourceList, astrictDatabaseType]);
- useEffect(() => {
- getDataSourceList();
- }, []);
-
const getDataSourceList = () => {
setDataSourceList(null);
+ invalidateDataSourceOptionRequests(requestLifecycle);
setDatabaseList([]);
setSchemaList([]);
- treeConfig['dataSources']
+ treeConfig[TreeNodeType.DATA_SOURCES]
.getChildren?.({
refresh: true,
})
.then((res) => {
- const _dataSourceList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.dataSourceId!,
- label: item.originalTitle,
- databaseType: item.extraParams.databaseType!,
- };
- });
- setDataSourceList(_dataSourceList);
+ if (!mountedRef.current) {
+ return;
+ }
+ const options = normalizeTreeNodeLoadResult(res).children.map((item) => ({
+ value: item.extraParams.dataSourceId!,
+ label: item.originalTitle,
+ databaseType: item.extraParams.databaseType!,
+ }));
+ setDataSourceList(options);
})
.catch(() => {
- setDataSourceList([]);
+ if (mountedRef.current) {
+ setDataSourceList([]);
+ }
});
};
+ useEffect(() => {
+ getDataSourceList();
+ }, []);
+
const getDatabaseList = (params: { dataSourceId: number; databaseType: DatabaseTypeCode }) => {
+ invalidateDatabaseOptionRequests(requestLifecycle);
setDatabaseList(null);
setSchemaList([]);
- treeConfig['dataSource']
- .getChildren?.({
- ...params,
- needAiDataCollections: false,
- refresh: true,
- })
- .then((res) => {
- const _databaseList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.databaseName!,
- label: item.originalTitle,
- };
- });
- setDatabaseList(_databaseList);
- })
- .catch(() => {
- setDatabaseList([]);
- });
+
+ const getChildren = treeConfig[TreeNodeType.DATA_SOURCE].getChildren;
+ if (!getChildren) {
+ setDatabaseList([]);
+ return;
+ }
+
+ void runDatabaseOptionRequest(
+ requestLifecycle,
+ () =>
+ getChildren({
+ ...params,
+ needAiDataCollections: false,
+ refresh: true,
+ }),
+ (res) => setDatabaseList(normalizeDatabaseOptions(normalizeTreeNodeLoadResult(res).children)),
+ () => setDatabaseList([]),
+ );
};
- const getSchemaList = (params) => {
+ const getSchemaList = (params: NonNullable) => {
setSchemaList(null);
- treeConfig['database']
- .getChildren?.({
- ...params,
- needAiDataCollections: false,
- refresh: true,
- })
- .then((res) => {
- const _schemaList = normalizeTreeNodeLoadResult(res).children.map((item) => {
- return {
- value: item.extraParams.schemaName!,
- label: item.originalTitle,
- };
- });
- setSchemaList(_schemaList);
- })
- .catch(() => {
- setSchemaList([]);
- });
+ const getChildren = treeConfig[TreeNodeType.DATABASE].getChildren;
+ if (!getChildren) {
+ setSchemaList([]);
+ return;
+ }
+
+ void runSchemaOptionRequest(
+ requestLifecycle,
+ () =>
+ getChildren({
+ ...params,
+ needAiDataCollections: false,
+ refresh: true,
+ }),
+ (res) => setSchemaList(normalizeSchemaOptions(normalizeTreeNodeLoadResult(res).children)),
+ () => setSchemaList([]),
+ );
};
const isSelectDone = (params: ISelectDatabase) => {
- let flag = true;
- if (params?.supportDatabase) {
- if (!params.databaseName) {
- flag = false;
- }
+ if (params?.supportDatabase && !params.databaseName) {
+ return false;
}
-
- if (params?.supportSchema) {
- if (!params.schemaName) {
- flag = false;
- }
+ if (params?.supportSchema && !params.schemaName) {
+ return false;
}
+ return true;
+ };
- return flag;
+ const resetSelectDatabase = (): ISelectDatabase => {
+ if (!astrictDatabaseType) {
+ return null;
+ }
+ const { supportSchema, supportDatabase } = databaseMap[astrictDatabaseType];
+ return {
+ databaseType: undefined,
+ supportSchema,
+ supportDatabase,
+ selectDone: false,
+ };
};
const onChangeSelectDatabase = (changedValues: IChangedValues) => {
- let newSelectDatabase: any = {
+ let newSelectDatabase: ISelectDatabase = {
...selectDatabase,
};
if ('dataSourceId' in changedValues) {
- const dataSource = astrictDataSourceList?.find((item) => item.value === changedValues?.dataSourceId);
+ invalidateDataSourceOptionRequests(requestLifecycle);
+ setDatabaseList([]);
+ setSchemaList([]);
+ const dataSource = astrictDataSourceList?.find((item) => item.value === changedValues.dataSourceId);
if (!dataSource) {
+ if (changedValues.dataSourceId !== undefined) {
+ return;
+ }
+ setSelectDatabase(resetSelectDatabase());
return;
}
const databaseType = dataSource.databaseType;
-
const { supportSchema, supportDatabase } = getDatabaseSupport(databaseType);
-
newSelectDatabase = {
dataSourceId: dataSource.value,
databaseName: undefined,
schemaName: undefined,
- selectDone: false,
+ selectDone: !supportDatabase && !supportSchema,
databaseType,
supportSchema,
supportDatabase,
@@ -198,44 +223,44 @@ const useSelectDatabase = (props: IUseSelectDatabaseProps) => {
if (supportDatabase) {
getDatabaseList({
dataSourceId: dataSource.value,
- databaseType: dataSource.databaseType,
- });
- } else {
- getSchemaList({
- dataSourceId: dataSource.value,
- databaseType: dataSource.databaseType
+ databaseType,
});
+ } else if (supportSchema) {
+ getSchemaList(newSelectDatabase);
}
-
}
-
- if ('databaseName' in changedValues) {
+
+ if (hasApplicableDatabaseNameChange(changedValues, newSelectDatabase?.supportDatabase)) {
+ invalidateDatabaseOptionRequests(requestLifecycle);
+ setSchemaList([]);
newSelectDatabase = {
...newSelectDatabase,
schemaName: undefined,
databaseName: changedValues.databaseName,
};
- // Do you choose to complete
- if (isSelectDone(newSelectDatabase)) {
- newSelectDatabase.selectDone = true;
+ newSelectDatabase.selectDone = isSelectDone(newSelectDatabase);
+ if (changedValues.databaseName && newSelectDatabase.supportSchema) {
+ getSchemaList(newSelectDatabase);
}
- getSchemaList(newSelectDatabase);
}
-
+
if ('schemaName' in changedValues) {
newSelectDatabase = {
...newSelectDatabase,
schemaName: changedValues.schemaName,
};
- // Do you choose to complete
- if (isSelectDone(newSelectDatabase)) {
- newSelectDatabase.selectDone = true;
- }
+ newSelectDatabase.selectDone = isSelectDone(newSelectDatabase);
}
setSelectDatabase(newSelectDatabase);
};
- return { dataSourceList: astrictDataSourceList, databaseList, schemaList, selectDatabase, onChangeSelectDatabase };
+ return {
+ dataSourceList: astrictDataSourceList,
+ databaseList,
+ schemaList,
+ selectDatabase,
+ onChangeSelectDatabase,
+ };
};
export default useSelectDatabase;
diff --git a/chat2db-community-client/src/pages/main/organization/components/SQLAudit/index.tsx b/chat2db-community-client/src/pages/main/organization/components/SQLAudit/index.tsx
index 03c95c016d..cd61883dbd 100644
--- a/chat2db-community-client/src/pages/main/organization/components/SQLAudit/index.tsx
+++ b/chat2db-community-client/src/pages/main/organization/components/SQLAudit/index.tsx
@@ -1,31 +1,37 @@
-import { useEffect, useMemo, useState } from 'react';
-import historyService, { IHistoryRecord, OperationTypeEnum } from '@/service/history';
-import { Button } from 'antd';
-import i18n from '@/i18n';
-import { useStyles } from './style';
+import AntdTable from '@/components/AntdTable';
+import OperationLogFilters, {
+ OperationLogFilterValues,
+ useDebouncedOperationLogFilters,
+} from '@/components/OperationLogFilters';
+import { areOperationLogFiltersEqual, buildOperationLogListParams } from '@/components/OperationLogFilters/model';
import PageTitle from '@/components/PageTitle';
+import i18n from '@/i18n';
+import historyService, { IHistoryRecord, OperationTypeEnum } from '@/service/history';
import { IconfontSvg } from '@chat2db/ui';
-import AntdTable from '@/components/AntdTable';
-/**
- historyService
- .getHistoryList({
- // dataSourceId:props.curWorkspaceParams.dataSourceId,
- pageNo: curPageRef.current++,
- pageSize: 40,
- operationType: OperationTypeEnum.SQL_EXECUTE,
- })
- */
+import { Button, TablePaginationConfig } from 'antd';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useStyles } from './style';
-const initPagination = {
- searchKey: '',
- current: 1,
- pageSize: 10,
- total: 0,
-};
+interface QueryState {
+ current: number;
+ pageSize: number;
+ filters: OperationLogFilterValues;
+ refreshVersion: number;
+}
const SQLAudit = () => {
const [dataSource, setDataSource] = useState([]);
- const [pagination, setPagination] = useState(initPagination);
+ const [filters, setFilters] = useState({});
+ const [query, setQuery] = useState({
+ current: 1,
+ pageSize: 10,
+ filters: {},
+ refreshVersion: 0,
+ });
+ const [total, setTotal] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const appliedFilters = useDebouncedOperationLogFilters(filters);
+ const requestGenerationRef = useRef(0);
const { styles } = useStyles();
@@ -53,62 +59,89 @@ const SQLAudit = () => {
);
useEffect(() => {
- queryHistoryList();
- }, [pagination.searchKey, pagination.current, pagination.pageSize]);
-
- const queryHistoryList = async () => {
- const res = await historyService.getHistoryList({
- pageNo: pagination.current,
- pageSize: pagination.pageSize,
- searchKey: pagination.searchKey,
- operationType: OperationTypeEnum.SQL_AUDIT,
+ setDataSource([]);
+ setTotal(0);
+ setQuery((previousQuery) => {
+ if (areOperationLogFiltersEqual(previousQuery.filters, appliedFilters)) {
+ return previousQuery;
+ }
+ return {
+ ...previousQuery,
+ current: 1,
+ filters: appliedFilters,
+ };
});
+ }, [appliedFilters]);
- if (res) {
- setDataSource(res?.data ?? []);
- setPagination({
- ...pagination,
- total: res.total,
- });
- }
- };
+ useEffect(() => {
+ const generation = requestGenerationRef.current + 1;
+ requestGenerationRef.current = generation;
+ setLoading(true);
- const handleTableChange = (p) => {
- setPagination({
- ...pagination,
- ...p,
- });
- };
+ const queryHistoryList = async () => {
+ try {
+ const res = await historyService.getHistoryList(
+ buildOperationLogListParams(query.filters, query.current, query.pageSize, OperationTypeEnum.SQL_AUDIT),
+ );
+
+ if (generation !== requestGenerationRef.current) {
+ return;
+ }
+ setDataSource(res?.data ?? []);
+ setTotal(res?.total ?? 0);
+ } catch {
+ // Request errors are surfaced by the shared request layer.
+ } finally {
+ if (generation === requestGenerationRef.current) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void queryHistoryList();
+
+ return () => {
+ if (generation === requestGenerationRef.current) {
+ requestGenerationRef.current += 1;
+ }
+ };
+ }, [query]);
+
+ const handleTableChange = useCallback((pagination: TablePaginationConfig) => {
+ setQuery((previousQuery) => ({
+ ...previousQuery,
+ current: pagination.current ?? 1,
+ pageSize: pagination.pageSize ?? previousQuery.pageSize,
+ }));
+ }, []);
+
+ const refresh = useCallback(() => {
+ setQuery((previousQuery) => ({
+ ...previousQuery,
+ refreshVersion: previousQuery.refreshVersion + 1,
+ }));
+ }, []);
return (
- {/*
}
- /> */}
-
-
-
-
}
- onClick={() => {
- queryHistoryList();
- }}
- >
+
+
} onClick={refresh}>
{i18n('common.button.refresh')}
diff --git a/chat2db-community-client/src/pages/main/organization/components/SQLAudit/style.ts b/chat2db-community-client/src/pages/main/organization/components/SQLAudit/style.ts
index e4db53878e..797bc8a53c 100644
--- a/chat2db-community-client/src/pages/main/organization/components/SQLAudit/style.ts
+++ b/chat2db-community-client/src/pages/main/organization/components/SQLAudit/style.ts
@@ -10,10 +10,13 @@ export const useStyles = createStyles(({ css }) => {
`,
tableTop: css`
display: flex;
- justify-content: space-between;
align-items: center;
+ gap: 12px;
margin: 16px 0;
`,
+ filters: css`
+ min-width: 0;
+ `,
empty: css`
display: flex;
justify-content: center;
diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
index e84681c49d..4cc7fa2732 100644
--- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
+++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
@@ -28,10 +28,12 @@
import ai.chat2db.community.tools.wrapper.result.DataResult;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.DateUtil;
+import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.List;
+import java.util.Objects;
@Component
public class LocalWorkspaceStorage implements IWorkspaceStorage {
@@ -166,7 +168,9 @@ public Long createOperationLog(OperationLog request) {
@Override
public PageResponse operationLogList(OpsOperationLogPageQueryRequest operationLogPageQueryRequest) {
- List logs = OperationLogStorage.INSTANCE.getDataList();
+ List logs = OperationLogStorage.INSTANCE.getDataList().stream()
+ .filter(operationLog -> matchesOperationLog(operationLog, operationLogPageQueryRequest))
+ .toList();
return page(logs, operationLogPageQueryRequest.getPageNo(), operationLogPageQueryRequest.getPageSize());
}
@@ -219,6 +223,26 @@ private String encryptString(String password) {
return AesGcmUtil.configured().encrypt(password);
}
+ private boolean matchesOperationLog(OperationLog operationLog, OpsOperationLogPageQueryRequest request) {
+ if (operationLog == null) {
+ return false;
+ }
+ if (request.getDataSourceId() != null
+ && !Objects.equals(request.getDataSourceId(), operationLog.getDataSourceId())) {
+ return false;
+ }
+ if (StringUtils.isNotBlank(request.getDatabaseName())
+ && !Objects.equals(request.getDatabaseName(), operationLog.getDatabaseName())) {
+ return false;
+ }
+ if (StringUtils.isNotBlank(request.getSchemaName())
+ && !Objects.equals(request.getSchemaName(), operationLog.getSchemaName())) {
+ return false;
+ }
+ String searchKey = StringUtils.trimToNull(request.getSearchKey());
+ return searchKey == null || StringUtils.containsIgnoreCase(operationLog.getDdl(), searchKey);
+ }
+
private int normalizePageNo(Integer pageNo) {
return Math.max(1, pageNo == null ? 1 : pageNo);
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
index d229ec4c39..e649cf4090 100644
--- a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
+++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
@@ -97,6 +97,64 @@ void operationLogListReturnsOnlyTheRequestedSlice() {
assertFalse(page3.getHasNextPage());
}
+ @Test
+ void operationLogListFiltersFullSqlCaseInsensitivelyBeforePagination() {
+ String firstDdl = "a".repeat(220) + " FrOm OrDeRs";
+ String secondDdl = "b".repeat(220) + " FROM ORDERS";
+ Long firstMatchId = saveOperationLog(1L, "sales", "public", firstDdl);
+ Long secondMatchId = saveOperationLog(1L, "sales", "public", secondDdl);
+ saveOperationLog(1L, "sales", "public", "select * from customers");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setSearchKey("from orders");
+
+ PageResponse page1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page1.getData().size());
+ assertEquals(secondMatchId, page1.getData().get(0).getId());
+ assertEquals(secondDdl, page1.getData().get(0).getDdl());
+ assertEquals(2L, page1.getTotal());
+ assertTrue(page1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse page2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page2.getData().size());
+ assertEquals(firstMatchId, page2.getData().get(0).getId());
+ assertEquals(firstDdl, page2.getData().get(0).getDdl());
+ assertEquals(2L, page2.getTotal());
+ assertFalse(page2.getHasNextPage());
+ }
+
+ @Test
+ void operationLogListAppliesAllScopeFiltersBeforePagination() {
+ Long firstMatchId = saveOperationLog(7L, "warehouse", "analytics", "select 1");
+ Long secondMatchId = saveOperationLog(7L, "warehouse", "analytics", "select 2");
+ saveOperationLog(8L, "warehouse", "analytics", "wrong datasource");
+ saveOperationLog(7L, "reporting", "analytics", "wrong database");
+ saveOperationLog(7L, "warehouse", "staging", "wrong schema");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setDataSourceId(7L);
+ request.setDatabaseName("warehouse");
+ request.setSchemaName("analytics");
+
+ PageResponse page1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page1.getData().size());
+ assertEquals(secondMatchId, page1.getData().get(0).getId());
+ assertEquals(2L, page1.getTotal());
+ assertTrue(page1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse page2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, page2.getData().size());
+ assertEquals(firstMatchId, page2.getData().get(0).getId());
+ assertEquals(2L, page2.getTotal());
+ assertFalse(page2.getHasNextPage());
+ }
+
@Test
void listDataSourcesReturnsOnlyTheRequestedSlice() {
for (int i = 0; i < 3; i++) {
@@ -191,4 +249,13 @@ void fullUpdateStillPreservesStoredPasswordInsideDatasourceStorage() {
System.clearProperty(AesGcmUtil.KEY_PROPERTY);
}
}
+
+ private static Long saveOperationLog(Long dataSourceId, String databaseName, String schemaName, String ddl) {
+ OperationLog log = new OperationLog();
+ log.setDataSourceId(dataSourceId);
+ log.setDatabaseName(databaseName);
+ log.setSchemaName(schemaName);
+ log.setDdl(ddl);
+ return OperationLogStorage.INSTANCE.save(log);
+ }
}
From d9c2c1c46d826df9a2a4da08ed436fa10f490c30 Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:49:23 +0800
Subject: [PATCH 2/5] fix(history): filter operation type in local workspace
storage
---
.../api/model/operation/OperationLog.java | 3 ++
.../OpsOperationLogPageQueryRequest.java | 3 ++
.../storage/LocalWorkspaceStorage.java | 4 ++
.../LocalWorkspaceStoragePaginationTest.java | 49 +++++++++++++++++++
.../log/OperationLogConverterTest.java | 32 ++++++++++++
5 files changed, 91 insertions(+)
create mode 100644 chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
index 427edf2834..de926a42fc 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/operation/OperationLog.java
@@ -38,6 +38,9 @@ public class OperationLog {
private String type;
+ private String operationType;
+
+
private String ddl;
private Boolean more = false;
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
index b6047f40e3..e12ebcc305 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/model/request/operation/OpsOperationLogPageQueryRequest.java
@@ -24,4 +24,7 @@ public class OpsOperationLogPageQueryRequest extends PageQueryParam {
private String schemaName;
+
+
+ private String operationType;
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
index 4cc7fa2732..3c16ac621a 100644
--- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
+++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
@@ -239,6 +239,10 @@ private boolean matchesOperationLog(OperationLog operationLog, OpsOperationLogPa
&& !Objects.equals(request.getSchemaName(), operationLog.getSchemaName())) {
return false;
}
+ if (StringUtils.isNotBlank(request.getOperationType())
+ && !Objects.equals(request.getOperationType(), operationLog.getOperationType())) {
+ return false;
+ }
String searchKey = StringUtils.trimToNull(request.getSearchKey());
return searchKey == null || StringUtils.containsIgnoreCase(operationLog.getDdl(), searchKey);
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
index e649cf4090..f944dc7ab3 100644
--- a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
+++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
@@ -155,6 +155,49 @@ void operationLogListAppliesAllScopeFiltersBeforePagination() {
assertFalse(page2.getHasNextPage());
}
+ @Test
+ void operationLogListSeparatesSqlExecuteAndSqlAuditStreamsBeforePagination() {
+ Long firstExecuteId = saveOperationLog(1L, "sales", "public", "select 1", "SQL_EXECUTE");
+ Long secondExecuteId = saveOperationLog(1L, "sales", "public", "select 2", "SQL_EXECUTE");
+ saveOperationLog(1L, "sales", "public", "select 3", "SQL_AUDIT");
+ saveOperationLog(1L, "sales", "public", "select 4", "SQL_AUDIT");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(1);
+ request.setOperationType("SQL_EXECUTE");
+
+ PageResponse executePage1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage1.getData().size());
+ assertEquals(secondExecuteId, executePage1.getData().get(0).getId());
+ assertEquals("SQL_EXECUTE", executePage1.getData().get(0).getOperationType());
+ assertEquals(2L, executePage1.getTotal());
+ assertTrue(executePage1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse executePage2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage2.getData().size());
+ assertEquals(firstExecuteId, executePage2.getData().get(0).getId());
+ assertEquals("SQL_EXECUTE", executePage2.getData().get(0).getOperationType());
+ assertEquals(2L, executePage2.getTotal());
+ assertFalse(executePage2.getHasNextPage());
+
+ request.setPageNo(1);
+ request.setOperationType("SQL_AUDIT");
+ PageResponse auditPage1 = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage1.getData().size());
+ assertEquals("select 4", auditPage1.getData().get(0).getDdl());
+ assertEquals(2L, auditPage1.getTotal());
+ assertTrue(auditPage1.getHasNextPage());
+
+ request.setPageNo(2);
+ PageResponse auditPage2 = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage2.getData().size());
+ assertEquals("select 3", auditPage2.getData().get(0).getDdl());
+ assertEquals(2L, auditPage2.getTotal());
+ assertFalse(auditPage2.getHasNextPage());
+ }
+
@Test
void listDataSourcesReturnsOnlyTheRequestedSlice() {
for (int i = 0; i < 3; i++) {
@@ -251,11 +294,17 @@ void fullUpdateStillPreservesStoredPasswordInsideDatasourceStorage() {
}
private static Long saveOperationLog(Long dataSourceId, String databaseName, String schemaName, String ddl) {
+ return saveOperationLog(dataSourceId, databaseName, schemaName, ddl, null);
+ }
+
+ private static Long saveOperationLog(Long dataSourceId, String databaseName, String schemaName, String ddl,
+ String operationType) {
OperationLog log = new OperationLog();
log.setDataSourceId(dataSourceId);
log.setDatabaseName(databaseName);
log.setSchemaName(schemaName);
log.setDdl(ddl);
+ log.setOperationType(operationType);
return OperationLogStorage.INSTANCE.save(log);
}
}
diff --git a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
new file mode 100644
index 0000000000..6d8c7315c2
--- /dev/null
+++ b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
@@ -0,0 +1,32 @@
+package ai.chat2db.community.web.api.converter.operation.log;
+
+import ai.chat2db.community.domain.api.model.request.operation.OpsOperationLogPageQueryRequest;
+import ai.chat2db.community.web.api.model.request.operation.log.OperationLogQueryRequest;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class OperationLogConverterTest {
+
+ private final OperationLogConverter converter = new OperationLogConverterImpl();
+
+ @Test
+ void request2paramCarriesOperationTypeAndScopeFilters() {
+ OperationLogQueryRequest request = new OperationLogQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(20);
+ request.setSearchKey("from orders");
+ request.setDataSourceId(7L);
+ request.setDatabaseName("warehouse");
+ request.setSchemaName("analytics");
+ request.setOperationType("SQL_EXECUTE");
+
+ OpsOperationLogPageQueryRequest param = converter.request2param(request);
+
+ assertEquals("SQL_EXECUTE", param.getOperationType());
+ assertEquals("from orders", param.getSearchKey());
+ assertEquals(7L, param.getDataSourceId());
+ assertEquals("warehouse", param.getDatabaseName());
+ assertEquals("analytics", param.getSchemaName());
+ }
+}
From 8162c5f326e91d7cc2bb0e9334030b0b1423f3c2 Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Sun, 23 Aug 2026 12:44:02 +0800
Subject: [PATCH 3/5] fix(history): tag execution logs with operation type
---
.../enums/operation/OperationTypeEnum.java | 18 ++++++++++++++++
.../converter/SqlOperationLogConverter.java | 3 +++
.../SqlOperationLogConverterTest.java | 15 +++++++++++++
.../storage/LocalWorkspaceStorage.java | 12 ++++++++---
.../LocalWorkspaceStoragePaginationTest.java | 21 +++++++++++++++++++
.../operation/log/OperationLogResponse.java | 3 +++
.../log/OperationLogConverterTest.java | 13 ++++++++++++
7 files changed, 82 insertions(+), 3 deletions(-)
create mode 100644 chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java
new file mode 100644
index 0000000000..a5059e5aec
--- /dev/null
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-api/src/main/java/ai/chat2db/community/domain/api/enums/operation/OperationTypeEnum.java
@@ -0,0 +1,18 @@
+package ai.chat2db.community.domain.api.enums.operation;
+
+/**
+ * User-facing operation log categories. Mirrors the frontend
+ * {@code OperationTypeEnum} contract in chat2db-community-client.
+ */
+public enum OperationTypeEnum {
+
+ /**
+ * SQL execution history, e.g. the console output panel.
+ */
+ SQL_EXECUTE,
+
+ /**
+ * SQL audit history.
+ */
+ SQL_AUDIT
+}
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
index c902814a20..4aac4df737 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/main/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverter.java
@@ -2,6 +2,7 @@
import java.util.Map;
+import ai.chat2db.community.domain.api.enums.operation.OperationTypeEnum;
import ai.chat2db.community.domain.api.enums.operation.SqlOperationLogStatusEnum;
import ai.chat2db.community.domain.api.model.operation.OperationLog;
import ai.chat2db.community.domain.api.model.operation.SqlOperationLogRecord;
@@ -55,6 +56,8 @@ public OperationLog sqlRecord2operationLog(SqlOperationLogRecord record) {
operationLog.setSchemaName(connectionProfile.getSchemaName());
operationLog.setUseTime(record.getUseTime());
operationLog.setType(connectionProfile.getDbType());
+ // The recording pipeline only logs executions; audit entries enter through the create endpoint.
+ operationLog.setOperationType(OperationTypeEnum.SQL_EXECUTE.name());
operationLog.setOperationRows(record.getOperationRows());
operationLog.setExtendInfo(JSON.toJSONString(Map.of(
"source", StringUtils.defaultString(record.getSource()),
diff --git a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
index 3ccb3bde1c..70fc31d1f4 100644
--- a/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
+++ b/chat2db-community-server/chat2db-community-domain/chat2db-community-domain-core/src/test/java/ai/chat2db/community/domain/core/converter/SqlOperationLogConverterTest.java
@@ -1,5 +1,6 @@
package ai.chat2db.community.domain.core.converter;
+import ai.chat2db.community.domain.api.model.operation.OperationLog;
import ai.chat2db.community.domain.api.model.operation.SqlOperationLogRecord;
import ai.chat2db.community.domain.api.model.result.ExecuteResponse;
import ai.chat2db.community.domain.api.model.result.ExecutionMetrics;
@@ -24,4 +25,18 @@ void mapsUseTimeFromExecutionMetrics() {
assertEquals(17L, record.getUseTime());
}
+
+ @Test
+ void tagsRecordedExecutionsAsSqlExecute() {
+ SqlOperationLogRecord record = SqlOperationLogRecord.builder()
+ .sql("select 1")
+ .status("success")
+ .connectionProfile(new ConnectionProfile())
+ .context(new Context())
+ .build();
+
+ OperationLog operationLog = new SqlOperationLogConverter().sqlRecord2operationLog(record);
+
+ assertEquals("SQL_EXECUTE", operationLog.getOperationType());
+ }
}
diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
index 3c16ac621a..53d1961245 100644
--- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
+++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
@@ -2,6 +2,7 @@
import ai.chat2db.community.domain.api.model.PageResponse;
import ai.chat2db.community.domain.api.enums.StorageTypeEnum;
+import ai.chat2db.community.domain.api.enums.operation.OperationTypeEnum;
import ai.chat2db.community.domain.api.model.datasource.DataSource;
import ai.chat2db.community.domain.api.model.datasource.DataSourceIdentityColorUtils;
import ai.chat2db.community.domain.api.model.datasource.DataSourceNamespace;
@@ -239,9 +240,14 @@ private boolean matchesOperationLog(OperationLog operationLog, OpsOperationLogPa
&& !Objects.equals(request.getSchemaName(), operationLog.getSchemaName())) {
return false;
}
- if (StringUtils.isNotBlank(request.getOperationType())
- && !Objects.equals(request.getOperationType(), operationLog.getOperationType())) {
- return false;
+ if (StringUtils.isNotBlank(request.getOperationType())) {
+ // Logs saved before operationType existed all come from the execution pipeline,
+ // so default them to SQL_EXECUTE instead of hiding them from the execution panel.
+ String operationType = StringUtils.defaultIfBlank(operationLog.getOperationType(),
+ OperationTypeEnum.SQL_EXECUTE.name());
+ if (!Objects.equals(request.getOperationType(), operationType)) {
+ return false;
+ }
}
String searchKey = StringUtils.trimToNull(request.getSearchKey());
return searchKey == null || StringUtils.containsIgnoreCase(operationLog.getDdl(), searchKey);
diff --git a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
index f944dc7ab3..198db889ad 100644
--- a/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
+++ b/chat2db-community-server/chat2db-community-storage/src/test/java/ai/chat2db/community/storage/LocalWorkspaceStoragePaginationTest.java
@@ -198,6 +198,27 @@ void operationLogListSeparatesSqlExecuteAndSqlAuditStreamsBeforePagination() {
assertFalse(auditPage2.getHasNextPage());
}
+ @Test
+ void operationLogListTreatsLegacyUntypedLogsAsSqlExecute() {
+ Long legacyId = saveOperationLog(1L, "sales", "public", "select 0");
+ saveOperationLog(1L, "sales", "public", "select 9", "SQL_AUDIT");
+
+ OpsOperationLogPageQueryRequest request = new OpsOperationLogPageQueryRequest();
+ request.setPageNo(1);
+ request.setPageSize(10);
+ request.setOperationType("SQL_EXECUTE");
+ PageResponse executePage = workspaceStorage.operationLogList(request);
+ assertEquals(1, executePage.getData().size());
+ assertEquals(legacyId, executePage.getData().get(0).getId());
+ assertEquals(1L, executePage.getTotal());
+
+ request.setOperationType("SQL_AUDIT");
+ PageResponse auditPage = workspaceStorage.operationLogList(request);
+ assertEquals(1, auditPage.getData().size());
+ assertEquals("select 9", auditPage.getData().get(0).getDdl());
+ assertEquals(1L, auditPage.getTotal());
+ }
+
@Test
void listDataSourcesReturnsOnlyTheRequestedSlice() {
for (int i = 0; i < 3; i++) {
diff --git a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
index ac2ed2ff49..c03b916690 100644
--- a/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
+++ b/chat2db-community-server/chat2db-community-web/src/main/java/ai/chat2db/community/web/api/model/response/operation/log/OperationLogResponse.java
@@ -36,6 +36,9 @@ public class OperationLogResponse {
private String type;
+ private String operationType;
+
+
private String ddl;
diff --git a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
index 6d8c7315c2..8b1f4c84d5 100644
--- a/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
+++ b/chat2db-community-server/chat2db-community-web/src/test/java/ai/chat2db/community/web/api/converter/operation/log/OperationLogConverterTest.java
@@ -1,7 +1,9 @@
package ai.chat2db.community.web.api.converter.operation.log;
+import ai.chat2db.community.domain.api.model.operation.OperationLog;
import ai.chat2db.community.domain.api.model.request.operation.OpsOperationLogPageQueryRequest;
import ai.chat2db.community.web.api.model.request.operation.log.OperationLogQueryRequest;
+import ai.chat2db.community.web.api.model.response.operation.log.OperationLogResponse;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -29,4 +31,15 @@ void request2paramCarriesOperationTypeAndScopeFilters() {
assertEquals("warehouse", param.getDatabaseName());
assertEquals("analytics", param.getSchemaName());
}
+
+ @Test
+ void toResponseExposesOperationType() {
+ OperationLog operationLog = new OperationLog();
+ operationLog.setDdl("select 1");
+ operationLog.setOperationType("SQL_EXECUTE");
+
+ OperationLogResponse response = converter.toResponse(operationLog);
+
+ assertEquals("SQL_EXECUTE", response.getOperationType());
+ }
}
From 1740eca907f66fa3593093620dad6f4d84838734 Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Sun, 23 Aug 2026 13:26:03 +0800
Subject: [PATCH 4/5] fix(history): default operation type on explicit log
creation
---
.../ai/chat2db/community/storage/LocalWorkspaceStorage.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
index 53d1961245..625aba7d3a 100644
--- a/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
+++ b/chat2db-community-server/chat2db-community-storage/src/main/java/ai/chat2db/community/storage/LocalWorkspaceStorage.java
@@ -164,6 +164,8 @@ public void deletePinTable(PinTable request) {
public Long createOperationLog(OperationLog request) {
request.setGmtCreate(DateUtil.format(new Date(), DatePattern.NORM_DATETIME_PATTERN));
request.setGmtModified(DateUtil.format(new Date(), DatePattern.NORM_DATETIME_PATTERN));
+ request.setOperationType(StringUtils.defaultIfBlank(request.getOperationType(),
+ OperationTypeEnum.SQL_EXECUTE.name()));
return OperationLogStorage.INSTANCE.save(request);
}
From 95f1c844421ff5a3a53158e76211eb98cb93f397 Mon Sep 17 00:00:00 2001
From: HandSonic <8078023+handsonic@users.noreply.github.com>
Date: Sun, 23 Aug 2026 15:07:20 +0800
Subject: [PATCH 5/5] refactor(history): extract request guard predicates, drop
dead filter equality helper
---
.../OperationLogFilters/model.test.ts | 38 ++++++++++++++-----
.../components/OperationLogFilters/model.ts | 33 +++++++++++-----
.../src/components/Output/index.tsx | 23 +++++++----
3 files changed, 69 insertions(+), 25 deletions(-)
diff --git a/chat2db-community-client/src/components/OperationLogFilters/model.test.ts b/chat2db-community-client/src/components/OperationLogFilters/model.test.ts
index c7ad07bebc..5211e1ec0e 100644
--- a/chat2db-community-client/src/components/OperationLogFilters/model.test.ts
+++ b/chat2db-community-client/src/components/OperationLogFilters/model.test.ts
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict';
import type { OperationTypeEnum } from '@/service/history';
import {
- areOperationLogFiltersEqual,
buildOperationLogListParams,
normalizeOperationLogFilters,
+ shouldApplyOperationLogPageResponse,
+ shouldStartOperationLogPageRequest,
updateOperationLogFilters,
} from './model';
@@ -49,14 +50,6 @@ const sqlExecute = 'SQL_EXECUTE' as OperationTypeEnum;
}
{
- assert.equal(
- areOperationLogFiltersEqual(
- { dataSourceId: 12, databaseName: ' application ', searchKey: 'orders' },
- { dataSourceId: 12, databaseName: 'application', searchKey: ' orders ' },
- ),
- true,
- );
-
assert.deepEqual(
buildOperationLogListParams(
{
@@ -81,4 +74,31 @@ const sqlExecute = 'SQL_EXECUTE' as OperationTypeEnum;
);
}
+{
+ const current = { currentGeneration: 3, finished: false };
+
+ // Fresh replace request for the current generation starts.
+ assert.equal(shouldStartOperationLogPageRequest(3, true, { ...current }), true);
+ // Append request starts while pages remain.
+ assert.equal(shouldStartOperationLogPageRequest(3, false, { ...current }), true);
+ // Stale generation from an older filter change is dropped.
+ assert.equal(shouldStartOperationLogPageRequest(2, true, { ...current }), false);
+ // Append is dropped once the stream is finished, but a replace (filter change) still runs.
+ assert.equal(shouldStartOperationLogPageRequest(3, false, { ...current, finished: true }), false);
+ assert.equal(shouldStartOperationLogPageRequest(3, true, { ...current, finished: true }), true);
+ // A request already in flight for the same generation is not duplicated.
+ assert.equal(shouldStartOperationLogPageRequest(3, true, { ...current, activeRequestGeneration: 3 }), false);
+ assert.equal(shouldStartOperationLogPageRequest(3, true, { ...current, activeRequestGeneration: 2 }), true);
+}
+
+{
+ const response = { mounted: true, currentGeneration: 5 };
+
+ assert.equal(shouldApplyOperationLogPageResponse(5, response), true);
+ // Response arriving after a filter change (generation bumped) is dropped.
+ assert.equal(shouldApplyOperationLogPageResponse(4, response), false);
+ // Response arriving after unmount is dropped.
+ assert.equal(shouldApplyOperationLogPageResponse(5, { ...response, mounted: false }), false);
+}
+
console.log('Operation log filter tests passed');
diff --git a/chat2db-community-client/src/components/OperationLogFilters/model.ts b/chat2db-community-client/src/components/OperationLogFilters/model.ts
index 46f034f608..a62454c3bd 100644
--- a/chat2db-community-client/src/components/OperationLogFilters/model.ts
+++ b/chat2db-community-client/src/components/OperationLogFilters/model.ts
@@ -67,16 +67,31 @@ export function updateOperationLogFilters(
};
}
-export function areOperationLogFiltersEqual(left: OperationLogFilterValues, right: OperationLogFilterValues) {
- const normalizedLeft = normalizeOperationLogFilters(left);
- const normalizedRight = normalizeOperationLogFilters(right);
+export interface OperationLogPageRequestState {
+ currentGeneration: number;
+ finished: boolean;
+ activeRequestGeneration?: number;
+}
+
+export function shouldStartOperationLogPageRequest(
+ generation: number,
+ replace: boolean,
+ state: OperationLogPageRequestState,
+): boolean {
+ if (generation !== state.currentGeneration) {
+ return false;
+ }
+ if (!replace && state.finished) {
+ return false;
+ }
+ return state.activeRequestGeneration !== generation;
+}
- return (
- normalizedLeft.dataSourceId === normalizedRight.dataSourceId &&
- normalizedLeft.databaseName === normalizedRight.databaseName &&
- normalizedLeft.schemaName === normalizedRight.schemaName &&
- normalizedLeft.searchKey === normalizedRight.searchKey
- );
+export function shouldApplyOperationLogPageResponse(
+ generation: number,
+ state: { mounted: boolean; currentGeneration: number },
+): boolean {
+ return state.mounted && generation === state.currentGeneration;
}
export function buildOperationLogListParams(
diff --git a/chat2db-community-client/src/components/Output/index.tsx b/chat2db-community-client/src/components/Output/index.tsx
index 09c0d65297..097dc4d451 100644
--- a/chat2db-community-client/src/components/Output/index.tsx
+++ b/chat2db-community-client/src/components/Output/index.tsx
@@ -5,7 +5,11 @@ import OperationLogFilters, {
OperationLogFilterValues,
useDebouncedOperationLogFilters,
} from '@/components/OperationLogFilters';
-import { buildOperationLogListParams } from '@/components/OperationLogFilters/model';
+import {
+ buildOperationLogListParams,
+ shouldApplyOperationLogPageResponse,
+ shouldStartOperationLogPageRequest,
+} from '@/components/OperationLogFilters/model';
import historyService, { IHistoryRecord, OperationTypeEnum } from '@/service/history';
import i18n from '@/i18n';
import { useStyles } from './style';
@@ -131,11 +135,13 @@ export default memo((props) => {
const loadHistoryPage = useCallback(
async (pageNo: number, requestFilters: OperationLogFilterValues, generation: number, replace: boolean) => {
- if (generation !== requestGenerationRef.current || (!replace && finishedRef.current)) {
- return;
- }
-
- if (activeRequestRef.current?.generation === generation) {
+ if (
+ !shouldStartOperationLogPageRequest(generation, replace, {
+ currentGeneration: requestGenerationRef.current,
+ finished: finishedRef.current,
+ activeRequestGeneration: activeRequestRef.current?.generation,
+ })
+ ) {
return;
}
@@ -147,7 +153,10 @@ export default memo((props) => {
buildOperationLogListParams(requestFilters, pageNo, 40, OperationTypeEnum.SQL_EXECUTE),
);
- if (!mountedRef.current || generation !== requestGenerationRef.current) {
+ if (!shouldApplyOperationLogPageResponse(generation, {
+ mounted: mountedRef.current,
+ currentGeneration: requestGenerationRef.current,
+ })) {
return;
}