LogsPreviewer.tsx392 lines · main
1import { useParams } from 'common'
2import dayjs from 'dayjs'
3import { Rewind } from 'lucide-react'
4import { useRouter } from 'next/router'
5import { PropsWithChildren, useEffect, useRef, useState } from 'react'
6import { Button } from 'ui'
7import { LogsBarChart } from 'ui-patterns/LogsBarChart'
8
9import {
10 LOG_ROUTES_WITH_REPLICA_SUPPORT,
11 LOGS_TABLES,
12 LogsTableName,
13 PREVIEWER_DATEPICKER_HELPERS,
14} from './Logs.constants'
15import { DatePickerValue } from './Logs.DatePickers'
16import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
17import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
18import { LogTable } from './LogTable'
19import UpgradePrompt from './UpgradePrompt'
20import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
21import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
22import LoadingOpacity from '@/components/ui/LoadingOpacity'
23import ShimmerLine from '@/components/ui/ShimmerLine'
24import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
25import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
26import useLogsPreview from '@/hooks/analytics/useLogsPreview'
27import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
28import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
29import useSingleLog from '@/hooks/analytics/useSingleLog'
30import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
31import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
32import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
33import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
34import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
35
36/**
37 * Calculates the appropriate time range for bar click filtering based on the current time range duration.
38 *
39 * @param currentRangeStart - The start timestamp of the current time range
40 * @param currentRangeEnd - The end timestamp of the current time range
41 * @param clickedTimestamp - The timestamp of the clicked bar
42 * @returns Object containing the new start and end timestamps for filtering
43 */
44export const calculateBarClickTimeRange = (
45 currentRangeStart: string,
46 currentRangeEnd: string | undefined,
47 clickedTimestamp: string
48) => {
49 const datumTimestamp = dayjs(clickedTimestamp).toISOString()
50
51 // Calculate the current time range duration in hours
52 // If currentRangeEnd is not provided, use current time as the end
53 const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
54 const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
55
56 let rangeOffset: number
57 let rangeUnit: dayjs.ManipulateType
58
59 if (currentRangeDuration >= 12) {
60 // For ranges >= 12h, use 1h range
61 rangeOffset = 0.5
62 rangeUnit = 'hour'
63 } else if (currentRangeDuration >= 1) {
64 // For ranges >= 1h but < 12h, use 5min range
65 rangeOffset = 2.5
66 rangeUnit = 'minute'
67 } else if (currentRangeDuration >= 1 / 30) {
68 // 2 minutes = 1/30 hour
69 // For ranges >= 2min but < 1h, use 2min range
70 rangeOffset = 1
71 rangeUnit = 'minute'
72 } else {
73 // For ranges < 2min, use 15sec range
74 rangeOffset = 7.5
75 rangeUnit = 'second'
76 }
77
78 return {
79 start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
80 end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
81 }
82}
83
84/**
85 * Acts as a container component for the entire log display
86 *
87 * ## Query Params Syncing
88 * Query params are synced on query submission.
89 *
90 * params used are:
91 * - `s` for search query.
92 * - `te` for timestamp start value.
93 */
94interface LogsPreviewerProps {
95 projectRef: string
96 queryType: QueryType
97 filterOverride?: Filters
98 condensedLayout?: boolean
99 tableName?: LogsTableName
100 EmptyState?: React.ReactNode
101 filterPanelClassName?: string
102}
103export const LogsPreviewer = ({
104 projectRef,
105 queryType,
106 filterOverride,
107 condensedLayout = false,
108 tableName,
109 children,
110 EmptyState,
111 filterPanelClassName,
112}: PropsWithChildren<LogsPreviewerProps>) => {
113 const router = useRouter()
114 const { db } = useParams()
115 const { data: organization } = useSelectedOrganizationQuery()
116 const state = useDatabaseSelectorStateSnapshot()
117
118 const searchInputRef = useRef<HTMLInputElement>(null)
119 const [showChart, setShowChart] = useState(true)
120 const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
121 getDefaultDatePickerValue()
122 )
123
124 const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } =
125 useLogsUrlState()
126
127 useEffect(() => {
128 if (timestampStart && timestampEnd) {
129 setSelectedDatePickerValue({
130 to: timestampEnd,
131 from: timestampStart,
132 text: `${dayjs(timestampStart).format('DD MMM, HH:mm')} - ${dayjs(timestampEnd).format('DD MMM, HH:mm')}`,
133 isHelper: false,
134 })
135 }
136 }, [timestampStart, timestampEnd])
137
138 const [selectedLogId, setSelectedLogId] = useSelectedLog()
139 const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef })
140
141 // TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi
142 function getDefaultDatePickerValue() {
143 const iso_timestamp_start = router.query.iso_timestamp_start as string
144 const iso_timestamp_end = router.query.iso_timestamp_end as string
145
146 if (iso_timestamp_start && iso_timestamp_end) {
147 return {
148 to: iso_timestamp_end,
149 from: iso_timestamp_start,
150 text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
151 isHelper: false,
152 }
153 }
154
155 const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default)
156 return {
157 to: defaultDatePickerValue!.calcTo(),
158 from: defaultDatePickerValue!.calcFrom(),
159 text: defaultDatePickerValue!.text,
160 isHelper: true,
161 }
162 }
163
164 const table = !tableName ? LOGS_TABLES[queryType] : tableName
165
166 const {
167 error,
168 logData,
169 params,
170 newCount,
171 isLoading,
172 eventChartData,
173 isLoadingOlder,
174 loadOlder,
175 refresh,
176 } = useLogsPreview({ projectRef, table, filterOverride })
177
178 const {
179 data: selectedLog,
180 isLoading: isSelectedLogLoading,
181 error: selectedLogError,
182 } = useSingleLog({
183 projectRef,
184 id: selectedLogId ?? undefined,
185 queryType,
186 paramsToMerge: params,
187 })
188
189 const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(timestampStart)
190
191 const onSelectTemplate = (template: LogTemplate) => {
192 setFilters({ ...filters, search_query: template.searchString })
193 }
194
195 // [Joshen] For helper date picker values, reset the timestamp start to prevent data caching
196 // Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result
197 // Whereas if a specific range is selected, you'd not expect new data to show up
198 const handleRefresh = () => {
199 if (selectedDatePickerValue.isHelper) {
200 const helper = PREVIEWER_DATEPICKER_HELPERS.find(
201 (x) => x.text === selectedDatePickerValue.text
202 )
203 if (helper) {
204 const newTimestampStart = helper.calcFrom()
205 setTimeRange(newTimestampStart, timestampEnd)
206 }
207 }
208 refresh()
209 }
210
211 const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
212 const entitledToAuditLogDays = getEntitlementNumericValue()
213
214 const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
215 if (event === 'search-input-change') {
216 setSearch(query || '')
217 setSelectedLogId(null)
218 } else if (event === 'event-chart-bar-click') {
219 setTimeRange(from || '', to || '')
220 } else if (event === 'datepicker-change') {
221 const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
222 from || '',
223 entitledToAuditLogDays
224 )
225 if (shouldShowUpgradePrompt) {
226 setShowUpgradePrompt(!showUpgradePrompt)
227 } else {
228 setTimeRange(from || '', to || '')
229 }
230 }
231 }
232
233 // Show the prompt on page load based on query params
234 useEffect(() => {
235 if (timestampStart) {
236 const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
237 timestampStart,
238 entitledToAuditLogDays
239 )
240 if (shouldShowUpgradePrompt) {
241 setShowUpgradePrompt(!showUpgradePrompt)
242 }
243 }
244 }, [timestampStart, organization])
245
246 useEffect(() => {
247 if (db !== undefined) {
248 const database = databases?.find((d) => d.identifier === db)
249 if (database !== undefined) state.setSelectedDatabaseId(db)
250 } else if (state.selectedDatabaseId !== undefined && state.selectedDatabaseId !== projectRef) {
251 if (LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)) {
252 router.push({
253 pathname: router.pathname,
254 query: { ...router.query, db: state.selectedDatabaseId },
255 })
256 } else {
257 state.setSelectedDatabaseId(projectRef)
258 }
259 }
260 }, [db, isSuccess])
261
262 // Common props shared between both filter panel components to avoid duplication
263 const filterPanelProps = {
264 className: filterPanelClassName,
265 csvData: logData,
266 isLoading,
267 newCount,
268 onRefresh: handleRefresh,
269 onSearch: handleSearch,
270 defaultSearchValue: search,
271 defaultToValue: timestampEnd,
272 defaultFromValue: timestampStart,
273 queryUrl: `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
274 params.sql || ''
275 )}&its=${encodeURIComponent(timestampStart)}&ite=${encodeURIComponent(timestampEnd)}`,
276 onSelectTemplate,
277 filters,
278 onFiltersChange: setFilters,
279 table,
280 condensedLayout,
281 isShowingEventChart: showChart,
282 onToggleEventChart: () => setShowChart(!showChart),
283 onSelectedDatabaseChange: (id: string) => {
284 setFilters({ ...filters, database: id !== projectRef ? id : undefined })
285 const { db, ...params } = router.query
286 router.push({
287 pathname: router.pathname,
288 query: id !== projectRef ? { ...router.query, db: id } : params,
289 })
290 },
291 selectedDatePickerValue,
292 setSelectedDatePickerValue,
293 searchInputRef,
294 }
295
296 useLogsPreviewShortcuts({
297 searchInputRef,
298 hasSearch: search.length > 0,
299 onResetSearch: () => {
300 setSearch('')
301 setSelectedLogId(null)
302 },
303 onRefresh: handleRefresh,
304 onToggleChart: () => setShowChart((prev) => !prev),
305 onLoadOlder: loadOlder,
306 canLoadOlder: !error && logData.length > 0 && !isLoadingOlder,
307 })
308
309 return (
310 <div className="flex-1 flex flex-col h-full">
311 <PreviewFilterPanel {...filterPanelProps} />
312 {children}
313 <div
314 className={
315 'transition-all duration-500 ' +
316 (showChart && logData.length > 0 ? 'mb-2 mt-1 opacity-100' : 'h-0 opacity-0')
317 }
318 >
319 <div className={condensedLayout ? 'px-3' : ''}>
320 {showChart && (
321 <LogsBarChart
322 data={eventChartData}
323 onBarClick={(datum) => {
324 if (!datum?.timestamp) return
325
326 const { start, end } = calculateBarClickTimeRange(
327 timestampStart,
328 timestampEnd,
329 datum.timestamp
330 )
331
332 handleSearch('event-chart-bar-click', {
333 query: filters.search_query?.toString(),
334 to: end,
335 from: start,
336 })
337 }}
338 EmptyState={
339 <div className="flex flex-col items-center justify-center h-[67px]">
340 <p className="text-foreground-light text-xs">No data</p>
341 <p className="text-foreground-lighter text-xs">
342 It may take up to 24 hours for data to refresh
343 </p>
344 </div>
345 }
346 />
347 )}
348 </div>
349 </div>
350 <div className="relative flex flex-col grow flex-1 overflow-auto">
351 <ShimmerLine active={isLoading} />
352 <LoadingOpacity active={isLoading}>
353 <LogTable
354 projectRef={projectRef}
355 isLoading={isLoading}
356 data={logData}
357 queryType={queryType}
358 isHistogramShowing={showChart}
359 onHistogramToggle={() => setShowChart(!showChart)}
360 error={error}
361 EmptyState={EmptyState}
362 onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
363 selectedLog={selectedLog}
364 isSelectedLogLoading={isSelectedLogLoading}
365 selectedLogError={selectedLogError ?? undefined}
366 />
367 </LoadingOpacity>
368 </div>
369 {!error && logData.length > 0 && (
370 <div className="border-t flex flex-row items-center gap-3 p-2">
371 <ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER} side="top">
372 <Button
373 onClick={loadOlder}
374 icon={<Rewind />}
375 type="default"
376 loading={isLoadingOlder}
377 disabled={isLoadingOlder}
378 >
379 Load older
380 </Button>
381 </ShortcutTooltip>
382 <div className="text-sm text-foreground-lighter">
383 Showing <span className="font-mono">{logData.length}</span> results
384 </div>
385 <div className="flex flex-row justify-end mt-2">
386 <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
387 </div>
388 </div>
389 )}
390 </div>
391 )
392}