import { ColumnFiltersState, getCoreRowModel, getFacetedRowModel, getFilteredRowModel, getSortedRowModel, getFacetedMinMaxValues as getTTableFacetedMinMaxValues, getFacetedUniqueValues as getTTableFacetedUniqueValues, Row, RowSelectionState, SortingState, Table, useReactTable, VisibilityState, } from '@tanstack/react-table' import { LOCAL_STORAGE_KEYS, useDebounce, useParams } from 'common' import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { useQueryStates } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' import { Button, ChartConfig, cn, ResizableHandle, ResizablePanel, ResizablePanelGroup, useIsMobile, } from 'ui' import { RefreshButton } from '../../ui/DataTable/RefreshButton' import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns' import { DownloadLogsButton } from './components/DownloadLogsButton' import { LogsFilterBar } from './components/LogsFilterBar' import { LogsListPanel } from './components/LogsListPanel' import { TooltipLabel } from './components/TooltipLabel' import { RowSelectionHeader } from './RowSelectionHeader' import { ServiceFlowPanel } from './ServiceFlowPanel' import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' import { ColumnSchema } from './UnifiedLogs.schema' import { QuerySearchParamsType } from './UnifiedLogs.types' import { getFacetedUniqueValues, getLevelRowClassName } from './UnifiedLogs.utils' import { LEVELS } from '@/components/ui/DataTable/DataTable.constants' import { Option } from '@/components/ui/DataTable/DataTable.types' import { arrSome, inDateRange } from '@/components/ui/DataTable/DataTable.utils' import { DataTableFilterControlsDrawer } from '@/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer' import { DataTableInfinite } from '@/components/ui/DataTable/DataTableInfinite' import { DataTableSideBarLayout } from '@/components/ui/DataTable/DataTableSideBarLayout' import { DataTableViewOptions } from '@/components/ui/DataTable/DataTableViewOptions' import { FilterSideBar } from '@/components/ui/DataTable/FilterSideBar' import { LiveButton } from '@/components/ui/DataTable/LiveButton' import { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider' import { TimelineChart } from '@/components/ui/DataTable/TimelineChart' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useUnifiedLogsChartQuery } from '@/data/logs/unified-logs-chart-query' import { useUnifiedLogsCountQuery } from '@/data/logs/unified-logs-count-query' import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useTrack } from '@/lib/telemetry/track' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' export const CHART_CONFIG = { success: { label: , color: 'hsl(var(--foreground-muted))', }, warning: { label: , color: 'hsl(var(--warning-default))', }, error: { label: , color: 'hsl(var(--destructive-default))', }, } satisfies ChartConfig export const UnifiedLogs = () => { useResetFocus() const { ref: projectRef } = useParams() const track = useTrack() const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) const { sort, start, size, id, cursor, direction, live, ...filter } = search const defaultColumnSorting = sort ? [sort] : [] const defaultColumnVisibility = { uuid: false } const defaultColumnFilters = Object.entries(filter) .map(([key, value]) => ({ id: key, value })) .filter(({ value }) => value ?? undefined) const [topBarHeight, setTopBarHeight] = useState(0) const topBarRef = useRef(null) useEffect(() => { const observer = new ResizeObserver(() => { const rect = topBarRef.current?.getBoundingClientRect() if (rect) setTopBarHeight(rect.height) }) const topBar = topBarRef.current if (!topBar) return observer.observe(topBar) return () => observer.unobserve(topBar) }, []) const [sorting, setSorting] = useState(defaultColumnSorting) const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) const [rowSelection, setRowSelection] = useState({}) const [openRowId, setOpenRowId] = useState(search.id ?? undefined) const [dock, setDock] = useLocalStorageQuery<'bottom' | 'right'>( LOCAL_STORAGE_KEYS.UNIFIED_LOGS_DOCK, 'bottom' ) const [columnVisibility, setColumnVisibility] = useLocalStorageQuery( 'data-table-visibility', defaultColumnVisibility ) const [columnOrder, setColumnOrder] = useLocalStorageQuery( 'data-table-column-order', [] ) // Create a stable query key object by removing nulls/undefined, id, and live // Mainly to prevent the react queries from unnecessarily re-fetching const searchParameters = useMemo( () => Object.entries(search).reduce( (acc, [key, value]) => { if (!['id', 'live'].includes(key) && value !== null && value !== undefined) { acc[key] = value } return acc }, {} as Record ) as QuerySearchParamsType, [search] ) const { data: unifiedLogsData, error, isError, isLoading, isFetching, isFetchingNextPage, isFetchingPreviousPage, hasNextPage, refetch: refetchLogs, fetchNextPage, fetchPreviousPage, } = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters }) const { data: counts, isPending: isLoadingCounts, isFetching: isFetchingCounts, refetch: refetchCounts, } = useUnifiedLogsCountQuery({ projectRef, search: searchParameters, }) const { data: unifiedLogsChart = [], isFetching: isFetchingCharts, refetch: refetchCharts, } = useUnifiedLogsChartQuery({ projectRef, search: searchParameters, }) const refetchAllData = () => { refetchLogs() refetchCounts() refetchCharts() } const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts // Only fade when filtering (not when loading more data or live mode) const isFetchingButNotPaginating = isFetching && !isFetchingNextPage && !isFetchingPreviousPage const rawFlatData = useMemo(() => { return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? [] }, [unifiedLogsData?.pages]) // [Joshen] Refer to unified-logs-infinite-query on why the need to deupe const flatData = useMemo(() => { return rawFlatData.filter((value, idx) => { return idx === rawFlatData.findIndex((x) => x.id === value.id) }) }, [rawFlatData]) const liveMode = useLiveMode(flatData) const totalDBRowCount = counts?.totalRowCount const filterDBRowCount = flatData.length const facets = counts?.facets const totalFetched = flatData?.length // Create a filtered version of the chart config based on selected levels const filteredChartConfig = useMemo(() => { const levelFilter = search.level || LEVELS return Object.fromEntries( Object.entries(CHART_CONFIG).filter(([key]) => levelFilter.includes(key as (typeof LEVELS)[number]) ) ) as ChartConfig }, [search.level]) const getRowClassName = < TData extends { date: Date; level: (typeof LEVELS)[number]; timestamp: number }, >( row: Row ) => { const rowTimestamp = row.original.timestamp const isPast = rowTimestamp <= (liveMode.timestamp || -1) const levelClassName = getLevelRowClassName(row.original.level) return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]') } // Generate dynamic columns based on current data const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => { return generateDynamicColumns({ data: flatData }) }, [flatData]) const table: Table = useReactTable({ data: flatData, columns: dynamicColumns, state: { columnFilters, sorting, columnVisibility: { ...dynamicColumnVisibility, ...columnVisibility }, rowSelection, columnOrder, }, enableMultiRowSelection: true, columnResizeMode: 'onChange', filterFns: { inDateRange, arrSome }, meta: { getRowClassName }, getRowId: (row) => row.id, onColumnVisibilityChange: setColumnVisibility, onColumnFiltersChange: setColumnFilters, onRowSelectionChange: setRowSelection, onSortingChange: setSorting, onColumnOrderChange: setColumnOrder, getSortedRowModel: getSortedRowModel(), getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getFacetedRowModel: getFacetedRowModel(), getFacetedUniqueValues: getTTableFacetedUniqueValues(), getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), }) const selectedRow = useMemo(() => { if ((isLoading || isFetching) && !flatData.length) return return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId) }, [isLoading, isFetching, flatData.length, table, openRowId]) // REMINDER: this is currently needed for the cmdk search // [Joshen] This is where facets are getting dynamically loaded // TODO: auto search via API when the user changes the filter instead of hardcoded // Will need to refactor this bit // - Each facet just handles its own state, rather than getting passed down like this const filterFields = useMemo(() => { return defaultFilterFields.map((field) => { const facetsField = facets?.[field.value] // If no facets data available, use the predefined field if (!facetsField) return field // For hardcoded enum fields, keep the predefined options (facets only used for counts) if (field.value === 'log_type' || field.value === 'method' || field.value === 'level') { return field } // For dynamic fields, use faceted options const options: Option[] = facetsField.rows.map(({ value }) => ({ label: `${value}`, value, })) return { ...field, options } }) }, [facets]) // Debounced filter application to avoid too many API calls when user clicks multiple filters quickly const applyFilterSearch = () => { const columnFiltersWithNullable = filterFields.map((field) => { const filterValue = columnFilters.find((filter) => filter.id === field.value) if (!filterValue) return { id: field.value, value: null } return { id: field.value, value: filterValue.value } }) const search = columnFiltersWithNullable.reduce( (prev, curr) => { // Add to search parameters prev[curr.id as string] = curr.value return prev }, {} as Record ) setSearch(search) } const debouncedApplyFilterSearch = useDebounce(applyFilterSearch, 250) useEffect(() => { debouncedApplyFilterSearch() }, [columnFilters, debouncedApplyFilterSearch]) useEffect(() => { setSearch({ sort: sorting?.[0] || null }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sorting]) useEffect(() => { if (isLoading || isFetching) return if (openRowId && !selectedRow) { // Clear both uuid and logId when the open row no longer exists in data setSearch({ id: null }) setOpenRowId(undefined) } else if (openRowId && selectedRow) { setSearch({ id: openRowId }) track('unified_logs_row_clicked', { logType: selectedRow.original.log_type }) } else if (!openRowId && search.id) { // Clear the URL parameter when no row is open setSearch({ id: null }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [openRowId, selectedRow, isLoading, isFetching]) const isMobile = useIsMobile() const [isFilterBarOpen, setIsFilterBarOpen] = useState(!isMobile) useShortcut(SHORTCUT_IDS.DATA_TABLE_TOGGLE_FILTERS, () => setIsFilterBarOpen((prev) => !prev)) useEffect(() => { if (isMobile) { setIsFilterBarOpen(false) } else { setIsFilterBarOpen(true) } }, [isMobile]) useEffect(() => { table.resetRowSelection() }, [searchParameters, table]) return (
div]:h-full', '[&_thead_th]:[border-top:none]! [&_thead_th]:[border-bottom:none]!', '[&_thead_th]:[box-shadow:inset_0_-1px_0_hsl(var(--border-default))]!', '[&_thead_th]:text-foreground-lighter! [&_thead_tr:hover]:bg-surface-75', '[&_thead_tr]:border-b-0! [&_tbody_tr]:border-b-0!' )} >
{!!openRowId && !!selectedRow && ( <> )}
) }