From 98aeecbf8eea4940bfa71746cd70d3e0bb18f4cc Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 26 Jun 2026 00:35:10 -0700 Subject: [PATCH 1/4] feat: add audit log filters and export --- src/hooks/useQuery.ts | 6 +- src/lib/export-csv.ts | 2 +- src/pages/AuditLog.tsx | 419 ++++++++++++++++++++++++++++++++++------- 3 files changed, 354 insertions(+), 73 deletions(-) diff --git a/src/hooks/useQuery.ts b/src/hooks/useQuery.ts index 8f013f1..c77c150 100644 --- a/src/hooks/useQuery.ts +++ b/src/hooks/useQuery.ts @@ -328,11 +328,13 @@ export function useTerminateProcess() { }); } +const AUDIT_LOG_LIMIT = 1000; + export function useAuditLogEntries(connectionId: string, enabled = true) { return useQuery({ queryKey: queryKeys.auditLog(connectionId), queryFn: async () => { - const response = await queryClient.getAuditLogEntries({ connectionId, limit: 100 }); + const response = await queryClient.getAuditLogEntries({ connectionId, limit: AUDIT_LOG_LIMIT }); return response.entries; }, enabled: enabled && !!connectionId, @@ -346,7 +348,7 @@ export function useSystemAuditLogEntries(enabled = true) { return useQuery({ queryKey: queryKeys.systemAuditLog(), queryFn: async () => { - const response = await queryClient.getSystemAuditLogEntries({ limit: 100 }); + const response = await queryClient.getSystemAuditLogEntries({ limit: AUDIT_LOG_LIMIT }); return response.entries; }, enabled, diff --git a/src/lib/export-csv.ts b/src/lib/export-csv.ts index 3f74eed..92ce6ec 100644 --- a/src/lib/export-csv.ts +++ b/src/lib/export-csv.ts @@ -2,7 +2,7 @@ import Papa from 'papaparse' import type { ColumnMetadata } from '@/components/sql-editor/hooks/useEditorTabs' export function exportToCsv( - columns: ColumnMetadata[], + columns: Array>, rows: Record[], filename: string ) { diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 9a625b8..e21f583 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -1,19 +1,43 @@ +import { useMemo, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' -import { ScrollText } from 'lucide-react' +import { Download, RotateCcw, ScrollText } from 'lucide-react' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' import { useConnections, useAuditLogEntries, useSystemAuditLogEntries } from '@/hooks/useQuery' import { useConnectionPermissions } from '@/hooks/usePermissions' import { useOwner } from '@/hooks/useOwner' +import { exportToCsv } from '@/lib/export-csv' import type { AuditLogEntry } from '@/gen/query_pb' interface AuditLogProps { connectionId: string } +type AuditScope = 'connection' | 'system' + +interface AuditFilters { + search: string + action: string + source: string + status: string + fromDate: string + toDate: string +} + +const DEFAULT_FILTERS: AuditFilters = { + search: '', + action: 'all', + source: 'all', + status: 'all', + fromDate: '', + toDate: '', +} + function StatusBadge({ success }: { success: boolean }) { return ( {success ? 'Success' : 'Failed'} @@ -38,6 +62,327 @@ function EmptyState({ label }: { label: string }) { ) } +function uniqueValues(entries: AuditLogEntry[], pick: (entry: AuditLogEntry) => string) { + return Array.from(new Set(entries.map(pick).filter(Boolean))).sort() +} + +function matchesDateRange(entry: AuditLogEntry, fromDate: string, toDate: string) { + if (!fromDate && !toDate) return true + const ts = new Date(entry.timestamp).getTime() + if (Number.isNaN(ts)) return false + if (fromDate) { + const from = new Date(`${fromDate}T00:00:00`).getTime() + if (ts < from) return false + } + if (toDate) { + const to = new Date(`${toDate}T23:59:59.999`).getTime() + if (ts > to) return false + } + return true +} + +function entrySearchText(entry: AuditLogEntry) { + return [ + entry.timestamp, + entry.actor, + entry.action, + entry.connection, + entry.database, + entry.sql, + entry.error, + entry.format, + entry.source, + entry.tool, + entry.agent, + entry.provider, + entry.ip, + ].join(' ').toLowerCase() +} + +function filterEntries(entries: AuditLogEntry[], filters: AuditFilters) { + const search = filters.search.trim().toLowerCase() + return entries.filter((entry) => { + if (filters.action !== 'all' && entry.action !== filters.action) return false + if (filters.source !== 'all' && entry.source !== filters.source) return false + if (filters.status === 'success' && !entry.success) return false + if (filters.status === 'failed' && entry.success) return false + if (!matchesDateRange(entry, filters.fromDate, filters.toDate)) return false + if (search && !entrySearchText(entry).includes(search)) return false + return true + }) +} + +function csvRows(entries: AuditLogEntry[]) { + return entries.map((entry) => ({ + Time: entry.timestamp, + Actor: entry.actor, + Action: entry.action, + Source: entry.source, + Status: entry.success ? 'success' : 'failed', + Connection: entry.connection, + Database: entry.database, + Provider: entry.provider, + IP: entry.ip, + Rows: entry.rowCount ?? '', + 'Duration ms': entry.durationMs ?? '', + SQL: entry.sql, + Error: entry.error, + Format: entry.format, + Tool: entry.tool, + Agent: entry.agent, + })) +} + +function exportAuditEntries(entries: AuditLogEntry[], scope: AuditScope) { + const columns = [ + 'Time', + 'Actor', + 'Action', + 'Source', + 'Status', + 'Connection', + 'Database', + 'Provider', + 'IP', + 'Rows', + 'Duration ms', + 'SQL', + 'Error', + 'Format', + 'Tool', + 'Agent', + ].map((name) => ({ name })) + const timestamp = new Date().toISOString().replace(/[:.]/g, '-') + exportToCsv(columns, csvRows(entries), `audit-log-${scope}-${timestamp}.csv`) +} + +function AuditFilterBar({ + scope, + entries, + filters, + onFiltersChange, + filteredCount, + onExport, +}: { + scope: AuditScope + entries: AuditLogEntry[] + filters: AuditFilters + onFiltersChange: (filters: AuditFilters) => void + filteredCount: number + onExport: () => void +}) { + const actions = uniqueValues(entries, (entry) => entry.action) + const sources = uniqueValues(entries, (entry) => entry.source) + const hasFilters = Object.entries(filters).some(([key, value]) => value !== DEFAULT_FILTERS[key as keyof AuditFilters]) + const searchId = `audit-${scope}-search` + const fromId = `audit-${scope}-from` + const toId = `audit-${scope}-to` + + return ( +
+
+
+ + onFiltersChange({ ...filters, search: event.target.value })} + placeholder="Actor, SQL, error, IP..." + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + onFiltersChange({ ...filters, fromDate: event.target.value })} + /> +
+
+ + onFiltersChange({ ...filters, toDate: event.target.value })} + /> +
+
+ +
+
+ +
+
+

+ Showing {filteredCount} of {entries.length} entries +

+
+ ) +} + +function AuditEntriesView({ entries, scope }: { entries: AuditLogEntry[]; scope: AuditScope }) { + const [filters, setFilters] = useState(DEFAULT_FILTERS) + const filteredEntries = useMemo(() => filterEntries(entries, filters), [entries, filters]) + + return ( + <> + exportAuditEntries(filteredEntries, scope)} + /> + {filteredEntries.length === 0 ? ( + + ) : scope === 'connection' ? ( + + ) : ( + + )} + + ) +} + +function ConnectionAuditTable({ entries }: { entries: AuditLogEntry[] }) { + return ( +
+ + + + Time + Actor + Action + Status + Rows + Duration + SQL + + + + {entries.map((entry, idx) => ( + + {new Date(entry.timestamp).toLocaleString()} + {entry.actor} + + + {entry.rowCount !== undefined ? entry.rowCount : '—'} + {entry.durationMs !== undefined ? `${entry.durationMs}ms` : '—'} + +
+ {entry.error || entry.sql || '—'} +
+
+
+ ))} +
+
+
+ ) +} + +function SystemAuditTable({ entries }: { entries: AuditLogEntry[] }) { + return ( +
+ + + + Time + Actor + Action + Status + Provider + IP + Detail + + + + {entries.map((entry, idx) => ( + + {new Date(entry.timestamp).toLocaleString()} + {entry.actor} + + + {entry.provider || '—'} + {entry.ip || '—'} + +
+ {entry.error || '—'} +
+
+
+ ))} +
+
+
+ ) +} + // Shared loading / error / empty handling for a tab's entries; renders the table only // once entries have loaded and are non-empty. function EntriesPanel({ @@ -51,7 +396,7 @@ function EntriesPanel({ isLoading: boolean error: unknown emptyLabel: string - children: (entries: AuditLogEntry[]) => React.ReactNode + children: (entries: AuditLogEntry[]) => ReactNode }) { // Only show the spinner while actually fetching. When the query is disabled // (e.g. a non-owner), React Query leaves `isLoading` false and `data` undefined — @@ -92,40 +437,7 @@ export default function AuditLog({ connectionId }: AuditLogProps) { error={connQuery.error} emptyLabel="No audit log entries yet." > - {(entries) => ( -
- - - - Time - Actor - Action - Status - Rows - Duration - SQL - - - - {entries.map((entry, idx) => ( - - {new Date(entry.timestamp).toLocaleString()} - {entry.actor} - - - {entry.rowCount !== undefined ? entry.rowCount : '—'} - {entry.durationMs !== undefined ? `${entry.durationMs}ms` : '—'} - -
- {entry.error || entry.sql || '—'} -
-
-
- ))} -
-
-
- )} + {(entries) => } ) @@ -137,40 +449,7 @@ export default function AuditLog({ connectionId }: AuditLogProps) { error={sysQuery.error} emptyLabel="No system audit log entries yet." > - {(entries) => ( -
- - - - Time - Actor - Action - Status - Provider - IP - Detail - - - - {entries.map((entry, idx) => ( - - {new Date(entry.timestamp).toLocaleString()} - {entry.actor} - - - {entry.provider || '—'} - {entry.ip || '—'} - -
- {entry.error || '—'} -
-
-
- ))} -
-
-
- )} + {(entries) => } ) From 8fc45626984bf9f92017681ac38704760408941d Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 26 Jun 2026 00:40:44 -0700 Subject: [PATCH 2/4] fix: align audit log filter window --- server/services/query-service.ts | 4 ++-- src/pages/AuditLog.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/services/query-service.ts b/server/services/query-service.ts index 2fbb460..d31fc1d 100644 --- a/server/services/query-service.ts +++ b/server/services/query-service.ts @@ -1223,7 +1223,7 @@ export const queryServiceHandlers: ServiceImpl = { requirePermission(user, req.connectionId, 'admin', 'view audit log'); getConnectionDetails(req.connectionId); - const limit = req.limit > 0 ? Math.min(req.limit, 500) : 100; + const limit = req.limit > 0 ? Math.min(req.limit, 1000) : 100; const entries = listAuditEvents(req.connectionId, limit).map(toAuditLogEntry); return { entries }; @@ -1241,7 +1241,7 @@ export const queryServiceHandlers: ServiceImpl = { throw new ConnectError("Permission denied: viewing the system audit log requires instance owner", Code.PermissionDenied); } - const limit = req.limit > 0 ? Math.min(req.limit, 500) : 100; + const limit = req.limit > 0 ? Math.min(req.limit, 1000) : 100; const entries = listSystemAuditEvents(limit).map(toAuditLogEntry); return { entries }; diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index e21f583..0b75145 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -278,7 +278,7 @@ function AuditFilterBar({

- Showing {filteredCount} of {entries.length} entries + Showing {filteredCount} of {entries.length} recent entries

) From dedc4b08c892ef9c314371693c607761f67092e2 Mon Sep 17 00:00:00 2001 From: tianzhou Date: Fri, 26 Jun 2026 00:53:29 -0700 Subject: [PATCH 3/4] fix: address audit filter review comments --- src/lib/export-csv.ts | 7 ++++++- src/pages/AuditLog.tsx | 17 ++++++++++------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/export-csv.ts b/src/lib/export-csv.ts index 92ce6ec..a116ca2 100644 --- a/src/lib/export-csv.ts +++ b/src/lib/export-csv.ts @@ -1,6 +1,11 @@ import Papa from 'papaparse' import type { ColumnMetadata } from '@/components/sql-editor/hooks/useEditorTabs' +function sanitizeCsvCell(value: unknown): unknown { + if (typeof value !== 'string') return value + return /^[\s]*[=+\-@\t\r]/.test(value) ? `'${value}` : value +} + export function exportToCsv( columns: Array>, rows: Record[], @@ -9,7 +14,7 @@ export function exportToCsv( const columnNames = columns.map((col) => col.name) const csv = Papa.unparse({ fields: columnNames, - data: rows.map((row) => columnNames.map((name) => row[name])), + data: rows.map((row) => columnNames.map((name) => sanitizeCsvCell(row[name]))), }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) diff --git a/src/pages/AuditLog.tsx b/src/pages/AuditLog.tsx index 0b75145..0de8b76 100644 --- a/src/pages/AuditLog.tsx +++ b/src/pages/AuditLog.tsx @@ -175,6 +175,9 @@ function AuditFilterBar({ const sources = uniqueValues(entries, (entry) => entry.source) const hasFilters = Object.entries(filters).some(([key, value]) => value !== DEFAULT_FILTERS[key as keyof AuditFilters]) const searchId = `audit-${scope}-search` + const actionId = `audit-${scope}-action` + const sourceId = `audit-${scope}-source` + const statusId = `audit-${scope}-status` const fromId = `audit-${scope}-from` const toId = `audit-${scope}-to` @@ -192,12 +195,12 @@ function AuditFilterBar({ />
- +
- +
- +