mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-13 18:14:24 +08:00
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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
|
|
))
|
|
);
|