UnifiedLogs.tsx492 lines · main
1import {
2 ColumnFiltersState,
3 getCoreRowModel,
4 getFacetedRowModel,
5 getFilteredRowModel,
6 getSortedRowModel,
7 getFacetedMinMaxValues as getTTableFacetedMinMaxValues,
8 getFacetedUniqueValues as getTTableFacetedUniqueValues,
9 Row,
10 RowSelectionState,
11 SortingState,
12 Table,
13 useReactTable,
14 VisibilityState,
15} from '@tanstack/react-table'
16import { LOCAL_STORAGE_KEYS, useDebounce, useParams } from 'common'
17import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
18import { useQueryStates } from 'nuqs'
19import { useEffect, useMemo, useRef, useState } from 'react'
20import {
21 Button,
22 ChartConfig,
23 cn,
24 ResizableHandle,
25 ResizablePanel,
26 ResizablePanelGroup,
27 useIsMobile,
28} from 'ui'
29
30import { RefreshButton } from '../../ui/DataTable/RefreshButton'
31import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns'
32import { DownloadLogsButton } from './components/DownloadLogsButton'
33import { LogsFilterBar } from './components/LogsFilterBar'
34import { LogsListPanel } from './components/LogsListPanel'
35import { TooltipLabel } from './components/TooltipLabel'
36import { RowSelectionHeader } from './RowSelectionHeader'
37import { ServiceFlowPanel } from './ServiceFlowPanel'
38import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
39import { filterFields as defaultFilterFields } from './UnifiedLogs.fields'
40import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks'
41import { ColumnSchema } from './UnifiedLogs.schema'
42import { QuerySearchParamsType } from './UnifiedLogs.types'
43import { getFacetedUniqueValues, getLevelRowClassName } from './UnifiedLogs.utils'
44import { LEVELS } from '@/components/ui/DataTable/DataTable.constants'
45import { Option } from '@/components/ui/DataTable/DataTable.types'
46import { arrSome, inDateRange } from '@/components/ui/DataTable/DataTable.utils'
47import { DataTableFilterControlsDrawer } from '@/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer'
48import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite'
49import { DataTableSideBarLayout } from '@/components/ui/DataTable/DataTableSideBarLayout'
50import { DataTableViewOptions } from '@/components/ui/DataTable/DataTableViewOptions'
51import { FilterSideBar } from '@/components/ui/DataTable/FilterSideBar'
52import { LiveButton } from '@/components/ui/DataTable/LiveButton'
53import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider'
54import { TimelineChart } from '@/components/ui/DataTable/TimelineChart'
55import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
56import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query'
57import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query'
58import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query'
59import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
60import { useTrack } from '@/lib/telemetry/track'
61import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
62import { useShortcut } from '@/state/shortcuts/useShortcut'
63
64export const CHART_CONFIG = {
65 success: {
66 label: <TooltipLabel level="success" />,
67 color: 'hsl(var(--foreground-muted))',
68 },
69 warning: {
70 label: <TooltipLabel level="warning" />,
71 color: 'hsl(var(--warning-default))',
72 },
73 error: {
74 label: <TooltipLabel level="error" />,
75 color: 'hsl(var(--destructive-default))',
76 },
77} satisfies ChartConfig
78
79export const UnifiedLogs = () => {
80 useResetFocus()
81
82 const { ref: projectRef } = useParams()
83 const track = useTrack()
84 const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER)
85
86 const { sort, start, size, id, cursor, direction, live, ...filter } = search
87 const defaultColumnSorting = sort ? [sort] : []
88 const defaultColumnVisibility = { uuid: false }
89 const defaultColumnFilters = Object.entries(filter)
90 .map(([key, value]) => ({ id: key, value }))
91 .filter(({ value }) => value ?? undefined)
92
93 const [topBarHeight, setTopBarHeight] = useState(0)
94 const topBarRef = useRef<HTMLDivElement>(null)
95
96 useEffect(() => {
97 const observer = new ResizeObserver(() => {
98 const rect = topBarRef.current?.getBoundingClientRect()
99 if (rect) setTopBarHeight(rect.height)
100 })
101 const topBar = topBarRef.current
102 if (!topBar) return
103 observer.observe(topBar)
104 return () => observer.unobserve(topBar)
105 }, [])
106
107 const [sorting, setSorting] = useState<SortingState>(defaultColumnSorting)
108 const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(defaultColumnFilters)
109 const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
110 const [openRowId, setOpenRowId] = useState<string | undefined>(search.id ?? undefined)
111
112 const [dock, setDock] = useLocalStorageQuery<'bottom' | 'right'>(
113 LOCAL_STORAGE_KEYS.UNIFIED_LOGS_DOCK,
114 'bottom'
115 )
116
117 const [columnVisibility, setColumnVisibility] = useLocalStorageQuery<VisibilityState>(
118 'data-table-visibility',
119 defaultColumnVisibility
120 )
121 const [columnOrder, setColumnOrder] = useLocalStorageQuery<string[]>(
122 'data-table-column-order',
123 []
124 )
125
126 // Create a stable query key object by removing nulls/undefined, id, and live
127 // Mainly to prevent the react queries from unnecessarily re-fetching
128 const searchParameters = useMemo(
129 () =>
130 Object.entries(search).reduce(
131 (acc, [key, value]) => {
132 if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
133 acc[key] = value
134 }
135 return acc
136 },
137 {} as Record<string, unknown>
138 ) as QuerySearchParamsType,
139 [search]
140 )
141
142 const {
143 data: unifiedLogsData,
144 error,
145 isError,
146 isLoading,
147 isFetching,
148 isFetchingNextPage,
149 isFetchingPreviousPage,
150 hasNextPage,
151 refetch: refetchLogs,
152 fetchNextPage,
153 fetchPreviousPage,
154 } = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters })
155
156 const {
157 data: counts,
158 isPending: isLoadingCounts,
159 isFetching: isFetchingCounts,
160 refetch: refetchCounts,
161 } = useUnifiedLogsCountQuery({
162 projectRef,
163 search: searchParameters,
164 })
165
166 const {
167 data: unifiedLogsChart = [],
168 isFetching: isFetchingCharts,
169 refetch: refetchCharts,
170 } = useUnifiedLogsChartQuery({
171 projectRef,
172 search: searchParameters,
173 })
174
175 const refetchAllData = () => {
176 refetchLogs()
177 refetchCounts()
178 refetchCharts()
179 }
180
181 const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts
182
183 // Only fade when filtering (not when loading more data or live mode)
184 const isFetchingButNotPaginating = isFetching && !isFetchingNextPage && !isFetchingPreviousPage
185
186 const rawFlatData = useMemo(() => {
187 return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? []
188 }, [unifiedLogsData?.pages])
189 // [Joshen] Refer to unified-logs-infinite-query on why the need to deupe
190 const flatData = useMemo(() => {
191 return rawFlatData.filter((value, idx) => {
192 return idx === rawFlatData.findIndex((x) => x.id === value.id)
193 })
194 }, [rawFlatData])
195 const liveMode = useLiveMode(flatData)
196
197 const totalDBRowCount = counts?.totalRowCount
198 const filterDBRowCount = flatData.length
199
200 const facets = counts?.facets
201 const totalFetched = flatData?.length
202
203 // Create a filtered version of the chart config based on selected levels
204 const filteredChartConfig = useMemo(() => {
205 const levelFilter = search.level || LEVELS
206 return Object.fromEntries(
207 Object.entries(CHART_CONFIG).filter(([key]) =>
208 levelFilter.includes(key as (typeof LEVELS)[number])
209 )
210 ) as ChartConfig
211 }, [search.level])
212
213 const getRowClassName = <
214 TData extends { date: Date; level: (typeof LEVELS)[number]; timestamp: number },
215 >(
216 row: Row<TData>
217 ) => {
218 const rowTimestamp = row.original.timestamp
219 const isPast = rowTimestamp <= (liveMode.timestamp || -1)
220 const levelClassName = getLevelRowClassName(row.original.level)
221 return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]')
222 }
223
224 // Generate dynamic columns based on current data
225 const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => {
226 return generateDynamicColumns({ data: flatData })
227 }, [flatData])
228
229 const table: Table<ColumnSchema> = useReactTable({
230 data: flatData,
231 columns: dynamicColumns,
232 state: {
233 columnFilters,
234 sorting,
235 columnVisibility: { ...dynamicColumnVisibility, ...columnVisibility },
236 rowSelection,
237 columnOrder,
238 },
239 enableMultiRowSelection: true,
240 columnResizeMode: 'onChange',
241 filterFns: { inDateRange, arrSome },
242 meta: { getRowClassName },
243 getRowId: (row) => row.id,
244 onColumnVisibilityChange: setColumnVisibility,
245 onColumnFiltersChange: setColumnFilters,
246 onRowSelectionChange: setRowSelection,
247 onSortingChange: setSorting,
248 onColumnOrderChange: setColumnOrder,
249 getSortedRowModel: getSortedRowModel(),
250 getCoreRowModel: getCoreRowModel(),
251 getFilteredRowModel: getFilteredRowModel(),
252 getFacetedRowModel: getFacetedRowModel(),
253 getFacetedUniqueValues: getTTableFacetedUniqueValues(),
254 getFacetedMinMaxValues: getTTableFacetedMinMaxValues(),
255 })
256
257 const selectedRow = useMemo(() => {
258 if ((isLoading || isFetching) && !flatData.length) return
259 return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId)
260 }, [isLoading, isFetching, flatData.length, table, openRowId])
261
262 // REMINDER: this is currently needed for the cmdk search
263 // [Joshen] This is where facets are getting dynamically loaded
264 // TODO: auto search via API when the user changes the filter instead of hardcoded
265
266 // Will need to refactor this bit
267 // - Each facet just handles its own state, rather than getting passed down like this
268 const filterFields = useMemo(() => {
269 return defaultFilterFields.map((field) => {
270 const facetsField = facets?.[field.value]
271
272 // If no facets data available, use the predefined field
273 if (!facetsField) return field
274
275 // For hardcoded enum fields, keep the predefined options (facets only used for counts)
276 if (field.value === 'log_type' || field.value === 'method' || field.value === 'level') {
277 return field
278 }
279
280 // For dynamic fields, use faceted options
281 const options: Option[] = facetsField.rows.map(({ value }) => ({
282 label: `${value}`,
283 value,
284 }))
285
286 return { ...field, options }
287 })
288 }, [facets])
289
290 // Debounced filter application to avoid too many API calls when user clicks multiple filters quickly
291 const applyFilterSearch = () => {
292 const columnFiltersWithNullable = filterFields.map((field) => {
293 const filterValue = columnFilters.find((filter) => filter.id === field.value)
294 if (!filterValue) return { id: field.value, value: null }
295 return { id: field.value, value: filterValue.value }
296 })
297
298 const search = columnFiltersWithNullable.reduce(
299 (prev, curr) => {
300 // Add to search parameters
301 prev[curr.id as string] = curr.value
302 return prev
303 },
304 {} as Record<string, unknown>
305 )
306
307 setSearch(search)
308 }
309
310 const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250)
311
312 useEffect(() => {
313 debouncedApplyFilterSearch()
314 }, [columnFilters, debouncedApplyFilterSearch])
315
316 useEffect(() => {
317 setSearch({ sort: sorting?.[0] || null })
318 // eslint-disable-next-line react-hooks/exhaustive-deps
319 }, [sorting])
320
321 useEffect(() => {
322 if (isLoading || isFetching) return
323
324 if (openRowId && !selectedRow) {
325 // Clear both uuid and logId when the open row no longer exists in data
326 setSearch({ id: null })
327 setOpenRowId(undefined)
328 } else if (openRowId && selectedRow) {
329 setSearch({ id: openRowId })
330 track('unified_logs_row_clicked', { logType: selectedRow.original.log_type })
331 } else if (!openRowId && search.id) {
332 // Clear the URL parameter when no row is open
333 setSearch({ id: null })
334 }
335 // eslint-disable-next-line react-hooks/exhaustive-deps
336 }, [openRowId, selectedRow, isLoading, isFetching])
337
338 const isMobile = useIsMobile()
339 const [isFilterBarOpen, setIsFilterBarOpen] = useState(!isMobile)
340
341 useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_FILTERS, () => setIsFilterBarOpen((prev) => !prev))
342
343 useEffect(() => {
344 if (isMobile) {
345 setIsFilterBarOpen(false)
346 } else {
347 setIsFilterBarOpen(true)
348 }
349 }, [isMobile])
350
351 useEffect(() => {
352 table.resetRowSelection()
353 }, [searchParameters, table])
354
355 return (
356 <DataTableProvider
357 table={table}
358 error={error}
359 columns={UNIFIED_LOGS_COLUMNS}
360 filterFields={filterFields}
361 columnFilters={columnFilters}
362 sorting={sorting}
363 rowSelection={rowSelection}
364 openRowId={openRowId}
365 setOpenRowId={setOpenRowId}
366 columnOrder={columnOrder}
367 columnVisibility={columnVisibility}
368 searchParameters={searchParameters}
369 enableColumnOrdering={true}
370 isFetching={isFetching}
371 isError={isError}
372 isLoading={isLoading}
373 isLoadingCounts={isLoadingCounts}
374 getFacetedUniqueValues={getFacetedUniqueValues(facets)}
375 >
376 <DataTableSideBarLayout topBarHeight={topBarHeight}>
377 <ResizablePanelGroup orientation="horizontal" autoSaveId="logs-layout">
378 <FilterSideBar
379 isFilterBarOpen={isFilterBarOpen}
380 setIsFilterBarOpen={setIsFilterBarOpen}
381 dateRangeDisabled={{ after: new Date() }}
382 />
383 <ResizableHandle withHandle />
384 <ResizablePanel
385 id="panel-right"
386 className="flex max-w-full flex-1 flex-col overflow-hidden"
387 >
388 <div ref={topBarRef} className="top-0 z-10 flex flex-col bg-background">
389 <div className="flex flex-wrap items-center gap-2 px-2 border-b">
390 <ShortcutTooltip shortcutId={SHORTCUT_IDS.DATA_TABLE_TOGGLE_FILTERS} side="bottom">
391 <Button
392 size="tiny"
393 type="text"
394 icon={isFilterBarOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
395 onClick={() => setIsFilterBarOpen((prev) => !prev)}
396 className="hidden w-[26px] sm:flex"
397 aria-label={isFilterBarOpen ? 'Hide filters' : 'Show filters'}
398 />
399 </ShortcutTooltip>
400
401 <div className="h-full border-r" />
402
403 <div className="order-first w-full min-w-0 sm:order-0 sm:w-auto sm:flex-1 py-2">
404 <LogsFilterBar />
405 </div>
406
407 <div className="block sm:hidden">
408 <DataTableFilterControlsDrawer />
409 </div>
410
411 <div className="ml-auto flex items-center gap-x-2">
412 <RefreshButton isLoading={isRefetchingData} onRefresh={refetchAllData} />
413 <DataTableViewOptions />
414 <DownloadLogsButton searchParameters={searchParameters} />
415 {fetchPreviousPage ? (
416 <LiveButton
417 fetchPreviousPage={fetchPreviousPage}
418 searchParamsParser={SEARCH_PARAMS_PARSER}
419 />
420 ) : null}
421 </div>
422 </div>
423
424 <TimelineChart
425 data={unifiedLogsChart}
426 className={cn(
427 '-mb-1.5 mt-1.5',
428 isFetchingCharts && 'opacity-60 transition-opacity duration-150'
429 )}
430 columnId="timestamp"
431 filterColumnId="date"
432 chartConfig={filteredChartConfig}
433 />
434 </div>
435
436 <RowSelectionHeader />
437
438 <ResizablePanelGroup
439 key="main-logs"
440 className="flex-1 border-t"
441 orientation={dock === 'bottom' ? 'vertical' : 'horizontal'}
442 >
443 <ResizablePanel
444 defaultSize="100"
445 minSize="10"
446 className={cn(
447 'bg',
448 isFetchingButNotPaginating && 'opacity-60 transition-opacity duration-150'
449 )}
450 >
451 <div
452 className={cn(
453 'h-full [&>div]:h-full',
454 '[&_thead_th]:[border-top:none]! [&_thead_th]:[border-bottom:none]!',
455 '[&_thead_th]:[box-shadow:inset_0_-1px_0_hsl(var(--border-default))]!',
456 '[&_thead_th]:text-foreground-lighter! [&_thead_tr:hover]:bg-surface-75',
457 '[&_thead_tr]:border-b-0! [&_tbody_tr]:border-b-0!'
458 )}
459 >
460 <DataTableInfinite
461 columns={UNIFIED_LOGS_COLUMNS}
462 totalRows={totalDBRowCount}
463 filterRows={filterDBRowCount}
464 totalRowsFetched={totalFetched}
465 fetchNextPage={fetchNextPage}
466 hasNextPage={hasNextPage}
467 setColumnOrder={setColumnOrder}
468 setColumnVisibility={setColumnVisibility}
469 searchParamsParser={SEARCH_PARAMS_PARSER}
470 />
471 </div>
472 </ResizablePanel>
473
474 {!!openRowId && !!selectedRow && (
475 <>
476 <LogsListPanel selectedRow={selectedRow} />
477 <ServiceFlowPanel
478 dock={dock}
479 setDock={setDock}
480 selectedRow={selectedRow?.original}
481 selectedRowKey={openRowId}
482 searchParameters={searchParameters}
483 />
484 </>
485 )}
486 </ResizablePanelGroup>
487 </ResizablePanel>
488 </ResizablePanelGroup>
489 </DataTableSideBarLayout>
490 </DataTableProvider>
491 )
492}