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/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..2d02070 100644 --- a/src/lib/export-csv.ts +++ b/src/lib/export-csv.ts @@ -1,15 +1,23 @@ import Papa from 'papaparse' import type { ColumnMetadata } from '@/components/sql-editor/hooks/useEditorTabs' +function sanitizeCsvString(value: string): string { + return /^[\s]*[=+\-@\t\r]/.test(value) ? `'${value}` : value +} + +function sanitizeCsvCell(value: unknown): unknown { + return typeof value === 'string' ? sanitizeCsvString(value) : value +} + export function exportToCsv( - columns: ColumnMetadata[], + columns: Array>, rows: Record[], filename: string ) { const columnNames = columns.map((col) => col.name) const csv = Papa.unparse({ - fields: columnNames, - data: rows.map((row) => columnNames.map((name) => row[name])), + fields: columnNames.map((name) => sanitizeCsvString(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 9a625b8..0de8b76 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,330 @@ 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 actionId = `audit-${scope}-action` + const sourceId = `audit-${scope}-source` + const statusId = `audit-${scope}-status` + 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} recent 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 +399,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 +440,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 +452,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) => } )