mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-18 04:44:18 +08:00
✨ feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,6 +11,10 @@ export type PendingSqlEditorTransaction = {
|
||||
createdAt: number;
|
||||
autoCommitDueAt?: number | null;
|
||||
statementCount?: number;
|
||||
dbType?: string;
|
||||
dbName?: string;
|
||||
statements?: string[];
|
||||
executionDurationMs?: number;
|
||||
};
|
||||
|
||||
type QueryEditorTransactionToolbarProps = {
|
||||
|
||||
@@ -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')");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '';
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]) : []);
|
||||
|
||||
271
frontend/src/components/audit/SqlAuditDetailDrawer.tsx
Normal file
271
frontend/src/components/audit/SqlAuditDetailDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/audit/SqlAuditHealthAlert.test.tsx
Normal file
164
frontend/src/components/audit/SqlAuditHealthAlert.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
158
frontend/src/components/audit/SqlAuditHealthAlert.tsx
Normal file
158
frontend/src/components/audit/SqlAuditHealthAlert.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
frontend/src/components/audit/SqlAuditSettingsDrawer.tsx
Normal file
163
frontend/src/components/audit/SqlAuditSettingsDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
393
frontend/src/components/audit/SqlAuditWorkbench.css
Normal file
393
frontend/src/components/audit/SqlAuditWorkbench.css
Normal 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;
|
||||
}
|
||||
}
|
||||
56
frontend/src/components/audit/SqlAuditWorkbench.i18n.test.ts
Normal file
56
frontend/src/components/audit/SqlAuditWorkbench.i18n.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
113
frontend/src/components/audit/SqlAuditWorkbench.test.tsx
Normal file
113
frontend/src/components/audit/SqlAuditWorkbench.test.tsx
Normal 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)');
|
||||
});
|
||||
});
|
||||
698
frontend/src/components/audit/SqlAuditWorkbench.tsx
Normal file
698
frontend/src/components/audit/SqlAuditWorkbench.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
181
frontend/src/components/audit/sqlAuditModel.test.ts
Normal file
181
frontend/src/components/audit/sqlAuditModel.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
310
frontend/src/components/audit/sqlAuditModel.ts
Normal file
310
frontend/src/components/audit/sqlAuditModel.ts
Normal 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
|
||||
))
|
||||
);
|
||||
41
frontend/src/components/audit/sqlAuditRpc.ts
Normal file
41
frontend/src/components/audit/sqlAuditRpc.ts
Normal 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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
31
frontend/src/components/sidebar/SqlAuditRailButton.css
Normal file
31
frontend/src/components/sidebar/SqlAuditRailButton.css
Normal 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;
|
||||
}
|
||||
}
|
||||
32
frontend/src/components/sidebar/SqlAuditRailButton.tsx
Normal file
32
frontend/src/components/sidebar/SqlAuditRailButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/sqlEditorTransactionLog.test.ts
Normal file
34
frontend/src/components/sqlEditorTransactionLog.test.ts
Normal 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;');
|
||||
});
|
||||
});
|
||||
82
frontend/src/components/sqlEditorTransactionLog.ts
Normal file
82
frontend/src/components/sqlEditorTransactionLog.ts
Normal 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');
|
||||
};
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user