UnifiedLogs.tsx492 lines · main
| 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 | |
| 30 | import { RefreshButton } from '../../ui/DataTable/RefreshButton' |
| 31 | import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns' |
| 32 | import { DownloadLogsButton } from './components/DownloadLogsButton' |
| 33 | import { LogsFilterBar } from './components/LogsFilterBar' |
| 34 | import { LogsListPanel } from './components/LogsListPanel' |
| 35 | import { TooltipLabel } from './components/TooltipLabel' |
| 36 | import { RowSelectionHeader } from './RowSelectionHeader' |
| 37 | import { ServiceFlowPanel } from './ServiceFlowPanel' |
| 38 | import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' |
| 39 | import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' |
| 40 | import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' |
| 41 | import { ColumnSchema } from './UnifiedLogs.schema' |
| 42 | import { QuerySearchParamsType } from './UnifiedLogs.types' |
| 43 | import { getFacetedUniqueValues, getLevelRowClassName } from './UnifiedLogs.utils' |
| 44 | import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' |
| 45 | import { Option } from '@/components/ui/DataTable/DataTable.types' |
| 46 | import { arrSome, inDateRange } from '@/components/ui/DataTable/DataTable.utils' |
| 47 | import { DataTableFilterControlsDrawer } from '@/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer' |
| 48 | import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite' |
| 49 | import { DataTableSideBarLayout } from '@/components/ui/DataTable/DataTableSideBarLayout' |
| 50 | import { DataTableViewOptions } from '@/components/ui/DataTable/DataTableViewOptions' |
| 51 | import { FilterSideBar } from '@/components/ui/DataTable/FilterSideBar' |
| 52 | import { LiveButton } from '@/components/ui/DataTable/LiveButton' |
| 53 | import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider' |
| 54 | import { TimelineChart } from '@/components/ui/DataTable/TimelineChart' |
| 55 | import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' |
| 56 | import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query' |
| 57 | import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query' |
| 58 | import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query' |
| 59 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 60 | import { useTrack } from '@/lib/telemetry/track' |
| 61 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 62 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 63 | |
| 64 | export 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 | |
| 79 | export 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 | } |