UnifiedLogs.tsx 18 KB

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