diff --git a/apps/studio/components/interfaces/Account/AuditLogs.tsx b/apps/studio/components/interfaces/Account/AuditLogs.tsx deleted file mode 100644 index 354d510f330..00000000000 --- a/apps/studio/components/interfaces/Account/AuditLogs.tsx +++ /dev/null @@ -1,304 +0,0 @@ -import { keepPreviousData } from '@tanstack/react-query' -import { useDebounce } from '@uidotdev/usehooks' -import dayjs from 'dayjs' -import { ArrowDown, ArrowUp, RefreshCw } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' -import { Button } from 'ui' -import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { TimestampInfo } from 'ui-patterns/TimestampInfo' - -import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers' -import { filterByProjects, sortAuditLogs } from './AuditLogs.utils' -import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel' -import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' -import Table from '@/components/to-be-cleaned/Table' -import { AlertError } from '@/components/ui/AlertError' -import { ButtonTooltip } from '@/components/ui/ButtonTooltip' -import { FilterPopover } from '@/components/ui/FilterPopover' -import { - TIMESTAMP_MICROS_PER_MS, - type AuditLog, -} from '@/data/organizations/organization-audit-logs-query' -import { useOrganizationsQuery } from '@/data/organizations/organizations-query' -import { useProfileAuditLogsQuery } from '@/data/profile/profile-audit-logs-query' -import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query' - -export const AuditLogs = () => { - const currentTime = dayjs().utc().set('millisecond', 0) - - const [search, setSearch] = useState('') - const debouncedSearch = useDebounce(search, 500) - - const [dateSortDesc, setDateSortDesc] = useState(true) - const [dateRange, setDateRange] = useState({ - from: currentTime.subtract(1, 'day').toISOString(), - to: currentTime.toISOString(), - }) - - const [selectedLog, setSelectedLog] = useState() - const [filters, setFilters] = useState<{ projects: string[] }>({ - projects: [], - }) - - const { - data: projectsData, - isLoading: isLoadingProjects, - isFetching, - isFetchingNextPage, - hasNextPage, - fetchNextPage, - } = useProjectsInfiniteQuery( - { search: search.length === 0 ? search : debouncedSearch }, - { placeholderData: keepPreviousData } - ) - const projects = - useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || [] - - const { data: organizations } = useOrganizationsQuery() - const { - data, - error, - isPending: isLoading, - isSuccess, - isError, - isRefetching, - refetch, - } = useProfileAuditLogsQuery( - { - iso_timestamp_start: dateRange.from, - iso_timestamp_end: dateRange.to, - }, - { - retry: false, - } - ) - - const logs = data?.result ?? [] - const sortedLogs = filterByProjects(sortAuditLogs(logs, dateSortDesc), filters.projects) - - // This feature depends on the subscription tier of the user. Free user can view logs up to 1 day - // in the past. The API limits the logs to maximum of 1 day and 5 minutes so when the page is - // viewed for more than 5 minutes, the call parameters needs to be updated. This also works with - // higher tiers (7 days of logs).The user will see a loading shimmer. - useEffect(() => { - const duration = dayjs(dateRange.from).diff(dayjs(dateRange.to)) - const interval = setInterval(() => { - const currentTime = dayjs().utc().set('millisecond', 0) - setDateRange({ - from: currentTime.add(duration).toISOString(), - to: currentTime.toISOString(), - }) - }, 5 * 60000) - - return () => clearInterval(interval) - }, [dateRange.from, dateRange.to]) - - return ( - <> - - -
-
-
-

Filter by

- setFilters({ ...filters, projects: values })} - search={search} - setSearch={setSearch} - hasNextPage={hasNextPage} - isLoading={isLoadingProjects} - isFetching={isFetching} - isFetchingNextPage={isFetchingNextPage} - fetchNextPage={fetchNextPage} - /> - setDateRange(value)} - helpers={[ - { - text: 'Last 1 hour', - calcFrom: () => dayjs().subtract(1, 'hour').toISOString(), - calcTo: () => dayjs().toISOString(), - }, - { - text: 'Last 3 hours', - calcFrom: () => dayjs().subtract(3, 'hour').toISOString(), - calcTo: () => dayjs().toISOString(), - }, - - { - text: 'Last 6 hours', - calcFrom: () => dayjs().subtract(6, 'hour').toISOString(), - calcTo: () => dayjs().toISOString(), - }, - { - text: 'Last 12 hours', - calcFrom: () => dayjs().subtract(12, 'hour').toISOString(), - calcTo: () => dayjs().toISOString(), - }, - { - text: 'Last 24 hours', - calcFrom: () => dayjs().subtract(1, 'day').toISOString(), - calcTo: () => dayjs().toISOString(), - }, - ]} - /> - {isSuccess && ( - <> -
-

Viewing {sortedLogs.length} logs in total

- - )} -
- -
- - {isLoading && ( -
- - - -
- )} - - {isError && } - - {isSuccess && ( - <> - {logs.length === 0 ? ( -
-

You do not have any audit logs available yet

-
- ) : logs.length > 0 && sortedLogs.length === 0 ? ( -
-

- No audit logs found based on the filters applied -

-
- ) : ( -
- - Action - , - - Target - , - -
-

Date

- - ) : ( - - ) - } - onClick={() => setDateSortDesc(!dateSortDesc)} - tooltip={{ - content: { - side: 'bottom', - text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first', - }, - }} - /> -
-
, - , - ]} - body={ - sortedLogs?.map((log) => { - const project = projects?.find((p) => p.ref === log.project_ref) - const organization = organizations?.find( - (org) => org.slug === log.organization_slug - ) - const isoTimestamp = dayjs( - log.timestamp / TIMESTAMP_MICROS_PER_MS - ).toISOString() - - return ( - setSelectedLog(log)} - className="cursor-pointer hover:bg-alternative! transition duration-100" - > - -
-

- {log.action.status} -

-

- {log.action.method} -

-

- {log.action.name} -

-
-
- - {project || organization ? ( - <> -

- {project?.name - ? 'Project: ' - : organization?.name - ? 'Organization: ' - : null} - {project?.name ?? organization?.name} -

-

- {log.project_ref ? 'Ref: ' : 'Slug: '} - {log.project_ref ?? log.organization_slug} -

- - ) : ( -

- {log.project_ref ?? log.organization_slug ?? '-'} -

- )} -
- - - - - - -
- ) - }) ?? [] - } - /> - - )} - - )} - - - - - setSelectedLog(undefined)} /> - - ) -} diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogDetailsPanel.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogDetailsPanel.tsx new file mode 100644 index 00000000000..de3f0393dc2 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogDetailsPanel.tsx @@ -0,0 +1,89 @@ +import { Check, Copy, X } from 'lucide-react' +import { useState } from 'react' +import { + Button, + copyToClipboard, + FloatingPlate, + ResizableHandle, + ResizablePanel, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from 'ui' +import { CodeBlock } from 'ui-patterns/CodeBlock' + +import { AuditLogOverview } from './AuditLogOverview' +import type { AuditLog } from '@/data/organizations/organization-audit-logs-query' + +interface AuditLogDetailsPanelProps { + selectedLog: AuditLog + onClose: () => void +} + +export const AuditLogDetailsPanel = ({ selectedLog, onClose }: AuditLogDetailsPanelProps) => { + const [copied, setCopied] = useState(false) + const json = JSON.stringify(selectedLog, null, 2) + + return ( + <> + + + +
+ + + Overview + + + Raw JSON + + +
+ + + + + + +
+ + + +
+ + {json} + +
+
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogOverview.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogOverview.tsx new file mode 100644 index 00000000000..6094c6506ac --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogOverview.tsx @@ -0,0 +1,111 @@ +import dayjs from 'dayjs' +import { Activity, Clock, Copy, Globe, Hash, User } from 'lucide-react' +import { Button, cn, copyToClipboard } from 'ui' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { CollapsibleDetailSection } from '@/components/ui/DataTable/CollapsibleDetailSection' +import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { DetailSectionHeader } from '@/components/ui/DataTable/DetailSectionHeader' +import { + TIMESTAMP_MICROS_PER_MS, + type AuditLog, +} from '@/data/organizations/organization-audit-logs-query' + +interface AuditLogOverviewProps { + selectedLog: AuditLog +} + +const DetailRow = ({ + label, + value, +}: { + label: string + value: string | number | undefined | null +}) => { + const isEmpty = value === null || value === undefined || value === '' + + return ( +
+ {label} +
+ {isEmpty ? ( + — + ) : ( + {value} + )} + {!isEmpty && ( +
+
+ ) +} + +export const AuditLogOverview = ({ selectedLog }: AuditLogOverviewProps) => { + const hasMetadata = Object.keys(selectedLog.action.metadata ?? {}).length > 0 + + return ( +
+ + } + /> + + {selectedLog.request_id} + + } + /> + + + + + + + + + + + + + + + + + +
+ Status + +
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.columns.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.columns.tsx new file mode 100644 index 00000000000..bfff15fea8e --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.columns.tsx @@ -0,0 +1,236 @@ +import type { ColumnDef } from '@tanstack/react-table' +import dayjs from 'dayjs' +import { Box, Boxes } from 'lucide-react' +import type { MutableRefObject } from 'react' +import { Checkbox, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { TanStackTableHeadSort } from 'ui-patterns/Table' +import { TimestampInfo } from 'ui-patterns/TimestampInfo' + +import { getStatusLevel } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { + TIMESTAMP_MICROS_PER_MS, + type AuditLog, +} from '@/data/organizations/organization-audit-logs-query' + +interface AuditLogColumnsOptions { + projects: { ref?: string; name: string }[] + organizations: { slug?: string; name: string }[] + lastSelectedRowId: MutableRefObject + isLoadingProjects: boolean + isLoadingOrganizations: boolean +} + +export function getAuditLogColumns({ + projects, + organizations, + lastSelectedRowId, + isLoadingProjects, + isLoadingOrganizations, +}: AuditLogColumnsOptions): ColumnDef[] { + return [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllRowsSelected(!!value)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row, table }) => ( + { + e.stopPropagation() + + const nextSelected = !row.getIsSelected() + const rows = table.getRowModel().rows + const lastIndex = lastSelectedRowId.current + ? rows.findIndex((r) => r.id === lastSelectedRowId.current) + : -1 + const currentIndex = rows.findIndex((r) => r.id === row.id) + const canSelectRange = e.shiftKey && lastIndex !== -1 && currentIndex !== -1 + + if (canSelectRange) { + const [start, end] = + lastIndex < currentIndex ? [lastIndex, currentIndex] : [currentIndex, lastIndex] + table.setRowSelection((prev) => { + const next = { ...prev } + for (let i = start; i <= end; i++) { + if (nextSelected) { + next[rows[i].id] = true + } else { + delete next[rows[i].id] + } + } + return next + }) + } else { + row.toggleSelected(nextSelected) + } + + lastSelectedRowId.current = row.id + }} + /> + ), + enableSorting: false, + size: 30, + minSize: 30, + maxSize: 30, + meta: { + cellClassName: 'w-[30px] min-w-[30px]', + headerClassName: 'w-[30px] min-w-[30px]', + }, + }, + { + id: 'date', + accessorFn: (log) => log.timestamp, + header: ({ column }) => Date, + cell: ({ row }) => ( + + ), + size: 155, + minSize: 155, + maxSize: 155, + meta: { + cellClassName: 'w-[155px] min-w-[155px] font-mono tracking-tight pl-3', + headerClassName: 'w-[155px] min-w-[155px] pl-3', + }, + }, + { + id: 'target_type', + header: '', + accessorFn: (log) => log.action.status, + cell: ({ row }) => { + const log = row.original + + if (log.project_ref || log.organization_slug) { + return ( + + + {log.project_ref ? ( + + ) : ( + + )} + + + {log.project_ref ? 'Project' : 'Organization'} + + + ) + } else { + return null + } + }, + size: 20, + minSize: 20, + maxSize: 20, + meta: { + cellClassName: 'w-[20px] min-w-[20px] px-0', + headerClassName: 'w-[20px] min-w-[20px] px-0', + }, + }, + { + id: 'target', + header: 'Target', + accessorFn: (log) => log.project_ref ?? log.organization_slug, + cell: ({ row }) => { + const log = row.original + + if (log.project_ref && isLoadingProjects) + return + if (log.organization_slug && isLoadingOrganizations) { + return + } + + let target: string | undefined + if (log.project_ref) { + target = projects.find((p) => p.ref === log.project_ref)?.name ?? log.project_ref + } else if (log.organization_slug) { + target = + organizations.find((org) => org.slug === log.organization_slug)?.name ?? + log.organization_slug + } + + if (!target) return - + + return ( +

+ {target} +

+ ) + }, + size: 120, + minSize: 120, + maxSize: 120, + meta: { + cellClassName: 'w-[120px] min-w-[120px]', + headerClassName: 'w-[120px] min-w-[120px]', + }, + }, + { + id: 'status', + header: '', + accessorFn: (log) => log.action.status, + cell: ({ row }) => ( + + ), + size: 40, + minSize: 40, + maxSize: 40, + meta: { + cellClassName: 'w-[40px] min-w-[40px]', + headerClassName: 'w-[40px] min-w-[40px]', + }, + }, + { + id: 'method', + header: 'Method', + accessorFn: (log) => log.action.method, + cell: ({ row }) => ( + + {row.original.action.method} + + ), + size: 60, + minSize: 60, + maxSize: 60, + meta: { + cellClassName: 'w-[60px] min-w-[60px]', + headerClassName: 'w-[60px] min-w-[60px]', + }, + }, + { + id: 'action', + accessorFn: (log) => log.action.name, + header: 'Event', + meta: { + cellClassName: 'min-w-[240px]', + headerClassName: 'min-w-[240px]', + }, + cell: ({ row }) => { + return ( +

+ {row.original.action.name} +

+ ) + }, + }, + ] +} diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.tsx new file mode 100644 index 00000000000..5d11147e225 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.tsx @@ -0,0 +1,274 @@ +import { keepPreviousData } from '@tanstack/react-query' +import { + getCoreRowModel, + getSortedRowModel, + useReactTable, + type RowSelectionState, + type SortingState, +} from '@tanstack/react-table' +import { useDebounce } from '@uidotdev/usehooks' +import dayjs from 'dayjs' +import { RefreshCw } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { cn, ResizablePanel, ResizablePanelGroup } from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { LogsDatePicker } from '../../Settings/Logs/Logs.DatePickers' +import { AuditLogDetailsPanel } from './AuditLogDetailsPanel' +import { getAuditLogColumns } from './AuditLogs.columns' +import { filterByProjects } from './AuditLogs.utils' +import { AuditLogsSelectionHeader } from './AuditLogsSelectionHeader' +import { AuditLogsTable } from './AuditLogsTable' +import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' +import { AlertError } from '@/components/ui/AlertError' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { FilterPopover } from '@/components/ui/FilterPopover' +import { type AuditLog } from '@/data/organizations/organization-audit-logs-query' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useProfileAuditLogsQuery } from '@/data/profile/profile-audit-logs-query' +import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query' + +const CONTENT_PADDING = 'w-full px-6 xl:px-10' + +export const AuditLogs = () => { + const currentTime = dayjs().utc().set('millisecond', 0) + + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 500) + + const [dateRange, setDateRange] = useState({ + from: currentTime.subtract(1, 'day').toISOString(), + to: currentTime.toISOString(), + }) + + const [selectedLog, setSelectedLog] = useState() + const [filters, setFilters] = useState<{ projects: string[] }>({ + projects: [], + }) + + const { + data: projectsData, + isPending: isLoadingProjects, + isFetching: isFetchingProjects, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + } = useProjectsInfiniteQuery( + { search: search.length === 0 ? search : debouncedSearch }, + { placeholderData: keepPreviousData } + ) + const projects = useMemo( + () => projectsData?.pages.flatMap((page) => page.projects) ?? [], + [projectsData?.pages] + ) + + const { data: organizations, isPending: isLoadingOrganizations } = useOrganizationsQuery() + const { + data, + error, + isPending: isLoading, + isSuccess, + isError, + isRefetching, + refetch, + } = useProfileAuditLogsQuery( + { + iso_timestamp_start: dateRange.from, + iso_timestamp_end: dateRange.to, + }, + { + retry: false, + placeholderData: keepPreviousData, + } + ) + + const logs = data?.result ?? [] + const filteredLogs = filterByProjects(logs, filters.projects) + + const lastSelectedRowId = useRef(null) + const columns = useMemo( + () => + getAuditLogColumns({ + projects, + organizations: organizations ?? [], + lastSelectedRowId, + isLoadingProjects, + isLoadingOrganizations, + }), + [projects, organizations, isLoadingProjects, isLoadingOrganizations] + ) + const [sorting, setSorting] = useState([{ id: 'date', desc: true }]) + const [rowSelection, setRowSelection] = useState({}) + + const table = useReactTable({ + data: filteredLogs, + columns, + state: { sorting, rowSelection }, + enableMultiRowSelection: true, + getRowId: (row) => row.request_id, + onSortingChange: setSorting, + onRowSelectionChange: setRowSelection, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + + // This feature depends on the subscription tier of the user. Free user can view logs up to 1 day + // in the past. The API limits the logs to maximum of 1 day and 5 minutes so when the page is + // viewed for more than 5 minutes, the call parameters needs to be updated. This also works with + // higher tiers (7 days of logs).The user will see a loading shimmer. + useEffect(() => { + const duration = dayjs(dateRange.from).diff(dayjs(dateRange.to)) + const interval = setInterval(() => { + const currentTime = dayjs().utc().set('millisecond', 0) + setDateRange({ + from: currentTime.add(duration).toISOString(), + to: currentTime.toISOString(), + }) + }, 5 * 60000) + + return () => clearInterval(interval) + }, [dateRange.from, dateRange.to]) + + return ( + + +
+ {/* [Joshen] Can consider replacing this with filter bar */} +
+
+

Filter by

+ setFilters({ ...filters, projects: values })} + search={search} + setSearch={setSearch} + hasNextPage={hasNextPage} + isLoading={isLoadingProjects} + isFetching={isFetchingProjects} + isFetchingNextPage={isFetchingNextPage} + fetchNextPage={fetchNextPage} + /> + setDateRange(value)} + helpers={[ + { + text: 'Last 1 hour', + calcFrom: () => dayjs().subtract(1, 'hour').toISOString(), + calcTo: () => dayjs().toISOString(), + }, + { + text: 'Last 3 hours', + calcFrom: () => dayjs().subtract(3, 'hour').toISOString(), + calcTo: () => dayjs().toISOString(), + }, + + { + text: 'Last 6 hours', + calcFrom: () => dayjs().subtract(6, 'hour').toISOString(), + calcTo: () => dayjs().toISOString(), + }, + { + text: 'Last 12 hours', + calcFrom: () => dayjs().subtract(12, 'hour').toISOString(), + calcTo: () => dayjs().toISOString(), + }, + { + text: 'Last 24 hours', + calcFrom: () => dayjs().subtract(1, 'day').toISOString(), + calcTo: () => dayjs().toISOString(), + }, + ]} + /> +
+ } + onClick={() => refetch()} + tooltip={{ content: { side: 'bottom', text: 'Refresh logs' } }} + /> +
+ + {isLoading && ( +
+ + + +
+ )} + + {isError && ( +
+ +
+ )} + + {isSuccess && ( + <> + {logs.length === 0 ? ( +
+

You do not have any audit logs available yet

+
+ ) : logs.length > 0 && filteredLogs.length === 0 ? ( +
+

No audit logs found based on the filters applied

+
+ ) : ( +
+ + +
+ + + setSelectedLog((prev) => + prev?.request_id === log.request_id ? undefined : log + ) + } + /> +
+
+ {selectedLog && ( + setSelectedLog(undefined)} + /> + )} +
+
+ )} + + )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AuditLogs.utils.ts b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.utils.ts similarity index 60% rename from apps/studio/components/interfaces/Account/AuditLogs.utils.ts rename to apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.utils.ts index 2f19f1f96a1..c6fbaeb86ce 100644 --- a/apps/studio/components/interfaces/Account/AuditLogs.utils.ts +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.utils.ts @@ -1,11 +1,5 @@ import type { AuditLog } from '@/data/organizations/organization-audit-logs-query' -export function sortAuditLogs(logs: AuditLog[], descending: boolean): AuditLog[] { - return [...logs].sort((a, b) => - descending ? b.timestamp - a.timestamp : a.timestamp - b.timestamp - ) -} - export function filterByProjects(logs: AuditLog[], projectRefs: string[]): AuditLog[] { if (projectRefs.length === 0) return logs return logs.filter((log) => projectRefs.includes(log.project_ref ?? '')) diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsSelectionHeader.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsSelectionHeader.tsx new file mode 100644 index 00000000000..1a490671513 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsSelectionHeader.tsx @@ -0,0 +1,60 @@ +import type { Table } from '@tanstack/react-table' +import { AnimatePresence, motion } from 'framer-motion' +import { Copy, X } from 'lucide-react' +import { toast } from 'sonner' +import { copyToClipboard } from 'ui' + +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import type { AuditLog } from '@/data/organizations/organization-audit-logs-query' + +interface AuditLogsSelectionHeaderProps { + table: Table +} + +export const AuditLogsSelectionHeader = ({ table }: AuditLogsSelectionHeaderProps) => { + const selectedRows = table.getSelectedRowModel().rows.map((row) => row.original) + const hasSelection = selectedRows.length > 0 + + const handleCopy = () => { + const text = JSON.stringify(selectedRows, null, 2) + copyToClipboard(text, () => { + toast.success(`Copied ${selectedRows.length} log${selectedRows.length !== 1 ? 's' : ''}`) + }) + } + + return ( + + {hasSelection && ( + +

+ {selectedRows.length} row{selectedRows.length > 1 ? 's' : ''} selected +

+ +
+ } + className="w-7" + onClick={handleCopy} + tooltip={{ content: { side: 'bottom', text: 'Copy selected logs' } }} + /> + } + className="px-1" + onClick={() => table.resetRowSelection()} + tooltip={{ content: { side: 'bottom', text: 'Clear selection' } }} + /> +
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsTable.tsx b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsTable.tsx new file mode 100644 index 00000000000..72f391f7da8 --- /dev/null +++ b/apps/studio/components/interfaces/Account/AuditLogs/AuditLogsTable.tsx @@ -0,0 +1,100 @@ +import { flexRender, type Table as TanStackTable } from '@tanstack/react-table' +import { cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' + +import { formatCompactNumber } from '@/components/ui/DataTable/DataTable.utils' +import type { AuditLog } from '@/data/organizations/organization-audit-logs-query' + +interface AuditLogColumnMeta { + cellClassName?: string + headerClassName?: string +} + +interface AuditLogsTableProps { + table: TanStackTable + selectedLog?: AuditLog + onSelectLog: (log: AuditLog) => void +} + +export const AuditLogsTable = ({ table, selectedLog, onSelectLog }: AuditLogsTableProps) => { + const rows = table.getRowModel().rows + + return ( +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {rows.map((row) => ( + onSelectLog(row.original)} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter') { + onSelectLog(row.original) + } else if (e.key === ' ') { + e.preventDefault() + onSelectLog(row.original) + } + }} + className={cn( + 'cursor-pointer hover:bg-selection!', + selectedLog?.request_id === row.original.request_id && 'bg-selection!' + )} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + {rows.length > 0 && ( + + +
+

+ Viewing{' '} + {formatCompactNumber(rows.length)}{' '} + logs +

+
+
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/LogOverview.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/LogOverview.tsx index 29b777f78c4..932b528934a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/LogOverview.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/LogOverview.tsx @@ -13,8 +13,8 @@ import { MemoizedPostgRESTBlock, MemoizedStorageBlock, } from './components/ServiceBlocks' -import { DetailSectionHeader } from './components/shared/DetailSection' import type { ServiceFlowBlockProps } from './types' +import { DetailSectionHeader } from '@/components/ui/DataTable/DetailSectionHeader' type LogOverviewProps = Omit & { data: ColumnSchema diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx index 6a825baccff..00e4736c3a3 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/PostgresFlowDetail.tsx @@ -8,8 +8,8 @@ import { getRowTimestampMs } from '../../UnifiedLogs.utils' import { postgresDetailsFields, postgresPrimaryFields } from '../config/serviceFlowFields' import { BlockFieldConfig } from '../types' import { DetailRow } from './shared/DetailRow' -import { DetailSectionHeader } from './shared/DetailSection' import { DataTableFilterField } from '@/components/ui/DataTable/DataTable.types' +import { DetailSectionHeader } from '@/components/ui/DataTable/DetailSectionHeader' interface PostgresFlowDetailProps { data: ColumnSchema diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx index c59faec487c..01c25708d03 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/Block.tsx @@ -1,11 +1,10 @@ import { partition } from 'lodash' -import { ChevronDown, LucideIcon } from 'lucide-react' +import { LucideIcon } from 'lucide-react' import { memo } from 'react' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' import { BlockFieldConfig, BlockFieldProps, ServiceFlowBlockProps } from '../../types' import { DetailRow } from './DetailRow' -import { DetailSectionHeader } from './DetailSection' +import { CollapsibleDetailSection } from '@/components/ui/DataTable/CollapsibleDetailSection' interface BlockSection { title: string @@ -72,72 +71,60 @@ export function createBlock(config: BlockConfig) { return ( <> - - - - + {config.primaryFields?.map((field) => ( + - - - {config.primaryFields?.map((field) => ( - - ))} - {data.log_type !== 'auth' && - seeMoreFieldsSections.map((section) => { - return [section.primaryField, ...section.additionalFields].map((field) => ( - - )) - })} - - + ))} + {data.log_type !== 'auth' && + seeMoreFieldsSections.map((section) => { + return [section.primaryField, ...section.additionalFields].map((field) => ( + + )) + })} + {data.log_type !== 'auth' && - otherSections.map((section) => { - return ( - - - - - - - {section.fields.map((field) => ( - - ))} - - - ) - })} + otherSections.map((section) => ( + + {section.fields.map((field) => ( + + ))} + + ))} ) }) diff --git a/apps/studio/components/ui/DataTable/CollapsibleDetailSection.tsx b/apps/studio/components/ui/DataTable/CollapsibleDetailSection.tsx new file mode 100644 index 00000000000..d65caa29aba --- /dev/null +++ b/apps/studio/components/ui/DataTable/CollapsibleDetailSection.tsx @@ -0,0 +1,29 @@ +import { ChevronDown } from 'lucide-react' +import type { ReactNode } from 'react' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui' + +import { DetailSectionHeader, type IconComponent } from './DetailSectionHeader' + +interface CollapsibleDetailSectionProps { + title: string + icon?: IconComponent + defaultOpen?: boolean + className?: string + children: ReactNode +} + +export const CollapsibleDetailSection = ({ + title, + icon, + defaultOpen, + className, + children, +}: CollapsibleDetailSectionProps) => ( + + + + + + {children} + +) diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx b/apps/studio/components/ui/DataTable/DetailSectionHeader.tsx similarity index 97% rename from apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx rename to apps/studio/components/ui/DataTable/DetailSectionHeader.tsx index ee0e849ea74..96af1fbb44f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx +++ b/apps/studio/components/ui/DataTable/DetailSectionHeader.tsx @@ -2,7 +2,7 @@ import { LucideIcon } from 'lucide-react' import { ReactNode } from 'react' import { cn } from 'ui' -type IconComponent = +export type IconComponent = | LucideIcon | React.ComponentType<{ size?: number; strokeWidth?: number; className?: string }> diff --git a/apps/studio/pages/account/audit.tsx b/apps/studio/pages/account/audit.tsx index 9980adf7598..f43e799d2f5 100644 --- a/apps/studio/pages/account/audit.tsx +++ b/apps/studio/pages/account/audit.tsx @@ -6,7 +6,7 @@ import { PageHeaderTitle, } from 'ui-patterns/PageHeader' -import { AuditLogs } from '@/components/interfaces/Account/AuditLogs' +import { AuditLogs } from '@/components/interfaces/Account/AuditLogs/AuditLogs' import AccountLayout from '@/components/layouts/AccountLayout/AccountLayout' import { AppLayout } from '@/components/layouts/AppLayout/AppLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -14,19 +14,21 @@ import type { NextPageWithLayout } from '@/types' const Audit: NextPageWithLayout = () => { return ( - <> - +
+ Audit Logs - View a detailed history of account activities and security events. + Detailed history of your account activities and security events. - - +
+ +
+
) }