From c9e618bfdbf005cbc3526e149f949d7cd4b80b01 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Tue, 8 Sep 2026 16:08:14 +0200 Subject: [PATCH] unify worker logs in single tab --- .../WorkersLogsColumnRender.tsx | 22 -- .../UnifiedLogs/UnifiedLogs.chart-config.tsx | 20 + .../UnifiedLogs/UnifiedLogs.constants.tsx | 3 + .../UnifiedLogs/UnifiedLogs.fields.tsx | 8 +- .../UnifiedLogs/UnifiedLogs.queries.test.ts | 22 ++ .../UnifiedLogs/UnifiedLogs.queries.ts | 20 + .../interfaces/UnifiedLogs/UnifiedLogs.tsx | 17 +- .../UnifiedLogs/UnifiedLogs.utils.test.ts | 16 + .../UnifiedLogs/UnifiedLogs.utils.ts | 8 + .../Workers/WorkerDetail/WorkerDetail.tsx | 19 +- .../Workers/WorkerDetail/WorkerLogsTab.tsx | 371 +++++++++++++----- apps/studio/data/workers/keys.ts | 10 - .../data/workers/worker-logs-query.test.ts | 88 ----- apps/studio/data/workers/worker-logs-query.ts | 110 ------ 14 files changed, 368 insertions(+), 366 deletions(-) delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config.tsx delete mode 100644 apps/studio/data/workers/worker-logs-query.test.ts delete mode 100644 apps/studio/data/workers/worker-logs-query.ts diff --git a/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender.tsx deleted file mode 100644 index 1c9c897c016..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender.tsx +++ /dev/null @@ -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[] = [ - { - name: 'workers-logs-first-column', - key: 'workers-logs-first-column', - renderHeaderCell: () => null, - renderCell: (props) => ( - - - {props.row.severity_text ? ( - - ) : null} - - - ), - }, -] diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config.tsx new file mode 100644 index 00000000000..680b8016dd3 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.chart-config.tsx @@ -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: , + color: 'var(--chart-success)', + }, + warning: { + label: , + color: 'var(--chart-warning)', + }, + error: { + label: , + color: 'hsl(var(--destructive-default))', + }, +} satisfies ChartConfig diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index 7b968ec97d5..31af27ffa3a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -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. diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index 4ae39344de1..3471e549ade 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts index c593da987f4..def44dfd0f3 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts @@ -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 diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index e9f3ecba3e6..03955e8d8d9 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -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 ')})` } diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index d39a55acd5c..4893ef9ec83 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -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: , - color: 'var(--chart-success)', - }, - warning: { - label: , - color: 'var(--chart-warning)', - }, - error: { - label: , - color: 'hsl(var(--destructive-default))', - }, -} satisfies ChartConfig - export const UnifiedLogs = () => { useResetFocus() diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts index 12b7f79b467..8482ac6de26 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts @@ -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', () => { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts index c51518ffb94..27b5092d2b4 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -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()}` } diff --git a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerDetail.tsx b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerDetail.tsx index cca4bac374e..3d447abedca 100644 --- a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerDetail.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerDetail.tsx @@ -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 = { overview: 'Overview', - invocations: 'Invocations', logs: 'Logs', - activity: 'Activity', -} - -const TAB_STREAM: Partial> = { - invocations: 'requests', - logs: 'output', - activity: 'builds', } export const WorkerDetail = () => { @@ -85,8 +76,6 @@ export const WorkerDetail = () => { ) } - const stream = TAB_STREAM[tab] - return (
@@ -138,9 +127,9 @@ export const WorkerDetail = () => { {tab === 'overview' && } - {stream !== undefined && ( + {tab === 'logs' && (
- +
)}
diff --git a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx index 183af913a42..477c84922d3 100644 --- a/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerDetail/WorkerLogsTab.tsx @@ -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>>( + (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(null) - const [dateRange, setDateRange] = useState(defaultDateRange) - const [message, setMessage] = useState('') - const debouncedMessage = useDebouncedValue(message, 300) + const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) + + const [columnFilters, setColumnFilters] = useState(() => + seedColumnFilters(search) + ) + const [columnVisibility, setColumnVisibility] = useState({ select: false }) + const [columnOrder, setColumnOrder] = useState([]) + + // 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 + ) 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) => + cn(getLevelRowClassName(row.original.level), 'h-[30px]') + + const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => { + return generateDynamicColumns({ data: flatData }) + }, [flatData]) + + const table: Table = 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 ( -
-
-
- +
+
+ +
+ +
+
+ + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( + - - setMessage(event.target.value)} - /> - - - - -
- -
+ )} - {isError && ( -
- -
- )} - - {!isError && isPending && ( -
- -
- )} - - {!isError && !isPending && ( -
- setSelectedLog(log)} - EmptyState={ +
+ -

No {label} in the selected time range

+

No logs in the selected time range

Follow them from the Supabase CLI while you wait for traffic.

@@ -135,7 +298,7 @@ export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => { } />
- )} -
+
+ ) } diff --git a/apps/studio/data/workers/keys.ts b/apps/studio/data/workers/keys.ts index cdbce564452..2feecbc6431 100644 --- a/apps/studio/data/workers/keys.ts +++ b/apps/studio/data/workers/keys.ts @@ -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, } diff --git a/apps/studio/data/workers/worker-logs-query.test.ts b/apps/studio/data/workers/worker-logs-query.test.ts deleted file mode 100644 index baf6d79a80d..00000000000 --- a/apps/studio/data/workers/worker-logs-query.test.ts +++ /dev/null @@ -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() - }) -}) diff --git a/apps/studio/data/workers/worker-logs-query.ts b/apps/studio/data/workers/worker-logs-query.ts deleted file mode 100644 index 4ec0b1cdbd1..00000000000 --- a/apps/studio/data/workers/worker-logs-query.ts +++ /dev/null @@ -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 = { - 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 = {} -) => { - 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 { - 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', - }) -}