diff --git a/README.md b/README.md index 8a422787..98f2505f 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,10 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience ### Observability and Update - SQL execution logs with timing information. +- SQL audit center for persisted SQL-editor operations, transaction boundaries, data edits/imports, SQL-file jobs, data sync, object DDL, table design, message publishing, and built-in AI/MCP database actions, with filters, transaction timelines, JSON/CSV export, retention controls, and writer health. +- Audit content is redacted by default: Redis values and message payloads are hidden, while non-SQL operations that cannot be parsed safely retain metadata only. The audit database lives at `audit/sql_audit.db` under the active data root. +- SQL-file, import, and sync entries are privacy-safe task summaries (content hash, target/count metadata), not raw per-row evidence. Source values identify the called GoNavi entry point rather than an unforgeable user identity. +- The local SHA-256 hash chain is a consistency check, not a keyed signature or tamper-proof guarantee. Known persistence gaps are marked with `audit_gap` after recovery. - Startup/scheduled/manual update checks. ### UI/UX diff --git a/README.zh-CN.md b/README.zh-CN.md index fd486985..2de7ff4d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -120,6 +120,10 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做 ### 可观测性与更新 - SQL 执行日志(含耗时)。 +- SQL 审计中心:持久化记录 SQL 编辑器、事务边界、数据编辑/导入、SQL 文件任务、数据同步、对象 DDL、表设计、消息发布以及内置 AI/MCP 数据库操作,支持筛选、事务时间线、JSON/CSV 导出、保留策略与写入健康状态。 +- 审计内容默认脱敏;Redis 隐藏值与消息载荷,无法安全解析的非 SQL 操作仅保留元数据。审计库位于当前数据目录的 `audit/sql_audit.db`。 +- SQL 文件、导入和同步记录为隐私安全的任务摘要(内容哈希、目标与计数元数据),不是逐行原始证据;来源字段表示调用的 GoNavi 功能入口,不代表不可伪造的用户身份。 +- 本地 SHA-256 哈希链用于一致性检查,并非密钥签名或不可篡改证明;写入故障恢复后会用 `audit_gap` 标记已知空洞。 - 启动/定时/手动更新检查。 ### UI 体验 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c5d36fda..f9c658d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import Modal from './components/common/ResizableDraggableModal'; import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip, Alert } from 'antd'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined, TableOutlined, MenuOutlined, PoweroffOutlined, TagOutlined, UserOutlined, UpCircleOutlined, MessageOutlined, FileTextOutlined, SyncOutlined, SendOutlined } from '@ant-design/icons'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined, TableOutlined, MenuOutlined, PoweroffOutlined, TagOutlined, UserOutlined, UpCircleOutlined, MessageOutlined, FileTextOutlined, SyncOutlined, SendOutlined, AuditOutlined } from '@ant-design/icons'; import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -72,6 +72,7 @@ import { normalizeConnectionPackagePassword, } from './utils/connectionExport'; import { downloadBrowserTextFile } from './utils/browserFileTransfer'; +import { buildSqlAuditWorkbenchTab } from './utils/sqlAuditTab'; import { extractCustomThemeAntTokens, } from './utils/customTheme'; @@ -7088,6 +7089,18 @@ function App() { handleOpenToolCenterPane('workspace', 'shortcut-settings'); }, }, + { + key: 'sql-audit', + icon: , + title: t('app.tools.entry.sql_audit.title'), + description: t('app.tools.entry.sql_audit.description'), + onClick: () => { + setIsToolsModalOpen(false); + setActiveToolCenterPane(null); + setToolCenterBackGroupKey(null); + addTab(buildSqlAuditWorkbenchTab()); + }, + }, ], }, ] as const; diff --git a/frontend/src/components/FloatingWorkbenchWindows.tsx b/frontend/src/components/FloatingWorkbenchWindows.tsx index 9a6f029a..10defbe1 100644 --- a/frontend/src/components/FloatingWorkbenchWindows.tsx +++ b/frontend/src/components/FloatingWorkbenchWindows.tsx @@ -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'); diff --git a/frontend/src/components/LogPanel.test.tsx b/frontend/src/components/LogPanel.test.tsx index 9cb2ba54..296d9d43 100644 --- a/frontend/src/components/LogPanel.test.tsx +++ b/frontend/src/components/LogPanel.test.tsx @@ -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;"); }); diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx index 23627919..a7870f08 100644 --- a/frontend/src/components/LogPanel.tsx +++ b/frontend/src/components/LogPanel.tsx @@ -101,6 +101,9 @@ const LogPanel: React.FC = ({ dataIndex: 'sql', render: (text: string, record: any) => (
+ {record.category === 'transaction' && ( + TX + )}
{text}
{record.message &&
{record.message}
} {record.affectedRows !== undefined &&
{t('log_panel.affected_rows', { count: record.affectedRows })}
} diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx index dc324ab0..b4bea4b9 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -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 = ({ 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', { diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx index 5751d8fb..07618305 100644 --- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx +++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx @@ -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 () => { diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx index 2db0a547..5964916d 100644 --- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx +++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx @@ -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 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(); + }); + + 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' }; diff --git a/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts index 3fb532cf..572a8233 100644 --- a/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts +++ b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts @@ -38,6 +38,18 @@ describe('SQL analysis workbench wiring', () => { expect(source).not.toContain(' { + 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') diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 5d29f4d3..6a4d2801 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -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 diff --git a/frontend/src/components/QueryEditorTransactionToolbar.tsx b/frontend/src/components/QueryEditorTransactionToolbar.tsx index d3077760..c598f020 100644 --- a/frontend/src/components/QueryEditorTransactionToolbar.tsx +++ b/frontend/src/components/QueryEditorTransactionToolbar.tsx @@ -11,6 +11,10 @@ export type PendingSqlEditorTransaction = { createdAt: number; autoCommitDueAt?: number | null; statementCount?: number; + dbType?: string; + dbName?: string; + statements?: string[]; + executionDurationMs?: number; }; type QueryEditorTransactionToolbarProps = { diff --git a/frontend/src/components/Sidebar.message-publish.test.tsx b/frontend/src/components/Sidebar.message-publish.test.tsx index 4cb589b5..ecc3d629 100644 --- a/frontend/src/components/Sidebar.message-publish.test.tsx +++ b/frontend/src/components/Sidebar.message-publish.test.tsx @@ -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(' +
)} diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 788fe227..3f4d6545 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -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 ''; }; diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 33bf0c6b..737b0282 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -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) diff --git a/frontend/src/components/WorkbenchTabContent.tsx b/frontend/src/components/WorkbenchTabContent.tsx index 3bd30e81..221b9af8 100644 --- a/frontend/src/components/WorkbenchTabContent.tsx +++ b/frontend/src/components/WorkbenchTabContent.tsx @@ -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 ; } + if (tab.type === 'sql-audit') { + return ; + } if (tab.type === 'jvm-overview') { return ; } diff --git a/frontend/src/components/ai/aiLocalToolRuntime.ts b/frontend/src/components/ai/aiLocalToolRuntime.ts index 7e8c8e9d..130f0811 100644 --- a/frontend/src/components/ai/aiLocalToolRuntime.ts +++ b/frontend/src/components/ai/aiLocalToolRuntime.ts @@ -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(); diff --git a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx index aef603d2..f73bb9d9 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx @@ -226,7 +226,7 @@ const HighlightedCodeBlock: React.FC = ({ 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 = ({ 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]) : []); diff --git a/frontend/src/components/audit/SqlAuditDetailDrawer.tsx b/frontend/src/components/audit/SqlAuditDetailDrawer.tsx new file mode 100644 index 00000000..41c7be7f --- /dev/null +++ b/frontend/src/components/audit/SqlAuditDetailDrawer.tsx @@ -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([]); + 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; + 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 ( + + ); +} diff --git a/frontend/src/components/audit/SqlAuditHealthAlert.test.tsx b/frontend/src/components/audit/SqlAuditHealthAlert.test.tsx new file mode 100644 index 00000000..e1699f08 --- /dev/null +++ b/frontend/src/components/audit/SqlAuditHealthAlert.test.tsx @@ -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) => ( + undefined}> + + +); + +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(); + } + }); +}); diff --git a/frontend/src/components/audit/SqlAuditHealthAlert.tsx b/frontend/src/components/audit/SqlAuditHealthAlert.tsx new file mode 100644 index 00000000..89e85f40 --- /dev/null +++ b/frontend/src/components/audit/SqlAuditHealthAlert.tsx @@ -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(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; + 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 ( +
+ {t('sql_audit.health.capture_status')}: {t(captureStatusKey)} + {t('sql_audit.health.capture_mode')}: {captureMode} + {value.firstFailureAt > 0 ? ( + {t('sql_audit.health.first_failure')}: + ) : null} + {value.lastFailureAt > 0 ? ( + {t('sql_audit.health.last_failure')}: + ) : null} + {value.lastSuccessAt > 0 ? ( + {t('sql_audit.health.last_success')}: + ) : null} + {value.lastError ? {t('sql_audit.health.last_error')}: {value.lastError} : null} +
+ ); + }; + const loadingAction = loading ? : undefined; + + if (!health && loading) { + return ( + + ); + } + const phase: SQLAuditHealthPhase = health ? getSQLAuditHealthPhase(health) : 'unknown'; + if (!health || error || phase === 'unknown') { + return ( + +
{t('sql_audit.health.unavailable.description')}
+ {error ? {error} : null} + + )} + /> + ); + } + + const count = numberFormatter.format(health.droppedEvents); + const description = ( +
+
{t(`sql_audit.health.${phase}.description`, { count })}
+ {renderDetails(health)} +
+ ); + + return ( + + ); +} diff --git a/frontend/src/components/audit/SqlAuditSettingsDrawer.tsx b/frontend/src/components/audit/SqlAuditSettingsDrawer.tsx new file mode 100644 index 00000000..602ee1f6 --- /dev/null +++ b/frontend/src/components/audit/SqlAuditSettingsDrawer.tsx @@ -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(); + 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 ( + + + + + )} + styles={{ body: { overscrollBehavior: 'contain' } }} + > + +
+ {error ? ( + void loadSettings()}>{t('common.retry')}} + style={{ marginBottom: 16 }} + /> + ) : null} +
+
+ + + + + updateFilter('search', event.target.value)} + prefix={