mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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): <img width="1450" height="957" alt="image" src="https://github.com/user-attachments/assets/4b69511e-1d4f-4725-8ea9-78ddacf76942" /> <img width="1450" height="954" alt="image" src="https://github.com/user-attachments/assets/ec099f52-adef-499c-965a-11b8404bf235" /> Am opting to follow the same UX as that of our Unified Logs, so opting to update the UI as such: <img width="1451" height="958" alt="Screenshot 2026-09-23 at 22 04 33" src="https://github.com/user-attachments/assets/016bd26d-4078-42dc-9ff2-5bae9edbc1b2" /> <img width="1451" height="957" alt="Screenshot 2026-09-23 at 22 16 09" src="https://github.com/user-attachments/assets/f038b635-1359-4710-bc84-0f32ace1fb82" /> <img width="1450" height="956" alt="Screenshot 2026-09-23 at 22 16 37" src="https://github.com/user-attachments/assets/7230b94a-c7ec-4550-bb55-5405ebc074a7" /> More importantly, we'll allow users to copy logs as JSON so users can eject to their own agent if needed <img width="1201" height="350" alt="image" src="https://github.com/user-attachments/assets/587ab881-a501-41e6-8c9b-59191a2b2047" /> ## 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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
985ac1a8a1
commit
9a60994efb
14 files changed
+963
-385
No files matched your search
@@ -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<AuditLog>()
|
||||
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 (
|
||||
<>
|
||||
<ScaffoldContainer className="px-6 xl:px-10">
|
||||
<ScaffoldSection isFullWidth>
|
||||
<div className="space-y-4 flex flex-col">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Projects"
|
||||
options={projects ?? []}
|
||||
labelKey="name"
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoadingProjects}
|
||||
isFetching={isFetching}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
value={dateRange}
|
||||
onSubmit={(value) => 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 && (
|
||||
<>
|
||||
<div className="h-[20px] border-r border-strong !ml-4 !mr-2" />
|
||||
<p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={<RefreshCw className={isRefetching ? 'animate-spin' : ''} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{isRefetching ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve audit logs" />}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
{logs.length === 0 ? (
|
||||
<div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
|
||||
<p className="prose text-sm">You do not have any audit logs available yet</p>
|
||||
</div>
|
||||
) : logs.length > 0 && sortedLogs.length === 0 ? (
|
||||
<div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
|
||||
<p className="prose text-sm">
|
||||
No audit logs found based on the filters applied
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden md:overflow-auto overflow-x-scroll">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="action" className="py-2">
|
||||
Action
|
||||
</Table.th>,
|
||||
<Table.th key="target" className="py-2">
|
||||
Target
|
||||
</Table.th>,
|
||||
<Table.th key="date" className="py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p>Date</p>
|
||||
<ButtonTooltip
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={
|
||||
dateSortDesc ? (
|
||||
<ArrowDown strokeWidth={1.5} size={14} />
|
||||
) : (
|
||||
<ArrowUp strokeWidth={1.5} size={14} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDateSortDesc(!dateSortDesc)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Table.th>,
|
||||
<Table.th key="actions" className="py-2"></Table.th>,
|
||||
]}
|
||||
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 (
|
||||
<Table.tr
|
||||
key={log.request_id}
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="cursor-pointer hover:bg-alternative! transition duration-100"
|
||||
>
|
||||
<Table.td className="max-w-[250px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="bg-surface-200 rounded-sm px-1 flex items-center justify-center text-xs font-mono border">
|
||||
{log.action.status}
|
||||
</p>
|
||||
<p className="text-foreground-light text-xs font-mono">
|
||||
{log.action.method}
|
||||
</p>
|
||||
<p className="truncate" title={log.action.name}>
|
||||
{log.action.name}
|
||||
</p>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{project || organization ? (
|
||||
<>
|
||||
<p
|
||||
className="text-foreground-light max-w-[230px] truncate"
|
||||
title={project?.name ?? organization?.name}
|
||||
>
|
||||
{project?.name
|
||||
? 'Project: '
|
||||
: organization?.name
|
||||
? 'Organization: '
|
||||
: null}
|
||||
{project?.name ?? organization?.name}
|
||||
</p>
|
||||
<p
|
||||
className="text-foreground-light text-xs mt-0.5 truncate"
|
||||
title={log.project_ref ?? log.organization_slug ?? ''}
|
||||
>
|
||||
{log.project_ref ? 'Ref: ' : 'Slug: '}
|
||||
{log.project_ref ?? log.organization_slug}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-foreground-light text-sm">
|
||||
{log.project_ref ?? log.organization_slug ?? '-'}
|
||||
</p>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<TimestampInfo className="text-sm" utcTimestamp={isoTimestamp} />
|
||||
</Table.td>
|
||||
<Table.td align="right">
|
||||
<Button>View details</Button>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
|
||||
<LogDetailsPanel selectedLog={selectedLog} onClose={() => setSelectedLog(undefined)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel id="audit-log-details-panel" defaultSize="33%" minSize={320} maxSize="50%">
|
||||
<Tabs defaultValue="overview" className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between px-4 border-b border-border h-[40px]">
|
||||
<TabsList className="flex h-auto gap-x-4 rounded-none border-none!">
|
||||
<TabsTrigger value="overview" className="border-b py-3 font-mono text-xs uppercase">
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw-json" className="border-b py-3 font-mono text-xs uppercase">
|
||||
Raw JSON
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={<X />}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview" className="mt-0 grow overflow-auto py-2">
|
||||
<AuditLogOverview selectedLog={selectedLog} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="raw-json"
|
||||
className="mt-0 grow overflow-auto bg-surface-100/50 relative"
|
||||
>
|
||||
<div className="sticky top-2 z-10 flex justify-end px-2 -mb-9 pointer-events-none">
|
||||
<FloatingPlate className="pointer-events-auto">
|
||||
<Button
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
icon={copied ? <Check size={12} /> : <Copy size={12} />}
|
||||
onClick={() => {
|
||||
copyToClipboard(json, () => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1000)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : ''}
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
hideCopy
|
||||
wrapperClassName="!overflow-visible bg-surface-100/50 [&_pre]:!bg-surface-100/50"
|
||||
className="rounded-none border-none !overflow-x-visible [&_code]:!leading-tight [&_pre]:!leading-tight"
|
||||
>
|
||||
{json}
|
||||
</CodeBlock>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-9 items-center justify-between gap-x-10 px-4 pl-[38px]">
|
||||
<span className="shrink-0 text-sm text-foreground-lighter">{label}</span>
|
||||
<div
|
||||
className={cn('flex items-center gap-x-2 min-w-0 flex-1 justify-end', isEmpty && 'pr-2')}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<span className="font-mono text-xs text-foreground-muted">—</span>
|
||||
) : (
|
||||
<span className="truncate font-mono text-xs text-foreground text-right">{value}</span>
|
||||
)}
|
||||
{!isEmpty && (
|
||||
<Button
|
||||
aria-label={`Copy ${label}`}
|
||||
variant="text"
|
||||
className="px-1"
|
||||
icon={<Copy size={12} />}
|
||||
onClick={() => copyToClipboard(String(value))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const AuditLogOverview = ({ selectedLog }: AuditLogOverviewProps) => {
|
||||
const hasMetadata = Object.keys(selectedLog.action.metadata ?? {}).length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DetailSectionHeader
|
||||
title="Request started"
|
||||
icon={Clock}
|
||||
className="border-b"
|
||||
summary={
|
||||
<TimestampInfo
|
||||
className="text-xs font-mono"
|
||||
utcTimestamp={dayjs(selectedLog.timestamp / TIMESTAMP_MICROS_PER_MS).toISOString()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<DetailSectionHeader
|
||||
title="Request ID"
|
||||
icon={Hash}
|
||||
className="border-b"
|
||||
summary={
|
||||
<span className="truncate text-right font-mono text-xs text-foreground">
|
||||
{selectedLog.request_id}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<CollapsibleDetailSection defaultOpen className="border-b" title="Target" icon={Globe}>
|
||||
<DetailRow label="Organization slug" value={selectedLog.organization_slug} />
|
||||
<DetailRow label="Project ref" value={selectedLog.project_ref} />
|
||||
</CollapsibleDetailSection>
|
||||
|
||||
<CollapsibleDetailSection defaultOpen className="border-b" title="Actor" icon={User}>
|
||||
<DetailRow label="Token type" value={selectedLog.actor.token_type} />
|
||||
<DetailRow label="Email" value={selectedLog.actor.email} />
|
||||
<DetailRow label="User ID" value={selectedLog.actor.user_id} />
|
||||
<DetailRow label="IP address" value={selectedLog.actor.ip} />
|
||||
</CollapsibleDetailSection>
|
||||
|
||||
<CollapsibleDetailSection defaultOpen className="border-b" title="Action" icon={Activity}>
|
||||
<DetailRow label="Name" value={selectedLog.action.name} />
|
||||
<DetailRow label="Method" value={selectedLog.action.method} />
|
||||
<DetailRow label="Route" value={selectedLog.action.route} />
|
||||
<div className="flex h-9 items-center justify-between gap-x-10 px-4 pl-[38px]">
|
||||
<span className="shrink-0 text-sm text-foreground-lighter">Status</span>
|
||||
<DataTableColumnStatusCode
|
||||
value={selectedLog.action.status}
|
||||
level={getStatusLevel(selectedLog.action.status)}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<DetailRow
|
||||
label="Metadata"
|
||||
value={hasMetadata ? JSON.stringify(selectedLog.action.metadata) : undefined}
|
||||
/>
|
||||
</CollapsibleDetailSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>
|
||||
isLoadingProjects: boolean
|
||||
isLoadingOrganizations: boolean
|
||||
}
|
||||
|
||||
export function getAuditLogColumns({
|
||||
projects,
|
||||
organizations,
|
||||
lastSelectedRowId,
|
||||
isLoadingProjects,
|
||||
isLoadingOrganizations,
|
||||
}: AuditLogColumnsOptions): ColumnDef<AuditLog>[] {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllRowsSelected() ||
|
||||
(table.getIsSomeRowsSelected() ? 'indeterminate' : false)
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row, table }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onClick={(e) => {
|
||||
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 }) => <TanStackTableHeadSort column={column}>Date</TanStackTableHeadSort>,
|
||||
cell: ({ row }) => (
|
||||
<TimestampInfo
|
||||
className="text-xs font-mono"
|
||||
utcTimestamp={dayjs(row.original.timestamp / TIMESTAMP_MICROS_PER_MS).toISOString()}
|
||||
/>
|
||||
),
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
{log.project_ref ? (
|
||||
<Box size={12} className="text-foreground-light" />
|
||||
) : (
|
||||
<Boxes size={12} className="text-foreground-light" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{log.project_ref ? 'Project' : 'Organization'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
} 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 <ShimmeringLoader className="w-20 h-4 py-0" />
|
||||
if (log.organization_slug && isLoadingOrganizations) {
|
||||
return <ShimmeringLoader className="w-20 h-4 py-0" />
|
||||
}
|
||||
|
||||
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 <span className="text-foreground-light text-xs">-</span>
|
||||
|
||||
return (
|
||||
<p className="truncate text-foreground-light" title={target}>
|
||||
{target}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
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 }) => (
|
||||
<DataTableColumnStatusCode
|
||||
value={row.original.action.status}
|
||||
level={getStatusLevel(row.original.action.status)}
|
||||
className="text-xs"
|
||||
/>
|
||||
),
|
||||
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 }) => (
|
||||
<span className="text-foreground-light text-xs font-mono">
|
||||
{row.original.action.method}
|
||||
</span>
|
||||
),
|
||||
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 (
|
||||
<p
|
||||
className="truncate text-foreground-light font-mono tracking-tighter group-hover:text-foreground"
|
||||
title={row.original.action.name}
|
||||
>
|
||||
{row.original.action.name}
|
||||
</p>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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<AuditLog>()
|
||||
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<string | null>(null)
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getAuditLogColumns({
|
||||
projects,
|
||||
organizations: organizations ?? [],
|
||||
lastSelectedRowId,
|
||||
isLoadingProjects,
|
||||
isLoadingOrganizations,
|
||||
}),
|
||||
[projects, organizations, isLoadingProjects, isLoadingOrganizations]
|
||||
)
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'date', desc: true }])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
|
||||
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 (
|
||||
<ScaffoldContainer size="full" className="px-0 h-full flex flex-col">
|
||||
<ScaffoldSection isFullWidth className="pt-6! pb-0! flex-1 min-h-0">
|
||||
<div className="space-y-4 flex flex-col h-full min-h-0">
|
||||
{/* [Joshen] Can consider replacing this with filter bar */}
|
||||
<div
|
||||
className={cn(
|
||||
CONTENT_PADDING,
|
||||
'flex flex-col md:flex-row md:items-center justify-between'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
<FilterPopover
|
||||
name="Projects"
|
||||
options={projects}
|
||||
labelKey="name"
|
||||
valueKey="ref"
|
||||
activeOptions={filters.projects}
|
||||
onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
hasNextPage={hasNextPage}
|
||||
isLoading={isLoadingProjects}
|
||||
isFetching={isFetchingProjects}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
value={dateRange}
|
||||
onSubmit={(value) => 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(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
disabled={isLoading}
|
||||
loading={isLoading || isRefetching}
|
||||
className="w-7"
|
||||
icon={<RefreshCw />}
|
||||
onClick={() => refetch()}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Refresh logs' } }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className={cn(CONTENT_PADDING, 'space-y-2')}>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className={CONTENT_PADDING}>
|
||||
<AlertError error={error} subject="Failed to retrieve audit logs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
{logs.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
CONTENT_PADDING,
|
||||
'bg-surface-100 border rounded-sm p-4 flex items-center justify-between'
|
||||
)}
|
||||
>
|
||||
<p className="prose text-sm">You do not have any audit logs available yet</p>
|
||||
</div>
|
||||
) : logs.length > 0 && filteredLogs.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
CONTENT_PADDING,
|
||||
'bg-surface-100 border rounded-sm p-4 flex items-center justify-between'
|
||||
)}
|
||||
>
|
||||
<p className="prose text-sm">No audit logs found based on the filters applied</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-y overflow-hidden flex-1 min-h-0">
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full">
|
||||
<ResizablePanel
|
||||
defaultSize={100}
|
||||
minSize={30}
|
||||
className="flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="grow relative overflow-hidden">
|
||||
<AuditLogsSelectionHeader table={table} />
|
||||
<AuditLogsTable
|
||||
table={table}
|
||||
selectedLog={selectedLog}
|
||||
onSelectLog={(log) =>
|
||||
setSelectedLog((prev) =>
|
||||
prev?.request_id === log.request_id ? undefined : log
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
{selectedLog && (
|
||||
<AuditLogDetailsPanel
|
||||
selectedLog={selectedLog}
|
||||
onClose={() => setSelectedLog(undefined)}
|
||||
/>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
-6
@@ -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 ?? ''))
|
||||
@@ -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<AuditLog>
|
||||
}
|
||||
|
||||
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 (
|
||||
<AnimatePresence>
|
||||
{hasSelection && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: '40px', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ type: 'spring', stiffness: 420, damping: 30, mass: 0.4 }}
|
||||
className="px-10 flex items-center justify-between border-b bg-surface-75 w-full overflow-hidden absolute top-0 left-0 right-0 z-20"
|
||||
>
|
||||
<p className="text-xs">
|
||||
{selectedRows.length} row{selectedRows.length > 1 ? 's' : ''} selected
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<ButtonTooltip
|
||||
size="tiny"
|
||||
icon={<Copy size={12} />}
|
||||
className="w-7"
|
||||
onClick={handleCopy}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Copy selected logs' } }}
|
||||
/>
|
||||
<ButtonTooltip
|
||||
size="tiny"
|
||||
variant="text"
|
||||
icon={<X />}
|
||||
className="px-1"
|
||||
onClick={() => table.resetRowSelection()}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Clear selection' } }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -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<AuditLog>
|
||||
selectedLog?: AuditLog
|
||||
onSelectLog: (log: AuditLog) => void
|
||||
}
|
||||
|
||||
export const AuditLogsTable = ({ table, selectedLog, onSelectLog }: AuditLogsTableProps) => {
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="table-fixed"
|
||||
containerProps={{
|
||||
containerClassName: 'h-full',
|
||||
className: 'h-full w-full overflow-auto @container',
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b-0! hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'sticky top-0 z-10 bg-surface-100',
|
||||
'[border-bottom:none]! [box-shadow:inset_0_-1px_0_var(--border-default)]!',
|
||||
(header.column.columnDef.meta as AuditLogColumnMeta | undefined)?.headerClassName,
|
||||
'px-2 first:pl-3'
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
tabIndex={0}
|
||||
aria-selected={selectedLog?.request_id === row.original.request_id}
|
||||
onClick={() => 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) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'py-2 px-2 text-xs first:pl-3',
|
||||
(cell.column.columnDef.meta as AuditLogColumnMeta | undefined)?.cellClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{rows.length > 0 && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={table.getAllLeafColumns().length} className="p-0! overflow-visible">
|
||||
<div className="sticky left-0 w-[100cqw] py-2 text-center">
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Viewing{' '}
|
||||
<span className="font-mono font-medium">{formatCompactNumber(rows.length)}</span>{' '}
|
||||
logs
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
@@ -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<ServiceFlowBlockProps, 'data'> & {
|
||||
data: ColumnSchema
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+53
-66
@@ -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 (
|
||||
<>
|
||||
<Collapsible defaultOpen className="border-b">
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
|
||||
<DetailSectionHeader title={config.title} icon={config.icon} />
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
<CollapsibleDetailSection
|
||||
defaultOpen
|
||||
className="border-b"
|
||||
title={config.title}
|
||||
icon={config.icon}
|
||||
>
|
||||
{config.primaryFields?.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{config.primaryFields?.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
))}
|
||||
{data.log_type !== 'auth' &&
|
||||
seeMoreFieldsSections.map((section) => {
|
||||
return [section.primaryField, ...section.additionalFields].map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
))
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
))}
|
||||
{data.log_type !== 'auth' &&
|
||||
seeMoreFieldsSections.map((section) => {
|
||||
return [section.primaryField, ...section.additionalFields].map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
))
|
||||
})}
|
||||
</CollapsibleDetailSection>
|
||||
|
||||
{data.log_type !== 'auth' &&
|
||||
otherSections.map((section) => {
|
||||
return (
|
||||
<Collapsible key={section.title} className="border-b">
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
|
||||
<DetailSectionHeader title={section.title} icon={section.icon} />
|
||||
<ChevronDown
|
||||
className="transition-transform duration-200"
|
||||
strokeWidth={1.5}
|
||||
size={14}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{section.fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
})}
|
||||
otherSections.map((section) => (
|
||||
<CollapsibleDetailSection
|
||||
key={section.title}
|
||||
className="border-b"
|
||||
title={section.title}
|
||||
icon={section.icon}
|
||||
>
|
||||
{section.fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
config={field}
|
||||
data={data}
|
||||
enrichedData={enrichedData}
|
||||
isLoading={isLoading}
|
||||
filterFields={filterFields}
|
||||
table={table}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleDetailSection>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) => (
|
||||
<Collapsible defaultOpen={defaultOpen} className={className}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between pr-4 [&[data-state=open]>svg]:-rotate-180! transition hover:bg-surface-100">
|
||||
<DetailSectionHeader title={title} icon={icon} />
|
||||
<ChevronDown className="transition-transform duration-200" strokeWidth={1.5} size={14} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{children}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
+1
-1
@@ -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 }>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageHeader size="default">
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader size="full">
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>Audit Logs</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
View a detailed history of account activities and security events.
|
||||
Detailed history of your account activities and security events.
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<AuditLogs />
|
||||
</>
|
||||
<div className="flex-1 min-h-0">
|
||||
<AuditLogs />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user