AuditLogs.tsx305 lines · main
1import { keepPreviousData } from '@tanstack/react-query'
2import { useDebounce } from '@uidotdev/usehooks'
3import dayjs from 'dayjs'
4import { ArrowDown, ArrowUp, RefreshCw } from 'lucide-react'
5import { useEffect, useMemo, useState } from 'react'
6import { Button } from 'ui'
7import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
8import { TimestampInfo } from 'ui-patterns/TimestampInfo'
9
10import { LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
11import { filterByProjects, sortAuditLogs } from './AuditLogs.utils'
12import { LogDetailsPanel } from '@/components/interfaces/AuditLogs/LogDetailsPanel'
13import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
14import Table from '@/components/to-be-cleaned/Table'
15import AlertError from '@/components/ui/AlertError'
16import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
17import { FilterPopover } from '@/components/ui/FilterPopover'
18import {
19 TIMESTAMP_MICROS_PER_MS,
20 type AuditLog,
21} from '@/data/organizations/organization-audit-logs-query'
22import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
23import { useProfileAuditLogsQuery } from '@/data/profile/profile-audit-logs-query'
24import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query'
25
26export const AuditLogs = () => {
27 const currentTime = dayjs().utc().set('millisecond', 0)
28
29 const [search, setSearch] = useState('')
30 const debouncedSearch = useDebounce(search, 500)
31
32 const [dateSortDesc, setDateSortDesc] = useState(true)
33 const [dateRange, setDateRange] = useState({
34 from: currentTime.subtract(1, 'day').toISOString(),
35 to: currentTime.toISOString(),
36 })
37
38 const [selectedLog, setSelectedLog] = useState<AuditLog>()
39 const [filters, setFilters] = useState<{ projects: string[] }>({
40 projects: [],
41 })
42
43 const {
44 data: projectsData,
45 isLoading: isLoadingProjects,
46 isFetching,
47 isFetchingNextPage,
48 hasNextPage,
49 fetchNextPage,
50 } = useProjectsInfiniteQuery(
51 { search: search.length === 0 ? search : debouncedSearch },
52 { placeholderData: keepPreviousData }
53 )
54 const projects =
55 useMemo(() => projectsData?.pages.flatMap((page) => page.projects), [projectsData?.pages]) || []
56
57 const { data: organizations } = useOrganizationsQuery()
58 const {
59 data,
60 error,
61 isPending: isLoading,
62 isSuccess,
63 isError,
64 isRefetching,
65 refetch,
66 } = useProfileAuditLogsQuery(
67 {
68 iso_timestamp_start: dateRange.from,
69 iso_timestamp_end: dateRange.to,
70 },
71 {
72 retry: false,
73 }
74 )
75
76 const logs = data?.result ?? []
77 const sortedLogs = filterByProjects(sortAuditLogs(logs, dateSortDesc), filters.projects)
78
79 // This feature depends on the subscription tier of the user. Free user can view logs up to 1 day
80 // in the past. The API limits the logs to maximum of 1 day and 5 minutes so when the page is
81 // viewed for more than 5 minutes, the call parameters needs to be updated. This also works with
82 // higher tiers (7 days of logs).The user will see a loading shimmer.
83 useEffect(() => {
84 const duration = dayjs(dateRange.from).diff(dayjs(dateRange.to))
85 const interval = setInterval(() => {
86 const currentTime = dayjs().utc().set('millisecond', 0)
87 setDateRange({
88 from: currentTime.add(duration).toISOString(),
89 to: currentTime.toISOString(),
90 })
91 }, 5 * 60000)
92
93 return () => clearInterval(interval)
94 }, [dateRange.from, dateRange.to])
95
96 return (
97 <>
98 <ScaffoldContainer className="px-6 xl:px-10">
99 <ScaffoldSection isFullWidth>
100 <div className="space-y-4 flex flex-col">
101 <div className="flex flex-col md:flex-row md:items-center justify-between">
102 <div className="flex items-center space-x-2">
103 <p className="text-xs prose">Filter by</p>
104 <FilterPopover
105 name="Projects"
106 options={projects ?? []}
107 labelKey="name"
108 valueKey="ref"
109 activeOptions={filters.projects}
110 onSaveFilters={(values) => setFilters({ ...filters, projects: values })}
111 search={search}
112 setSearch={setSearch}
113 hasNextPage={hasNextPage}
114 isLoading={isLoadingProjects}
115 isFetching={isFetching}
116 isFetchingNextPage={isFetchingNextPage}
117 fetchNextPage={fetchNextPage}
118 />
119 <LogsDatePicker
120 hideWarnings
121 value={dateRange}
122 onSubmit={(value) => setDateRange(value)}
123 helpers={[
124 {
125 text: 'Last 1 hour',
126 calcFrom: () => dayjs().subtract(1, 'hour').toISOString(),
127 calcTo: () => dayjs().toISOString(),
128 },
129 {
130 text: 'Last 3 hours',
131 calcFrom: () => dayjs().subtract(3, 'hour').toISOString(),
132 calcTo: () => dayjs().toISOString(),
133 },
134
135 {
136 text: 'Last 6 hours',
137 calcFrom: () => dayjs().subtract(6, 'hour').toISOString(),
138 calcTo: () => dayjs().toISOString(),
139 },
140 {
141 text: 'Last 12 hours',
142 calcFrom: () => dayjs().subtract(12, 'hour').toISOString(),
143 calcTo: () => dayjs().toISOString(),
144 },
145 {
146 text: 'Last 24 hours',
147 calcFrom: () => dayjs().subtract(1, 'day').toISOString(),
148 calcTo: () => dayjs().toISOString(),
149 },
150 ]}
151 />
152 {isSuccess && (
153 <>
154 <div className="h-[20px] border-r border-strong !ml-4 !mr-2" />
155 <p className="prose text-xs">Viewing {sortedLogs.length} logs in total</p>
156 </>
157 )}
158 </div>
159 <Button
160 type="default"
161 disabled={isLoading || isRefetching}
162 icon={<RefreshCw className={isRefetching ? 'animate-spin' : ''} />}
163 onClick={() => refetch()}
164 >
165 {isRefetching ? 'Refreshing' : 'Refresh'}
166 </Button>
167 </div>
168
169 {isLoading && (
170 <div className="space-y-2">
171 <ShimmeringLoader />
172 <ShimmeringLoader className="w-3/4" />
173 <ShimmeringLoader className="w-1/2" />
174 </div>
175 )}
176
177 {isError && <AlertError error={error} subject="Failed to retrieve audit logs" />}
178
179 {isSuccess && (
180 <>
181 {logs.length === 0 ? (
182 <div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
183 <p className="prose text-sm">You do not have any audit logs available yet</p>
184 </div>
185 ) : logs.length > 0 && sortedLogs.length === 0 ? (
186 <div className="bg-surface-100 border rounded-sm p-4 flex items-center justify-between">
187 <p className="prose text-sm">
188 No audit logs found based on the filters applied
189 </p>
190 </div>
191 ) : (
192 <div className="overflow-hidden md:overflow-auto overflow-x-scroll">
193 <Table
194 head={[
195 <Table.th key="action" className="py-2">
196 Action
197 </Table.th>,
198 <Table.th key="target" className="py-2">
199 Target
200 </Table.th>,
201 <Table.th key="date" className="py-2">
202 <div className="flex items-center space-x-2">
203 <p>Date</p>
204 <ButtonTooltip
205 type="text"
206 className="px-1"
207 icon={
208 dateSortDesc ? (
209 <ArrowDown strokeWidth={1.5} size={14} />
210 ) : (
211 <ArrowUp strokeWidth={1.5} size={14} />
212 )
213 }
214 onClick={() => setDateSortDesc(!dateSortDesc)}
215 tooltip={{
216 content: {
217 side: 'bottom',
218 text: dateSortDesc ? 'Sort latest first' : 'Sort earliest first',
219 },
220 }}
221 />
222 </div>
223 </Table.th>,
224 <Table.th key="actions" className="py-2"></Table.th>,
225 ]}
226 body={
227 sortedLogs?.map((log) => {
228 const project = projects?.find((p) => p.ref === log.project_ref)
229 const organization = organizations?.find(
230 (org) => org.slug === log.organization_slug
231 )
232 const isoTimestamp = dayjs(
233 log.timestamp / TIMESTAMP_MICROS_PER_MS
234 ).toISOString()
235
236 return (
237 <Table.tr
238 key={log.request_id}
239 onClick={() => setSelectedLog(log)}
240 className="cursor-pointer hover:bg-alternative! transition duration-100"
241 >
242 <Table.td className="max-w-[250px]">
243 <div className="flex items-center space-x-2">
244 <p className="bg-surface-200 rounded-sm px-1 flex items-center justify-center text-xs font-mono border">
245 {log.action.status}
246 </p>
247 <p className="text-foreground-light text-xs font-mono">
248 {log.action.method}
249 </p>
250 <p className="truncate" title={log.action.name}>
251 {log.action.name}
252 </p>
253 </div>
254 </Table.td>
255 <Table.td>
256 {project || organization ? (
257 <>
258 <p
259 className="text-foreground-light max-w-[230px] truncate"
260 title={project?.name ?? organization?.name}
261 >
262 {project?.name
263 ? 'Project: '
264 : organization?.name
265 ? 'Organization: '
266 : null}
267 {project?.name ?? organization?.name}
268 </p>
269 <p
270 className="text-foreground-light text-xs mt-0.5 truncate"
271 title={log.project_ref ?? log.organization_slug ?? ''}
272 >
273 {log.project_ref ? 'Ref: ' : 'Slug: '}
274 {log.project_ref ?? log.organization_slug}
275 </p>
276 </>
277 ) : (
278 <p className="text-foreground-light text-sm">
279 {log.project_ref ?? log.organization_slug ?? '-'}
280 </p>
281 )}
282 </Table.td>
283 <Table.td>
284 <TimestampInfo className="text-sm" utcTimestamp={isoTimestamp} />
285 </Table.td>
286 <Table.td align="right">
287 <Button type="default">View details</Button>
288 </Table.td>
289 </Table.tr>
290 )
291 }) ?? []
292 }
293 />
294 </div>
295 )}
296 </>
297 )}
298 </div>
299 </ScaffoldSection>
300 </ScaffoldContainer>
301
302 <LogDetailsPanel selectedLog={selectedLog} onClose={() => setSelectedLog(undefined)} />
303 </>
304 )
305}