feat(sql-audit): 新增 SQL 审计中心并完善事务追踪

- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
This commit is contained in:
Syngnat
2026-07-12 15:24:15 +08:00
parent 0b33b3a683
commit 7c8e8d8dd3
89 changed files with 11421 additions and 194 deletions

View File

@@ -24,6 +24,7 @@ const getTabKindLabel = (type: string): string => {
if (type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (type === 'sql-file-execution') return t('sidebar.sql_file_exec.title');
if (type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
if (type === 'sql-audit') return t('tab_manager.kind_badge.sql_audit');
if (type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (type === 'trigger') return t('tab_manager.kind_badge.trigger');

View File

@@ -15,6 +15,7 @@ const storeState = {
duration: number;
message?: string;
affectedRows?: number;
category?: "query" | "transaction";
}>,
clearSqlLogs: vi.fn(),
theme: "light",
@@ -153,6 +154,7 @@ describe("LogPanel i18n", () => {
sql: "START TRANSACTION;\nUPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;\nCOMMIT;",
status: "success",
duration: 295,
category: "transaction",
},
];
@@ -164,6 +166,7 @@ describe("LogPanel i18n", () => {
));
expect(sqlNodes).toHaveLength(1);
expect(textContent(sqlNodes[0])).toContain("TX");
expect(textContent(sqlNodes[0])).toContain("UPDATE `users` SET `name` = 'new-name' WHERE `id` = 8;");
});

View File

@@ -101,6 +101,9 @@ const LogPanel: React.FC<LogPanelProps> = ({
dataIndex: 'sql',
render: (text: string, record: any) => (
<div style={{ fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all', whiteSpace: 'pre-wrap', fontSize: '12px', lineHeight: '1.45' }}>
{record.category === 'transaction' && (
<Tag color="processing" style={{ margin: '0 0 4px', borderRadius: 999, fontSize: 10, fontWeight: 700 }}>TX</Tag>
)}
<div style={{ color: darkMode ? '#a6e22e' : '#005cc5' }}>{text}</div>
{record.message && <div style={{ color: '#ff4d4f', marginTop: 2 }}>{record.message}</div>}
{record.affectedRows !== undefined && <div style={{ color: panelMutedTextColor, marginTop: 1 }}>{t('log_panel.affected_rows', { count: record.affectedRows })}</div>}

View File

@@ -2,7 +2,7 @@ import Modal from './common/ResizableDraggableModal';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Checkbox, Form, Input, Select, Space, Typography, message } from 'antd';
import { DBQuery } from '../../wailsjs/go/app/App';
import { DBQueryAudited } from '../../wailsjs/go/app/App';
import type { SavedConnection } from '../types';
import { useI18n } from '../i18n/provider';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
@@ -98,10 +98,11 @@ const MessagePublishModal: React.FC<MessagePublishModalProps> = ({
setSubmitting(true);
try {
const res = await DBQuery(
const res = await DBQueryAudited(
buildRpcConnectionConfig(connection.config) as any,
executionDbName,
command.commandText,
'message_publish',
);
if (!res?.success) {
void message.error(t('message_publish_modal.error.send_failed_detail', {

View File

@@ -849,6 +849,7 @@ describe('QueryEditor external SQL save', () => {
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections = createDefaultConnections();
storeState.sqlLogs = [];
storeState.addSqlLog.mockReset();
storeState.sqlSnippets = [];
storeState.clearSqlLogs.mockReset();
storeState.connections[0].config.type = 'mysql';
@@ -7469,6 +7470,13 @@ describe('QueryEditor external SQL save', () => {
expect(textContent(renderer!.root)).not.toContain('未提交');
expect(textContent(renderer!.root)).toContain('提交');
expect(textContent(renderer!.root)).toContain('影响行数2');
expect(storeState.sqlEditorPendingTransactions['tab-1']).toMatchObject({
id: 'tx-1',
dbType: 'mysql',
dbName: 'main',
statements: ["UPDATE users SET name = 'new' WHERE id = 1"],
executionDurationMs: expect.any(Number),
});
await act(async () => {
await findButton(renderer!, '提交').props.onClick();
@@ -7479,6 +7487,11 @@ describe('QueryEditor external SQL save', () => {
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1');
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: "START TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nCOMMIT;",
status: 'success',
dbName: 'main',
}));
expect(textContent(renderer!.root)).not.toContain('未提交');
});
@@ -7571,6 +7584,26 @@ describe('QueryEditor external SQL save', () => {
expect(dataGridState.latestProps?.data?.[0]).toMatchObject({ name: 'new' });
expect(textContent(renderer!.root)).toContain('提交');
expect(textContent(renderer!.root)).toContain('回滚');
expect(storeState.sqlEditorPendingTransactions['tab-1']).toMatchObject({
statements: [
"UPDATE users SET name = 'new' WHERE id = 1",
'SELECT name FROM users WHERE id = 1',
],
statementCount: 2,
});
await act(async () => {
await findButton(renderer!, '提交').props.onClick();
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: "START TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nSELECT name FROM users WHERE id = 1;\nCOMMIT;",
status: 'success',
}));
});
it('runs SQL editor WITH DML through a pending managed transaction', async () => {

View File

@@ -304,7 +304,7 @@ vi.mock('@monaco-editor/react', () => ({
onMount?.(editorState.editor, {
editor: { setTheme: vi.fn() },
KeyMod: { CtrlCmd: 2048, WinCtrl: 256, Alt: 512, Shift: 1024 },
KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyS: 83 },
KeyCode: { KeyF: 70, KeyM: 77, KeyQ: 81, KeyR: 82, KeyS: 83 },
languages: {
CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
CompletionItemInsertTextRule: { InsertAsSnippet: 1 },
@@ -1778,6 +1778,66 @@ describe('QueryEditor external SQL save', () => {
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('registers Windows Ctrl+R with Monaco CtrlCmd and runs the selected SQL', async () => {
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+R' };
const windowListeners: Record<string, ((event?: any) => void)[]> = {};
vi.stubGlobal('window', {
addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
windowListeners[type] ||= [];
windowListeners[type].push(listener);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn((event: Event) => {
windowListeners[event.type]?.forEach((listener) => listener(event));
return true;
}),
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
callback(0);
return 1;
}),
cancelAnimationFrame: vi.fn(),
innerHeight: 900,
});
backendApp.DBQueryMulti.mockResolvedValueOnce({
success: true,
data: [{ columns: ['total'], rows: [{ total: 1 }] }],
});
await act(async () => {
create(<QueryEditor tab={createTab({
dbName: 'main',
query: 'select 1;\nselect count(*) as total from messages;\nselect 3;',
})} />);
});
editorState.selection = {
startLineNumber: 2,
startColumn: 1,
endLineNumber: 2,
endColumn: 'select count(*) as total from messages'.length + 1,
};
const runAction = findEditorAction('gonavi.runQuery');
expect(runAction).toMatchObject({
keybindings: [2048 | 82],
keybindingContext: 'editorTextFocus',
});
await act(async () => {
await runAction.run();
await Promise.resolve();
await Promise.resolve();
});
expect(backendApp.DBQueryMulti).toHaveBeenCalledWith(
expect.anything(),
'main',
expect.stringContaining('select count(*) as total from messages'),
'query-1',
);
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 1');
expect(String(backendApp.DBQueryMulti.mock.calls[0][2])).not.toContain('select 3');
});
it('does not run SQL from the run shortcut when nothing is selected', async () => {
storeState.shortcutOptions.runQuery.mac = { enabled: true, combo: 'Meta+Enter' };
storeState.shortcutOptions.runQuery.windows = { enabled: true, combo: 'Ctrl+Enter' };

View File

@@ -38,6 +38,18 @@ describe('SQL analysis workbench wiring', () => {
expect(source).not.toContain('<Tabs')
})
it('fills the SQL analysis report viewport through the Ant Spin wrapper', () => {
const source = readFileSync(new URL('./explain/ExplainWorkbench.tsx', import.meta.url), 'utf8')
expect(source).toContain('wrapperClassName="gn-explain-report-spinner"')
expect(source).toMatch(
/\.gn-explain-report-spinner > \.ant-spin-container \{[^}]*height: 100%;[^}]*min-height: 0;[^}]*display: flex;[^}]*flex-direction: column;/s,
)
expect(source).toMatch(
/\.gn-explain-report-shell \{[^}]*flex: 1 1 auto;[^}]*min-height: 0;[^}]*display: flex;[^}]*flex-direction: column;/s,
)
})
it('keeps the editor draft separate from the SQL submitted for diagnosis', () => {
const source = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')

View File

@@ -257,7 +257,7 @@ const buildQueryEditorInlineMemoryEntries = ({
});
sqlLogs.forEach((log) => {
if (log.status !== 'success') {
if (log.status !== 'success' || log.category === 'transaction') {
return;
}
if (!matchesQueryEditorInlineMemoryDb(currentDb, log.dbName)) {
@@ -1553,7 +1553,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = triggerSqlAiCompletionShortcutBinding;
const keyBinding = binding?.enabled && binding.combo
? comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode)
? comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform)
: null;
triggerSqlAiCompletionActionRef.current = editor.addAction({
id: 'gonavi.triggerSqlAiCompletion',
@@ -1565,7 +1565,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
triggerAiInlineCompletionRef.current?.();
},
});
}, [triggerSqlAiCompletionShortcutBinding]);
}, [activeShortcutPlatform, triggerSqlAiCompletionShortcutBinding]);
useEffect(() => {
// Prefer remount session cache (detach/attach); otherwise follow tab draft flag.
if (restoredResultSessionRef.current && restoredResultSessionRef.current.isResultPanelVisible !== undefined) {
@@ -1621,6 +1621,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
: 0;
const {
activatePendingSqlTransaction,
appendPendingSqlTransactionExecution,
autoCommitRemainingSeconds: sqlEditorAutoCommitRemainingSeconds,
finishPendingSqlTransaction,
pendingSqlTransaction,
@@ -1629,6 +1630,10 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
tabId: tab.id,
translate: (key, params) => translate(key, params),
});
const handleFinishPendingSqlTransaction = useCallback(async (action: 'commit' | 'rollback') => {
await finishPendingSqlTransaction(action, 'manual');
handleShowSqlExecutionLog('open');
}, [finishPendingSqlTransaction, handleShowSqlExecutionLog]);
const autoFetchVisible = useAutoFetchVisibility();
useEffect(() => {
@@ -4496,7 +4501,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const runBinding = runQueryShortcutBinding;
if (runBinding?.enabled && runBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
runBinding.combo, monaco.KeyMod, monaco.KeyCode
runBinding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
@@ -4516,7 +4521,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const selectStatementBinding = selectCurrentStatementShortcutBinding;
if (selectStatementBinding?.enabled && selectStatementBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode
selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
selectCurrentStatementActionRef.current = editor.addAction({
@@ -4531,6 +4536,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const macFindWithSelectionGuardKeyBinding = activeShortcutPlatform === 'mac'
? comboToMonacoKeyBinding(
QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO, monaco.KeyMod, monaco.KeyCode,
activeShortcutPlatform,
)
: null;
if (macFindWithSelectionGuardKeyBinding) {
@@ -4556,6 +4562,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (duplicateLineBinding?.enabled && duplicateLineBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
duplicateLineBinding.combo, monaco.KeyMod, monaco.KeyCode,
activeShortcutPlatform,
);
if (keyBinding) {
duplicateCurrentLineActionRef.current = editor.addAction({
@@ -4570,7 +4577,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const saveBinding = saveQueryShortcutBinding;
if (saveBinding?.enabled && saveBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
saveBinding.combo, monaco.KeyMod, monaco.KeyCode
saveBinding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
saveQueryActionRef.current = editor.addAction({
@@ -4585,7 +4592,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
const findInEditorKeyBinding = comboToMonacoKeyBinding(
findInEditorShortcutCombo, monaco.KeyMod, monaco.KeyCode
findInEditorShortcutCombo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (findInEditorKeyBinding) {
findInEditorActionRef.current = editor.addAction({
@@ -4601,7 +4608,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const formatBinding = formatSqlShortcutBinding;
if (formatBinding?.enabled && formatBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
formatBinding.combo, monaco.KeyMod, monaco.KeyCode
formatBinding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
formatSqlActionRef.current = editor.addAction({
@@ -4620,7 +4627,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const toggleResultsBinding = toggleQueryResultsPanelShortcutBinding;
if (toggleResultsBinding?.enabled && toggleResultsBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
toggleResultsBinding.combo, monaco.KeyMod, monaco.KeyCode
toggleResultsBinding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
toggleQueryResultsPanelActionRef.current = editor.addAction({
@@ -6486,14 +6493,27 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return;
}
if (useManagedTransaction && res.transactionPending && res.transactionId) {
activatePendingSqlTransaction({
id: String(res.transactionId),
commitMode: sqlEditorCommitMode,
autoCommitDelayMs: sqlEditorAutoCommitDelayMs,
createdAt: Date.now(),
statementCount: managedTransactionStatementCount,
});
if (res.transactionPending && res.transactionId) {
const transactionId = String(res.transactionId);
if (useManagedTransaction) {
activatePendingSqlTransaction({
id: transactionId,
commitMode: sqlEditorCommitMode,
autoCommitDelayMs: sqlEditorAutoCommitDelayMs,
createdAt: Date.now(),
statementCount: managedTransactionStatementCount,
dbType: normalizedDbType,
dbName: currentDb,
statements: sourceStatements,
executionDurationMs: duration,
});
} else {
appendPendingSqlTransactionExecution({
transactionId,
statements: sourceStatements,
durationMs: duration,
});
}
}
// res.data 是 ResultSetData[] 数组
@@ -6897,7 +6917,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = runQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
runQueryActionRef.current = editor.addAction({
id: 'gonavi.runQuery',
@@ -6918,7 +6940,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
runQueryActionRef.current = null;
}
};
}, [languagePreference, runQueryShortcutBinding]);
}, [activeShortcutPlatform, languagePreference, runQueryShortcutBinding]);
useEffect(() => {
if (selectCurrentStatementActionRef.current) {
@@ -6936,7 +6958,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = selectCurrentStatementShortcutBinding;
if (binding?.enabled && binding.combo) {
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
selectCurrentStatementActionRef.current = editor.addAction({
id: 'gonavi.selectCurrentStatement',
@@ -6952,6 +6976,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
QUERY_EDITOR_MAC_FIND_WITH_SELECTION_COMBO,
monaco.KeyMod,
monaco.KeyCode,
activeShortcutPlatform,
)
: null;
if (macFindWithSelectionGuardKeyBinding) {
@@ -6998,7 +7023,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = duplicateCurrentLineShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
duplicateCurrentLineActionRef.current = editor.addAction({
id: 'gonavi.duplicateCurrentLine',
@@ -7014,7 +7041,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
duplicateCurrentLineActionRef.current = null;
}
};
}, [duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, languagePreference]);
}, [activeShortcutPlatform, duplicateCurrentLineShortcutBinding, handleDuplicateCurrentLine, languagePreference]);
useEffect(() => {
if (saveQueryActionRef.current) {
@@ -7029,7 +7056,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = saveQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
saveQueryActionRef.current = editor.addAction({
id: 'gonavi.saveQuery',
@@ -7047,7 +7076,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
saveQueryActionRef.current = null;
}
};
}, [languagePreference, saveQueryShortcutBinding]);
}, [activeShortcutPlatform, languagePreference, saveQueryShortcutBinding]);
useEffect(() => {
if (findInEditorActionRef.current) {
@@ -7063,6 +7092,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
findInEditorShortcutCombo,
monaco.KeyMod,
monaco.KeyCode,
activeShortcutPlatform,
);
if (keyBinding) {
findInEditorActionRef.current = editor.addAction({
@@ -7081,7 +7111,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
findInEditorActionRef.current = null;
}
};
}, [findInEditorShortcutCombo, languagePreference]);
}, [activeShortcutPlatform, findInEditorShortcutCombo, languagePreference]);
useEffect(() => {
if (formatSqlActionRef.current) {
@@ -7096,7 +7126,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = formatSqlShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
formatSqlActionRef.current = editor.addAction({
id: 'gonavi.formatSql',
@@ -7114,7 +7146,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
formatSqlActionRef.current = null;
}
};
}, [languagePreference, formatSqlShortcutBinding]);
}, [activeShortcutPlatform, languagePreference, formatSqlShortcutBinding]);
useEffect(() => {
const editor = editorRef.current;
@@ -7144,7 +7176,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const binding = toggleQueryResultsPanelShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
const keyBinding = comboToMonacoKeyBinding(
binding.combo, monaco.KeyMod, monaco.KeyCode, activeShortcutPlatform,
);
if (keyBinding) {
toggleQueryResultsPanelActionRef.current = editor.addAction({
id: 'gonavi.toggleQueryResultsPanel',
@@ -7160,7 +7194,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
toggleQueryResultsPanelActionRef.current = null;
}
};
}, [languagePreference, toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]);
}, [activeShortcutPlatform, languagePreference, toggleQueryResultsPanelShortcutBinding, toggleResultPanelVisibility]);
useEffect(() => {
const handleRunActiveQuery = (event: Event) => {
@@ -7937,7 +7971,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
darkMode={darkMode}
transaction={pendingSqlTransaction}
autoCommitRemainingSeconds={sqlEditorAutoCommitRemainingSeconds}
onFinish={(action) => void finishPendingSqlTransaction(action, 'manual')}
onFinish={(action) => void handleFinishPendingSqlTransaction(action)}
/>
);
const queryEditorStageStyle: React.CSSProperties = isResultPanelVisible

View File

@@ -11,6 +11,10 @@ export type PendingSqlEditorTransaction = {
createdAt: number;
autoCommitDueAt?: number | null;
statementCount?: number;
dbType?: string;
dbName?: string;
statements?: string[];
executionDurationMs?: number;
};
type QueryEditorTransactionToolbarProps = {

View File

@@ -3,22 +3,27 @@ import { readFileSync } from 'node:fs';
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const contextMenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8');
const legacyMenuSource = readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8');
const v2ActionSource = readFileSync(new URL('./sidebar/useSidebarV2ActionHandlers.tsx', import.meta.url), 'utf8');
const modalSource = readFileSync(new URL('./MessagePublishModal.tsx', import.meta.url), 'utf8');
describe('Sidebar Kafka publish entry', () => {
it('adds a Kafka topic publish action in both legacy and v2 table menus', () => {
expect(sidebarSource).toContain("key: 'publish-message'");
expect(sidebarSource).toContain("label: t('message_publish_modal.title')");
expect(sidebarSource).toContain('openMessagePublishModal(node)');
expect(legacyMenuSource).toContain("key: 'publish-message'");
expect(legacyMenuSource).toContain("label: t('message_publish_modal.title')");
expect(legacyMenuSource).toContain('openMessagePublishModal(node)');
expect(contextMenuSource).toContain("| 'publish-message'");
expect(contextMenuSource).toContain("title: t('message_publish_modal.title')");
expect(v2ActionSource).toContain("case 'publish-message'");
expect(v2ActionSource).toContain('openMessagePublishModal(node)');
expect(contextMenuSource).not.toContain("title: '测试发送消息'");
});
it('renders the dedicated message publish modal and executes DBQuery through the encoder', () => {
it('renders the dedicated message publish modal and executes an audited user action through the encoder', () => {
expect(sidebarSource).toContain('<MessagePublishModal');
expect(modalSource).toContain('buildMessagePublishCommand');
expect(modalSource).toContain('DBQuery(');
expect(modalSource).toContain('DBQueryAudited(');
expect(modalSource).toContain("'message_publish'");
expect(modalSource).toContain("t('message_publish_modal.field.body.label')");
});
});

View File

@@ -1,6 +1,7 @@
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import SlowQueryRailButton from './sidebar/SlowQueryRailButton';
import SqlAuditRailButton from './sidebar/SqlAuditRailButton';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
getMetadataDialect,
@@ -3044,6 +3045,10 @@ const Sidebar: React.FC<{
className="gn-v2-sidebar-slow-query-button"
tooltipPlacement="top"
/>
<SqlAuditRailButton
className="gn-v2-sidebar-sql-audit-button"
tooltipPlacement="top"
/>
</div>
)}
</div>

View File

@@ -44,6 +44,7 @@ const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (tab.type === 'sql-file-execution') return t('sidebar.sql_file_exec.title');
if (tab.type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
if (tab.type === 'sql-audit') return t('tab_manager.kind_badge.sql_audit');
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
@@ -69,6 +70,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
if (tab.type === 'sql-file-execution') return t('sidebar.sql_file_exec.title');
if (tab.type === 'sql-analysis') return t('tab_manager.hover.kind.sql_analysis');
if (tab.type === 'sql-audit') return t('tab_manager.hover.kind.sql_audit');
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
@@ -100,7 +102,7 @@ const getTabObjectLabel = (tab: TabData): string => {
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
if (tab.type === 'sql-analysis') return tab.title;
if (tab.type === 'sql-analysis' || tab.type === 'sql-audit') return tab.title;
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return '';
};

View File

@@ -8,7 +8,7 @@ import { CSS } from '@dnd-kit/utilities';
import Editor from './MonacoEditor';
import { TabData, ColumnDefinition, IndexDefinition, ForeignKeyDefinition, TriggerDefinition } from '../types';
import { useStore } from '../store';
import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { DBGetColumns, DBGetIndexes, DBQueryAudited, DBGetForeignKeys, DBGetTriggers, DBShowCreateTable } from '../../wailsjs/go/app/App';
import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils';
import { buildIndexCreateSqlPreview } from './tableDesignerIndexSql';
import { buildAlterTablePreviewSql, buildCreateTablePreviewSql, hasAlterTableDraftChanges, type StarRocksCreateTableOptions, type StarRocksDistributionType, type StarRocksKeyModel, type StarRocksTableKind } from './tableDesignerSchemaSql';
@@ -1209,7 +1209,7 @@ ${selectedTrigger.statement}`;
const dropSql = buildDropTriggerSql(selectedTrigger.name);
try {
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
const res = await DBQueryAudited(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql, 'table_designer');
if (res.success) {
message.success(t('table_designer.message.trigger_deleted', undefined, i18nLanguage));
setSelectedTrigger(null);
@@ -1246,7 +1246,7 @@ ${selectedTrigger.statement}`;
// 如果是编辑模式,先删除旧触发器
if (triggerEditMode === 'edit' && selectedTrigger) {
const dropSql = buildDropTriggerSql(selectedTrigger.name);
const dropRes = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql);
const dropRes = await DBQueryAudited(buildRpcConnectionConfig(config) as any, tab.dbName || '', dropSql, 'table_designer');
if (!dropRes.success) {
message.error(t('table_designer.message.drop_old_trigger_failed', { detail: dropRes.message }, i18nLanguage));
setTriggerExecuting(false);
@@ -1255,7 +1255,7 @@ ${selectedTrigger.statement}`;
}
// 执行创建语句
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql);
const res = await DBQueryAudited(buildRpcConnectionConfig(config) as any, tab.dbName || '', triggerEditSql, 'table_designer');
if (res.success) {
message.success(triggerEditMode === 'create'
? t('table_designer.message.trigger_created', undefined, i18nLanguage)
@@ -1761,7 +1761,7 @@ ${selectedTrigger.statement}`;
const sql = buildCreateTableSql(copyTableName.trim(), selectedColumns, copyCharset, copyCollation);
setCopyExecuting(true);
try {
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql);
const res = await DBQueryAudited(buildRpcConnectionConfig(config) as any, tab.dbName || '', sql, 'table_designer');
if (res.success) {
message.success(t('table_designer.message.columns_copied_to_new_table', { count: selectedColumns.length, table: copyTableName.trim() }, i18nLanguage));
setIsCopyColumnsModalOpen(false);
@@ -1790,7 +1790,7 @@ ${selectedTrigger.statement}`;
const statements = splitSchemaExecutionStatements(sqlText);
for (let i = 0; i < statements.length; i++) {
const stmt = normalizeSchemaStatementForExecution(statements[i], dbType);
const res = await DBQuery(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt);
const res = await DBQueryAudited(buildRpcConnectionConfig(config) as any, tab.dbName || '', stmt, 'table_designer');
if (!res.success) {
const prefix = statements.length > 1
? t('table_designer.message.statement_execution_failed_prefix', { current: i + 1, total: statements.length }, i18nLanguage)

View File

@@ -17,6 +17,7 @@ import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
import SqlAuditWorkbench from './audit/SqlAuditWorkbench';
export const WorkbenchTabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({ tab, isActive }) => {
if (tab.type === 'query') {
@@ -55,6 +56,9 @@ export const WorkbenchTabContent: React.FC<{ tab: TabData; isActive: boolean }>
if (tab.type === 'sql-analysis') {
return <SqlAnalysisWorkbench tab={tab} />;
}
if (tab.type === 'sql-audit') {
return <SqlAuditWorkbench tab={tab} isActive={isActive} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}

View File

@@ -57,7 +57,7 @@ export const buildDefaultLocalToolRuntime = (): AILocalToolRuntime => ({
},
query: async (config, dbName, sql) => {
const mod = await import('../../../wailsjs/go/app/App');
return mod.DBQuery(config, dbName, sql);
return mod.DBQueryAI(config, dbName, sql);
},
checkSQL: async (sql) => {
const service = getAIService();

View File

@@ -226,7 +226,7 @@ const HighlightedCodeBlock: React.FC<HighlightedCodeBlockProps> = ({
setPreviewError('');
setPreviewData(null);
try {
const { DBQuery } = await import('../../../../wailsjs/go/app/App');
const { DBQueryAI } = await import('../../../../wailsjs/go/app/App');
const previewSql = buildAIReadonlyPreviewSQL(
activeConnectionConfig?.type || '',
displayText,
@@ -234,7 +234,7 @@ const HighlightedCodeBlock: React.FC<HighlightedCodeBlockProps> = ({
activeConnectionConfig?.driver || '',
{ oceanBaseProtocol: activeConnectionConfig?.oceanBaseProtocol },
);
const response = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
const response = await DBQueryAI(activeConnectionConfig, activeDbName || '', previewSql);
if (response.success && Array.isArray(response.data)) {
const rows = response.data as any[];
setPreviewCols(rows.length > 0 ? Object.keys(rows[0]) : []);

View File

@@ -0,0 +1,271 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Button, Descriptions, Drawer, Empty, Pagination, Space, Spin, Tag, Typography, message, theme } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
import { useI18n } from '../../i18n/provider';
import {
buildSQLAuditFilterPayload,
DEFAULT_SQL_AUDIT_FILTER,
getSQLAuditEnumLabelKey,
normalizeSQLAuditPage,
sortSQLAuditTimeline,
type SQLAuditEvent,
} from './sqlAuditModel';
import {
requireSQLAuditMethod,
resolveSQLAuditBackend,
unwrapSQLAuditResult,
type SQLAuditBackend,
} from './sqlAuditRpc';
const { Paragraph, Text, Title } = Typography;
const SQL_AUDIT_TIMELINE_PAGE_SIZE = 50;
interface SqlAuditDetailDrawerProps {
event: SQLAuditEvent | null;
open: boolean;
onClose: () => void;
backend?: SQLAuditBackend;
connectionName?: string;
}
const resolveStatusColor = (status: string): string => {
if (status === 'success') return 'success';
if (status === 'error') return 'error';
if (status === 'cancelled') return 'warning';
return 'default';
};
export default function SqlAuditDetailDrawer({
event,
open,
onClose,
backend: backendOverride,
connectionName,
}: SqlAuditDetailDrawerProps) {
const { t, language } = useI18n();
const { token } = theme.useToken();
const [timeline, setTimeline] = useState<SQLAuditEvent[]>([]);
const [timelineTotal, setTimelineTotal] = useState(0);
const [timelinePageSelection, setTimelinePageSelection] = useState({ eventId: '', page: 1 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const requestSequenceRef = useRef(0);
const backend = backendOverride ?? resolveSQLAuditBackend();
const timelinePage = event && timelinePageSelection.eventId === event.id
? timelinePageSelection.page
: 1;
const dateTimeFormatter = useMemo(() => new Intl.DateTimeFormat(language, {
dateStyle: 'medium',
timeStyle: 'medium',
hour12: false,
}), [language]);
const labelEnum = (kind: 'event_type' | 'status' | 'source', value: string): string => {
const key = getSQLAuditEnumLabelKey(kind, value);
return key ? t(key) : (value || t('common.unknown'));
};
useEffect(() => {
if (!open || !event) {
setTimeline([]);
setTimelineTotal(0);
setError('');
setLoading(false);
return;
}
if (!event.transactionId) {
setTimeline([event]);
setTimelineTotal(1);
setError('');
setLoading(false);
return;
}
const requestSequence = ++requestSequenceRef.current;
setLoading(true);
setError('');
const filter = {
...DEFAULT_SQL_AUDIT_FILTER,
transactionId: event.transactionId,
page: timelinePage,
pageSize: SQL_AUDIT_TIMELINE_PAGE_SIZE,
};
let getEvents: NonNullable<SQLAuditBackend['GetSQLAuditEvents']>;
try {
getEvents = requireSQLAuditMethod(backend, 'GetSQLAuditEvents');
} catch (cause) {
setTimeline([event]);
setTimelineTotal(1);
setError(cause instanceof Error ? cause.message : String(cause));
setLoading(false);
return;
}
void getEvents(buildSQLAuditFilterPayload(filter))
.then((result) => {
if (requestSequence !== requestSequenceRef.current) return;
const page = normalizeSQLAuditPage(unwrapSQLAuditResult(result), filter);
setTimeline(sortSQLAuditTimeline(page.items.length > 0 ? page.items : [event]));
setTimelineTotal(page.total || 1);
})
.catch((cause) => {
if (requestSequence !== requestSequenceRef.current) return;
setTimeline([event]);
setTimelineTotal(1);
setError(cause instanceof Error ? cause.message : String(cause));
})
.finally(() => {
if (requestSequence === requestSequenceRef.current) setLoading(false);
});
return () => {
requestSequenceRef.current += 1;
};
}, [backend, event, open, timelinePage]);
const copyText = async (text: string, successKey: string) => {
try {
if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable');
await navigator.clipboard.writeText(text);
message.success(t(successKey));
} catch {
message.error(t('sql_audit.message.copy_failed'));
}
};
if (!event) return null;
const formatTimestamp = (timestamp: number): string => (
timestamp > 0 ? dateTimeFormatter.format(new Date(timestamp)) : '-'
);
const statusLabel = labelEnum('status', event.status);
const sourceLabel = labelEnum('source', event.source);
const eventTypeLabel = labelEnum('event_type', event.eventType);
const boundaryModeLabel = t(`sql_audit.boundary_mode.${event.boundaryMode || 'unknown'}`);
return (
<Drawer
open={open}
onClose={onClose}
width="min(760px, calc(100vw - 24px))"
title={t('sql_audit.detail.title')}
destroyOnClose
styles={{
body: {
padding: 20,
overscrollBehavior: 'contain',
'--sql-audit-panel': token.colorBgContainer,
'--sql-audit-subtle': token.colorFillQuaternary,
'--sql-audit-border': token.colorBorderSecondary,
'--sql-audit-text': token.colorText,
'--sql-audit-muted': token.colorTextSecondary,
'--sql-audit-primary': token.colorPrimary,
} as React.CSSProperties,
}}
extra={(
<Button
size="small"
icon={<CopyOutlined aria-hidden="true" />}
onClick={() => void copyText(JSON.stringify(event, null, 2), 'sql_audit.message.json_copied')}
>
{t('sql_audit.action.copy_json')}
</Button>
)}
>
<div className="gn-sql-audit-detail">
<section aria-labelledby="sql-audit-detail-metadata">
<Title level={5} id="sql-audit-detail-metadata">{t('sql_audit.detail.metadata')}</Title>
<Descriptions size="small" bordered column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label={t('sql_audit.column.time')}>{formatTimestamp(event.timestamp)}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.status')}><Tag color={resolveStatusColor(event.status)}>{statusLabel}</Tag></Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.connection')}>{connectionName || event.connectionId || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.database')}>{event.database || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.db_type')}>{event.dbType || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.event_type')}>{eventTypeLabel}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.source')}>{sourceLabel}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.commit_mode')}>{event.commitMode || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.boundary_mode')}>{boundaryModeLabel}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.query_id')} span={2}>{event.queryId || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.transaction_id')} span={2}>{event.transactionId || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.connection_fingerprint')} span={2}>{event.connectionFingerprint || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.sql_fingerprint')} span={2}>{event.sqlFingerprint || '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.column.duration')}>{event.durationMs.toLocaleString(language)} ms</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.statement_position')}>
{event.statementCount > 0 ? `${event.statementIndex || 1} / ${event.statementCount}` : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.rows_affected')}>{event.rowsAffected?.toLocaleString(language) ?? '-'}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.rows_returned')}>{event.rowsReturned?.toLocaleString(language) ?? '-'}</Descriptions.Item>
</Descriptions>
</section>
<section aria-labelledby="sql-audit-detail-sql">
<div className="gn-sql-audit-detail-heading-row">
<Title level={5} id="sql-audit-detail-sql">{t('sql_audit.detail.sql')}</Title>
{event.sqlRedacted ? <Tag color="processing">{t('sql_audit.detail.redacted')}</Tag> : null}
<Button
type="text"
size="small"
icon={<CopyOutlined aria-hidden="true" />}
disabled={!event.sqlText}
onClick={() => void copyText(event.sqlText, 'sql_audit.message.sql_copied')}
>
{t('sql_audit.action.copy_sql')}
</Button>
</div>
{event.sqlText ? <pre className="gn-sql-audit-detail-sql">{event.sqlText}</pre> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('sql_audit.detail.no_sql')} />}
{event.error ? <Alert type="error" showIcon message={t('sql_audit.detail.error')} description={event.error} /> : null}
</section>
<section aria-labelledby="sql-audit-detail-timeline">
<div className="gn-sql-audit-detail-heading-row">
<Title level={5} id="sql-audit-detail-timeline">{t('sql_audit.detail.timeline')}</Title>
<Text type="secondary" aria-live="polite">
{t('sql_audit.detail.timeline_page_status', { loaded: timeline.length, total: timelineTotal })}
</Text>
</div>
{error ? <Alert type="warning" showIcon message={t('sql_audit.detail.timeline_partial')} description={error} style={{ marginBottom: 12 }} /> : null}
<Spin spinning={loading}>
<ol className="gn-sql-audit-timeline" aria-busy={loading}>
{timeline.map((item) => (
<li key={item.id} className={`is-${item.status}`}>
<div className="gn-sql-audit-timeline-header">
<Space size={8} wrap>
<Text strong>{labelEnum('event_type', item.eventType)}</Text>
<Tag color={resolveStatusColor(item.status)}>{labelEnum('status', item.status)}</Tag>
{item.statementCount > 0 ? (
<Text type="secondary">{t('sql_audit.detail.statement_position_value', { current: item.statementIndex || 1, total: item.statementCount })}</Text>
) : null}
</Space>
<time dateTime={new Date(item.timestamp).toISOString()}>{formatTimestamp(item.timestamp)}</time>
</div>
{item.sqlText ? <pre>{item.sqlText}</pre> : null}
{item.error ? <Text type="danger">{item.error}</Text> : null}
</li>
))}
</ol>
</Spin>
{event.transactionId && timelineTotal > SQL_AUDIT_TIMELINE_PAGE_SIZE ? (
<div className="gn-sql-audit-timeline-pagination">
<Pagination
size="small"
current={timelinePage}
pageSize={SQL_AUDIT_TIMELINE_PAGE_SIZE}
total={timelineTotal}
showSizeChanger={false}
showLessItems
onChange={(page) => setTimelinePageSelection({ eventId: event.id, page })}
aria-label={t('sql_audit.detail.timeline_pagination_aria_label')}
/>
</div>
) : null}
</section>
<section aria-labelledby="sql-audit-detail-integrity">
<Title level={5} id="sql-audit-detail-integrity">{t('sql_audit.detail.integrity')}</Title>
<Descriptions size="small" bordered column={1}>
<Descriptions.Item label={t('sql_audit.detail.sequence')}>{event.sequence.toLocaleString(language)}</Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.prev_hash')}><Paragraph copyable={{ text: event.prevHash }} className="gn-sql-audit-hash">{event.prevHash || '-'}</Paragraph></Descriptions.Item>
<Descriptions.Item label={t('sql_audit.detail.hash')}><Paragraph copyable={{ text: event.hash }} className="gn-sql-audit-hash">{event.hash || '-'}</Paragraph></Descriptions.Item>
</Descriptions>
</section>
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,164 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n/provider';
import SqlAuditHealthAlert from './SqlAuditHealthAlert';
import type { SQLAuditBackend } from './sqlAuditRpc';
vi.mock('../../i18n/runtime', () => ({
syncLanguageRuntime: vi.fn(async () => undefined),
}));
vi.mock('antd', async () => {
const React = await import('react');
return {
Alert: ({ message, description, action, type }: any) => React.createElement(
'section',
{ 'data-alert-type': type },
message,
description,
action,
),
Spin: ({ 'aria-label': ariaLabel }: any) => React.createElement('span', { 'aria-label': ariaLabel }, 'loading'),
Typography: {
Text: ({ children }: any) => React.createElement('span', null, children),
},
};
});
const renderHealth = (backend: SQLAuditBackend, refreshKey: number, isActive = true) => (
<I18nProvider preference="en-US" systemLanguages={['en-US']} onPreferenceChange={() => undefined}>
<SqlAuditHealthAlert backend={backend} refreshKey={refreshKey} isActive={isActive} />
</I18nProvider>
);
describe('SqlAuditHealthAlert', () => {
it('shows a degraded gap and refreshes into an explicitly marked recovery', async () => {
const getHealth = vi.fn()
.mockResolvedValueOnce({
success: true,
data: {
status: 'degraded',
captureEnabled: true,
captureMode: 'redacted',
droppedEvents: 4,
firstFailureAt: 100,
lastFailureAt: 200,
lastSuccessAt: 150,
lastError: 'audit store unavailable',
},
})
.mockResolvedValueOnce({
success: true,
data: {
status: 'healthy',
captureEnabled: true,
captureMode: 'redacted',
droppedEvents: 4,
firstFailureAt: 100,
lastFailureAt: 200,
lastSuccessAt: 300,
lastError: '',
},
});
const backend: SQLAuditBackend = { GetSQLAuditHealth: getHealth };
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(renderHealth(backend, 0));
});
let output = JSON.stringify(renderer!.toJSON());
expect(getHealth).toHaveBeenCalledTimes(1);
expect(output).toContain('Audit writing is degraded');
expect(output).toContain('4 dropped audit events');
await act(async () => {
renderer!.update(renderHealth(backend, 1));
});
output = JSON.stringify(renderer!.toJSON());
expect(getHealth).toHaveBeenCalledTimes(2);
expect(output).toContain('Audit writing has recovered');
expect(output).toContain('audit_gap');
await act(async () => {
renderer!.unmount();
});
});
it('labels disabled capture separately while keeping its retained mode visible', async () => {
const backend: SQLAuditBackend = {
GetSQLAuditHealth: vi.fn().mockResolvedValue({
success: true,
data: {
status: 'healthy',
captureEnabled: false,
captureMode: 'metadata',
droppedEvents: 0,
firstFailureAt: 0,
lastFailureAt: 0,
lastSuccessAt: 0,
lastError: '',
},
}),
};
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(renderHealth(backend, 0));
});
const output = JSON.stringify(renderer!.toJSON());
expect(output).toContain('SQL audit capture is disabled');
expect(output).toContain('Existing records remain available to browse, verify, and export');
expect(output).toContain('Capture mode');
expect(output).toContain('Metadata only');
expect(output).not.toContain('Audit writing is healthy');
await act(async () => {
renderer!.unmount();
});
});
it('polls health only while the audit workbench is active', async () => {
vi.useFakeTimers();
try {
const getHealth = vi.fn().mockResolvedValue({
success: true,
data: {
status: 'healthy',
captureEnabled: true,
captureMode: 'redacted',
droppedEvents: 0,
},
});
const backend: SQLAuditBackend = { GetSQLAuditHealth: getHealth };
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(renderHealth(backend, 0, false));
});
expect(getHealth).not.toHaveBeenCalled();
await act(async () => {
renderer!.update(renderHealth(backend, 0, true));
});
expect(getHealth).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(getHealth).toHaveBeenCalledTimes(2);
await act(async () => {
renderer!.update(renderHealth(backend, 0, false));
await vi.advanceTimersByTimeAsync(60_000);
});
expect(getHealth).toHaveBeenCalledTimes(2);
await act(async () => {
renderer!.unmount();
});
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Spin, Typography } from 'antd';
import { useI18n } from '../../i18n/provider';
import {
getSQLAuditHealthPhase,
normalizeSQLAuditHealth,
type SQLAuditHealth,
type SQLAuditHealthPhase,
} from './sqlAuditModel';
import {
requireSQLAuditMethod,
resolveSQLAuditBackend,
unwrapSQLAuditResult,
type SQLAuditBackend,
} from './sqlAuditRpc';
const { Text } = Typography;
const SQL_AUDIT_HEALTH_POLL_INTERVAL_MS = 30_000;
interface SqlAuditHealthAlertProps {
backend?: SQLAuditBackend;
refreshKey: number;
isActive?: boolean;
}
export default function SqlAuditHealthAlert({ backend: backendOverride, refreshKey, isActive = true }: SqlAuditHealthAlertProps) {
const { t, language } = useI18n();
const backend = backendOverride ?? resolveSQLAuditBackend();
const [health, setHealth] = useState<SQLAuditHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const requestSequenceRef = useRef(0);
const numberFormatter = useMemo(() => new Intl.NumberFormat(language), [language]);
const dateTimeFormatter = useMemo(() => new Intl.DateTimeFormat(language, {
dateStyle: 'short',
timeStyle: 'medium',
hour12: false,
}), [language]);
useEffect(() => {
if (!isActive) return undefined;
let getHealth: NonNullable<SQLAuditBackend['GetSQLAuditHealth']>;
try {
getHealth = requireSQLAuditMethod(backend, 'GetSQLAuditHealth');
} catch (cause) {
setHealth(null);
setError(cause instanceof Error ? cause.message : String(cause));
setLoading(false);
return undefined;
}
let requestInFlight = false;
const loadHealth = (showLoading: boolean) => {
if (requestInFlight) return;
requestInFlight = true;
const requestSequence = ++requestSequenceRef.current;
if (showLoading) setLoading(true);
setError('');
void getHealth()
.then((result) => {
if (requestSequence !== requestSequenceRef.current) return;
setHealth(normalizeSQLAuditHealth(unwrapSQLAuditResult(result)));
})
.catch((cause) => {
if (requestSequence !== requestSequenceRef.current) return;
setHealth(null);
setError(cause instanceof Error ? cause.message : String(cause));
})
.finally(() => {
requestInFlight = false;
if (requestSequence === requestSequenceRef.current) setLoading(false);
});
};
loadHealth(true);
const pollTimer = globalThis.setInterval(() => loadHealth(false), SQL_AUDIT_HEALTH_POLL_INTERVAL_MS);
return () => {
globalThis.clearInterval(pollTimer);
requestSequenceRef.current += 1;
};
}, [backend, isActive, refreshKey]);
const formatTimestamp = (timestamp: number): string => dateTimeFormatter.format(new Date(timestamp));
const renderDetails = (value: SQLAuditHealth) => {
const captureStatusKey = value.captureEnabled === true
? 'sql_audit.health.capture_enabled'
: value.captureEnabled === false
? 'sql_audit.health.capture_disabled'
: 'sql_audit.health.capture_unknown';
const captureMode = value.captureMode === 'unknown'
? t('common.unknown')
: t(`sql_audit.settings.capture_mode.${value.captureMode}`);
return (
<div className="gn-sql-audit-health-details">
<span>{t('sql_audit.health.capture_status')}: {t(captureStatusKey)}</span>
<span>{t('sql_audit.health.capture_mode')}: {captureMode}</span>
{value.firstFailureAt > 0 ? (
<span>{t('sql_audit.health.first_failure')}: <time dateTime={new Date(value.firstFailureAt).toISOString()}>{formatTimestamp(value.firstFailureAt)}</time></span>
) : null}
{value.lastFailureAt > 0 ? (
<span>{t('sql_audit.health.last_failure')}: <time dateTime={new Date(value.lastFailureAt).toISOString()}>{formatTimestamp(value.lastFailureAt)}</time></span>
) : null}
{value.lastSuccessAt > 0 ? (
<span>{t('sql_audit.health.last_success')}: <time dateTime={new Date(value.lastSuccessAt).toISOString()}>{formatTimestamp(value.lastSuccessAt)}</time></span>
) : null}
{value.lastError ? <span>{t('sql_audit.health.last_error')}: <code>{value.lastError}</code></span> : null}
</div>
);
};
const loadingAction = loading ? <Spin size="small" aria-label={t('sql_audit.health.checking.title')} /> : undefined;
if (!health && loading) {
return (
<Alert
className="gn-sql-audit-health-alert"
type="info"
showIcon
message={t('sql_audit.health.checking.title')}
description={t('sql_audit.health.checking.description')}
action={loadingAction}
/>
);
}
const phase: SQLAuditHealthPhase = health ? getSQLAuditHealthPhase(health) : 'unknown';
if (!health || error || phase === 'unknown') {
return (
<Alert
className="gn-sql-audit-health-alert"
type="warning"
showIcon
message={t('sql_audit.health.unavailable.title')}
description={(
<div>
<div className="gn-sql-audit-health-summary">{t('sql_audit.health.unavailable.description')}</div>
{error ? <Text type="danger" code>{error}</Text> : null}
</div>
)}
/>
);
}
const count = numberFormatter.format(health.droppedEvents);
const description = (
<div>
<div className="gn-sql-audit-health-summary">{t(`sql_audit.health.${phase}.description`, { count })}</div>
{renderDetails(health)}
</div>
);
return (
<Alert
className="gn-sql-audit-health-alert"
type={phase === 'degraded' || phase === 'historical_gap' ? 'warning' : phase === 'disabled' ? 'info' : 'success'}
showIcon
message={t(`sql_audit.health.${phase}.title`)}
description={description}
action={loadingAction}
/>
);
}

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useState } from 'react';
import { Alert, Button, Drawer, Form, InputNumber, Select, Space, Spin, Switch, message } from 'antd';
import { SaveOutlined } from '@ant-design/icons';
import { useI18n } from '../../i18n/provider';
import {
DEFAULT_SQL_AUDIT_SETTINGS,
normalizeSQLAuditSettings,
type SQLAuditSettings,
} from './sqlAuditModel';
import {
requireSQLAuditMethod,
resolveSQLAuditBackend,
unwrapSQLAuditResult,
type SQLAuditBackend,
} from './sqlAuditRpc';
interface SqlAuditSettingsDrawerProps {
open: boolean;
onClose: () => void;
onSaved?: (settings: SQLAuditSettings) => void;
backend?: SQLAuditBackend;
}
export default function SqlAuditSettingsDrawer({
open,
onClose,
onSaved,
backend: backendOverride,
}: SqlAuditSettingsDrawerProps) {
const { t } = useI18n();
const [form] = Form.useForm<SQLAuditSettings>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [settingsReady, setSettingsReady] = useState(false);
const backend = backendOverride ?? resolveSQLAuditBackend();
const loadSettings = useCallback(async () => {
setLoading(true);
setSettingsReady(false);
setError('');
try {
const getSettings = requireSQLAuditMethod(backend, 'GetSQLAuditSettings');
const data = unwrapSQLAuditResult(await getSettings());
form.setFieldsValue(normalizeSQLAuditSettings(data));
setSettingsReady(true);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
form.resetFields();
} finally {
setLoading(false);
}
}, [backend, form]);
useEffect(() => {
if (open) void loadSettings();
}, [loadSettings, open]);
const handleSave = async () => {
if (!settingsReady || loading) return;
const values = normalizeSQLAuditSettings(await form.validateFields());
setSaving(true);
setError('');
try {
const updateSettings = requireSQLAuditMethod(backend, 'UpdateSQLAuditSettings');
unwrapSQLAuditResult(await updateSettings(values));
message.success(t('sql_audit.settings.message.saved'));
onSaved?.(values);
onClose();
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
setError(detail);
message.error(t('sql_audit.settings.message.save_failed', { detail }));
} finally {
setSaving(false);
}
};
return (
<Drawer
open={open}
onClose={onClose}
width={440}
title={t('sql_audit.settings.title')}
destroyOnClose
extra={(
<Space>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button
type="primary"
icon={<SaveOutlined aria-hidden="true" />}
loading={saving}
disabled={!settingsReady || loading}
onClick={() => void handleSave()}
>
{t('common.save')}
</Button>
</Space>
)}
styles={{ body: { overscrollBehavior: 'contain' } }}
>
<Spin spinning={loading}>
<div aria-live="polite">
{error ? (
<Alert
type="error"
showIcon
message={t('sql_audit.settings.error.load_or_save')}
description={error}
action={<Button size="small" onClick={() => void loadSettings()}>{t('common.retry')}</Button>}
style={{ marginBottom: 16 }}
/>
) : null}
</div>
<Form
form={form}
layout="vertical"
initialValues={DEFAULT_SQL_AUDIT_SETTINGS}
requiredMark={false}
disabled={!settingsReady || loading}
>
<Form.Item
name="enabled"
label={t('sql_audit.settings.enabled.label')}
valuePropName="checked"
extra={t('sql_audit.settings.enabled.description')}
>
<Switch aria-label={t('sql_audit.settings.enabled.label')} />
</Form.Item>
<Form.Item
name="captureMode"
label={t('sql_audit.settings.capture_mode.label')}
extra={t('sql_audit.settings.capture_mode.description')}
>
<Select
aria-label={t('sql_audit.settings.capture_mode.label')}
options={[
{ value: 'redacted', label: t('sql_audit.settings.capture_mode.redacted') },
{ value: 'metadata', label: t('sql_audit.settings.capture_mode.metadata') },
]}
/>
</Form.Item>
<Form.Item
name="retentionDays"
label={t('sql_audit.settings.retention_days.label')}
extra={t('sql_audit.settings.retention_days.description')}
rules={[{ required: true, type: 'number', min: 1, max: 3650 }]}
>
<InputNumber min={1} max={3650} precision={0} style={{ width: '100%' }} aria-label={t('sql_audit.settings.retention_days.label')} />
</Form.Item>
<Form.Item
name="maxRecords"
label={t('sql_audit.settings.max_records.label')}
extra={t('sql_audit.settings.max_records.description')}
rules={[{ required: true, type: 'number', min: 100, max: 10_000_000 }]}
>
<InputNumber min={100} max={10_000_000} step={1_000} precision={0} style={{ width: '100%' }} aria-label={t('sql_audit.settings.max_records.label')} />
</Form.Item>
</Form>
</Spin>
</Drawer>
);
}

View File

@@ -0,0 +1,393 @@
.gn-sql-audit-workbench {
box-sizing: border-box;
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow-x: hidden;
overflow-y: auto;
padding: 16px;
background: var(--sql-audit-bg);
color: var(--sql-audit-text);
}
.gn-sql-audit-header {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.gn-sql-audit-title-group,
.gn-sql-audit-title-copy,
.gn-sql-audit-header-actions {
min-width: 0;
}
.gn-sql-audit-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.gn-sql-audit-title-icon {
display: grid;
width: 40px;
height: 40px;
flex: 0 0 auto;
place-items: center;
border-radius: 12px;
background: var(--gn-accent-soft, var(--sql-audit-subtle));
color: var(--gn-accent-2, var(--sql-audit-primary));
font-size: 20px;
}
.gn-sql-audit-title-copy .ant-typography {
margin: 0;
}
.gn-sql-audit-title-copy h4 {
text-wrap: balance;
}
.gn-sql-audit-privacy-note,
.gn-sql-audit-health-alert,
.gn-sql-audit-integrity-alert,
.gn-sql-audit-load-alert {
flex: 0 0 auto;
}
.gn-sql-audit-health-summary {
margin-bottom: 6px;
}
.gn-sql-audit-health-details {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 4px 16px;
color: var(--sql-audit-muted);
font-size: 12px;
}
.gn-sql-audit-health-details code {
overflow-wrap: anywhere;
white-space: normal;
}
.gn-sql-audit-toolbar {
display: grid;
flex: 0 0 auto;
grid-template-columns: minmax(220px, 1.4fr) repeat(7, minmax(126px, 0.8fr));
gap: 8px;
padding: 12px;
border: 1px solid var(--sql-audit-border);
border-radius: 12px;
background: var(--sql-audit-panel);
}
.gn-sql-audit-filter-search,
.gn-sql-audit-filter-time {
min-width: 0;
}
.gn-sql-audit-filter-time {
display: grid;
grid-column: span 2;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.gn-sql-audit-toolbar-actions {
display: flex;
grid-column: span 2;
justify-content: flex-end;
gap: 8px;
}
.gn-sql-audit-summary {
display: grid;
flex: 0 0 auto;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 8px;
}
.gn-sql-audit-summary-card {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--sql-audit-border);
border-radius: 10px;
background: var(--sql-audit-panel);
}
.gn-sql-audit-summary-card span {
overflow: hidden;
color: var(--sql-audit-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-sql-audit-summary-card strong {
flex: 0 0 auto;
color: var(--sql-audit-text);
font-size: 18px;
font-variant-numeric: tabular-nums;
}
.gn-sql-audit-summary-card.is-error strong {
color: var(--ant-color-error, #dc2626);
}
.gn-sql-audit-table-panel {
display: flex;
min-height: 280px;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--sql-audit-border);
border-radius: 12px;
background: var(--sql-audit-panel);
}
.gn-sql-audit-table-panel > .ant-empty {
margin: auto;
}
.gn-sql-audit-table-panel .ant-table-wrapper {
min-height: 0;
flex: 1 1 auto;
}
.gn-sql-audit-table-panel .ant-table-thead > tr > th,
.gn-sql-audit-table-panel .ant-table-tbody > tr > td {
padding-block: 9px;
}
.gn-sql-audit-table-row td {
height: 44px;
font-variant-numeric: tabular-nums;
}
.gn-sql-audit-sql-cell {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.gn-sql-audit-sql-cell code {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: var(--sql-audit-text);
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.gn-sql-audit-sql-cell .ant-tag {
flex: 0 0 auto;
margin-inline-end: 0;
}
.gn-sql-audit-pagination {
display: flex;
min-height: 48px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
border-top: 1px solid var(--sql-audit-border);
}
.gn-sql-audit-detail {
display: flex;
flex-direction: column;
gap: 24px;
}
.gn-sql-audit-detail section > h5 {
margin-block: 0 12px;
scroll-margin-top: 16px;
text-wrap: balance;
}
.gn-sql-audit-detail-heading-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.gn-sql-audit-detail-heading-row h5 {
min-width: 0;
flex: 1 1 auto;
margin: 0;
}
.gn-sql-audit-detail-sql,
.gn-sql-audit-timeline pre {
max-height: 360px;
margin: 0 0 12px;
overflow: auto;
padding: 12px;
border: 1px solid var(--sql-audit-border);
border-radius: 8px;
background: var(--sql-audit-subtle);
color: var(--sql-audit-text);
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 12px;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.gn-sql-audit-timeline {
margin: 0;
padding: 0;
list-style: none;
}
.gn-sql-audit-timeline li {
position: relative;
margin-left: 7px;
padding: 0 0 18px 22px;
border-left: 2px solid var(--sql-audit-border);
}
.gn-sql-audit-timeline li::before {
position: absolute;
top: 4px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid var(--sql-audit-panel);
border-radius: 50%;
background: var(--sql-audit-primary);
content: '';
}
.gn-sql-audit-timeline li.is-error::before {
background: var(--ant-color-error, #dc2626);
}
.gn-sql-audit-timeline li:last-child {
padding-bottom: 0;
border-left-color: transparent;
}
.gn-sql-audit-timeline-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
overflow-x: auto;
padding: 2px;
}
.gn-sql-audit-timeline-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.gn-sql-audit-timeline-header time {
flex: 0 0 auto;
color: var(--sql-audit-muted);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.gn-sql-audit-hash {
margin-bottom: 0 !important;
overflow-wrap: anywhere;
font-family: var(--gn-font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 12px;
}
@media (max-width: 1280px) {
.gn-sql-audit-toolbar {
grid-template-columns: repeat(4, minmax(150px, 1fr));
}
.gn-sql-audit-filter-search,
.gn-sql-audit-filter-time {
grid-column: span 2;
}
}
@media (max-width: 820px) {
.gn-sql-audit-workbench {
overflow-y: auto;
}
.gn-sql-audit-header,
.gn-sql-audit-pagination,
.gn-sql-audit-timeline-header {
align-items: stretch;
flex-direction: column;
}
.gn-sql-audit-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gn-sql-audit-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.gn-sql-audit-table-panel {
min-height: 440px;
flex: 0 0 auto;
}
.gn-sql-audit-pagination .ant-pagination {
overflow-x: auto;
}
}
@media (max-width: 520px) {
.gn-sql-audit-workbench {
padding: 12px;
}
.gn-sql-audit-toolbar,
.gn-sql-audit-summary {
grid-template-columns: 1fr;
}
.gn-sql-audit-filter-search,
.gn-sql-audit-filter-time,
.gn-sql-audit-toolbar-actions {
grid-column: auto;
}
.gn-sql-audit-filter-time {
grid-template-columns: 1fr;
}
.gn-sql-audit-toolbar-actions > .ant-btn {
flex: 1 1 0;
}
}
@media (prefers-reduced-motion: reduce) {
.gn-sql-audit-workbench *,
.gn-sql-audit-workbench *::before,
.gn-sql-audit-workbench *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const catalogs = Object.fromEntries(locales.map((locale) => [
locale,
JSON.parse(readFileSync(new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>,
])) as Record<typeof locales[number], Record<string, string>>;
const placeholdersOf = (value: string): string[] => (
Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), (match) => match[1]).sort()
);
describe('SQL audit workbench i18n', () => {
it('keeps every SQL audit key and placeholder set aligned in all six catalogs', () => {
const keys = Object.keys(catalogs['en-US']).filter((key) => (
key.startsWith('sql_audit.')
|| key === 'app.tools.entry.sql_audit.title'
|| key === 'app.tools.entry.sql_audit.description'
|| key === 'tab_manager.kind_badge.sql_audit'
|| key === 'tab_manager.hover.kind.sql_audit'
));
expect(keys.length).toBeGreaterThan(100);
keys.forEach((key) => {
const expectedPlaceholders = placeholdersOf(catalogs['en-US'][key]);
locales.forEach((locale) => {
expect(catalogs[locale][key], `${locale}:${key}`).toBeTruthy();
expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders);
});
});
});
it('states the redacted/metadata-only privacy boundary in every language', () => {
locales.forEach((locale) => {
expect(catalogs[locale]['sql_audit.privacy.description']).toBeTruthy();
expect(catalogs[locale]['sql_audit.settings.capture_mode.redacted']).toBeTruthy();
expect(catalogs[locale]['sql_audit.settings.capture_mode.metadata']).toBeTruthy();
expect(catalogs[locale]).not.toHaveProperty('sql_audit.settings.capture_mode.raw');
expect(catalogs[locale]).not.toHaveProperty('sql_audit.settings.capture_mode.full');
});
});
it('labels writer gaps separately from tamper-proof integrity claims', () => {
locales.forEach((locale) => {
expect(catalogs[locale]['sql_audit.event_type.query_statement']).toBeTruthy();
expect(catalogs[locale]['sql_audit.event_type.audit_gap']).toBeTruthy();
expect(catalogs[locale]['sql_audit.health.degraded.description']).toContain('{{count}}');
expect(catalogs[locale]['sql_audit.health.recovered.description']).toContain('audit_gap');
expect(catalogs[locale]['sql_audit.health.disabled.title']).toBeTruthy();
expect(catalogs[locale]['sql_audit.health.disabled.description']).toBeTruthy();
expect(catalogs[locale]['sql_audit.health.capture_mode']).toBeTruthy();
});
expect(catalogs['en-US']['sql_audit.health.healthy.description']).toContain('not a tamper-proof guarantee');
});
});

View File

@@ -0,0 +1,113 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../i18n/provider';
import type { LanguagePreference } from '../../i18n/types';
import SqlAuditWorkbench from './SqlAuditWorkbench';
const connections = [{
id: 'conn-1',
name: 'orders-prod',
config: { type: 'mysql', host: 'localhost', port: 3306 },
}];
vi.mock('../../store', () => ({
useStore: (selector: (state: any) => unknown) => selector({ connections }),
}));
const renderWorkbench = (preference: LanguagePreference = 'en-US') => renderToStaticMarkup(
<I18nProvider preference={preference} systemLanguages={[preference]} onPreferenceChange={vi.fn()}>
<SqlAuditWorkbench
tab={{ id: 'sql-audit-center', title: 'SQL Audit', type: 'sql-audit', connectionId: '' }}
backend={{}}
/>
</I18nProvider>,
);
describe('SqlAuditWorkbench', () => {
it('renders a localized, privacy-first audit workspace empty state', () => {
const markup = renderWorkbench('en-US');
expect(markup).toContain('SQL Audit Center');
expect(markup).toContain('Audit SQL is redacted by default');
expect(markup).toContain('No SQL audit records yet');
expect(markup).toContain('Search SQL, fingerprint, query ID, or error…');
expect(markup).not.toContain('SQL 审计中心');
});
it('keeps large-record rendering server-paged and details outside variable-height rows', () => {
const source = readFileSync(new URL('./SqlAuditWorkbench.tsx', import.meta.url), 'utf8');
const detailSource = readFileSync(new URL('./SqlAuditDetailDrawer.tsx', import.meta.url), 'utf8');
const styleSource = readFileSync(new URL('./SqlAuditWorkbench.css', import.meta.url), 'utf8');
expect(source).toContain('pageSizeOptions={[25, 50]}');
expect(source).toContain('pagination={false}');
expect(source).toContain('getSQLAuditEventPreview(record)');
expect(source).toContain('<SqlAuditDetailDrawer');
expect(source).not.toContain('pageSizeOptions={[25, 50, 100');
expect(detailSource).toContain('SQL_AUDIT_TIMELINE_PAGE_SIZE = 50');
expect(detailSource).toContain('page: timelinePage');
expect(detailSource).toContain('setTimelineTotal(page.total || 1)');
expect(detailSource).toContain('setLoading(false)');
expect(styleSource).toContain('overflow-y: auto;');
expect(styleSource).toMatch(/\.gn-sql-audit-table-panel\s*\{[\s\S]*?min-height:\s*280px;/);
});
it('debounces only free-text search before querying SQLite', () => {
const source = readFileSync(new URL('./SqlAuditWorkbench.tsx', import.meta.url), 'utf8');
expect(source).toContain('SQL_AUDIT_SEARCH_DEBOUNCE_MS = 250');
expect(source).toContain('setDebouncedSearch(filter.search)');
expect(source).toContain('search: debouncedSearch');
expect(source).not.toContain('useDeferredValue');
});
it('uses desktop SaveFileDialog export and browser payload export on their respective runtimes', () => {
const source = readFileSync(new URL('./SqlAuditWorkbench.tsx', import.meta.url), 'utf8');
expect(source).toContain("__GONAVI_WEB_RUNTIME__?.buildType === 'web'");
expect(source).toContain('backend.ExportSQLAuditFile(filterPayload, format)');
expect(source).toContain("requireSQLAuditMethod(backend, 'BuildSQLAuditExport')");
expect(source).toContain('downloadBrowserTextFile(content, fileName, mimeType)');
expect(source).toContain("cancellationMessage === 'cancelled'");
});
it('describes hash-chain verification as a consistency check rather than tamper proofing', () => {
const source = readFileSync(new URL('./SqlAuditWorkbench.tsx', import.meta.url), 'utf8');
expect(source).toContain('data.weakValidation === true');
expect(source).toContain("t('sql_audit.integrity.weak_validation')");
expect(source).toContain('data.partialChain === true || data.truncatedPrefix === true');
expect(source).toContain("t('sql_audit.integrity.partial_chain')");
});
it('loads writer health on workbench load and explicit refresh without calling it integrity', () => {
const source = readFileSync(new URL('./SqlAuditWorkbench.tsx', import.meta.url), 'utf8');
const healthSource = readFileSync(new URL('./SqlAuditHealthAlert.tsx', import.meta.url), 'utf8');
expect(source).toContain('<SqlAuditHealthAlert backend={backend} refreshKey={reloadKey} isActive={isActive} />');
expect(healthSource).toContain("requireSQLAuditMethod(backend, 'GetSQLAuditHealth')");
expect(healthSource).toContain('SQL_AUDIT_HEALTH_POLL_INTERVAL_MS = 30_000');
expect(healthSource).toContain("getSQLAuditHealthPhase(health)");
expect(healthSource).toContain("phase === 'degraded' || phase === 'historical_gap'");
});
it('never offers raw or full SQL capture modes', () => {
const source = readFileSync(new URL('./SqlAuditSettingsDrawer.tsx', import.meta.url), 'utf8');
expect(source).toContain("value: 'redacted'");
expect(source).toContain("value: 'metadata'");
expect(source).not.toContain("value: 'raw'");
expect(source).not.toContain("value: 'full'");
});
it('does not allow failed settings loads to overwrite persisted configuration', () => {
const source = readFileSync(new URL('./SqlAuditSettingsDrawer.tsx', import.meta.url), 'utf8');
expect(source).toContain('setSettingsReady(false)');
expect(source).toContain('disabled={!settingsReady || loading}');
expect(source).toContain('if (!settingsReady || loading) return;');
expect(source).not.toContain('form.setFieldsValue(DEFAULT_SQL_AUDIT_SETTINGS)');
});
});

View File

@@ -0,0 +1,698 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button,
Dropdown,
Empty,
Input,
Modal,
Pagination,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
theme,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
AuditOutlined,
ClearOutlined,
ExportOutlined,
EyeOutlined,
ReloadOutlined,
SafetyCertificateOutlined,
SearchOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useI18n } from '../../i18n/provider';
import { useStore } from '../../store';
import type { TabData } from '../../types';
import { downloadBrowserTextFile } from '../../utils/browserFileTransfer';
import {
buildSQLAuditFilterPayload,
DEFAULT_SQL_AUDIT_FILTER,
getSQLAuditEnumLabelKey,
getSQLAuditEventPreview,
getSQLAuditPrimaryRowCount,
normalizeSQLAuditPage,
SQL_AUDIT_EVENT_TYPES,
SQL_AUDIT_SOURCES,
SQL_AUDIT_STATUSES,
type SQLAuditEvent,
type SQLAuditFilter,
type SQLAuditPage,
} from './sqlAuditModel';
import SqlAuditDetailDrawer from './SqlAuditDetailDrawer';
import SqlAuditHealthAlert from './SqlAuditHealthAlert';
import SqlAuditSettingsDrawer from './SqlAuditSettingsDrawer';
import {
requireSQLAuditMethod,
resolveSQLAuditBackend,
unwrapSQLAuditResult,
type SQLAuditBackend,
} from './sqlAuditRpc';
import './SqlAuditWorkbench.css';
const { Text, Title } = Typography;
const SQL_AUDIT_SEARCH_DEBOUNCE_MS = 250;
const EMPTY_AUDIT_PAGE: SQLAuditPage = {
items: [],
total: 0,
page: 1,
pageSize: DEFAULT_SQL_AUDIT_FILTER.pageSize,
summary: {
totalEvents: 0,
successCount: 0,
errorCount: 0,
transactionCount: 0,
cancelledCount: 0,
},
};
type IntegrityState = {
type: 'success' | 'error';
message: string;
description?: string;
} | null;
interface SqlAuditWorkbenchProps {
tab: TabData;
backend?: SQLAuditBackend;
isActive?: boolean;
}
const resolveStatusColor = (status: string): string => {
if (status === 'success') return 'success';
if (status === 'error') return 'error';
if (status === 'cancelled') return 'warning';
return 'default';
};
const uniqueOptions = (
knownValues: readonly string[],
discoveredValues: string[],
resolveLabel: (value: string) => string,
) => Array.from(new Set([...knownValues, ...discoveredValues].map((value) => String(value || '').trim()).filter(Boolean)))
.map((value) => ({ value, label: resolveLabel(value) }));
const formatDateTimeInputValue = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '';
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
return localDate.toISOString().slice(0, 16);
};
const parseDateTimeInputValue = (value: string): number | undefined => {
if (!value) return undefined;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
};
export default function SqlAuditWorkbench({ tab, backend: backendOverride, isActive = true }: SqlAuditWorkbenchProps) {
const { t, language } = useI18n();
const { token } = theme.useToken();
const connections = useStore((state) => state.connections);
const [filter, setFilter] = useState<SQLAuditFilter>(() => ({
...DEFAULT_SQL_AUDIT_FILTER,
connectionId: String(tab.connectionId || '').trim(),
transactionId: String(tab.sqlAuditTransactionId || '').trim(),
}));
const [debouncedSearch, setDebouncedSearch] = useState(filter.search);
const [pageData, setPageData] = useState<SQLAuditPage>(EMPTY_AUDIT_PAGE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const [selectedEvent, setSelectedEvent] = useState<SQLAuditEvent | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [verifying, setVerifying] = useState(false);
const [clearing, setClearing] = useState(false);
const [integrityState, setIntegrityState] = useState<IntegrityState>(null);
const requestSequenceRef = useRef(0);
const backend = backendOverride ?? resolveSQLAuditBackend();
useEffect(() => {
setFilter((current) => ({
...current,
connectionId: String(tab.connectionId || '').trim(),
transactionId: String(tab.sqlAuditTransactionId || '').trim(),
page: 1,
}));
}, [tab.connectionId, tab.sqlAuditRequestKey, tab.sqlAuditTransactionId]);
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearch(filter.search);
}, SQL_AUDIT_SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [filter.search]);
const numberFormatter = useMemo(() => new Intl.NumberFormat(language), [language]);
const dateTimeFormatter = useMemo(() => new Intl.DateTimeFormat(language, {
dateStyle: 'short',
timeStyle: 'medium',
hour12: false,
}), [language]);
const connectionNameById = useMemo(
() => new Map(connections.map((connection) => [connection.id, connection.name])),
[connections],
);
const requestFilter = useMemo<SQLAuditFilter>(() => ({
search: debouncedSearch,
connectionId: filter.connectionId,
database: filter.database,
dbType: filter.dbType,
eventType: filter.eventType,
status: filter.status,
transactionId: filter.transactionId,
source: filter.source,
fromTimestamp: filter.fromTimestamp,
toTimestamp: filter.toTimestamp,
page: filter.page,
pageSize: filter.pageSize,
}), [
debouncedSearch,
filter.connectionId,
filter.database,
filter.dbType,
filter.eventType,
filter.fromTimestamp,
filter.page,
filter.pageSize,
filter.source,
filter.status,
filter.toTimestamp,
filter.transactionId,
]);
const filterPayload = useMemo(
() => buildSQLAuditFilterPayload(requestFilter),
[requestFilter],
);
const labelEnum = useCallback((kind: 'event_type' | 'status' | 'source', value: string): string => {
const key = getSQLAuditEnumLabelKey(kind, value);
return key ? t(key) : (value || t('common.unknown'));
}, [t]);
const loadEvents = useCallback(async () => {
const requestSequence = ++requestSequenceRef.current;
setLoading(true);
setError('');
try {
const getEvents = requireSQLAuditMethod(backend, 'GetSQLAuditEvents');
const result = await getEvents(filterPayload);
if (requestSequence !== requestSequenceRef.current) return;
setPageData(normalizeSQLAuditPage(unwrapSQLAuditResult(result), requestFilter));
} catch (cause) {
if (requestSequence !== requestSequenceRef.current) return;
setPageData((current) => ({ ...EMPTY_AUDIT_PAGE, page: requestFilter.page, pageSize: requestFilter.pageSize, summary: current.summary }));
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
if (requestSequence === requestSequenceRef.current) setLoading(false);
}
}, [backend, filterPayload, requestFilter]);
useEffect(() => {
void loadEvents();
return () => {
requestSequenceRef.current += 1;
};
}, [loadEvents, reloadKey]);
const updateFilter = <K extends keyof SQLAuditFilter>(key: K, value: SQLAuditFilter[K]) => {
setFilter((current) => ({ ...current, [key]: value, page: key === 'page' ? Number(value) : 1 }));
};
const resetFilters = () => {
setFilter({ ...DEFAULT_SQL_AUDIT_FILTER });
setIntegrityState(null);
};
const hasActiveFilters = Boolean(
filter.search
|| filter.connectionId
|| filter.database
|| filter.dbType
|| filter.eventType
|| filter.status
|| filter.transactionId
|| filter.source
|| filter.fromTimestamp
|| filter.toTimestamp,
);
const handleVerifyIntegrity = async () => {
setVerifying(true);
setIntegrityState(null);
try {
const verifyIntegrity = requireSQLAuditMethod(backend, 'VerifySQLAuditIntegrity');
const data = (unwrapSQLAuditResult(await verifyIntegrity()) || {}) as Record<string, unknown>;
const valid = data.valid !== false;
const partialChain = data.partialChain === true || data.truncatedPrefix === true;
const checkedRecords = Number(data.checkedRecords ?? data.checkedCount ?? 0);
const verificationDetails = [
checkedRecords > 0
? t('sql_audit.integrity.checked_records', { count: numberFormatter.format(checkedRecords) })
: '',
partialChain ? t('sql_audit.integrity.partial_chain') : '',
data.weakValidation === true ? t('sql_audit.integrity.weak_validation') : '',
!valid ? String(data.message || '').trim() : '',
].filter(Boolean);
setIntegrityState({
type: valid ? 'success' : 'error',
message: valid
? t(partialChain ? 'sql_audit.integrity.valid_partial' : 'sql_audit.integrity.valid')
: t('sql_audit.integrity.invalid'),
description: verificationDetails.join(' ') || undefined,
});
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
setIntegrityState({ type: 'error', message: t('sql_audit.integrity.verify_failed'), description: detail });
} finally {
setVerifying(false);
}
};
const handleExport = async (format: 'json' | 'csv') => {
if (pageData.total <= 0) return;
setExporting(true);
try {
const isWebRuntime = typeof window !== 'undefined'
&& (window as any).__GONAVI_WEB_RUNTIME__?.buildType === 'web';
if (!isWebRuntime && typeof backend.ExportSQLAuditFile === 'function') {
const result = await backend.ExportSQLAuditFile(filterPayload, format);
const cancellationMessage = String(result?.message || '').trim().toLocaleLowerCase();
if (result?.success === false && (cancellationMessage === 'cancelled' || cancellationMessage === '已取消')) {
return;
}
const data = (unwrapSQLAuditResult(result) || {}) as Record<string, unknown>;
const filePath = String(data.filePath || data.path || data.fileName || '').trim();
message.success(t('sql_audit.export.desktop_success', { filePath }));
return;
}
const buildExport = requireSQLAuditMethod(backend, 'BuildSQLAuditExport');
const data = (unwrapSQLAuditResult(await buildExport(filterPayload, format)) || {}) as Record<string, unknown>;
const fileName = String(data.fileName || `gonavi-sql-audit.${format}`).trim();
const mimeType = String(data.mimeType || (format === 'json' ? 'application/json' : 'text/csv;charset=utf-8')).trim();
const content = typeof data.content === 'string' ? data.content : '';
if (!content || !downloadBrowserTextFile(content, fileName, mimeType)) {
throw new Error(t('sql_audit.export.unavailable'));
}
message.success(t('sql_audit.export.success', { fileName }));
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
message.error(t('sql_audit.export.failed', { detail }));
} finally {
setExporting(false);
}
};
const handleClear = () => {
Modal.confirm({
title: t('sql_audit.clear.title'),
content: t('sql_audit.clear.description'),
okText: t('sql_audit.clear.confirm'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true, loading: clearing },
onOk: async () => {
setClearing(true);
try {
const clearEvents = requireSQLAuditMethod(backend, 'ClearSQLAuditEvents');
unwrapSQLAuditResult(await clearEvents(Date.now() + 1));
setSelectedEvent(null);
setIntegrityState(null);
setPageData(EMPTY_AUDIT_PAGE);
setReloadKey((current) => current + 1);
message.success(t('sql_audit.clear.success'));
} catch (cause) {
const detail = cause instanceof Error ? cause.message : String(cause);
message.error(t('sql_audit.clear.failed', { detail }));
throw cause;
} finally {
setClearing(false);
}
},
});
};
const formatDateTime = (timestamp: number): string => (
timestamp > 0 ? dateTimeFormatter.format(new Date(timestamp)) : '-'
);
const eventTypeOptions = useMemo(() => uniqueOptions(
SQL_AUDIT_EVENT_TYPES,
pageData.items.map((item) => item.eventType),
(value) => labelEnum('event_type', value),
), [labelEnum, pageData.items]);
const statusOptions = useMemo(() => uniqueOptions(
SQL_AUDIT_STATUSES,
pageData.items.map((item) => item.status),
(value) => labelEnum('status', value),
), [labelEnum, pageData.items]);
const sourceOptions = useMemo(() => uniqueOptions(
SQL_AUDIT_SOURCES,
pageData.items.map((item) => item.source),
(value) => labelEnum('source', value),
), [labelEnum, pageData.items]);
const dbTypeOptions = useMemo(() => Array.from(new Set([
...connections.map((connection) => String(connection.config?.type || '').trim()),
...pageData.items.map((item) => item.dbType),
].filter(Boolean))).sort().map((value) => ({ value, label: value })), [connections, pageData.items]);
const columns = useMemo<ColumnsType<SQLAuditEvent>>(() => [
{
title: t('sql_audit.column.time'),
dataIndex: 'timestamp',
key: 'timestamp',
width: 168,
render: (value: number) => <time dateTime={new Date(value).toISOString()}>{formatDateTime(value)}</time>,
},
{
title: t('sql_audit.column.status'),
dataIndex: 'status',
key: 'status',
width: 92,
render: (value: string) => <Tag color={resolveStatusColor(value)}>{labelEnum('status', value)}</Tag>,
},
{
title: t('sql_audit.column.connection'),
dataIndex: 'connectionId',
key: 'connectionId',
width: 150,
ellipsis: true,
render: (value: string) => <Tooltip title={value}><span>{connectionNameById.get(value) || value || '-'}</span></Tooltip>,
},
{
title: t('sql_audit.column.database'),
dataIndex: 'database',
key: 'database',
width: 130,
ellipsis: true,
render: (value: string) => value || '-',
},
{
title: t('sql_audit.column.event_type'),
dataIndex: 'eventType',
key: 'eventType',
width: 150,
render: (value: string) => labelEnum('event_type', value),
},
{
title: t('sql_audit.column.sql'),
dataIndex: 'sqlText',
key: 'sqlText',
width: 360,
render: (_value: string, record) => (
<div className="gn-sql-audit-sql-cell">
<code title={record.sqlText}>{getSQLAuditEventPreview(record) || t('sql_audit.detail.no_sql')}</code>
{record.sqlRedacted ? <Tag color="processing">{t('sql_audit.detail.redacted')}</Tag> : null}
</div>
),
},
{
title: t('sql_audit.column.duration'),
dataIndex: 'durationMs',
key: 'durationMs',
width: 100,
align: 'right',
render: (value: number) => `${numberFormatter.format(value)} ms`,
},
{
title: t('sql_audit.column.rows'),
key: 'rows',
width: 100,
align: 'right',
render: (_value, record) => numberFormatter.format(getSQLAuditPrimaryRowCount(record)),
},
{
title: t('sql_audit.column.source'),
dataIndex: 'source',
key: 'source',
width: 120,
render: (value: string) => labelEnum('source', value),
},
{
title: t('sql_audit.column.action'),
key: 'action',
width: 74,
fixed: 'right',
render: (_value, record) => (
<Tooltip title={t('sql_audit.action.view_detail')}>
<Button
type="text"
size="small"
icon={<EyeOutlined aria-hidden="true" />}
aria-label={t('sql_audit.action.view_detail')}
onClick={() => setSelectedEvent(record)}
/>
</Tooltip>
),
},
], [connectionNameById, dateTimeFormatter, labelEnum, numberFormatter, t]);
const hasLoadedRecords = pageData.items.length > 0;
const emptyDescription = hasActiveFilters ? t('sql_audit.empty.no_matches') : t('sql_audit.empty.no_records');
const detailConnectionName = selectedEvent ? connectionNameById.get(selectedEvent.connectionId) : undefined;
const workbenchStyle = {
'--sql-audit-bg': token.colorBgLayout,
'--sql-audit-panel': token.colorBgContainer,
'--sql-audit-subtle': token.colorFillQuaternary,
'--sql-audit-border': token.colorBorderSecondary,
'--sql-audit-text': token.colorText,
'--sql-audit-muted': token.colorTextSecondary,
'--sql-audit-primary': token.colorPrimary,
} as React.CSSProperties;
return (
<main className="gn-sql-audit-workbench" style={workbenchStyle} aria-labelledby="sql-audit-workbench-title" aria-busy={loading}>
<header className="gn-sql-audit-header">
<div className="gn-sql-audit-title-group">
<div className="gn-sql-audit-title-icon" aria-hidden="true"><AuditOutlined /></div>
<div className="gn-sql-audit-title-copy">
<Title level={4} id="sql-audit-workbench-title">{t('sql_audit.workbench.title')}</Title>
<Text type="secondary">{t('sql_audit.workbench.description')}</Text>
</div>
</div>
<Space wrap className="gn-sql-audit-header-actions">
<Tooltip title={t('sql_audit.action.verify')}>
<Button icon={<SafetyCertificateOutlined aria-hidden="true" />} loading={verifying} onClick={() => void handleVerifyIntegrity()}>
{t('sql_audit.action.verify')}
</Button>
</Tooltip>
<Dropdown
menu={{
items: [
{ key: 'json', label: t('sql_audit.action.export_json') },
{ key: 'csv', label: t('sql_audit.action.export_csv') },
],
onClick: ({ key }) => void handleExport(key as 'json' | 'csv'),
}}
disabled={pageData.total <= 0 || exporting}
>
<Button icon={<ExportOutlined aria-hidden="true" />} loading={exporting}>{t('sql_audit.action.export')}</Button>
</Dropdown>
<Button icon={<SettingOutlined aria-hidden="true" />} onClick={() => setSettingsOpen(true)}>{t('sql_audit.action.settings')}</Button>
<Button danger icon={<ClearOutlined aria-hidden="true" />} disabled={pageData.total <= 0} loading={clearing} onClick={handleClear}>{t('sql_audit.action.clear')}</Button>
</Space>
</header>
<Alert
className="gn-sql-audit-privacy-note"
type="info"
showIcon
message={t('sql_audit.privacy.title')}
description={t('sql_audit.privacy.description')}
/>
<SqlAuditHealthAlert backend={backend} refreshKey={reloadKey} isActive={isActive} />
<section className="gn-sql-audit-toolbar" aria-label={t('sql_audit.filter.aria_label')}>
<Input
value={filter.search}
onChange={(event) => updateFilter('search', event.target.value)}
prefix={<SearchOutlined aria-hidden="true" />}
placeholder={t('sql_audit.filter.search_placeholder')}
aria-label={t('sql_audit.filter.search_aria_label')}
name="sql-audit-search"
autoComplete="off"
allowClear
className="gn-sql-audit-filter-search"
/>
<Select
value={filter.connectionId || undefined}
onChange={(value) => updateFilter('connectionId', value || '')}
placeholder={t('sql_audit.filter.connection')}
aria-label={t('sql_audit.filter.connection')}
allowClear
showSearch
optionFilterProp="label"
options={connections.map((connection) => ({ value: connection.id, label: connection.name }))}
/>
<Input
value={filter.database}
onChange={(event) => updateFilter('database', event.target.value)}
placeholder={t('sql_audit.filter.database')}
aria-label={t('sql_audit.filter.database')}
name="sql-audit-database"
autoComplete="off"
allowClear
/>
<Select
value={filter.dbType || undefined}
onChange={(value) => updateFilter('dbType', value || '')}
placeholder={t('sql_audit.filter.db_type')}
aria-label={t('sql_audit.filter.db_type')}
allowClear
showSearch
optionFilterProp="label"
options={dbTypeOptions}
/>
<Select
value={filter.eventType || undefined}
onChange={(value) => updateFilter('eventType', value || '')}
placeholder={t('sql_audit.filter.event_type')}
aria-label={t('sql_audit.filter.event_type')}
allowClear
options={eventTypeOptions}
/>
<Select
value={filter.status || undefined}
onChange={(value) => updateFilter('status', value || '')}
placeholder={t('sql_audit.filter.status')}
aria-label={t('sql_audit.filter.status')}
allowClear
options={statusOptions}
/>
<Select
value={filter.source || undefined}
onChange={(value) => updateFilter('source', value || '')}
placeholder={t('sql_audit.filter.source')}
aria-label={t('sql_audit.filter.source')}
allowClear
options={sourceOptions}
/>
<Input
value={filter.transactionId}
onChange={(event) => updateFilter('transactionId', event.target.value)}
placeholder={t('sql_audit.filter.transaction_id')}
aria-label={t('sql_audit.filter.transaction_id')}
name="sql-audit-transaction-id"
autoComplete="off"
spellCheck={false}
allowClear
/>
<div className="gn-sql-audit-filter-time" role="group" aria-label={t('sql_audit.filter.time_range')}>
<Input
type="datetime-local"
value={formatDateTimeInputValue(filter.fromTimestamp)}
onChange={(event) => updateFilter('fromTimestamp', parseDateTimeInputValue(event.target.value))}
aria-label={t('sql_audit.filter.time_from')}
name="sql-audit-time-from"
autoComplete="off"
/>
<Input
type="datetime-local"
value={formatDateTimeInputValue(filter.toTimestamp)}
onChange={(event) => updateFilter('toTimestamp', parseDateTimeInputValue(event.target.value))}
aria-label={t('sql_audit.filter.time_to')}
name="sql-audit-time-to"
autoComplete="off"
/>
</div>
<div className="gn-sql-audit-toolbar-actions">
<Button icon={<ReloadOutlined aria-hidden="true" />} loading={loading} onClick={() => setReloadKey((current) => current + 1)}>{t('common.refresh')}</Button>
<Button disabled={!hasActiveFilters} onClick={resetFilters}>{t('sql_audit.action.reset_filters')}</Button>
</div>
</section>
<section className="gn-sql-audit-summary" aria-label={t('sql_audit.summary.aria_label')} aria-live="polite">
{[
{ key: 'total', label: t('sql_audit.summary.events'), value: pageData.summary.totalEvents || pageData.total },
{ key: 'success', label: t('sql_audit.summary.success'), value: pageData.summary.successCount },
{ key: 'error', label: t('sql_audit.summary.errors'), value: pageData.summary.errorCount },
{ key: 'transactions', label: t('sql_audit.summary.transactions'), value: pageData.summary.transactionCount },
].map((item) => (
<div key={item.key} className={`gn-sql-audit-summary-card is-${item.key}`}>
<span>{item.label}</span>
<strong>{numberFormatter.format(item.value)}</strong>
</div>
))}
</section>
{integrityState ? (
<Alert
className="gn-sql-audit-integrity-alert"
type={integrityState.type}
showIcon
closable
onClose={() => setIntegrityState(null)}
message={integrityState.message}
description={integrityState.description}
/>
) : null}
{error ? (
<Alert
className="gn-sql-audit-load-alert"
type="error"
showIcon
message={t('sql_audit.error.load_failed')}
description={error}
action={<Button size="small" onClick={() => setReloadKey((current) => current + 1)}>{t('common.retry')}</Button>}
/>
) : null}
<section className="gn-sql-audit-table-panel" aria-label={t('sql_audit.table.aria_label')}>
{hasLoadedRecords || loading ? (
<Table<SQLAuditEvent>
rowKey="id"
columns={columns}
dataSource={pageData.items}
loading={loading}
pagination={false}
size="small"
tableLayout="fixed"
scroll={{ x: 1444, y: 'calc(100vh - 540px)' }}
rowClassName="gn-sql-audit-table-row"
/>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={emptyDescription}
>
{hasActiveFilters ? <Button onClick={resetFilters}>{t('sql_audit.action.reset_filters')}</Button> : null}
</Empty>
)}
<div className="gn-sql-audit-pagination">
<Text type="secondary">{t('sql_audit.pagination.total', { count: numberFormatter.format(pageData.total) })}</Text>
<Pagination
current={filter.page}
pageSize={filter.pageSize}
total={pageData.total}
showSizeChanger
pageSizeOptions={[25, 50]}
onChange={(page, pageSize) => setFilter((current) => ({ ...current, page, pageSize }))}
aria-label={t('sql_audit.pagination.aria_label')}
/>
</div>
</section>
<SqlAuditDetailDrawer
event={selectedEvent}
open={!!selectedEvent}
onClose={() => setSelectedEvent(null)}
backend={backend}
connectionName={detailConnectionName}
/>
<SqlAuditSettingsDrawer
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => setReloadKey((current) => current + 1)}
backend={backend}
/>
</main>
);
}

View File

@@ -0,0 +1,44 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), 'utf8');
describe('SQL audit workbench wiring', () => {
it('routes one stable sql-audit tab through the workbench shell', () => {
const typesSource = read('../../types.ts');
const tabSource = read('../../utils/sqlAuditTab.ts');
const workbenchSource = read('../WorkbenchTabContent.tsx');
expect(typesSource).toContain('| "sql-audit"');
expect(tabSource).toContain("SQL_AUDIT_WORKBENCH_TAB_ID = 'sql-audit-center'");
expect(workbenchSource).toContain("tab.type === 'sql-audit'");
expect(workbenchSource).toContain('<SqlAuditWorkbench tab={tab} isActive={isActive} />');
});
it('provides both the V2 footer shortcut and the cross-version tool-center entry', () => {
const sidebarSource = read('../Sidebar.tsx');
const railSource = read('../sidebar/SqlAuditRailButton.tsx');
const appSource = read('../../App.tsx');
expect(sidebarSource).toContain('gn-v2-sidebar-sql-audit-button');
expect(railSource).toContain('buildSqlAuditWorkbenchTab()');
expect(appSource).toContain("key: 'sql-audit'");
expect(appSource).toContain('addTab(buildSqlAuditWorkbenchTab())');
expect(appSource).toContain('setIsToolsModalOpen(false)');
});
it('registers audit labels for docked and detached tab presentations', () => {
expect(read('../TabManager.tsx')).toContain("tab_manager.kind_badge.sql_audit");
expect(read('../FloatingWorkbenchWindows.tsx')).toContain("tab_manager.kind_badge.sql_audit");
expect(read('../../utils/tabDisplay.ts')).toContain("if (tab.type === 'sql-audit') return 'AUDIT'");
});
it('keeps audit RPC calls on the runtime bridge rather than generated desktop-only imports', () => {
const rpcSource = read('./sqlAuditRpc.ts');
const workbenchSource = read('./SqlAuditWorkbench.tsx');
expect(rpcSource).toContain('(window as any).go?.app?.App');
expect(rpcSource).toContain('GetSQLAuditHealth?: ()');
expect(workbenchSource).not.toContain("from '../../../wailsjs/go/app/App'");
});
});

View File

@@ -0,0 +1,181 @@
import { describe, expect, it } from 'vitest';
import {
buildSQLAuditFilterPayload,
DEFAULT_SQL_AUDIT_FILTER,
getSQLAuditEnumLabelKey,
getSQLAuditEventPreview,
getSQLAuditPrimaryRowCount,
getSQLAuditHealthPhase,
normalizeSQLAuditEvent,
normalizeSQLAuditHealth,
normalizeSQLAuditPage,
normalizeSQLAuditSettings,
sortSQLAuditTimeline,
} from './sqlAuditModel';
describe('sqlAuditModel', () => {
it('normalizes audit events without inventing raw SQL or unsupported boundary modes', () => {
const event = normalizeSQLAuditEvent({
id: 'event-1',
sequence: '12',
timestamp: 1720000000000,
eventType: 'transaction_statement',
status: 'error',
boundaryMode: 'untrusted-mode',
sqlText: 'UPDATE users SET token = ?',
sqlRedacted: true,
rowsAffected: '2',
error: 'permission denied',
});
expect(event).toMatchObject({
id: 'event-1',
sequence: 12,
eventType: 'transaction_statement',
status: 'error',
boundaryMode: 'unknown',
sqlText: 'UPDATE users SET token = ?',
sqlRedacted: true,
rowsAffected: 2,
error: 'permission denied',
});
});
it('normalizes paged results and explicit summary field names', () => {
const page = normalizeSQLAuditPage({
items: [{ id: 'event-1', sqlText: 'SELECT ?' }],
total: 40,
page: 2,
pageSize: 25,
summary: {
totalEvents: 40,
successCount: 35,
errorCount: 4,
transactionCount: 8,
cancelledCount: 1,
},
}, { page: 1, pageSize: 50 });
expect(page).toMatchObject({
total: 40,
page: 2,
pageSize: 25,
summary: {
totalEvents: 40,
successCount: 35,
errorCount: 4,
transactionCount: 8,
cancelledCount: 1,
},
});
expect(page.items[0].id).toBe('event-1');
});
it('allows only redacted or metadata capture settings', () => {
expect(normalizeSQLAuditSettings({ captureMode: 'metadata' }).captureMode).toBe('metadata');
expect(normalizeSQLAuditSettings({ captureMode: 'full' }).captureMode).toBe('redacted');
expect(normalizeSQLAuditSettings({ captureMode: 'raw' }).captureMode).toBe('redacted');
});
it('normalizes health reports without treating unknown states as healthy', () => {
expect(normalizeSQLAuditHealth({
status: 'degraded',
droppedEvents: '7',
firstFailureAt: 100,
lastFailureAt: 200,
lastSuccessAt: 150,
lastError: 'disk full',
})).toEqual({
status: 'degraded',
captureEnabled: null,
captureMode: 'unknown',
droppedEvents: 7,
firstFailureAt: 100,
lastFailureAt: 200,
lastSuccessAt: 150,
lastError: 'disk full',
});
expect(normalizeSQLAuditHealth({ status: 'future-state' }).status).toBe('unknown');
});
it('claims an audit_gap recovery marker only after a post-failure success', () => {
expect(getSQLAuditHealthPhase(normalizeSQLAuditHealth({
status: 'healthy', captureEnabled: true, captureMode: 'redacted', droppedEvents: 3, lastFailureAt: 100, lastSuccessAt: 101,
}))).toBe('recovered');
expect(getSQLAuditHealthPhase(normalizeSQLAuditHealth({
status: 'healthy', captureEnabled: true, captureMode: 'redacted', droppedEvents: 3, lastFailureAt: 100, lastSuccessAt: 99,
}))).toBe('historical_gap');
expect(getSQLAuditHealthPhase(normalizeSQLAuditHealth({
status: 'healthy', captureEnabled: true, captureMode: 'redacted', droppedEvents: 0,
}))).toBe('healthy');
});
it('distinguishes disabled capture from a healthy active writer and preserves its mode', () => {
const disabled = normalizeSQLAuditHealth({
status: 'healthy', captureEnabled: false, captureMode: 'metadata', droppedEvents: 0,
});
expect(disabled.captureEnabled).toBe(false);
expect(disabled.captureMode).toBe('metadata');
expect(getSQLAuditHealthPhase(disabled)).toBe('disabled');
expect(getSQLAuditHealthPhase(normalizeSQLAuditHealth({
status: 'healthy', captureEnabled: true, captureMode: 'redacted', droppedEvents: 0,
}))).toBe('healthy');
});
it('builds the exact backend filter contract and omits empty values', () => {
expect(buildSQLAuditFilterPayload({
...DEFAULT_SQL_AUDIT_FILTER,
search: ' orders ',
connectionId: 'conn-1',
database: '',
fromTimestamp: 1000,
toTimestamp: 2000,
page: 3,
pageSize: 25,
})).toEqual({
search: 'orders',
connectionId: 'conn-1',
fromTimestamp: 1000,
toTimestamp: 2000,
page: 3,
pageSize: 25,
});
});
it('sorts a transaction timeline by chain sequence before timestamp', () => {
const events = [
normalizeSQLAuditEvent({ id: 'commit', sequence: 3, timestamp: 100 }),
normalizeSQLAuditEvent({ id: 'begin', sequence: 1, timestamp: 300 }),
normalizeSQLAuditEvent({ id: 'statement', sequence: 2, timestamp: 200 }),
];
expect(sortSQLAuditTimeline(events).map((event) => event.id)).toEqual(['begin', 'statement', 'commit']);
});
it('keeps table previews single-line and bounded', () => {
const preview = getSQLAuditEventPreview(normalizeSQLAuditEvent({
sqlText: 'SELECT *\nFROM orders WHERE customer_id = ?',
}), 24);
expect(preview).toBe('SELECT * FROM orders WH…');
expect(preview).not.toContain('\n');
});
it('shows returned rows for SELECT events and affected rows for mutations', () => {
expect(getSQLAuditPrimaryRowCount({ rowsAffected: 0, rowsReturned: 7 })).toBe(7);
expect(getSQLAuditPrimaryRowCount({ rowsAffected: 3, rowsReturned: 0 })).toBe(3);
});
it('localizes known enum values while leaving unknown values dynamic', () => {
expect(getSQLAuditEnumLabelKey('event_type', 'query_statement')).toBe('sql_audit.event_type.query_statement');
expect(getSQLAuditEnumLabelKey('event_type', 'audit_gap')).toBe('sql_audit.event_type.audit_gap');
expect(getSQLAuditEnumLabelKey('event_type', 'transaction_commit')).toBe('sql_audit.event_type.transaction_commit');
expect(getSQLAuditEnumLabelKey('source', 'query_editor')).toBe('sql_audit.source.query_editor');
expect(getSQLAuditEnumLabelKey('source', 'app_shutdown')).toBe('sql_audit.source.app_shutdown');
expect(getSQLAuditEnumLabelKey('source', 'table_designer')).toBe('sql_audit.source.table_designer');
expect(getSQLAuditEnumLabelKey('source', 'data_import')).toBe('sql_audit.source.data_import');
expect(getSQLAuditEnumLabelKey('source', 'message_publish')).toBe('sql_audit.source.message_publish');
expect(getSQLAuditEnumLabelKey('source', 'data_grid')).toBeNull();
expect(getSQLAuditEnumLabelKey('status', 'future-status')).toBeNull();
});
});

View File

@@ -0,0 +1,310 @@
export type SQLAuditEventStatus = 'success' | 'error' | 'cancelled' | string;
export interface SQLAuditEvent {
id: string;
sequence: number;
timestamp: number;
eventType: string;
status: SQLAuditEventStatus;
connectionId: string;
connectionFingerprint: string;
dbType: string;
database: string;
queryId: string;
transactionId: string;
source: string;
commitMode: string;
boundaryMode: string;
sqlText: string;
sqlRedacted: boolean;
sqlFingerprint: string;
statementIndex: number;
statementCount: number;
durationMs: number;
rowsAffected?: number;
rowsReturned?: number;
error: string;
prevHash: string;
hash: string;
}
export interface SQLAuditSummary {
totalEvents: number;
successCount: number;
errorCount: number;
transactionCount: number;
cancelledCount: number;
}
export interface SQLAuditFilter {
search: string;
connectionId: string;
database: string;
dbType: string;
eventType: string;
status: string;
transactionId: string;
source: string;
fromTimestamp?: number;
toTimestamp?: number;
page: number;
pageSize: number;
}
export interface SQLAuditPage {
items: SQLAuditEvent[];
total: number;
page: number;
pageSize: number;
summary: SQLAuditSummary;
}
export interface SQLAuditSettings {
enabled: boolean;
captureMode: 'redacted' | 'metadata';
retentionDays: number;
maxRecords: number;
}
export interface SQLAuditHealth {
status: 'healthy' | 'degraded' | 'unknown';
captureEnabled: boolean | null;
captureMode: 'redacted' | 'metadata' | 'unknown';
droppedEvents: number;
firstFailureAt: number;
lastFailureAt: number;
lastSuccessAt: number;
lastError: string;
}
export type SQLAuditHealthPhase = 'healthy' | 'disabled' | 'degraded' | 'recovered' | 'historical_gap' | 'unknown';
export const DEFAULT_SQL_AUDIT_FILTER: SQLAuditFilter = {
search: '',
connectionId: '',
database: '',
dbType: '',
eventType: '',
status: '',
transactionId: '',
source: '',
page: 1,
pageSize: 50,
};
export const DEFAULT_SQL_AUDIT_SETTINGS: SQLAuditSettings = {
enabled: true,
captureMode: 'redacted',
retentionDays: 30,
maxRecords: 100_000,
};
export const SQL_AUDIT_EVENT_TYPES = [
'query',
'query_statement',
'transaction_begin',
'transaction_statement',
'transaction_commit_requested',
'transaction_commit',
'transaction_rollback_requested',
'transaction_rollback',
'transaction_auto_rollback',
'audit_gap',
'audit_settings_change',
'audit_clear',
] as const;
export const SQL_AUDIT_STATUSES = ['success', 'error', 'cancelled'] as const;
export const SQL_AUDIT_SOURCES = [
'query_editor',
'sql_file',
'sync',
'mcp',
'system',
'tab_close',
'app_shutdown',
'data_editor',
'data_import',
'table_designer',
'object_editor',
'message_publish',
'ai_action',
'application_api',
'audit_control',
] as const;
export const getSQLAuditEnumLabelKey = (
kind: 'event_type' | 'status' | 'source',
value: string,
): string | null => {
const normalized = String(value || '').trim().toLowerCase();
const knownValues = kind === 'event_type'
? SQL_AUDIT_EVENT_TYPES
: kind === 'status'
? SQL_AUDIT_STATUSES
: SQL_AUDIT_SOURCES;
return (knownValues as readonly string[]).includes(normalized)
? `sql_audit.${kind}.${normalized}`
: null;
};
const toStringValue = (value: unknown): string => String(value ?? '').trim();
const toFiniteNumber = (value: unknown, fallback = 0): number => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
const toOptionalFiniteNumber = (value: unknown): number | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
};
export const normalizeSQLAuditEvent = (value: unknown, index = 0): SQLAuditEvent => {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const rawBoundaryMode = toStringValue(raw.boundaryMode);
const boundaryMode = ['driver_api', 'text_sql', 'implicit'].includes(rawBoundaryMode)
? rawBoundaryMode
: 'unknown';
return {
id: toStringValue(raw.id) || `sql-audit-${index + 1}`,
sequence: Math.max(0, toFiniteNumber(raw.sequence)),
timestamp: Math.max(0, toFiniteNumber(raw.timestamp)),
eventType: toStringValue(raw.eventType) || 'query',
status: toStringValue(raw.status) || 'success',
connectionId: toStringValue(raw.connectionId),
connectionFingerprint: toStringValue(raw.connectionFingerprint),
dbType: toStringValue(raw.dbType),
database: toStringValue(raw.database),
queryId: toStringValue(raw.queryId),
transactionId: toStringValue(raw.transactionId),
source: toStringValue(raw.source),
commitMode: toStringValue(raw.commitMode),
boundaryMode,
sqlText: typeof raw.sqlText === 'string' ? raw.sqlText : '',
sqlRedacted: raw.sqlRedacted === true,
sqlFingerprint: toStringValue(raw.sqlFingerprint),
statementIndex: Math.max(0, toFiniteNumber(raw.statementIndex)),
statementCount: Math.max(0, toFiniteNumber(raw.statementCount)),
durationMs: Math.max(0, toFiniteNumber(raw.durationMs)),
rowsAffected: toOptionalFiniteNumber(raw.rowsAffected),
rowsReturned: toOptionalFiniteNumber(raw.rowsReturned),
error: toStringValue(raw.error ?? raw.errorMessage),
prevHash: toStringValue(raw.prevHash),
hash: toStringValue(raw.hash),
};
};
const normalizeSQLAuditSummary = (value: unknown, total: number): SQLAuditSummary => {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
return {
totalEvents: Math.max(0, toFiniteNumber(raw.totalEvents ?? raw.total, total)),
successCount: Math.max(0, toFiniteNumber(raw.successCount ?? raw.success)),
errorCount: Math.max(0, toFiniteNumber(raw.errorCount ?? raw.error)),
transactionCount: Math.max(0, toFiniteNumber(raw.transactionCount ?? raw.transactions)),
cancelledCount: Math.max(0, toFiniteNumber(raw.cancelledCount ?? raw.cancelled)),
};
};
export const normalizeSQLAuditPage = (value: unknown, fallback: Pick<SQLAuditFilter, 'page' | 'pageSize'>): SQLAuditPage => {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const items = Array.isArray(raw.items)
? raw.items.map((item, index) => normalizeSQLAuditEvent(item, index))
: [];
const total = Math.max(items.length, toFiniteNumber(raw.total, items.length));
return {
items,
total,
page: Math.max(1, toFiniteNumber(raw.page, fallback.page)),
pageSize: Math.max(1, toFiniteNumber(raw.pageSize, fallback.pageSize)),
summary: normalizeSQLAuditSummary(raw.summary, total),
};
};
export const normalizeSQLAuditSettings = (value: unknown): SQLAuditSettings => {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const captureMode = raw.captureMode === 'metadata' ? 'metadata' : 'redacted';
return {
enabled: raw.enabled !== false,
captureMode,
retentionDays: Math.max(1, Math.round(toFiniteNumber(raw.retentionDays, DEFAULT_SQL_AUDIT_SETTINGS.retentionDays))),
maxRecords: Math.max(100, Math.round(toFiniteNumber(raw.maxRecords, DEFAULT_SQL_AUDIT_SETTINGS.maxRecords))),
};
};
export const normalizeSQLAuditHealth = (value: unknown): SQLAuditHealth => {
const raw = value && typeof value === 'object' ? value as Record<string, unknown> : {};
const rawStatus = toStringValue(raw.status).toLowerCase();
const status = rawStatus === 'healthy' || rawStatus === 'degraded' ? rawStatus : 'unknown';
const rawCaptureMode = toStringValue(raw.captureMode).toLowerCase();
const captureMode = rawCaptureMode === 'redacted' || rawCaptureMode === 'metadata'
? rawCaptureMode
: 'unknown';
return {
status,
captureEnabled: typeof raw.captureEnabled === 'boolean' ? raw.captureEnabled : null,
captureMode,
droppedEvents: Math.max(0, Math.round(toFiniteNumber(raw.droppedEvents))),
firstFailureAt: Math.max(0, toFiniteNumber(raw.firstFailureAt)),
lastFailureAt: Math.max(0, toFiniteNumber(raw.lastFailureAt)),
lastSuccessAt: Math.max(0, toFiniteNumber(raw.lastSuccessAt)),
lastError: toStringValue(raw.lastError),
};
};
export const getSQLAuditHealthPhase = (health: SQLAuditHealth): SQLAuditHealthPhase => {
if (health.status === 'degraded') return 'degraded';
if (health.status !== 'healthy') return 'unknown';
if (health.captureEnabled === null || health.captureMode === 'unknown') return 'unknown';
if (health.captureEnabled === false) return 'disabled';
if (health.droppedEvents <= 0) return 'healthy';
if (health.lastFailureAt > 0 && health.lastSuccessAt >= health.lastFailureAt) return 'recovered';
return 'historical_gap';
};
export const buildSQLAuditFilterPayload = (filter: SQLAuditFilter): Record<string, string | number> => {
const payload: Record<string, string | number> = {
page: Math.max(1, Math.round(filter.page)),
pageSize: Math.max(1, Math.round(filter.pageSize)),
};
const stringFields = [
'search',
'connectionId',
'database',
'dbType',
'eventType',
'status',
'transactionId',
'source',
] as const;
stringFields.forEach((field) => {
const value = String(filter[field] || '').trim();
if (value) payload[field] = value;
});
if (Number.isFinite(filter.fromTimestamp)) payload.fromTimestamp = Number(filter.fromTimestamp);
if (Number.isFinite(filter.toTimestamp)) payload.toTimestamp = Number(filter.toTimestamp);
return payload;
};
export const getSQLAuditEventPreview = (event: SQLAuditEvent, maxLength = 180): string => {
const normalized = event.sqlText.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(0, maxLength - 1))}`;
};
export const getSQLAuditPrimaryRowCount = (event: Pick<SQLAuditEvent, 'rowsAffected' | 'rowsReturned'>): number => {
const rowsReturned = Math.max(0, toFiniteNumber(event.rowsReturned));
return rowsReturned > 0
? rowsReturned
: Math.max(0, toFiniteNumber(event.rowsAffected));
};
export const sortSQLAuditTimeline = (events: SQLAuditEvent[]): SQLAuditEvent[] => (
[...events].sort((left, right) => (
left.sequence - right.sequence
|| left.timestamp - right.timestamp
|| left.statementIndex - right.statementIndex
))
);

View File

@@ -0,0 +1,41 @@
import type { SQLAuditHealth, SQLAuditSettings } from './sqlAuditModel';
export interface SQLAuditRpcResult<T = unknown> {
success?: boolean;
data?: T;
message?: string;
}
export interface SQLAuditBackend {
GetSQLAuditEvents?: (filter: Record<string, string | number>) => Promise<SQLAuditRpcResult>;
GetSQLAuditHealth?: () => Promise<SQLAuditRpcResult<SQLAuditHealth>>;
GetSQLAuditSettings?: () => Promise<SQLAuditRpcResult>;
UpdateSQLAuditSettings?: (settings: SQLAuditSettings) => Promise<SQLAuditRpcResult>;
VerifySQLAuditIntegrity?: () => Promise<SQLAuditRpcResult>;
BuildSQLAuditExport?: (filter: Record<string, string | number>, format: 'json' | 'csv') => Promise<SQLAuditRpcResult>;
ExportSQLAuditFile?: (filter: Record<string, string | number>, format: 'json' | 'csv') => Promise<SQLAuditRpcResult>;
ClearSQLAuditEvents?: (beforeTimestamp: number) => Promise<SQLAuditRpcResult>;
}
export const resolveSQLAuditBackend = (): SQLAuditBackend => {
if (typeof window === 'undefined') return {};
return ((window as any).go?.app?.App || {}) as SQLAuditBackend;
};
export const requireSQLAuditMethod = <T extends keyof SQLAuditBackend>(
backend: SQLAuditBackend,
method: T,
): NonNullable<SQLAuditBackend[T]> => {
const candidate = backend[method];
if (typeof candidate !== 'function') {
throw new Error(`SQL audit backend method unavailable: ${String(method)}`);
}
return candidate as NonNullable<SQLAuditBackend[T]>;
};
export const unwrapSQLAuditResult = <T>(result: SQLAuditRpcResult<T>): T => {
if (result?.success === false) {
throw new Error(String(result.message || '').trim() || 'SQL audit request failed');
}
return result?.data as T;
};

View File

@@ -0,0 +1,50 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const read = (relativePath: string) => readFileSync(new URL(relativePath, import.meta.url), 'utf8');
const occurrences = (source: string, value: string): number => source.split(value).length - 1;
describe('application user-action SQL audit coverage', () => {
it('audits explicit TableDesigner writes with one stable source', () => {
const source = read('../TableDesigner.tsx');
expect(source).toContain('DBQueryAudited');
expect(occurrences(source, 'DBQueryAudited(')).toBe(5);
expect(occurrences(source, "'table_designer'")).toBe(5);
expect(source).not.toContain('DBQuery(');
});
it('audits explicit message publication', () => {
const source = read('../MessagePublishModal.tsx');
expect(source).toContain('DBQueryAudited(');
expect(source).toContain("'message_publish'");
expect(source).not.toContain('DBQuery(');
});
it('routes AI database probes through the fixed-source backend method', () => {
const runtimeSource = read('../ai/aiLocalToolRuntime.ts');
const codeBlockSource = read('../ai/messageBubble/AIMessageCodeBlock.tsx');
expect(runtimeSource).toContain('mod.DBQueryAI(config, dbName, sql)');
expect(codeBlockSource).toContain('DBQueryAI(activeConnectionConfig');
expect(runtimeSource).not.toContain('mod.DBQuery(config, dbName, sql)');
});
it('keeps metadata, counts, browsing, and definition reads outside application audit', () => {
const readOnlySources = [
read('../DataViewer.tsx'),
read('../DefinitionViewer.tsx'),
read('../TriggerViewer.tsx'),
read('../TableOverview.tsx'),
read('../sidebar/sidebarMetadataLoaders.ts'),
read('../sidebar/useSidebarTreeLoaders.tsx'),
read('../sidebar/useSidebarV2ContextMenu.tsx'),
];
readOnlySources.forEach((source) => {
expect(source).toContain('DBQuery');
expect(source).not.toContain('DBQueryAudited');
});
});
});

View File

@@ -166,7 +166,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
<Empty description={t('sql_analysis.explain.empty')} style={{ padding: '48px 0' }} />
)}
{!error && report && (
<Spin spinning={loading} tip={t('sql_analysis.explain.loading')} className="gn-explain-report-spinner">
<Spin spinning={loading} tip={t('sql_analysis.explain.loading')} wrapperClassName="gn-explain-report-spinner">
<div className="gn-explain-report-shell">
<div className="gn-explain-report-switcher-row">
<Segmented
@@ -297,6 +297,8 @@ const reportViewStyles = `
.gn-explain-report-spinner > .ant-spin-container {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.gn-explain-report-switcher-row {
flex: 0 0 auto;

View File

@@ -0,0 +1,31 @@
.gn-sql-audit-rail-button {
display: flex;
width: 32px;
height: 32px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--gn-fg-2, #1f2937);
cursor: pointer;
touch-action: manipulation;
transition: color 150ms ease, background-color 150ms ease;
}
.gn-sql-audit-rail-button:hover {
background: var(--gn-accent-soft, #dcfce7);
color: var(--gn-accent-2, #15803d);
}
.gn-sql-audit-rail-button:focus-visible {
outline: 2px solid var(--gn-accent-2, #15803d);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.gn-sql-audit-rail-button {
transition: none;
}
}

View File

@@ -0,0 +1,32 @@
import { Tooltip } from 'antd';
import { AuditOutlined } from '@ant-design/icons';
import { useI18n } from '../../i18n/provider';
import { useStore } from '../../store';
import { buildSqlAuditWorkbenchTab } from '../../utils/sqlAuditTab';
import './SqlAuditRailButton.css';
interface SqlAuditRailButtonProps {
className?: string;
tooltipPlacement?: 'top' | 'right' | 'bottom' | 'left';
}
export default function SqlAuditRailButton({
className,
tooltipPlacement = 'top',
}: SqlAuditRailButtonProps) {
const { t } = useI18n();
const addTab = useStore((state) => state.addTab);
return (
<Tooltip title={t('sql_audit.rail.tooltip')} placement={tooltipPlacement}>
<button
type="button"
className={['gn-sql-audit-rail-button', className].filter(Boolean).join(' ')}
onClick={() => addTab(buildSqlAuditWorkbenchTab())}
aria-label={t('sql_audit.rail.aria_label')}
>
<AuditOutlined aria-hidden="true" />
</button>
</Tooltip>
);
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildSqlEditorTransactionLog } from './sqlEditorTransactionLog';
describe('buildSqlEditorTransactionLog', () => {
it('renders a complete MySQL commit transaction', () => {
expect(buildSqlEditorTransactionLog({
dbType: 'mysql',
statements: ["UPDATE users SET name = 'new' WHERE id = 1"],
action: 'commit',
})).toBe("START TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nCOMMIT;");
});
it('uses SQL Server transaction boundaries for rollback', () => {
expect(buildSqlEditorTransactionLog({
dbType: 'sqlserver',
statements: ['DELETE FROM audit_log WHERE id = 7;'],
action: 'rollback',
})).toBe('BEGIN TRANSACTION;\nDELETE FROM audit_log WHERE id = 7;\nROLLBACK TRANSACTION;');
});
it('describes Oracle implicit begin without inventing a BEGIN statement', () => {
const result = buildSqlEditorTransactionLog({
dbType: 'oracle',
statements: ['UPDATE users SET active = 1 WHERE id = 9'],
action: 'commit',
});
expect(result).toContain('Oracle starts the transaction implicitly');
expect(result).toContain('UPDATE users SET active = 1 WHERE id = 9;');
expect(result).toContain('COMMIT;');
expect(result).not.toContain('BEGIN;');
});
});

View File

@@ -0,0 +1,82 @@
export type SqlEditorTransactionFinishAction = 'commit' | 'rollback';
type TransactionBoundary = {
begin?: string;
commit: string;
rollback: string;
implicitBeginComment?: string;
};
const DEFAULT_TRANSACTION_BOUNDARY: TransactionBoundary = {
begin: 'BEGIN',
commit: 'COMMIT',
rollback: 'ROLLBACK',
};
const TRANSACTION_BOUNDARIES: Record<string, TransactionBoundary> = {
mysql: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
mariadb: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
diros: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
starrocks: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
sphinx: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
oceanbase: { begin: 'START TRANSACTION', commit: 'COMMIT', rollback: 'ROLLBACK' },
sqlserver: {
begin: 'BEGIN TRANSACTION',
commit: 'COMMIT TRANSACTION',
rollback: 'ROLLBACK TRANSACTION',
},
postgres: DEFAULT_TRANSACTION_BOUNDARY,
kingbase: DEFAULT_TRANSACTION_BOUNDARY,
highgo: DEFAULT_TRANSACTION_BOUNDARY,
vastbase: DEFAULT_TRANSACTION_BOUNDARY,
opengauss: DEFAULT_TRANSACTION_BOUNDARY,
gaussdb: DEFAULT_TRANSACTION_BOUNDARY,
sqlite: DEFAULT_TRANSACTION_BOUNDARY,
duckdb: DEFAULT_TRANSACTION_BOUNDARY,
iris: DEFAULT_TRANSACTION_BOUNDARY,
oracle: {
commit: 'COMMIT',
rollback: 'ROLLBACK',
implicitBeginComment: '-- Oracle starts the transaction implicitly with the first DML statement.',
},
};
const terminateSqlStatement = (statement: string): string => {
const normalized = String(statement || '').trim();
if (!normalized || /[;/]\s*$/.test(normalized)) {
return normalized;
}
return `${normalized};`;
};
export const buildSqlEditorTransactionLog = ({
dbType,
statements,
action,
}: {
dbType?: string;
statements?: string[];
action: SqlEditorTransactionFinishAction;
}): string => {
const boundary = TRANSACTION_BOUNDARIES[String(dbType || '').trim().toLowerCase()]
|| DEFAULT_TRANSACTION_BOUNDARY;
const lines: string[] = [];
if (boundary.implicitBeginComment) {
lines.push(boundary.implicitBeginComment);
} else if (boundary.begin) {
lines.push(`${boundary.begin};`);
}
const normalizedStatements = Array.isArray(statements)
? statements.map(terminateSqlStatement).filter(Boolean)
: [];
if (normalizedStatements.length > 0) {
lines.push(...normalizedStatements);
} else {
lines.push('-- No SQL statements were captured for this transaction.');
}
lines.push(`${action === 'commit' ? boundary.commit : boundary.rollback};`);
return lines.join('\n');
};

View File

@@ -8,11 +8,12 @@ import { t as catalogTranslate } from '../i18n/catalog';
const storeState = vi.hoisted(() => ({
setSqlEditorPendingTransaction: vi.fn(),
addSqlLog: vi.fn(),
}));
const backendApp = vi.hoisted(() => ({
DBCommitTransaction: vi.fn(),
DBRollbackTransaction: vi.fn(),
DBCommitTransactionWithTrigger: vi.fn(),
DBRollbackTransactionWithTrigger: vi.fn(),
}));
const messageApi = vi.hoisted(() => ({
@@ -36,6 +37,10 @@ const createPendingTransaction = (overrides: Partial<PendingSqlEditorTransaction
autoCommitDelayMs: 0,
createdAt: Date.now(),
statementCount: 1,
dbType: 'mysql',
dbName: 'main',
statements: ["UPDATE users SET name = 'new' WHERE id = 1"],
executionDurationMs: 29,
...overrides,
});
@@ -61,12 +66,13 @@ describe('useSqlEditorTransactionController', () => {
controller = null;
renderer = null;
storeState.setSqlEditorPendingTransaction.mockReset();
backendApp.DBCommitTransaction.mockReset();
backendApp.DBRollbackTransaction.mockReset();
storeState.addSqlLog.mockReset();
backendApp.DBCommitTransactionWithTrigger.mockReset();
backendApp.DBRollbackTransactionWithTrigger.mockReset();
messageApi.error.mockReset();
messageApi.success.mockReset();
backendApp.DBCommitTransaction.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBRollbackTransaction.mockResolvedValue({ success: true, message: '事务已回滚' });
backendApp.DBCommitTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已提交' });
backendApp.DBRollbackTransactionWithTrigger.mockResolvedValue({ success: true, message: '事务已回滚' });
});
afterEach(() => {
@@ -88,15 +94,51 @@ describe('useSqlEditorTransactionController', () => {
await Promise.all([first, second]);
});
expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1);
expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1');
expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledTimes(1);
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-1', 'manual');
expect(backendApp.DBRollbackTransactionWithTrigger).not.toHaveBeenCalled();
expect(messageApi.success).toHaveBeenCalledWith('事务已提交');
expect(storeState.addSqlLog).toHaveBeenCalledTimes(1);
});
it('writes the complete managed transaction to the SQL log after commit', async () => {
renderController();
const transaction = createPendingTransaction();
await act(async () => {
controller?.activatePendingSqlTransaction(transaction);
await controller?.finishPendingSqlTransaction('commit', 'manual');
});
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: "START TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nCOMMIT;",
status: 'success',
dbName: 'main',
duration: expect.any(Number),
category: 'transaction',
transactionId: 'tx-1',
transactionAction: 'commit',
}));
});
it('writes the complete managed transaction to the SQL log after rollback', async () => {
renderController();
await act(async () => {
controller?.activatePendingSqlTransaction(createPendingTransaction({ dbType: 'sqlserver' }));
await controller?.finishPendingSqlTransaction('rollback', 'manual');
});
expect(storeState.addSqlLog).toHaveBeenCalledWith(expect.objectContaining({
sql: "BEGIN TRANSACTION;\nUPDATE users SET name = 'new' WHERE id = 1;\nROLLBACK TRANSACTION;",
status: 'success',
dbName: 'main',
}));
});
it('does not rollback a transaction while its auto commit is in flight', async () => {
let resolveCommit!: (value: { success: boolean; message: string }) => void;
backendApp.DBCommitTransaction.mockReturnValue(new Promise((resolve) => {
backendApp.DBCommitTransactionWithTrigger.mockReturnValue(new Promise((resolve) => {
resolveCommit = resolve;
}));
renderController();
@@ -114,8 +156,9 @@ describe('useSqlEditorTransactionController', () => {
renderer = null;
});
expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1);
expect(backendApp.DBRollbackTransactionWithTrigger).not.toHaveBeenCalled();
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledTimes(1);
expect(backendApp.DBCommitTransactionWithTrigger).toHaveBeenCalledWith('tx-1', 'auto');
await act(async () => {
resolveCommit({ success: true, message: '事务已提交' });
@@ -125,6 +168,20 @@ describe('useSqlEditorTransactionController', () => {
expect(messageApi.success).toHaveBeenCalledWith('自动提交成功');
});
it('marks the automatic rollback source when the editor unmounts', async () => {
renderController();
await act(async () => {
controller?.activatePendingSqlTransaction(createPendingTransaction());
});
act(() => {
renderer?.unmount();
renderer = null;
});
expect(backendApp.DBRollbackTransactionWithTrigger).toHaveBeenCalledWith('tx-1', 'tab_close');
});
it('uses the active language for transaction success messages', async () => {
renderController({ translate });
@@ -148,7 +205,7 @@ describe('useSqlEditorTransactionController', () => {
});
it('uses the active language for transaction failure wrappers and keeps raw error details', async () => {
backendApp.DBCommitTransaction.mockResolvedValueOnce({
backendApp.DBCommitTransactionWithTrigger.mockResolvedValueOnce({
success: false,
message: 'ORA-00060: deadlock detected while waiting for resource',
});
@@ -160,8 +217,13 @@ describe('useSqlEditorTransactionController', () => {
});
expect(messageApi.error).toHaveBeenLastCalledWith('Commit failed: ORA-00060: deadlock detected while waiting for resource');
expect(storeState.addSqlLog).toHaveBeenLastCalledWith(expect.objectContaining({
sql: expect.stringContaining('COMMIT;'),
status: 'error',
message: 'ORA-00060: deadlock detected while waiting for resource',
}));
backendApp.DBRollbackTransaction.mockRejectedValueOnce(new Error('SQLSTATE 40001 serialization failure'));
backendApp.DBRollbackTransactionWithTrigger.mockRejectedValueOnce(new Error('SQLSTATE 40001 serialization failure'));
await act(async () => {
controller?.activatePendingSqlTransaction(createPendingTransaction({ id: 'tx-2' }));
@@ -169,5 +231,10 @@ describe('useSqlEditorTransactionController', () => {
});
expect(messageApi.error).toHaveBeenLastCalledWith('Rollback failed: SQLSTATE 40001 serialization failure');
expect(storeState.addSqlLog).toHaveBeenLastCalledWith(expect.objectContaining({
sql: expect.stringContaining('ROLLBACK;'),
status: 'error',
message: 'SQLSTATE 40001 serialization failure',
}));
});
});

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { message } from 'antd';
import { DBCommitTransaction, DBRollbackTransaction } from '../../wailsjs/go/app/App';
import { DBCommitTransactionWithTrigger, DBRollbackTransactionWithTrigger } from '../../wailsjs/go/app/App';
import { t as catalogTranslate } from '../i18n/catalog';
import { useStore } from '../store';
import type { PendingSqlEditorTransaction } from './QueryEditorTransactionToolbar';
import { buildSqlEditorTransactionLog } from './sqlEditorTransactionLog';
type FinishSqlEditorTransactionAction = 'commit' | 'rollback';
type FinishSqlEditorTransactionSource = 'manual' | 'auto';
@@ -20,6 +21,7 @@ export const useSqlEditorTransactionController = ({
translate,
}: UseSqlEditorTransactionControllerOptions) => {
const setSqlEditorPendingTransaction = useStore(state => state.setSqlEditorPendingTransaction);
const addSqlLog = useStore(state => state.addSqlLog);
const [pendingSqlTransaction, setPendingSqlTransaction] = useState<PendingSqlEditorTransaction | null>(null);
const pendingSqlTransactionRef = useRef<PendingSqlEditorTransaction | null>(null);
const finishingTransactionIdsRef = useRef<Set<string>>(new Set());
@@ -58,6 +60,63 @@ export const useSqlEditorTransactionController = ({
setSqlEditorPendingTransaction(tabId, transaction);
}, [setSqlEditorPendingTransaction, tabId]);
const appendPendingSqlTransactionExecution = useCallback(({
transactionId,
statements,
durationMs,
}: {
transactionId: string;
statements: string[];
durationMs: number;
}) => {
const transaction = pendingSqlTransactionRef.current;
if (!transaction || transaction.id !== String(transactionId || '').trim()) {
return;
}
const nextStatements = Array.isArray(statements)
? statements.map((statement) => String(statement || '').trim()).filter(Boolean)
: [];
updatePendingSqlTransaction({
...transaction,
statements: [...(transaction.statements || []), ...nextStatements],
statementCount: Math.max(0, Number(transaction.statementCount) || 0) + nextStatements.length,
executionDurationMs: Math.max(0, Number(transaction.executionDurationMs) || 0)
+ Math.max(0, Number(durationMs) || 0),
});
}, [updatePendingSqlTransaction]);
const addTransactionCompletionLog = useCallback(({
transaction,
action,
status,
finishDurationMs,
detail,
}: {
transaction: PendingSqlEditorTransaction;
action: FinishSqlEditorTransactionAction;
status: 'success' | 'error';
finishDurationMs: number;
detail?: string;
}) => {
addSqlLog({
id: `transaction-${transaction.id}-${Date.now()}`,
timestamp: Date.now(),
sql: buildSqlEditorTransactionLog({
dbType: transaction.dbType,
statements: transaction.statements,
action,
}),
status,
duration: Math.max(0, Number(transaction.executionDurationMs) || 0)
+ Math.max(0, Number(finishDurationMs) || 0),
message: status === 'error' ? detail : undefined,
dbName: transaction.dbName,
category: 'transaction',
transactionId: transaction.id,
transactionAction: action,
});
}, [addSqlLog]);
const finishPendingSqlTransaction = useCallback(async (
action: FinishSqlEditorTransactionAction,
source: FinishSqlEditorTransactionSource = 'manual',
@@ -73,11 +132,18 @@ export const useSqlEditorTransactionController = ({
clearAutoCommitTimer();
finishingTransactionIdsRef.current.add(transaction.id);
updatePendingSqlTransaction(null);
const finishStartedAt = Date.now();
try {
const res = action === 'commit'
? await DBCommitTransaction(transaction.id)
: await DBRollbackTransaction(transaction.id);
? await DBCommitTransactionWithTrigger(transaction.id, source)
: await DBRollbackTransactionWithTrigger(transaction.id, source);
if (res?.success) {
addTransactionCompletionLog({
transaction,
action,
status: 'success',
finishDurationMs: Date.now() - finishStartedAt,
});
if (action === 'commit') {
message.success(source === 'auto'
? translateMessage('data_grid.message.auto_commit_success')
@@ -88,6 +154,13 @@ export const useSqlEditorTransactionController = ({
return;
}
const detail = rawErrorDetail(res?.message);
addTransactionCompletionLog({
transaction,
action,
status: 'error',
finishDurationMs: Date.now() - finishStartedAt,
detail,
});
const key = source === 'auto'
? 'data_grid.message.auto_commit_failed'
: action === 'commit'
@@ -96,6 +169,13 @@ export const useSqlEditorTransactionController = ({
message.error(translateMessage(key, { detail }));
} catch (err: any) {
const detail = rawErrorDetail(err);
addTransactionCompletionLog({
transaction,
action,
status: 'error',
finishDurationMs: Date.now() - finishStartedAt,
detail,
});
const key = source === 'auto'
? 'data_grid.message.auto_commit_failed'
: action === 'commit'
@@ -105,13 +185,22 @@ export const useSqlEditorTransactionController = ({
} finally {
finishingTransactionIdsRef.current.delete(transaction.id);
}
}, [clearAutoCommitTimer, rawErrorDetail, translateMessage, updatePendingSqlTransaction]);
}, [addTransactionCompletionLog, clearAutoCommitTimer, rawErrorDetail, translateMessage, updatePendingSqlTransaction]);
const activatePendingSqlTransaction = useCallback((transaction: PendingSqlEditorTransaction) => {
clearAutoCommitTimer();
const autoCommitDelayMs = Math.max(0, Number(transaction.autoCommitDelayMs) || 0);
const dueAt = transaction.commitMode === 'auto' ? Date.now() + autoCommitDelayMs : null;
const nextTransaction = { ...transaction, autoCommitDelayMs, autoCommitDueAt: dueAt };
const statements = Array.isArray(transaction.statements)
? transaction.statements.map((statement) => String(statement || '').trim()).filter(Boolean)
: [];
const nextTransaction = {
...transaction,
autoCommitDelayMs,
autoCommitDueAt: dueAt,
statements,
executionDurationMs: Math.max(0, Number(transaction.executionDurationMs) || 0),
};
updatePendingSqlTransaction(nextTransaction);
if (nextTransaction.commitMode !== 'auto' || !dueAt) {
return;
@@ -148,13 +237,14 @@ export const useSqlEditorTransactionController = ({
if (transaction?.id) {
pendingSqlTransactionRef.current = null;
setSqlEditorPendingTransaction(tabId, null);
void DBRollbackTransaction(transaction.id);
void DBRollbackTransactionWithTrigger(transaction.id, 'tab_close');
}
};
}, [clearAutoCommitTimer, setSqlEditorPendingTransaction, tabId]);
return {
activatePendingSqlTransaction,
appendPendingSqlTransactionExecution,
autoCommitRemainingSeconds,
finishPendingSqlTransaction,
pendingSqlTransaction,