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 (
- {/* } - /> */} - -
- -
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; }