mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 02:15:05 +03:00
unify worker logs in single tab
This commit is contained in:
14 files changed
+368
-366
No files matched your search
-22
@@ -1,22 +0,0 @@
|
||||
import { Column } from 'react-data-grid'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import type { LogData } from '../Logs.types'
|
||||
import { RowLayout, SeverityFormatter, TextFormatter } from '../LogsFormatters'
|
||||
|
||||
export const WorkersLogsColumnRender: Column<LogData>[] = [
|
||||
{
|
||||
name: 'workers-logs-first-column',
|
||||
key: 'workers-logs-first-column',
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (props) => (
|
||||
<RowLayout>
|
||||
<TimestampInfo utcTimestamp={props.row.timestamp!} />
|
||||
{props.row.severity_text ? (
|
||||
<SeverityFormatter value={props.row.severity_text as string} />
|
||||
) : null}
|
||||
<TextFormatter className="w-full" value={props.row.event_message} />
|
||||
</RowLayout>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChartConfig } from 'ui'
|
||||
|
||||
import { TooltipLabel } from './components/TooltipLabel'
|
||||
|
||||
// Extracted from UnifiedLogs.tsx so it can be reused by other timeline charts
|
||||
// (e.g. the embedded Worker logs tab) without pulling in the full page module.
|
||||
export const CHART_CONFIG = {
|
||||
success: {
|
||||
label: <TooltipLabel level="success" />,
|
||||
color: 'var(--chart-success)',
|
||||
},
|
||||
warning: {
|
||||
label: <TooltipLabel level="warning" />,
|
||||
color: 'var(--chart-warning)',
|
||||
},
|
||||
error: {
|
||||
label: <TooltipLabel level="error" />,
|
||||
color: 'hsl(var(--destructive-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
@@ -91,6 +91,9 @@ export const SEARCH_PARAMS_PARSER = {
|
||||
edge_auth: parseAsBoolean.withDefault(true),
|
||||
edge_storage: parseAsBoolean.withDefault(true),
|
||||
edge_postgrest: parseAsBoolean.withDefault(true),
|
||||
worker_requests: parseAsBoolean.withDefault(true),
|
||||
worker_output: parseAsBoolean.withDefault(true),
|
||||
worker_builds: parseAsBoolean.withDefault(true),
|
||||
|
||||
// Support searching for user against user ID
|
||||
// See userAttributionCondition in UnifiedLogs.queries.ts.
|
||||
|
||||
@@ -36,7 +36,13 @@ export const filterFields = [
|
||||
]
|
||||
: value === 'postgres'
|
||||
? [{ label: 'Connection logs', value: 'show_connection_logs' }]
|
||||
: [],
|
||||
: value === 'workers'
|
||||
? [
|
||||
{ label: 'Invocations', value: 'worker_requests' },
|
||||
{ label: 'Logs', value: 'worker_output' },
|
||||
{ label: 'Activity', value: 'worker_builds' },
|
||||
]
|
||||
: [],
|
||||
})),
|
||||
component: (props: Option) => {
|
||||
return (
|
||||
|
||||
@@ -181,6 +181,28 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
|
||||
expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['worker_requests', 'worker_ingress_logs'],
|
||||
['worker_output', 'worker_guest_logs'],
|
||||
['worker_builds', 'worker_api_logs'],
|
||||
] as const)('excludes the %s stream from the Workers log type when %s=false', (key, source) => {
|
||||
const sql = getUnifiedLogsQuery({ ...baseSearch, [key]: false } as any)
|
||||
expect(sql).toContain(`log_attributes['source'] != '${source}'`)
|
||||
})
|
||||
|
||||
it('does not filter worker streams by default (all worker_* toggles true)', () => {
|
||||
const sql = getUnifiedLogsQuery(baseSearch)
|
||||
expect(sql).not.toContain("log_attributes['source'] != 'worker_ingress_logs'")
|
||||
expect(sql).not.toContain("log_attributes['source'] != 'worker_guest_logs'")
|
||||
expect(sql).not.toContain("log_attributes['source'] != 'worker_api_logs'")
|
||||
})
|
||||
|
||||
it('scopes to a single worker via the generic log_attributes fallback', () => {
|
||||
const sql = getUnifiedLogsQuery(withFilters('log_type:eq:workers', 'worker:eq:fran-worker'))
|
||||
const where = sql.split(/\bWHERE\b/)[1] ?? ''
|
||||
expect(where).toContain(`log_attributes['worker'] IN ('fran-worker')`)
|
||||
})
|
||||
|
||||
it('leaves dedicated auth_logs/storage_logs/postgrest_logs rows untouched by the edge_* toggles', () => {
|
||||
// These toggles only hide traffic nested inside the `edge_logs` (API
|
||||
// Gateway) source — the dedicated sources are separate log types now
|
||||
|
||||
@@ -335,6 +335,18 @@ const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_post
|
||||
edge_postgrest: '%/rest/%',
|
||||
}
|
||||
|
||||
// Maps each worker stream's view-option toggle to the OTEL `log_attributes['source']`
|
||||
// value it hides. Mirrors EDGE_SERVICE_PATH_FILTER above, but for the three streams
|
||||
// (Invocations/Logs/Activity) nested under the `workers` log type.
|
||||
const WORKER_STREAM_VIEW_OPTION: Record<
|
||||
'worker_requests' | 'worker_output' | 'worker_builds',
|
||||
string
|
||||
> = {
|
||||
worker_requests: WORKER_LOG_SOURCES.requests,
|
||||
worker_output: WORKER_LOG_SOURCES.output,
|
||||
worker_builds: WORKER_LOG_SOURCES.builds,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns view-option WHERE conditions — toggles from the filter sidebar that
|
||||
* hide a subset of rows without being a `filter` URL param (Postgres
|
||||
@@ -424,6 +436,14 @@ const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragm
|
||||
}
|
||||
}
|
||||
|
||||
// Visible by default — only an explicit `false` hides that stream within
|
||||
// the Workers log type (Invocations/Logs/Activity).
|
||||
for (const key of ['worker_requests', 'worker_output', 'worker_builds'] as const) {
|
||||
if (search[key] === false) {
|
||||
conditions.push(safeSql`log_attributes['source'] != ${lit(WORKER_STREAM_VIEW_OPTION[key])}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return null
|
||||
return safeSql`(${joinSqlFragments(conditions, ' AND ')})`
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@ import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Colum
|
||||
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 { CHART_CONFIG } from './UnifiedLogs.chart-config'
|
||||
import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants'
|
||||
import { filterFields as defaultFilterFields } from './UnifiedLogs.fields'
|
||||
import {
|
||||
@@ -74,21 +74,6 @@ 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-success)',
|
||||
},
|
||||
warning: {
|
||||
label: <TooltipLabel level="warning" />,
|
||||
color: 'var(--chart-warning)',
|
||||
},
|
||||
error: {
|
||||
label: <TooltipLabel level="error" />,
|
||||
color: 'hsl(var(--destructive-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const UnifiedLogs = () => {
|
||||
useResetFocus()
|
||||
|
||||
|
||||
@@ -52,6 +52,22 @@ describe('buildUnifiedLogsUrl', () => {
|
||||
)
|
||||
expect(params.has('date')).toBe(false)
|
||||
})
|
||||
|
||||
it('appends extraFilters alongside the log_type filter', () => {
|
||||
const { params } = parse(
|
||||
buildUnifiedLogsUrl({
|
||||
projectRef: 'abc',
|
||||
logType: 'workers',
|
||||
extraFilters: ['worker:eq:fran-worker'],
|
||||
})
|
||||
)
|
||||
expect(params.getAll('filter')).toEqual(['log_type:eq:workers', 'worker:eq:fran-worker'])
|
||||
})
|
||||
|
||||
it('sets the id param to pre-select a row', () => {
|
||||
const { params } = parse(buildUnifiedLogsUrl({ projectRef: 'abc', id: 'row-123' }))
|
||||
expect(params.get('id')).toBe('row-123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMultigresEventMessage', () => {
|
||||
|
||||
@@ -34,6 +34,8 @@ export const buildUnifiedLogsUrl = ({
|
||||
user,
|
||||
start,
|
||||
end,
|
||||
extraFilters,
|
||||
id,
|
||||
}: {
|
||||
projectRef: string
|
||||
logType?: UnifiedLogType
|
||||
@@ -41,13 +43,19 @@ export const buildUnifiedLogsUrl = ({
|
||||
user?: string
|
||||
start?: string | Date
|
||||
end?: string | Date
|
||||
/** Additional raw `column:opAbbrev:value` filter strings, appended alongside `logType`. */
|
||||
extraFilters?: string[]
|
||||
/** Pre-selects this row so its detail panel opens as soon as the page loads. */
|
||||
id?: string
|
||||
}) => {
|
||||
const params = new URLSearchParams()
|
||||
if (logType) params.append('filter', `log_type:eq:${logType}`)
|
||||
extraFilters?.forEach((filter) => params.append('filter', filter))
|
||||
if (user) params.set('user', user)
|
||||
if (start && end) {
|
||||
params.set('date', `${new Date(start).valueOf()}-${new Date(end).valueOf()}`)
|
||||
}
|
||||
if (id) params.set('id', id)
|
||||
return `/project/${projectRef}/logs?${params.toString()}`
|
||||
}
|
||||
|
||||
|
||||
@@ -31,24 +31,15 @@ import { WorkerStatePill } from '../WorkerStatePill'
|
||||
import { WorkerLogsTab } from './WorkerLogsTab'
|
||||
import { WorkerOverviewTab } from './WorkerOverviewTab'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import type { WorkerLogStream } from '@/data/workers/worker-logs-query'
|
||||
import { workerQueryOptions } from '@/data/workers/worker-query'
|
||||
import { PRODUCT_NAME } from '@/lib/constants/workers'
|
||||
|
||||
type WorkerTab = 'overview' | 'invocations' | 'logs' | 'activity'
|
||||
const WORKER_TABS: WorkerTab[] = ['overview', 'invocations', 'logs', 'activity']
|
||||
type WorkerTab = 'overview' | 'logs'
|
||||
const WORKER_TABS: WorkerTab[] = ['overview', 'logs']
|
||||
|
||||
const TAB_LABEL: Record<WorkerTab, string> = {
|
||||
overview: 'Overview',
|
||||
invocations: 'Invocations',
|
||||
logs: 'Logs',
|
||||
activity: 'Activity',
|
||||
}
|
||||
|
||||
const TAB_STREAM: Partial<Record<WorkerTab, WorkerLogStream>> = {
|
||||
invocations: 'requests',
|
||||
logs: 'output',
|
||||
activity: 'builds',
|
||||
}
|
||||
|
||||
export const WorkerDetail = () => {
|
||||
@@ -85,8 +76,6 @@ export const WorkerDetail = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const stream = TAB_STREAM[tab]
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-full flex flex-col items-stretch">
|
||||
<PageBreadcrumbs>
|
||||
@@ -138,9 +127,9 @@ export const WorkerDetail = () => {
|
||||
</PageNav>
|
||||
|
||||
{tab === 'overview' && <WorkerOverviewTab worker={worker} />}
|
||||
{stream !== undefined && (
|
||||
{tab === 'logs' && (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<WorkerLogsTab key={stream} workerName={worker.name} stream={stream} />
|
||||
<WorkerLogsTab workerName={worker.name} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,130 +1,293 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ColumnFiltersState,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFilteredRowModel,
|
||||
getFacetedMinMaxValues as getTTableFacetedMinMaxValues,
|
||||
getFacetedUniqueValues as getTTableFacetedUniqueValues,
|
||||
Row,
|
||||
Table,
|
||||
useReactTable,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import { useParams } from 'common'
|
||||
import { RefreshCw, Search } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button, InputGroup, InputGroupAddon, InputGroupInput } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { ExternalLink, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { WorkerCommandLine } from '../WorkerCommandLine'
|
||||
import { WorkersLogsColumnRender } from '@/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender'
|
||||
import { EXPLORER_DATEPICKER_HELPERS } from '@/components/interfaces/Settings/Logs/Logs.constants'
|
||||
import {
|
||||
LogsDatePicker,
|
||||
type DatePickerValue,
|
||||
} from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
generateDynamicColumns,
|
||||
UNIFIED_LOGS_COLUMNS,
|
||||
} from '@/components/interfaces/UnifiedLogs/components/Columns'
|
||||
import { LogsFilterBar } from '@/components/interfaces/UnifiedLogs/components/LogsFilterBar'
|
||||
import { CHART_CONFIG } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config'
|
||||
import {
|
||||
WORKER_LOG_STREAM_LABEL,
|
||||
workerLogsQueryOptions,
|
||||
type WorkerLogStream,
|
||||
} from '@/data/workers/worker-logs-query'
|
||||
import { useDebouncedValue } from '@/hooks/misc/useDebouncedValue'
|
||||
LOG_TYPES,
|
||||
SEARCH_PARAMS_PARSER,
|
||||
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.constants'
|
||||
import { filterFields as defaultFilterFields } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.fields'
|
||||
import {
|
||||
buildDefaultColumnFilters,
|
||||
buildFilterSearchUpdate,
|
||||
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.filters'
|
||||
import { useFilterSearchSync } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.hooks'
|
||||
import { ColumnSchema } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.schema'
|
||||
import { QuerySearchParamsType } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.types'
|
||||
import {
|
||||
buildUnifiedLogsUrl,
|
||||
gateLogTypeOptions,
|
||||
getFacetedUniqueValues,
|
||||
getLevelRowClassName,
|
||||
type UnifiedLogType,
|
||||
} from '@/components/interfaces/UnifiedLogs/UnifiedLogs.utils'
|
||||
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 { DataTableProvider } from '@/components/ui/DataTable/providers/DataTableProvider'
|
||||
import { RefreshButton } from '@/components/ui/DataTable/RefreshButton'
|
||||
import { TimelineChart } from '@/components/ui/DataTable/TimelineChart'
|
||||
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 { CLI_NAME } from '@/lib/constants/workers'
|
||||
|
||||
interface WorkerLogsTabProps {
|
||||
workerName: string
|
||||
stream: WorkerLogStream
|
||||
}
|
||||
|
||||
const defaultDateRange = (): DatePickerValue => {
|
||||
const helper = EXPLORER_DATEPICKER_HELPERS.find((helper) => helper.text === 'Last 24 hours')!
|
||||
// This tab is always scoped to a single worker, so every log type other than
|
||||
// `workers` is irrelevant — restrict the shared Log Type filter down to just that
|
||||
// one option (with its nested Invocations/Logs/Activity stream toggles).
|
||||
const WORKERS_ONLY_VISIBILITY = LOG_TYPES.reduce<Partial<Record<UnifiedLogType, boolean>>>(
|
||||
(acc, type) => ({ ...acc, [type]: type === 'workers' }),
|
||||
{}
|
||||
)
|
||||
|
||||
return {
|
||||
from: helper.calcFrom(),
|
||||
to: helper.calcTo(),
|
||||
isHelper: true,
|
||||
text: helper.text,
|
||||
}
|
||||
}
|
||||
// Worker rows always have `level`/`method`/`status`/`pathname` forced to null
|
||||
// (see the worker source condition in UnifiedLogs.queries.ts), so those filters
|
||||
// would only ever be dead ends here — only keep what's actually usable.
|
||||
const WORKER_FILTER_FIELD_VALUES = new Set(['date', 'log_type', 'event_message'])
|
||||
|
||||
export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
|
||||
const WORKER_FILTER_FIELDS = gateLogTypeOptions(
|
||||
defaultFilterFields.filter((field) => WORKER_FILTER_FIELD_VALUES.has(field.value)),
|
||||
WORKERS_ONLY_VISIBILITY
|
||||
)
|
||||
|
||||
const seedColumnFilters = (search: QuerySearchParamsType): ColumnFiltersState => [
|
||||
...buildDefaultColumnFilters(search).filter((f) => f.id !== 'log_type'),
|
||||
{ id: 'log_type', value: ['workers'] },
|
||||
]
|
||||
|
||||
export const WorkerLogsTab = ({ workerName }: WorkerLogsTabProps) => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef } = useParams()
|
||||
const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DatePickerValue>(defaultDateRange)
|
||||
const [message, setMessage] = useState('')
|
||||
const debouncedMessage = useDebouncedValue(message, 300)
|
||||
const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER)
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(() =>
|
||||
seedColumnFilters(search)
|
||||
)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ select: false })
|
||||
const [columnOrder, setColumnOrder] = useState<string[]>([])
|
||||
|
||||
// Always scope the query to this worker's rows — regardless of what's reflected in
|
||||
// the (read-only, single-option) Log Type filter above — so an accidental change
|
||||
// to that filter's state can never widen the query beyond this worker.
|
||||
const searchParameters = useMemo(() => {
|
||||
const parameters = Object.entries(search).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (!['id', 'live'].includes(key) && value !== null && value !== undefined) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, unknown>
|
||||
) as QuerySearchParamsType
|
||||
|
||||
return {
|
||||
...parameters,
|
||||
filter: [...(parameters.filter ?? []), 'log_type:eq:workers', `worker:eq:${workerName}`],
|
||||
}
|
||||
}, [search, workerName])
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
data: unifiedLogsData,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
workerLogsQueryOptions({
|
||||
projectRef,
|
||||
name: workerName,
|
||||
stream,
|
||||
iso_timestamp_start: dateRange.from,
|
||||
iso_timestamp_end: dateRange.to,
|
||||
message: debouncedMessage,
|
||||
})
|
||||
)
|
||||
hasNextPage,
|
||||
refetch: refetchLogs,
|
||||
fetchNextPage,
|
||||
} = useUnifiedLogsInfiniteQuery({ projectRef, search: searchParameters })
|
||||
|
||||
const label = WORKER_LOG_STREAM_LABEL[stream].toLowerCase()
|
||||
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
|
||||
|
||||
const rawFlatData = useMemo(() => {
|
||||
return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? []
|
||||
}, [unifiedLogsData?.pages])
|
||||
// [Joshen] Refer to unified-logs-infinite-query on why the need to dedupe
|
||||
const flatData = useMemo(() => {
|
||||
return rawFlatData.filter(
|
||||
(value, idx) => idx === rawFlatData.findIndex((x) => x.id === value.id)
|
||||
)
|
||||
}, [rawFlatData])
|
||||
|
||||
const totalDBRowCount = counts?.totalRowCount
|
||||
const filterDBRowCount = flatData.length
|
||||
const totalFetched = flatData.length
|
||||
const facets = counts?.facets
|
||||
|
||||
const getRowClassName = (row: Row<ColumnSchema>) =>
|
||||
cn(getLevelRowClassName(row.original.level), 'h-[30px]')
|
||||
|
||||
const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => {
|
||||
return generateDynamicColumns({ data: flatData })
|
||||
}, [flatData])
|
||||
|
||||
const table: Table<ColumnSchema> = useReactTable({
|
||||
data: flatData,
|
||||
columns: dynamicColumns,
|
||||
state: {
|
||||
columnFilters,
|
||||
columnVisibility: { ...dynamicColumnVisibility, ...columnVisibility },
|
||||
columnOrder,
|
||||
},
|
||||
columnResizeMode: 'onChange',
|
||||
filterFns: { inDateRange, arrSome },
|
||||
meta: { getRowClassName },
|
||||
getRowId: (row) => row.id,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getTTableFacetedUniqueValues(),
|
||||
getFacetedMinMaxValues: getTTableFacetedMinMaxValues(),
|
||||
})
|
||||
|
||||
const applyFilterSearch = () => {
|
||||
setSearch(buildFilterSearchUpdate(columnFilters, WORKER_FILTER_FIELDS))
|
||||
}
|
||||
|
||||
useFilterSearchSync({ applyFilterSearch, columnFilters, enabled: true })
|
||||
|
||||
// Rows open in the global Logs Explorer instead of an inline panel — this tab
|
||||
// stays a compact, worker-scoped stream rather than duplicating the full page.
|
||||
const handleOpenRow = (rowId: string | undefined) => {
|
||||
if (!rowId || !projectRef) return
|
||||
const row = table.getCoreRowModel().flatRows.find((r) => r.id === rowId)
|
||||
if (!row) return
|
||||
|
||||
const halfWindowMs = 15 * 60 * 1000
|
||||
router.push(
|
||||
buildUnifiedLogsUrl({
|
||||
projectRef,
|
||||
logType: 'workers',
|
||||
extraFilters: [`worker:eq:${workerName}`],
|
||||
start: new Date(row.original.date.getTime() - halfWindowMs),
|
||||
end: new Date(row.original.date.getTime() + halfWindowMs),
|
||||
id: row.original.id,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (!projectRef) return null
|
||||
|
||||
const openInLogsExplorerUrl = buildUnifiedLogsUrl({
|
||||
projectRef,
|
||||
logType: 'workers',
|
||||
extraFilters: [`worker:eq:${workerName}`],
|
||||
...(search.date?.length === 2 ? { start: search.date[0], end: search.date[1] } : {}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-default px-4 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LogsDatePicker
|
||||
hideWarnings
|
||||
value={dateRange}
|
||||
onSubmit={setDateRange}
|
||||
helpers={EXPLORER_DATEPICKER_HELPERS}
|
||||
align="start"
|
||||
<DataTableProvider
|
||||
table={table}
|
||||
error={error}
|
||||
columns={UNIFIED_LOGS_COLUMNS}
|
||||
filterFields={WORKER_FILTER_FIELDS}
|
||||
columnFilters={columnFilters}
|
||||
openRowId={undefined}
|
||||
setOpenRowId={handleOpenRow}
|
||||
columnOrder={columnOrder}
|
||||
columnVisibility={columnVisibility}
|
||||
searchParameters={searchParameters}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
isLoadingCounts={isLoadingCounts}
|
||||
getFacetedUniqueValues={getFacetedUniqueValues(facets)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-default px-4 py-2">
|
||||
<DataTableFilterControlsDrawer />
|
||||
<div className="order-first w-full min-w-0 sm:order-none sm:w-auto sm:flex-1">
|
||||
<LogsFilterBar />
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-x-2">
|
||||
<RefreshButton isLoading={isRefetchingData} onRefresh={refetchAllData} />
|
||||
<Button asChild variant="outline" icon={<ExternalLink size={14} />}>
|
||||
<Link href={openInLogsExplorerUrl}>Open in Logs Explorer</Link>
|
||||
</Button>
|
||||
</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={CHART_CONFIG}
|
||||
/>
|
||||
<InputGroup className="w-60">
|
||||
<InputGroupInput
|
||||
size="tiny"
|
||||
placeholder="Filter by event message"
|
||||
value={message}
|
||||
onChange={(event) => setMessage(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<RefreshCw />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="p-4">
|
||||
<AlertError error={error} subject="Failed to retrieve worker logs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && isPending && (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && !isPending && (
|
||||
<div className="relative flex flex-1 flex-col grow overflow-auto">
|
||||
<LogTable
|
||||
projectRef={projectRef ?? ''}
|
||||
columnRenderers={WorkersLogsColumnRender}
|
||||
data={logs ?? []}
|
||||
isLoading={isPending}
|
||||
showHeader={false}
|
||||
showHistogramToggle={false}
|
||||
selectedLog={selectedLog ?? undefined}
|
||||
onSelectedLogChange={(log) => setSelectedLog(log)}
|
||||
EmptyState={
|
||||
<div className="flex-1 min-h-0 border-t">
|
||||
<DataTableInfinite
|
||||
columns={UNIFIED_LOGS_COLUMNS}
|
||||
defaultColumnVisibility={{ select: false }}
|
||||
totalRows={totalDBRowCount}
|
||||
filterRows={filterDBRowCount}
|
||||
totalRowsFetched={totalFetched}
|
||||
fetchNextPage={fetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
setColumnOrder={setColumnOrder}
|
||||
setColumnVisibility={setColumnVisibility}
|
||||
searchParamsParser={SEARCH_PARAMS_PARSER}
|
||||
emptyStateMessage={
|
||||
<div className="mx-auto max-w-md space-y-3 py-16 text-center">
|
||||
<p className="text-sm text-foreground">No {label} in the selected time range</p>
|
||||
<p className="text-sm text-foreground">No logs in the selected time range</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Follow them from the Supabase CLI while you wait for traffic.
|
||||
</p>
|
||||
@@ -135,7 +298,7 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DataTableProvider>
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,4 @@ export const workersKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const,
|
||||
detail: (projectRef: string | undefined, name: string | undefined) =>
|
||||
['projects', projectRef, 'worker', name, 'detail'] as const,
|
||||
logs: (
|
||||
projectRef: string | undefined,
|
||||
name: string | undefined,
|
||||
stream: string,
|
||||
filters: {
|
||||
iso_timestamp_start?: string
|
||||
iso_timestamp_end?: string
|
||||
message?: string
|
||||
}
|
||||
) => ['projects', projectRef, 'worker', name, 'logs', stream, filters] as const,
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { workersKeys } from './keys'
|
||||
import { parseWorkerLogRows, workerLogsSql } from './worker-logs-query'
|
||||
|
||||
describe('workerLogsSql', () => {
|
||||
it('reads one worker stream, newest first', () => {
|
||||
expect(workerLogsSql('embed', 'output')).toBe(
|
||||
"select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_guest_logs' order by timestamp desc limit 100"
|
||||
)
|
||||
})
|
||||
|
||||
it('filters by event message before applying the limit', () => {
|
||||
expect(workerLogsSql('embed', 'requests', { message: 'timeout' })).toBe(
|
||||
"select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_ingress_logs' and event_message ilike '%timeout%' order by timestamp desc limit 100"
|
||||
)
|
||||
})
|
||||
|
||||
it('names the right stream for each tab', () => {
|
||||
expect(workerLogsSql('embed', 'requests')).toContain("'worker_ingress_logs'")
|
||||
expect(workerLogsSql('embed', 'builds')).toContain("'worker_api_logs'")
|
||||
})
|
||||
|
||||
it('escapes a worker name rather than interpolating it raw', () => {
|
||||
expect(workerLogsSql("embed' or '1'='1", 'output')).toContain(
|
||||
"log_attributes['worker'] = 'embed'' or ''1''=''1'"
|
||||
)
|
||||
})
|
||||
|
||||
it('escapes filter values rather than interpolating them raw', () => {
|
||||
expect(workerLogsSql('embed', 'requests', { message: "can't connect" })).toContain(
|
||||
"event_message ilike '%can''t connect%'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workersKeys.logs', () => {
|
||||
it('includes the selected time range and filters', () => {
|
||||
expect(
|
||||
workersKeys.logs('project-ref', 'embed', 'requests', {
|
||||
iso_timestamp_start: '2026-09-01T12:00:00.000Z',
|
||||
iso_timestamp_end: '2026-09-02T12:00:00.000Z',
|
||||
message: 'timeout',
|
||||
})
|
||||
).toEqual([
|
||||
'projects',
|
||||
'project-ref',
|
||||
'worker',
|
||||
'embed',
|
||||
'logs',
|
||||
'requests',
|
||||
{
|
||||
iso_timestamp_start: '2026-09-01T12:00:00.000Z',
|
||||
iso_timestamp_end: '2026-09-02T12:00:00.000Z',
|
||||
message: 'timeout',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorkerLogRows', () => {
|
||||
it('shapes rows for the logs table with a numeric timestamp', () => {
|
||||
const [row] = parseWorkerLogRows([
|
||||
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: 'ERROR', message: 'boom' },
|
||||
])
|
||||
expect(row.id).toBe('a')
|
||||
expect(row.event_message).toBe('boom')
|
||||
expect(row.severity_text).toBe('ERROR')
|
||||
expect(typeof row.timestamp).toBe('number')
|
||||
expect(Number.isFinite(row.timestamp)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to empty strings for null severity and message', () => {
|
||||
const [row] = parseWorkerLogRows([
|
||||
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: null, message: null },
|
||||
])
|
||||
expect(row.severity_text).toBe('')
|
||||
expect(row.event_message).toBe('')
|
||||
})
|
||||
|
||||
it('returns an empty array for a missing result', () => {
|
||||
expect(parseWorkerLogRows(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a malformed row rather than passing bad data to the table', () => {
|
||||
expect(() => parseWorkerLogRows([{ timestamp: '2026-08-24T10:00:00.000000' }])).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { workersKeys } from './keys'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { otelTimestampToMicros } from '@/components/interfaces/Settings/Logs/Logs.utils.otel'
|
||||
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
|
||||
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
|
||||
import { analyticsLiteral, safeSql } from '@/data/logs/safe-analytics-sql'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
import { WORKER_LOG_SOURCES } from '@/lib/constants/workers'
|
||||
|
||||
export type WorkerLogStream = keyof typeof WORKER_LOG_SOURCES
|
||||
|
||||
export const WORKER_LOG_STREAM_LABEL: Record<WorkerLogStream, string> = {
|
||||
requests: 'Invocations',
|
||||
output: 'Logs',
|
||||
builds: 'Activity',
|
||||
}
|
||||
|
||||
// Both are read from `log_attributes` rather than the endpoint's own `source` column:
|
||||
// that column is derived from a mapping which does not currently classify worker rows.
|
||||
const WORKER_NAME_KEY = 'worker'
|
||||
const STREAM_KEY = 'source'
|
||||
|
||||
const LOG_LIMIT = 100
|
||||
|
||||
const workerLogRowSchema = z.object({
|
||||
id: z.string(),
|
||||
timestamp: z.union([z.string(), z.number()]),
|
||||
severity: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
})
|
||||
|
||||
export type WorkerLogsVariables = {
|
||||
projectRef?: string
|
||||
name?: string
|
||||
stream: WorkerLogStream
|
||||
iso_timestamp_start: string
|
||||
iso_timestamp_end: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const workerLogsSql = (
|
||||
name: string,
|
||||
stream: WorkerLogStream,
|
||||
{ message }: Pick<WorkerLogsVariables, 'message'> = {}
|
||||
) => {
|
||||
const messageFilter = message
|
||||
? safeSql` and event_message ilike ${analyticsLiteral(`%${message}%`)}`
|
||||
: safeSql``
|
||||
|
||||
return safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])}${messageFilter} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}`
|
||||
}
|
||||
|
||||
export const parseWorkerLogRows = (result: unknown): LogData[] =>
|
||||
z
|
||||
.array(workerLogRowSchema)
|
||||
.parse(result ?? [])
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
timestamp: otelTimestampToMicros(row.timestamp),
|
||||
event_message: row.message ?? '',
|
||||
severity_text: row.severity ?? '',
|
||||
}))
|
||||
|
||||
async function getWorkerLogs(
|
||||
{
|
||||
projectRef,
|
||||
name,
|
||||
stream,
|
||||
iso_timestamp_start,
|
||||
iso_timestamp_end,
|
||||
message,
|
||||
}: WorkerLogsVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<LogData[]> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!name) throw new Error('name is required')
|
||||
|
||||
const data = await executeAnalyticsSql({
|
||||
projectRef,
|
||||
endpoint: logsAllEndpointUrl(true),
|
||||
sql: workerLogsSql(name, stream, { message }),
|
||||
iso_timestamp_start,
|
||||
iso_timestamp_end,
|
||||
signal,
|
||||
})
|
||||
|
||||
return parseWorkerLogRows(data?.result)
|
||||
}
|
||||
|
||||
export const workerLogsQueryOptions = (variables: WorkerLogsVariables) => {
|
||||
const { projectRef, name, stream, iso_timestamp_start, iso_timestamp_end } = variables
|
||||
const message = variables.message?.trim() || undefined
|
||||
|
||||
return queryOptions({
|
||||
queryKey: workersKeys.logs(projectRef, name, stream, {
|
||||
iso_timestamp_start,
|
||||
iso_timestamp_end,
|
||||
message,
|
||||
}),
|
||||
queryFn: ({ signal }) =>
|
||||
getWorkerLogs(
|
||||
{ projectRef, name, stream, iso_timestamp_start, iso_timestamp_end, message },
|
||||
signal
|
||||
),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user