From 9a60994efbbc5ae5aca187d9536d80dd99164f39 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 24 Sep 2026 19:07:36 +0800 Subject: [PATCH] Update account audit logs to follow unified logs UX (#50799) ## Context This one's just something that I thought about while doing some debugging - our audit logs UI is pretty outdated and hard to skim as the information is very sparse. This is how it currently looks like (specifically Account audit logs for this PR, there's also org audit logs): image image Am opting to follow the same UX as that of our Unified Logs, so opting to update the UI as such: Screenshot 2026-09-23 at 22 04 33 Screenshot 2026-09-23 at 22 16 09 Screenshot 2026-09-23 at 22 16 37 More importantly, we'll allow users to copy logs as JSON so users can eject to their own agent if needed image ## To test - [ ] Mainly just need to make sure that account audit logs still works as expected - it's all read only so just need to ensure that all information that's available before, is still available ## Summary by CodeRabbit * **New Features** * Browse account activity logs with date-range and project filters, project search, sorting, and refresh controls. Results automatically refresh every five minutes. * Choose preset date ranges, select individual logs or ranges of rows, and copy selected entries as formatted JSON. * Open a log to view request, actor, target, and action details, or inspect and copy its raw data. * Select log rows with a mouse or keyboard, and view loading, error, empty-results, and filtered-empty states. --- .../interfaces/Account/AuditLogs.tsx | 304 ------------------ .../AuditLogs/AuditLogDetailsPanel.tsx | 89 +++++ .../Account/AuditLogs/AuditLogOverview.tsx | 111 +++++++ .../Account/AuditLogs/AuditLogs.columns.tsx | 236 ++++++++++++++ .../Account/AuditLogs/AuditLogs.tsx | 274 ++++++++++++++++ .../{ => AuditLogs}/AuditLogs.utils.ts | 6 - .../AuditLogs/AuditLogsSelectionHeader.tsx | 60 ++++ .../Account/AuditLogs/AuditLogsTable.tsx | 100 ++++++ .../UnifiedLogs/ServiceFlow/LogOverview.tsx | 2 +- .../components/PostgresFlowDetail.tsx | 2 +- .../ServiceFlow/components/shared/Block.tsx | 119 +++---- .../ui/DataTable/CollapsibleDetailSection.tsx | 29 ++ .../DataTable/DetailSectionHeader.tsx} | 2 +- apps/studio/pages/account/audit.tsx | 14 +- 14 files changed, 963 insertions(+), 385 deletions(-) delete mode 100644 apps/studio/components/interfaces/Account/AuditLogs.tsx create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogDetailsPanel.tsx create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogOverview.tsx create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.columns.tsx create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogs.tsx rename apps/studio/components/interfaces/Account/{ => AuditLogs}/AuditLogs.utils.ts (60%) create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogsSelectionHeader.tsx create mode 100644 apps/studio/components/interfaces/Account/AuditLogs/AuditLogsTable.tsx create mode 100644 apps/studio/components/ui/DataTable/CollapsibleDetailSection.tsx rename apps/studio/components/{interfaces/UnifiedLogs/ServiceFlow/components/shared/DetailSection.tsx => ui/DataTable/DetailSectionHeader.tsx} (97%) 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. - - +
+ +
+
) }