Files
supabase/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
Saxon FletcherandClaude Opus 5.5 7eb1e625de feat(studio): merge edge function Invocations into a scoped Logs tab
With the unified logs preview on, an edge function's Logs tab renders
the unified logs view scoped to that function: its invocations and
runtime output in one list.

- UnifiedLogs takes a `scope` prop. A scoped view drops the filter
  sidebar and the user filter, and only offers the date, log type,
  level and message filters
- The Invocations tab and its shortcut are hidden, and old Invocations
  links redirect to Logs with their time range and selected log
- getEdgeFunctionLogsUrl builds links into the scoped tab; invocation
  chart clicks use it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:59:53 +10:00

585 lines
20 KiB
TypeScript

import {
ColumnFiltersState,
getCoreRowModel,
getFacetedRowModel,
getFilteredRowModel,
getSortedRowModel,
getFacetedMinMaxValues as getTTableFacetedMinMaxValues,
getFacetedUniqueValues as getTTableFacetedUniqueValues,
Row,
SortingState,
Table,
useReactTable,
} from '@tanstack/react-table'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFeatureFlags, useFlag, useParams } from 'common'
import { Loader2, 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 { LogsTable } from './components/LogsTable'
import { TooltipLabel } from './components/TooltipLabel'
import { ServiceFlowPanel } from './ServiceFlowPanel'
import { EDGE_FUNCTION_FILTER_FIELDS, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
import { filterFields as defaultFilterFields } from './UnifiedLogs.fields'
import {
buildDefaultColumnFilters,
buildFilterSearchUpdate,
parseLogsFilterUrlParams,
} from './UnifiedLogs.filters'
import {
useFilterSearchSync,
useLiveMode,
useLogsTableColumns,
useResetFocus,
} from './UnifiedLogs.hooks'
import { isUserFilterUnreachable } from './UnifiedLogs.queries'
import { ColumnSchema } from './UnifiedLogs.schema'
import { UnifiedLogsScope } from './UnifiedLogs.types'
import {
gateLogTypeFilters,
gateLogTypeOptions,
getComputeLogsAvailability,
getFacetedUniqueValues,
getLogRowClassName,
getLogTypeVisibility,
getUniqueLogRows,
toQuerySearchParams,
} 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 { 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 { RowSelectionModifiers } from '@/components/ui/DataTable/rowSelection.utils'
import { TimelineChart } from '@/components/ui/DataTable/TimelineChart'
import { useTableRowSelection } from '@/components/ui/DataTable/useTableRowSelection'
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 { useShowMultigresLogs } from '@/hooks/misc/useShowMultigresLogs'
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: <TooltipLabel level="success" />,
color: 'var(--chart-muted)',
},
warning: {
label: <TooltipLabel level="warning" />,
color: 'var(--chart-status-warning)',
},
error: {
label: <TooltipLabel level="error" />,
color: 'hsl(var(--destructive-default))',
},
} satisfies ChartConfig
interface UnifiedLogsProps {
/** Narrows every query, count and filter to one resource, e.g. on an edge function's Logs tab */
scope?: UnifiedLogsScope
}
export const UnifiedLogs = ({ scope }: UnifiedLogsProps) => {
useResetFocus()
// A scoped view keeps its filters in the filter bar only
const hasFilterSideBar = !scope
const { ref: projectRef } = useParams()
const track = useTrack()
const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER)
const showMultigresLogs = useShowMultigresLogs()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const computeEnabled = !!useFlag('compute')
const computeAvailability = getComputeLogsAvailability({
isPlatform: IS_PLATFORM,
flagsLoaded,
computeEnabled,
})
const visibleSearchFilters = gateLogTypeFilters(
search.filter,
getLogTypeVisibility(
{
multigres: showMultigresLogs,
compute: computeAvailability.preserveComputeFilter,
},
scope
)
)
const defaultColumnSorting = search.sort ? [search.sort] : []
const defaultColumnFilters = buildDefaultColumnFilters({
...search,
filter: visibleSearchFilters,
})
const [topBarHeight, setTopBarHeight] = useState(0)
const topBarRef = useRef<HTMLDivElement>(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<SortingState>(defaultColumnSorting)
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(defaultColumnFilters)
const [dock, setDock] = useLocalStorageQuery<'bottom' | 'right'>(
LOCAL_STORAGE_KEYS.UNIFIED_LOGS_DOCK,
'bottom'
)
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
useLogsTableColumns()
// Callers usually pass a fresh scope object each render, so key memos on its value
const scopeFunctionId = scope?.functionId
// 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(() => {
const parameters = toQuerySearchParams(
search,
scopeFunctionId ? { functionId: scopeFunctionId } : undefined
)
if (parameters.filter) {
parameters.filter =
gateLogTypeFilters(
parameters.filter,
getLogTypeVisibility(
{
multigres: showMultigresLogs,
compute: computeAvailability.canQueryCompute,
},
parameters.scope
)
) ?? null
}
return parameters
}, [search, scopeFunctionId, showMultigresLogs, computeAvailability.canQueryCompute])
const queryableLogTypeVisibility = getLogTypeVisibility(
{
multigres: showMultigresLogs,
compute: computeAvailability.canQueryCompute,
},
searchParameters.scope
)
const { selection, selectRow, clearSelection } = useTableRowSelection({
scope: JSON.stringify([projectRef, searchParameters]),
initialId: search.id ?? undefined,
})
const rowSelection = selection.selected
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 flatData: ColumnSchema[] = useMemo(
() => getUniqueLogRows(unifiedLogsData?.pages),
[unifiedLogsData?.pages]
)
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 level filters in the URL.
const filteredChartConfig = useMemo(() => {
const levelFilters = parseLogsFilterUrlParams(search.filter).filter((f) => f.column === 'level')
const included = levelFilters.filter((f) => f.operator === '=').map((f) => f.value)
const excluded = new Set(levelFilters.filter((f) => f.operator === '<>').map((f) => f.value))
const baseLevels: readonly string[] = included.length > 0 ? included : LEVELS
const activeLevels = baseLevels.filter((l) => !excluded.has(l))
return Object.fromEntries(
Object.entries(CHART_CONFIG).filter(([key]) => activeLevels.includes(key))
) as ChartConfig
}, [search.filter])
const getRowClassName = (row: Row<ColumnSchema>) =>
getLogRowClassName(row.original, liveMode.timestamp)
// Generate dynamic columns based on current data
const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => {
return generateDynamicColumns({ data: flatData })
}, [flatData])
const table: Table<ColumnSchema> = 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,
onSortingChange: setSorting,
onColumnOrderChange: setColumnOrder,
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getTTableFacetedUniqueValues(),
getFacetedMinMaxValues: getTTableFacetedMinMaxValues(),
})
const selectedRows = table.getSelectedRowModel().rows
const selectedRow =
selectedRows.find((row) => row.id === selection.activeId) ?? selectedRows.at(-1)
const openRowId = selectedRow?.id
const handleSelectRow = (id: string, modifiers?: RowSelectionModifiers) => {
selectRow(
table.getRowModel().rows.map((row) => row.id),
id,
modifiers
)
}
const setOpenRowId = (id: string | undefined) => {
if (id) handleSelectRow(id)
else clearSelection()
}
// Will need to refactor this bit
// - Each facet just handles its own state, rather than getting passed down like this
const filterFields = useMemo(() => {
const scopeFilterFields: readonly string[] = EDGE_FUNCTION_FILTER_FIELDS
const availableFields = searchParameters.scope
? defaultFilterFields.filter((field) => scopeFilterFields.includes(field.value))
: defaultFilterFields
const gatedFields = gateLogTypeOptions(
availableFields,
getLogTypeVisibility(
{
multigres: showMultigresLogs,
compute: computeAvailability.canQueryCompute,
},
searchParameters.scope
)
)
return gatedFields.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') {
const fieldWithCounts = {
...field,
options: field.options.map((x) => {
return { ...x, count: facetsField.rows.find((y) => y.value === x.value)?.total ?? 0 }
}),
}
return fieldWithCounts
}
// For dynamic fields, use faceted options
const options: Option[] = facetsField.rows.map(({ value, total }) => ({
label: `${value}`,
value,
count: total,
}))
return { ...field, options }
})
}, [facets, searchParameters.scope, showMultigresLogs, computeAvailability.canQueryCompute])
const applyFilterSearch = () => {
const update = buildFilterSearchUpdate(columnFilters, filterFields)
if (Array.isArray(update.filter)) {
update.filter = gateLogTypeFilters(update.filter.map(String), queryableLogTypeVisibility)
}
setSearch(update)
}
useFilterSearchSync({
applyFilterSearch,
columnFilters,
enabled: computeAvailability.readyToSyncFilters,
})
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), {
enabled: hasFilterSideBar,
registerInCommandMenu: true,
})
useShortcut(SHORTCUT_IDS.UNIFIED_LOGS_CLEAR_FILTERS, () => table.resetColumnFilters(), {
enabled: columnFilters.length > 0,
registerInCommandMenu: true,
})
useEffect(() => {
if (isMobile) {
setIsFilterBarOpen(false)
} else {
setIsFilterBarOpen(true)
}
}, [isMobile])
return (
<DataTableProvider
table={table}
error={error}
columns={UNIFIED_LOGS_COLUMNS}
filterFields={filterFields}
columnFilters={columnFilters}
sorting={sorting}
rowSelection={rowSelection}
openRowId={openRowId}
setOpenRowId={setOpenRowId}
onSelectRow={handleSelectRow}
columnOrder={columnOrder}
columnVisibility={columnVisibility}
searchParameters={searchParameters}
enableColumnOrdering={true}
isFetching={isFetching}
isError={isError}
isLoading={isLoading}
isLoadingCounts={isLoadingCounts}
getFacetedUniqueValues={getFacetedUniqueValues(facets)}
>
<DataTableSideBarLayout topBarHeight={topBarHeight}>
<ResizablePanelGroup
orientation="horizontal"
autoSaveId={hasFilterSideBar ? 'logs-layout' : 'scoped-logs-layout'}
>
{hasFilterSideBar && (
<>
<FilterSideBar
isFilterBarOpen={isFilterBarOpen}
setIsFilterBarOpen={setIsFilterBarOpen}
dateRangeDisabled={{ after: new Date() }}
/>
<ResizableHandle withHandle />
</>
)}
<ResizablePanel
id="panel-right"
className="flex max-w-full flex-1 flex-col overflow-hidden"
>
<div ref={topBarRef} className="top-0 z-10 flex flex-col bg-background">
<div className="flex flex-wrap items-center gap-2 px-2 border-b">
{hasFilterSideBar && (
<>
<ShortcutTooltip
shortcutId={SHORTCUT_IDS.DATA_TABLE_TOGGLE_FILTERS}
side="bottom"
>
<Button
size="tiny"
variant="text"
icon={isFilterBarOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
onClick={() => setIsFilterBarOpen((prev) => !prev)}
className="hidden w-[26px] sm:flex"
aria-label={isFilterBarOpen ? 'Hide filters' : 'Show filters'}
/>
</ShortcutTooltip>
<div className="h-full border-r" />
</>
)}
<div className="order-first w-full min-w-0 sm:order-0 sm:w-auto sm:flex-1 py-2">
<LogsFilterBar />
</div>
{hasFilterSideBar && (
<div className="block sm:hidden">
<DataTableFilterControlsDrawer />
</div>
)}
<div className="ml-auto flex items-center gap-x-2">
<RefreshButton
isLoading={isRefetchingData}
onRefresh={refetchAllData}
shortcutId={SHORTCUT_IDS.UNIFIED_LOGS_REFRESH}
/>
<DataTableViewOptions />
<DownloadLogsButton searchParameters={searchParameters} />
{fetchPreviousPage ? (
<LiveButton
fetchPreviousPage={fetchPreviousPage}
searchParamsParser={SEARCH_PARAMS_PARSER}
/>
) : null}
</div>
</div>
{isLoading ? (
<div className="h-[60px] flex items-center justify-center">
<Loader2 size={14} className="animate-spin text-foreground-lighter" />
</div>
) : (
<TimelineChart
data={unifiedLogsChart}
className={cn(
'-mb-1.5 mt-1.5',
isFetchingCharts && 'opacity-60 transition-opacity duration-150'
)}
columnId="timestamp"
filterColumnId="date"
chartConfig={filteredChartConfig}
/>
)}
</div>
<ResizablePanelGroup
key="main-logs"
className="flex-1 border-t"
orientation={dock === 'bottom' ? 'vertical' : 'horizontal'}
>
<ResizablePanel
defaultSize="100"
minSize="10"
className={cn(
'bg',
isFetchingButNotPaginating && 'opacity-60 transition-opacity duration-150'
)}
>
<LogsTable
columns={UNIFIED_LOGS_COLUMNS}
totalRows={totalDBRowCount}
filterRows={filterDBRowCount}
totalRowsFetched={totalFetched}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
setColumnOrder={setColumnOrder}
setColumnVisibility={setColumnVisibility}
emptyStateMessage={
isUserFilterUnreachable(searchParameters) ? (
<div className="text-sm flex flex-col gap-y-1">
<p className="text-foreground-light">No results found</p>
<p className="text-foreground-lighter">
Filtering by user is only supported for Auth and API Gateway log types
</p>
</div>
) : undefined
}
/>
</ResizablePanel>
{!!openRowId && !!selectedRow && (
<>
{selectedRows.length === 1 && <LogsListPanel selectedRow={selectedRow} />}
<ServiceFlowPanel
dock={dock}
setDock={setDock}
selectedRows={selectedRows.map((row) => row.original)}
searchParameters={searchParameters}
/>
</>
)}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</DataTableSideBarLayout>
</DataTableProvider>
)
}