Files
MyGoNavi/frontend/src/components/audit/SqlAuditWorkbench.tsx
Syngnat 7c8e8d8dd3 feat(sql-audit): 新增 SQL 审计中心并完善事务追踪
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出
- 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口
- 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局
- 补充多语言、健康状态、数据目录迁移和回归测试
2026-07-12 15:24:27 +08:00

699 lines
26 KiB
TypeScript

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>
);
}