From 75644f8396ffefcfce477d6a26cff0c668b4d02f Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 20 Feb 2026 14:10:13 +0100 Subject: [PATCH 1/5] chore: Bump `react-data-grid` to fix a Sentry issue (#42934) This pull request updates the `react-data-grid` dependency and refactors code throughout the codebase to use the new import structure and updated APIs. The changes improve compatibility with the latest version of `react-data-grid`, simplify imports, and update row selection logic to match new hook signatures. Additionally, some code is reorganized to use absolute imports for internal components. --------- Co-authored-by: Joshen Lim --- apps/design-system/package.json | 2 +- .../default/example/data-grid-demo.tsx | 3 +-- .../grid/components/grid/AddColumn.tsx | 5 ++--- .../components/grid/components/grid/Grid.tsx | 2 +- .../grid/components/grid/SelectColumn.tsx | 19 +++++++++---------- .../components/grid/utils/gridColumns.tsx | 3 +-- .../interfaces/Auth/Users/Users.utils.tsx | 7 +++---- .../Auth/Users/UsersGridComponents.tsx | 6 +++--- apps/studio/components/ui/APIDocsButton.tsx | 5 +++-- apps/studio/package.json | 2 +- pnpm-lock.yaml | 18 +++++++++--------- 11 files changed, 34 insertions(+), 38 deletions(-) diff --git a/apps/design-system/package.json b/apps/design-system/package.json index aa0c8da105770..4694e992bfd59 100644 --- a/apps/design-system/package.json +++ b/apps/design-system/package.json @@ -31,7 +31,7 @@ "next-contentlayer2": "0.4.6", "next-themes": "^0.3.0", "react": "catalog:", - "react-data-grid": "7.0.0-beta.41", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.11.1", "react-dom": "catalog:", "react-hook-form": "^7.45.0", diff --git a/apps/design-system/registry/default/example/data-grid-demo.tsx b/apps/design-system/registry/default/example/data-grid-demo.tsx index 461d63e540654..66af73297fa7c 100644 --- a/apps/design-system/registry/default/example/data-grid-demo.tsx +++ b/apps/design-system/registry/default/example/data-grid-demo.tsx @@ -33,7 +33,7 @@ export default function DataGridDemo() { headerCellClass: 'border-default border-r border-b', renderCell: ({ row }) => { // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return (
@@ -43,7 +43,6 @@ export default function DataGridDemo() { e.stopPropagation() onRowSelectionChange({ row, - type: 'ROW', checked: !isRowSelected, isShiftClick: e.shiftKey, }) diff --git a/apps/studio/components/grid/components/grid/AddColumn.tsx b/apps/studio/components/grid/components/grid/AddColumn.tsx index 5cf1478412e5b..69c0a4dbef8fc 100644 --- a/apps/studio/components/grid/components/grid/AddColumn.tsx +++ b/apps/studio/components/grid/components/grid/AddColumn.tsx @@ -1,10 +1,10 @@ import { Plus } from 'lucide-react' import type { CalculatedColumn } from 'react-data-grid' - -import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' + import { ADD_COLUMN_KEY } from '../../constants' import { DefaultFormatter } from '../formatter/DefaultFormatter' +import { useTableEditorStateSnapshot } from '@/state/table-editor' export const AddColumn: CalculatedColumn = { key: ADD_COLUMN_KEY, @@ -15,7 +15,6 @@ export const AddColumn: CalculatedColumn = { resizable: false, sortable: false, frozen: false, - isLastFrozenColumn: false, renderHeaderCell() { return }, diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 2079e3692ea28..e15bca7e4743f 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -203,7 +203,7 @@ export const Grid = memo( // Compute rowClass function to style pending add/delete rows const computedRowClass = useMemo(() => { return (row: SupaRow) => { - const classes: string[] = [] + const classes: string[] = ['[&>.rdg-cell]:flex', '[&>.rdg-cell]:items-center'] // Call the original rowClass if provided if (rowClass) { diff --git a/apps/studio/components/grid/components/grid/SelectColumn.tsx b/apps/studio/components/grid/components/grid/SelectColumn.tsx index 0e07057b9217f..8596cc65772d6 100644 --- a/apps/studio/components/grid/components/grid/SelectColumn.tsx +++ b/apps/studio/components/grid/components/grid/SelectColumn.tsx @@ -5,14 +5,15 @@ import { RenderCellProps, RenderGroupCellProps, RenderHeaderCellProps, + useHeaderRowSelection, useRowSelection, } from 'react-data-grid' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useTableEditorStateSnapshot } from 'state/table-editor' -import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { SELECT_COLUMN_KEY } from '../../constants' import type { SupaRow } from '../../types' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useTableEditorStateSnapshot } from '@/state/table-editor' +import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' export const SelectColumn: CalculatedColumn = { key: SELECT_COLUMN_KEY, @@ -23,25 +24,24 @@ export const SelectColumn: CalculatedColumn = { resizable: false, sortable: false, frozen: true, - isLastFrozenColumn: false, renderHeaderCell: (props: RenderHeaderCellProps) => { // [Joshen] formatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useHeaderRowSelection() return ( onRowSelectionChange({ type: 'HEADER', checked })} + onChange={(checked) => onRowSelectionChange({ checked })} /> ) }, renderCell: (props: RenderCellProps) => { // [Alaister] formatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return ( = { value={isRowSelected} row={props.row} onChange={(checked, isShiftClick) => { - onRowSelectionChange({ type: 'ROW', row: props.row, checked, isShiftClick }) + onRowSelectionChange({ row: props.row, checked, isShiftClick }) }} // Stop propagation to prevent row selection onClick={stopPropagation} @@ -59,7 +59,7 @@ export const SelectColumn: CalculatedColumn = { renderGroupCell: (props: RenderGroupCellProps) => { // [Alaister] groupFormatter is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() return ( = { value={isRowSelected} onChange={(checked) => { onRowSelectionChange({ - type: 'ROW', row: props.row, checked, isShiftClick: false, diff --git a/apps/studio/components/grid/utils/gridColumns.tsx b/apps/studio/components/grid/utils/gridColumns.tsx index 064f055ec3b88..54d50cdc72bf1 100644 --- a/apps/studio/components/grid/utils/gridColumns.tsx +++ b/apps/studio/components/grid/utils/gridColumns.tsx @@ -1,4 +1,3 @@ -import { COLUMN_MIN_WIDTH } from 'components/grid/constants' import { CalculatedColumn, RenderCellProps } from 'react-data-grid' import { DefaultValue } from '../components/common/DefaultValue' @@ -39,6 +38,7 @@ import { isTextColumn, isTimeColumn, } from './types' +import { COLUMN_MIN_WIDTH } from '@/components/grid/constants' export const ESTIMATED_CHARACTER_PIXEL_WIDTH = 9 @@ -73,7 +73,6 @@ export function getGridColumns( width: columnWidth, minWidth: COLUMN_MIN_WIDTH, frozen: false, - isLastFrozenColumn: false, renderHeaderCell: (props) => ( { // This is actually a valid React component, so we can use hooks here // eslint-disable-next-line react-hooks/rules-of-hooks - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useRowSelection() const value = row?.[col.id] const user = users?.find((u) => u.id === row.id) @@ -329,7 +329,6 @@ export const formatUserColumns = ({ e.stopPropagation() onRowSelectionChange({ row, - type: 'ROW', checked: !isRowSelected, isShiftClick: e.shiftKey, }) diff --git a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx index 13a085c76ffca..67d4b04fb272d 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersGridComponents.tsx @@ -1,6 +1,6 @@ import { ChevronDown, SortAsc, SortDesc } from 'lucide-react' import { useEffect, useRef, useState } from 'react' -import { useRowSelection } from 'react-data-grid' +import { useHeaderRowSelection } from 'react-data-grid' import { Button, DropdownMenu, @@ -18,7 +18,7 @@ export const SelectHeaderCell = ({ allRowsSelected: boolean }) => { const inputRef = useRef(null) - const [isRowSelected, onRowSelectionChange] = useRowSelection() + const { isRowSelected, onRowSelectionChange } = useHeaderRowSelection() const isIndeterminate = selectedUsers.size > 0 && !allRowsSelected @@ -36,7 +36,7 @@ export const SelectHeaderCell = ({ className="sb-grid-select-cell__header__input" disabled={false} checked={isRowSelected} - onChange={(e) => onRowSelectionChange({ type: 'HEADER', checked: e.target.checked })} + onChange={(e) => onRowSelectionChange({ checked: e.target.checked })} />
diff --git a/apps/studio/components/ui/APIDocsButton.tsx b/apps/studio/components/ui/APIDocsButton.tsx index dc1d3fc072ee9..2f3291b58b6cd 100644 --- a/apps/studio/components/ui/APIDocsButton.tsx +++ b/apps/studio/components/ui/APIDocsButton.tsx @@ -1,8 +1,9 @@ -import { BookOpenText } from 'lucide-react' import { useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { BookOpenText } from 'lucide-react' import { useAppStateSnapshot } from 'state/app-state' + import { ButtonTooltip } from './ButtonTooltip' interface APIDocsButtonProps { @@ -36,7 +37,7 @@ export const APIDocsButton = ({ section, source }: APIDocsButtonProps) => { }) }} icon={} - className="h-7 w-7" + className="w-7" tooltip={{ content: { side: 'bottom', diff --git a/apps/studio/package.json b/apps/studio/package.json index c0c422e830d66..e95f81597a0db 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -114,7 +114,7 @@ "react": "catalog:", "react-beautiful-dnd": "^13.1.0", "react-contexify": "^5.0.0", - "react-data-grid": "7.0.0-beta.41", + "react-data-grid": "7.0.0-beta.47", "react-day-picker": "^9.11.1", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 748b1f7feec2a..26d2bb2a87119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,8 +171,8 @@ importers: specifier: 'catalog:' version: 18.3.1 react-data-grid: - specifier: 7.0.0-beta.41 - version: 7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.0.0-beta.47 + version: 7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-day-picker: specifier: ^9.11.1 version: 9.11.1(react@18.3.1) @@ -1002,8 +1002,8 @@ importers: specifier: ^5.0.0 version: 5.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-data-grid: - specifier: 7.0.0-beta.41 - version: 7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.0.0-beta.47 + version: 7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-day-picker: specifier: ^9.11.1 version: 9.11.1(react@18.3.1) @@ -15800,11 +15800,11 @@ packages: react: '>= 15' react-dom: '>= 15' - react-data-grid@7.0.0-beta.41: - resolution: {integrity: sha512-WmTP/PV+vtVjIaGVLgyG6WAhqvuPBM8I54bsR7oJZl6w43+mIasZM9rEBWjQ52XHJEy41/tjcMBIMNiWqoEbrQ==} + react-data-grid@7.0.0-beta.47: + resolution: {integrity: sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==} peerDependencies: - react: ^18.0 - react-dom: ^18.0 + react: ^18.0 || ^19.0 + react-dom: ^18.0 || ^19.0 react-day-picker@9.11.1: resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} @@ -34816,7 +34816,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-data-grid@7.0.0-beta.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-data-grid@7.0.0-beta.47(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: clsx: 2.1.1 react: 18.3.1 From e561196f07561156a0855ea1c0279d9692172a6d Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 20 Feb 2026 14:36:37 +0100 Subject: [PATCH 2/5] fix: Parse unqualified SQL functions as SQL snippet in the Cron jobs UI (#41593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary by CodeRabbit * **Bug Fixes** * Tightened detection of database function calls to require explicit schema-qualified names; commands without a schema prefix are now treated as SQL snippets, improving classification accuracy. * **Tests** * Added tests covering schema-qualified function calls, functions with underscores, use of search-path functions, and various edge-case snippet forms to validate parsing behavior. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../CronJobs/CronJobs.utils.test.ts | 34 +++++++++++++++++++ .../Integrations/CronJobs/CronJobs.utils.tsx | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts index dfe96116478f2..158a9c18c1ba2 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.test.ts @@ -41,6 +41,16 @@ describe('parseCronJobCommand', () => { }) }) + it('should return a sql function command when the function name contains an underscore', () => { + const command = 'SELECT random_schema.function_1()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_function', + schema: 'random_schema', + functionName: 'function_1', + snippet: command, + }) + }) + it('should return a sql snippet command when the command is SELECT public.test_fn(1, 2)', () => { const command = 'SELECT public.test_fn(1, 2)' expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ @@ -49,6 +59,30 @@ describe('parseCronJobCommand', () => { }) }) + it('should return a sql snippet command when the command is using a SQL function from the search path', () => { + const command = 'SELECT test_cron_function()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + + it('should return a sql snippet command when the command is SELECT .()', () => { + const command = 'SELECT .()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + + it('should return a sql snippet command when the command is SELECT schema.()', () => { + const command = 'SELECT schema.()' + expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ + type: 'sql_snippet', + snippet: command, + }) + }) + it('should return a edge function config when the command posts to its own supabase.co project', () => { const command = `select net.http_post( url:='https://random_project_ref.supabase.co/functions/v1/_', headers:=jsonb_build_object('Authorization', 'Bearer something'), body:='', timeout_milliseconds:=5000 );` expect(parseCronJobCommand(command, 'random_project_ref')).toStrictEqual({ diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx index 2f726f8050aa5..75ef9acf94edc 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobs.utils.tsx @@ -127,7 +127,7 @@ export const parseCronJobCommand = (originalCommand: string, projectRef: string) } } - const regexDBFunction = /select\s+[a-zA-Z-_]*\.?[a-zA-Z-_]*\s*\(\)/g + const regexDBFunction = /select\s+[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\s*\(\)/g if (command.toLocaleLowerCase().match(regexDBFunction)) { const [schemaName, functionName] = command .replace('SELECT ', '') From 41a7b3498591a4cf9358c113459517690fe9d416 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 20 Feb 2026 07:23:38 -0700 Subject: [PATCH 3/5] fix: Better error handling for filters (#43027) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES/NO ## What kind of change does this PR introduce? Update error handling for filter related errors on the table editor --- .../grid/components/grid/GridError.tsx | 48 +++++----- .../components/grid/GridError.utils.test.ts | 87 +++++++++++++++++++ .../grid/components/grid/GridError.utils.ts | 14 +++ e2e/studio/features/filter-bar.spec.ts | 47 ++++++++++ 4 files changed, 175 insertions(+), 21 deletions(-) create mode 100644 apps/studio/components/grid/components/grid/GridError.utils.test.ts create mode 100644 apps/studio/components/grid/components/grid/GridError.utils.ts diff --git a/apps/studio/components/grid/components/grid/GridError.tsx b/apps/studio/components/grid/components/grid/GridError.tsx index a44ca11a8f675..fa8def1d3710a 100644 --- a/apps/studio/components/grid/components/grid/GridError.tsx +++ b/apps/studio/components/grid/components/grid/GridError.tsx @@ -1,14 +1,18 @@ import { useParams } from 'common' import { useTableFilter } from 'components/grid/hooks/useTableFilter' +import { useTableFilterNew } from 'components/grid/hooks/useTableFilterNew' import { useTableSort } from 'components/grid/hooks/useTableSort' import AlertError from 'components/ui/AlertError' import { InlineLink } from 'components/ui/InlineLink' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useCallback } from 'react' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Button } from 'ui' import { Admonition } from 'ui-patterns' +import { isFilterRelatedError } from './GridError.utils' +import { useIsTableFilterBarEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { HighCostError } from '@/components/ui/HighQueryCost' import { COST_THRESHOLD_ERROR } from '@/data/sql/execute-sql-query' import { useTableEditorStateSnapshot } from '@/state/table-editor' @@ -18,12 +22,22 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const { id: _id } = useParams() const tableId = _id ? Number(_id) : undefined - const { filters } = useTableFilter() + const newFilterBarEnabled = useIsTableFilterBarEnabled() + const { filters: oldFilters, clearFilters: clearOldFilters } = useTableFilter() + const { filters: newFilters, clearFilters: clearNewFilters } = useTableFilterNew() const { sorts } = useTableSort() const snap = useTableEditorTableStateSnapshot() const tableEditorSnap = useTableEditorStateSnapshot() + const removeAllFilters = useCallback(() => { + if (newFilterBarEnabled) { + clearNewFilters() + } else { + clearOldFilters() + } + }, [clearOldFilters, clearNewFilters, newFilterBarEnabled]) + if (!error) return null const tableEntityType = snap.originalTable?.entity_type @@ -32,8 +46,9 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { const isForeignTableMissingVaultKeyError = isForeignTable && error?.message?.includes('query vault failed') - const isInvalidSyntaxError = - filters.length > 0 && error?.message?.includes('invalid input syntax') + const hasActiveFilters = oldFilters.length > 0 || newFilters.length > 0 + + const hasFilterRelatedError = hasActiveFilters && isFilterRelatedError(error?.message) const isInvalidOrderingOperatorError = sorts.length > 0 && error?.message?.includes('identify an ordering operator') @@ -55,8 +70,8 @@ export const GridError = ({ error }: { error?: ResponseError | null }) => { ) } else if (isForeignTableMissingVaultKeyError) { return - } else if (isInvalidSyntaxError) { - return + } else if (hasFilterRelatedError) { + return } else if (isInvalidOrderingOperatorError) { return } @@ -94,27 +109,18 @@ const ForeignTableMissingVaultKeyError = () => { ) } -const InvalidSyntaxError = ({ error }: { error: ResponseError }) => { - const { onApplyFilters } = useTableFilter() - +const FilterError = ({ removeAllFilters }: { removeAllFilters: () => void }) => { return ( -

- Unable to retrieve results as the provided value in your filter(s) doesn't match it's column - data type. +

+ One or more of your filters may have a value or operator that doesn't match the column's + data type. Try updating or removing the filter.

-

- Verify that your filter values are correct before applying the filters again. -

-

- Error: {error.message} -

- -
diff --git a/apps/studio/components/grid/components/grid/GridError.utils.test.ts b/apps/studio/components/grid/components/grid/GridError.utils.test.ts new file mode 100644 index 0000000000000..5a1095dd78267 --- /dev/null +++ b/apps/studio/components/grid/components/grid/GridError.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'vitest' + +import { isFilterRelatedError } from './GridError.utils' + +describe('isFilterRelatedError', () => { + test('returns false for null or undefined', () => { + expect(isFilterRelatedError(null)).toBe(false) + expect(isFilterRelatedError(undefined)).toBe(false) + }) + + test('returns false for empty string', () => { + expect(isFilterRelatedError('')).toBe(false) + }) + + test('returns false for unrelated error messages', () => { + expect(isFilterRelatedError('connection refused')).toBe(false) + expect(isFilterRelatedError('permission denied for table users')).toBe(false) + expect(isFilterRelatedError('relation "users" does not exist')).toBe(false) + expect(isFilterRelatedError('Query cost exceeds threshold')).toBe(false) + }) + + test('detects invalid input syntax errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type inet: "192.168.3"' + ) + ).toBe(true) + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type integer: "abc"' + ) + ).toBe(true) + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input syntax for type uuid: "not-a-uuid"' + ) + ).toBe(true) + }) + + test('detects operator does not exist errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 42883: operator does not exist: text > integer' + ) + ).toBe(true) + }) + + test('detects collation errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: could not determine which collation to use for string comparison' + ) + ).toBe(true) + }) + + test('detects invalid enum value errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: invalid input value for enum status: "badvalue"' + ) + ).toBe(true) + }) + + test('detects malformed array literal errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22P02: malformed array literal: "not-an-array"' + ) + ).toBe(true) + }) + + test('detects invalid byte sequence errors', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 22021: invalid byte sequence for encoding "UTF8"' + ) + ).toBe(true) + }) + + test('detects syntax errors from invalid IS operator values', () => { + expect( + isFilterRelatedError( + 'Failed to run sql query: ERROR: 42601: syntax error at or near "sdfsdf"' + ) + ).toBe(true) + }) +}) diff --git a/apps/studio/components/grid/components/grid/GridError.utils.ts b/apps/studio/components/grid/components/grid/GridError.utils.ts new file mode 100644 index 0000000000000..cc1331c74c9f2 --- /dev/null +++ b/apps/studio/components/grid/components/grid/GridError.utils.ts @@ -0,0 +1,14 @@ +const FILTER_ERROR_PATTERNS = [ + 'invalid input syntax', + 'operator does not exist', + 'could not determine which collation', + 'invalid input value for enum', + 'malformed array literal', + 'invalid byte sequence', + 'syntax error', +] + +export function isFilterRelatedError(errorMessage: string | undefined | null): boolean { + if (!errorMessage) return false + return FILTER_ERROR_PATTERNS.some((pattern) => errorMessage.includes(pattern)) +} diff --git a/e2e/studio/features/filter-bar.spec.ts b/e2e/studio/features/filter-bar.spec.ts index 09fc74545ae6f..86597c9e758c3 100644 --- a/e2e/studio/features/filter-bar.spec.ts +++ b/e2e/studio/features/filter-bar.spec.ts @@ -13,6 +13,7 @@ import { } from '../utils/filter-bar-helpers.js' import { test } from '../utils/test.js' import { toUrl } from '../utils/to-url.js' +import { createApiResponseWaiter } from '../utils/wait-for-response.js' const tableNamePrefix = 'pw_filter_bar' @@ -811,4 +812,50 @@ test.describe('Filter Bar', () => { } }) }) + + test.describe('Filter Error Feedback', () => { + test('invalid filter value shows friendly error and remove button clears filters', async ({ + page, + ref, + }) => { + const tableName = `${tableNamePrefix}_err_fb` + + await createTable(tableName, 'name', [{ name: 'Alice' }, { name: 'Bob' }]) + + try { + await setupFilterBarPage(page, ref, toUrl(`/project/${ref}/editor?schema=public`)) + await navigateToTable(page, ref, tableName) + + // Apply a filter using the 'is' operator with an invalid value + // 'is' only accepts null/not null/true/false, so 'badvalue' triggers a syntax error + await selectColumnFilter(page, 'name') + await selectOperatorByClick(page, 'name', 'is') + + const valueInput = page.getByTestId('filter-value-name') + const rowsWaiter = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-rows-') + await valueInput.fill('badvalue') + await page.keyboard.press('Enter') + await rowsWaiter + + // Should show the friendly filter error, not the scary general error + await expect(page.getByText('No results found — check your filter values')).toBeVisible({ + timeout: 10000, + }) + await expect(page.getByText("doesn't match the column's data type")).toBeVisible() + + const removeButton = page.getByRole('button', { name: 'Remove filters' }) + await expect(removeButton).toBeVisible() + + // Clicking "Remove filters" should clear filters and restore data + await removeButton.click() + + // Filter pill should be gone and data should be visible again + await expect(page.getByTestId('filter-condition-name')).not.toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('gridcell', { name: 'Alice' })).toBeVisible({ timeout: 10000 }) + await expect(page.getByRole('gridcell', { name: 'Bob' })).toBeVisible() + } finally { + await dropTable(tableName) + } + }) + }) }) From 03660838efddd51f5c12d1f49a5ff7067ba67007 Mon Sep 17 00:00:00 2001 From: Ivan Vasilov Date: Fri, 20 Feb 2026 15:32:12 +0100 Subject: [PATCH 4/5] chore: Refactor the script for posting PRs for review on Slack (#43022) This pull request refactors the GitHub Actions workflow for notifying about stale Dashboard PRs by replacing custom JavaScript scripts and the `actions/github-script` action with new TypeScript scripts that communicate via standard input/output. This simplifies the workflow, improves maintainability, and adds better error handling, especially for API rate limits. The Slack notification script is also rewritten in TypeScript and now reads PR data from stdin, making the workflow steps more composable. --- .github/workflows/dashboard-pr-reminder.yml | 39 ++- scripts/actions/find-stale-dashboard-prs.js | 226 --------------- scripts/actions/find-stale-dashboard-prs.ts | 266 ++++++++++++++++++ ...ation.js => send-slack-pr-notification.ts} | 80 ++++-- 4 files changed, 339 insertions(+), 272 deletions(-) delete mode 100644 scripts/actions/find-stale-dashboard-prs.js create mode 100644 scripts/actions/find-stale-dashboard-prs.ts rename scripts/actions/{send-slack-pr-notification.js => send-slack-pr-notification.ts} (65%) diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml index 876b0d6c4abde..76d464d0f4e85 100644 --- a/.github/workflows/dashboard-pr-reminder.yml +++ b/.github/workflows/dashboard-pr-reminder.yml @@ -18,29 +18,26 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + sparse-checkout: | + scripts - - name: Find Dashboard PRs older than 24 hours - id: find-prs - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + name: Install pnpm with: - script: | - const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js'); - return await findStalePRs({ github, context, core }); + run_install: false - - name: Send Slack notification - if: fromJSON(steps.find-prs.outputs.count) > 0 - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 - env: - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} - STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }} + - name: Use Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - script: | - const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js'); - const stalePRs = JSON.parse(process.env.STALE_PRS_JSON); - const webhookUrl = process.env.SLACK_WEBHOOK_URL; - await sendSlackNotification(stalePRs, webhookUrl); + node-version-file: '.nvmrc' + cache: 'pnpm' - - name: No stale PRs found - if: fromJSON(steps.find-prs.outputs.count) == 0 - run: | - echo "✓ No Dashboard PRs older than 24 hours found" + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Find stale Dashboard PRs and notify Slack + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} + run: pnpm tsx scripts/actions/find-stale-dashboard-prs.ts | pnpm tsx scripts/actions/send-slack-pr-notification.ts diff --git a/scripts/actions/find-stale-dashboard-prs.js b/scripts/actions/find-stale-dashboard-prs.js deleted file mode 100644 index e772025275ac8..0000000000000 --- a/scripts/actions/find-stale-dashboard-prs.js +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Finds stale Dashboard PRs (older than 24 hours) and fetches their status - * including review status and mergeable state. - * - * @param {Object} github - GitHub API client from actions/github-script - * @param {Object} context - GitHub Actions context - * @param {Object} core - GitHub Actions core utilities - * @returns {Array} Array of stale PRs with status information - */ -module.exports = async ({ github, context, core }) => { - const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) - const DASHBOARD_PATH = 'apps/studio/' - - console.log(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) - - const stalePRs = [] - let page = 1 - let hasMore = true - - // Fetch PRs page by page, newest first - while (hasMore && page <= 10) { - // Limit to 10 pages (1000 PRs) as safety measure - console.log(`Fetching page ${page}...`) - - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - sort: 'created', - direction: 'desc', - per_page: 100, - page: page, - }) - - if (prs.length === 0) { - hasMore = false - break - } - - // Check each PR - for (const pr of prs) { - // Skip PRs from forks - only check internal PRs - if (pr.head.repo && pr.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo) { - console.log(`PR #${pr.number} is from a fork, skipping...`) - continue - } - - // Skip dependabot PRs - if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { - console.log(`PR #${pr.number} is from dependabot, skipping...`) - continue - } - - // Skip draft PRs - if (pr.draft) { - console.log(`PR #${pr.number} is a draft, skipping...`) - continue - } - - const createdAt = new Date(pr.created_at) - - // If this PR is newer than 24 hours, skip it - if (createdAt > TWENTY_FOUR_HOURS_AGO) { - console.log(`PR #${pr.number} is too new, skipping...`) - continue - } - - console.log(`Checking PR #${pr.number}: ${pr.title}`) - - // Fetch files changed in this PR - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }) - - // Check if any file is under apps/studio/ - const touchesDashboard = files.some((file) => file.filename.startsWith(DASHBOARD_PATH)) - - if (touchesDashboard) { - const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) - const daysOld = Math.floor(hoursOld / 24) - - // Fetch review status - let reviewStatus = 'no-reviews' - let reviewEmoji = ':eyes:' - try { - const { data: reviews } = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - per_page: 100, - }) - - if (reviews.length === 0) { - reviewStatus = 'no-reviews' - reviewEmoji = ':eyes:' - } else { - // Get the most recent review from each reviewer - const latestReviews = {} - reviews.forEach((review) => { - if ( - !latestReviews[review.user.login] || - new Date(review.submitted_at) > - new Date(latestReviews[review.user.login].submitted_at) - ) { - latestReviews[review.user.login] = review - } - }) - - // Check for most critical state (Changes Requested > Approved > Commented) - const states = Object.values(latestReviews).map((r) => r.state) - if (states.includes('CHANGES_REQUESTED')) { - reviewStatus = 'changes-requested' - reviewEmoji = ':warning:' - } else if (states.includes('APPROVED')) { - reviewStatus = 'approved' - reviewEmoji = ':heavy_check_mark:' - } else { - reviewStatus = 'commented' - reviewEmoji = ':speech_balloon:' - } - } - } catch (error) { - console.log( - `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` - ) - } - - // Get mergeable state - let mergeableStatus = 'unknown' - let mergeableEmoji = ':grey_question:' - - // Fetch full PR details to get mergeable state (it's not always in the list response) - try { - const { data: fullPR } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }) - - const mergeableState = fullPR.mergeable_state - - switch (mergeableState) { - case 'clean': - mergeableStatus = 'ready' - mergeableEmoji = ':rocket:' - break - case 'dirty': - mergeableStatus = 'conflicts' - mergeableEmoji = ':collision:' - break - case 'blocked': - mergeableStatus = 'blocked' - mergeableEmoji = ':no_entry:' - break - case 'unstable': - mergeableStatus = 'unstable' - mergeableEmoji = ':warning:' - break - case 'behind': - mergeableStatus = 'behind' - mergeableEmoji = ':arrow_down:' - break - case 'draft': - mergeableStatus = 'draft' - mergeableEmoji = ':pencil2:' - break - default: - mergeableStatus = mergeableState || 'unknown' - mergeableEmoji = ':grey_question:' - } - } catch (error) { - console.log( - `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` - ) - } - - // Skip PRs that have already been actioned (reviewed) - if (reviewStatus !== 'no-reviews') { - console.log(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`) - continue - } - - // Skip PRs with merge conflicts - if (mergeableStatus === 'conflicts') { - console.log(`PR #${pr.number} has merge conflicts, skipping...`) - continue - } - - stalePRs.push({ - number: pr.number, - title: pr.title, - url: pr.html_url, - author: pr.user.login, - createdAt: pr.created_at, - hoursOld: hoursOld, - daysOld: daysOld, - fileCount: files.filter((f) => f.filename.startsWith(DASHBOARD_PATH)).length, - reviewStatus: reviewStatus, - reviewEmoji: reviewEmoji, - mergeableStatus: mergeableStatus, - mergeableEmoji: mergeableEmoji, - }) - - console.log( - `✓ Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` - ) - } - } - - page++ - } - - console.log(`Found ${stalePRs.length} stale Dashboard PRs`) - - // Sort by age (newest first) - stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) - - // Store results for next step - core.setOutput('stale_prs', JSON.stringify(stalePRs)) - core.setOutput('count', stalePRs.length) - - return stalePRs -} diff --git a/scripts/actions/find-stale-dashboard-prs.ts b/scripts/actions/find-stale-dashboard-prs.ts new file mode 100644 index 0000000000000..c73f52e0aa4aa --- /dev/null +++ b/scripts/actions/find-stale-dashboard-prs.ts @@ -0,0 +1,266 @@ +const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) +const DASHBOARD_PATH = 'apps/studio/' +const REPO_OWNER = 'supabase' +const REPO_NAME = 'supabase' + +const GITHUB_TOKEN = process.env.GITHUB_TOKEN + +class RateLimitError extends Error { + constructor(resetAt: string) { + super(`GitHub API rate limit exceeded. Resets at ${resetAt}`) + } +} + +async function githubApi(path: string) { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (GITHUB_TOKEN) { + headers.Authorization = `Bearer ${GITHUB_TOKEN}` + } + + const response = await fetch(`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}${path}`, { + headers, + }) + + if ( + response.status === 429 || + (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') + ) { + const resetEpoch = response.headers.get('x-ratelimit-reset') + const resetAt = resetEpoch ? new Date(Number(resetEpoch) * 1000).toISOString() : 'unknown' + throw new RateLimitError(resetAt) + } + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`GitHub API error: ${response.status} ${response.statusText}\n${errorText}`) + } + + return response.json() +} + +interface StalePR { + number: number + title: string + url: string + author: string + createdAt: string + hoursOld: number + daysOld: number + fileCount: number + reviewStatus: string + reviewEmoji: string + mergeableStatus: string + mergeableEmoji: string +} + +async function findStalePRs(): Promise { + console.error(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) + + const stalePRs: StalePR[] = [] + let page = 1 + let hasMore = true + + outer: while (hasMore && page <= 10) { + console.error(`Fetching page ${page}...`) + + let prs: any[] + try { + prs = await githubApi( + `/pulls?state=open&sort=created&direction=desc&per_page=100&page=${page}` + ) + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while listing PRs. ${error.message}`) + break + } + throw error + } + + if (prs.length === 0) { + hasMore = false + break + } + + for (const pr of prs) { + // Skip PRs from forks + if (pr.head.repo && pr.head.repo.full_name !== `${REPO_OWNER}/${REPO_NAME}`) { + console.error(`PR #${pr.number} is from a fork, skipping...`) + continue + } + + // Skip dependabot PRs + if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { + console.error(`PR #${pr.number} is from dependabot, skipping...`) + continue + } + + // Skip draft PRs + if (pr.draft) { + console.error(`PR #${pr.number} is a draft, skipping...`) + continue + } + + const createdAt = new Date(pr.created_at) + + if (createdAt > TWENTY_FOUR_HOURS_AGO) { + console.error(`PR #${pr.number} is too new, skipping...`) + continue + } + + console.error(`Checking PR #${pr.number}: ${pr.title}`) + + let files: any[] + try { + files = await githubApi(`/pulls/${pr.number}/files?per_page=100`) + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching files. ${error.message}`) + break outer + } + throw error + } + + const touchesDashboard = files.some((file: any) => file.filename.startsWith(DASHBOARD_PATH)) + + if (!touchesDashboard) continue + + const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) + const daysOld = Math.floor(hoursOld / 24) + + // Fetch review status + let reviewStatus = 'no-reviews' + let reviewEmoji = ':eyes:' + try { + const reviews = await githubApi(`/pulls/${pr.number}/reviews?per_page=100`) + + if (reviews.length > 0) { + const latestReviews: Record = {} + reviews.forEach((review: any) => { + if ( + !latestReviews[review.user.login] || + new Date(review.submitted_at) > + new Date(latestReviews[review.user.login].submitted_at) + ) { + latestReviews[review.user.login] = review + } + }) + + const states = Object.values(latestReviews).map((r) => r.state) + if (states.includes('CHANGES_REQUESTED')) { + reviewStatus = 'changes-requested' + reviewEmoji = ':warning:' + } else if (states.includes('APPROVED')) { + reviewStatus = 'approved' + reviewEmoji = ':heavy_check_mark:' + } + } + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching reviews. ${error.message}`) + break outer + } + console.error( + `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` + ) + } + + // Get mergeable state + let mergeableStatus = 'unknown' + let mergeableEmoji = ':grey_question:' + try { + const fullPR = await githubApi(`/pulls/${pr.number}`) + const mergeableState = fullPR.mergeable_state + + switch (mergeableState) { + case 'clean': + mergeableStatus = 'ready' + mergeableEmoji = ':rocket:' + break + case 'dirty': + mergeableStatus = 'conflicts' + mergeableEmoji = ':collision:' + break + case 'blocked': + mergeableStatus = 'blocked' + mergeableEmoji = ':no_entry:' + break + case 'unstable': + mergeableStatus = 'unstable' + mergeableEmoji = ':warning:' + break + case 'behind': + mergeableStatus = 'behind' + mergeableEmoji = ':arrow_down:' + break + case 'draft': + mergeableStatus = 'draft' + mergeableEmoji = ':pencil2:' + break + default: + mergeableStatus = mergeableState || 'unknown' + mergeableEmoji = ':grey_question:' + } + } catch (error: any) { + if (error instanceof RateLimitError) { + console.error(`Rate limited while fetching mergeable state. ${error.message}`) + break outer + } + console.error( + `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` + ) + } + + // Skip PRs that have already been reviewed + if (reviewStatus !== 'no-reviews') { + console.error(`PR #${pr.number} has already been reviewed (${reviewStatus}), skipping...`) + continue + } + + // Skip PRs with merge conflicts + if (mergeableStatus === 'conflicts') { + console.error(`PR #${pr.number} has merge conflicts, skipping...`) + continue + } + + stalePRs.push({ + number: pr.number, + title: pr.title, + url: pr.html_url, + author: pr.user.login, + createdAt: pr.created_at, + hoursOld, + daysOld, + fileCount: files.filter((f: any) => f.filename.startsWith(DASHBOARD_PATH)).length, + reviewStatus, + reviewEmoji, + mergeableStatus, + mergeableEmoji, + }) + + console.error( + `Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` + ) + } + + page++ + } + + console.error(`Found ${stalePRs.length} stale Dashboard PRs`) + + stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) + + return stalePRs +} + +findStalePRs() + .then((stalePRs) => { + // Output JSON to stdout for piping to the next script + console.log(JSON.stringify(stalePRs)) + }) + .catch((error) => { + console.error('Error:', error.message) + process.exit(1) + }) diff --git a/scripts/actions/send-slack-pr-notification.js b/scripts/actions/send-slack-pr-notification.ts similarity index 65% rename from scripts/actions/send-slack-pr-notification.js rename to scripts/actions/send-slack-pr-notification.ts index c9e56b0412377..7396c8a7af749 100644 --- a/scripts/actions/send-slack-pr-notification.js +++ b/scripts/actions/send-slack-pr-notification.ts @@ -1,31 +1,42 @@ -/** - * Sends a Slack notification with stale Dashboard PRs - * - * @param {Array} stalePRs - Array of stale PRs from find-stale-dashboard-prs.js - * @param {string} webhookUrl - Slack webhook URL - */ -module.exports = async (stalePRs, webhookUrl) => { +const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL + +if (!SLACK_WEBHOOK_URL) { + console.error('SLACK_WEBHOOK_URL environment variable is required') + process.exit(1) +} + +interface StalePR { + number: number + title: string + url: string + author: string + createdAt: string + hoursOld: number + daysOld: number + fileCount: number + reviewStatus: string + reviewEmoji: string + mergeableStatus: string + mergeableEmoji: string +} + +function escapeSlack(text: string) { + return text.replace(/&/g, '&').replace(//g, '>') +} + +async function sendSlackNotification(stalePRs: StalePR[]) { const count = stalePRs.length - // Build PR blocks with proper escaping for Slack mrkdwn const prBlocks = stalePRs.map((pr) => { - // Format age display const remainingHours = pr.hoursOld % 24 const ageText = pr.daysOld > 0 ? `${pr.daysOld}d ${remainingHours}h` : `${pr.hoursOld}h` - // Escape special characters for Slack mrkdwn (escape &, <, >) - const escapeSlack = (text) => { - return text.replace(/&/g, '&').replace(//g, '>') - } - - // Truncate title if too long (max 3000 chars for entire text field) const maxTitleLength = 200 const safeTitle = pr.title.length > maxTitleLength ? escapeSlack(pr.title.substring(0, maxTitleLength) + '...') : escapeSlack(pr.title) - // Format status text const reviewStatusText = pr.reviewStatus === 'approved' ? 'Approved' @@ -59,13 +70,10 @@ module.exports = async (stalePRs, webhookUrl) => { } }) - // Slack has a 50 block limit, we use 3 for header/intro/divider - // So we can show max 47 PRs const MAX_PRS_TO_SHOW = 47 const prBlocksToShow = prBlocks.slice(0, MAX_PRS_TO_SHOW) const hasMorePRs = prBlocks.length > MAX_PRS_TO_SHOW - // Build complete Slack message const slackMessage = { text: 'Dashboard PRs needing attention', blocks: [ @@ -90,12 +98,9 @@ module.exports = async (stalePRs, webhookUrl) => { ], } - // Send to Slack - const response = await fetch(webhookUrl, { + const response = await fetch(SLACK_WEBHOOK_URL!, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(slackMessage), }) @@ -106,5 +111,30 @@ module.exports = async (stalePRs, webhookUrl) => { ) } - console.log('✓ Slack notification sent successfully!') + console.error('Slack notification sent successfully!') } + +// Read JSON from stdin +async function readStdin(): Promise { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(chunk) + } + return Buffer.concat(chunks).toString('utf-8') +} + +readStdin() + .then(async (input) => { + const stalePRs: StalePR[] = JSON.parse(input) + + if (stalePRs.length === 0) { + console.error('No stale PRs to notify about') + return + } + + await sendSlackNotification(stalePRs) + }) + .catch((error) => { + console.error('Error:', error.message) + process.exit(1) + }) From 65dac5429e71f6d507d3f3a3314fff92a78b7a89 Mon Sep 17 00:00:00 2001 From: Ali Waseem Date: Fri, 20 Feb 2026 07:55:42 -0700 Subject: [PATCH 5/5] feat: show recent invocations in functions view (#43030) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? By far the most important feature in the functions view is the Invocations tab. This just adds recent invocations to the dashboard for a function. So it's easy for you to digest what's happening. ## Demo image --- .../EdgeFunctionRecentInvocations.tsx | 139 ++++++++++++++++++ ...dgeFunctionRecentInvocations.utils.test.ts | 87 +++++++++++ .../EdgeFunctionRecentInvocations.utils.ts | 37 +++++ .../[ref]/functions/[functionSlug]/index.tsx | 14 +- 4 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx new file mode 100644 index 0000000000000..04456c2dc279f --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx @@ -0,0 +1,139 @@ +import { useParams } from 'common' +import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants' +import useLogsPreview from 'hooks/analytics/useLogsPreview' +import { Clock, ExternalLink, RefreshCw } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { Button, cn } from 'ui' +import { Admonition, TimestampInfo } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +interface EdgeFunctionRecentInvocationsProps { + functionId: string + functionSlug: string +} + +export const EdgeFunctionRecentInvocations = ({ + functionId, + functionSlug, +}: EdgeFunctionRecentInvocationsProps) => { + const { ref } = useParams() + const router = useRouter() + + const { logData, isLoading, isSuccess, refresh } = useLogsPreview({ + projectRef: ref as string, + table: LOGS_TABLES.fn_edge, + filterOverride: { function_id: functionId }, + limit: 10, + }) + + return ( +
+
+
+

Recent Invocations

+

+ Latest invocation requests for this function +

+
+ +
+ + {isLoading && !isSuccess ? ( + + ) : logData.length === 0 ? ( + + ) : ( +
+ {logData.map((log) => { + const statusCode = String(log.status_code ?? '') + const method = String(log.method ?? '') + const executionTime = log.execution_time_ms + const is2xx = statusCode.startsWith('2') + const is4xx = statusCode.startsWith('4') + const is5xx = statusCode.startsWith('5') + const logUrl = `/project/${ref}/functions/${functionSlug}/invocations?log=${log.id}` + + return ( +
router.push(logUrl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + router.push(logUrl) + } + }} + className="group flex items-center font-mono px-3 py-2 gap-3 bg-surface-100 cursor-pointer hover:bg-surface-200 transition-colors" + > + + + +
+ {statusCode ? ( +
+ {statusCode} +
+ ) : ( + - + )} +
+ {method || '-'} + {executionTime !== undefined && ( + + + {Number(executionTime).toFixed(0)}ms + + )} + + {parseEdgeFunctionEventMessage( + String(log.event_message ?? ''), + method, + statusCode + )} + + +
+ ) + })} + + View all invocations + +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts new file mode 100644 index 0000000000000..e5809e2517b85 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +describe('parseEdgeFunctionEventMessage', () => { + it('should strip method and status code when they match the structured fields', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should strip method and status code for different HTTP methods', () => { + const message = 'GET | 404 | https://example.supabase.red/functions/v1/not-found' + expect(parseEdgeFunctionEventMessage(message, 'GET', '404')).toBe( + 'https://example.supabase.red/functions/v1/not-found' + ) + }) + + it('should strip method and status code for 500 errors', () => { + const message = 'POST | 500 | https://example.supabase.red/functions/v1/broken' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe( + 'https://example.supabase.red/functions/v1/broken' + ) + }) + + it('should preserve the rest of the message when it contains pipe separators', () => { + const message = 'POST | 200 | some | complex | message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe('some | complex | message') + }) + + it('should return original message when method does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'GET', '200')).toBe(message) + }) + + it('should return original message when status code does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should return original message when method is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, '200')).toBe(message) + }) + + it('should return original message when status code is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', undefined)).toBe(message) + }) + + it('should return original message when both method and status code are undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, undefined)).toBe(message) + }) + + it('should return original message when format has fewer than 3 parts', () => { + const message = 'some simple message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return original message when format has only 2 pipe-separated parts', () => { + const message = 'POST | 200' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return empty string for empty input', () => { + expect(parseEdgeFunctionEventMessage('', 'POST', '200')).toBe('') + }) + + it('should handle whitespace around parts correctly', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should return original message when the message does not follow the expected format', () => { + const message = 'Error: function crashed unexpectedly' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should handle messages with extra whitespace in the URL portion', () => { + const message = 'DELETE | 204 | ' + expect(parseEdgeFunctionEventMessage(message, 'DELETE', '204')).toBe('') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts new file mode 100644 index 0000000000000..37145dc578707 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts @@ -0,0 +1,37 @@ +/** + * Parses an edge function invocation event_message to extract the meaningful + * content, stripping out the method and status code that are already displayed + * as structured fields. + * + * The event_message typically follows the format: + * "{METHOD} | {STATUS_CODE} | {URL_OR_DETAIL}" + * + * e.g. "POST | 200 | https://example.supabase.red/functions/v1/hello-world" + * + * When the structured method and status_code fields match what's in the message, + * we strip them out to avoid duplication. If parsing fails or the message doesn't + * match the expected format, we return the original message as-is. + */ +export function parseEdgeFunctionEventMessage( + eventMessage: string, + method?: string, + statusCode?: string +): string { + if (!eventMessage) return eventMessage + + const parts = eventMessage.split(' | ') + + if (parts.length < 3) return eventMessage + + const messageMethod = parts[0].trim() + const messageStatus = parts[1].trim() + + const methodMatches = method !== undefined && messageMethod === method + const statusMatches = statusCode !== undefined && messageStatus === statusCode + + if (methodMatches && statusMatches) { + return parts.slice(2).join(' | ').trim() + } + + return eventMessage +} diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 7c7b05294bdb6..b2708a941938a 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useParams } from 'common' -import { useFlag } from 'common' +import { IS_PLATFORM, useFlag, useParams } from 'common' +import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' @@ -22,9 +22,9 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import type { ChartIntervals, NextPageWithLayout } from 'types' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, WarningIcon, } from 'ui' @@ -138,6 +138,14 @@ const PageLayout: NextPageWithLayout = () => { + {IS_PLATFORM && id && ( +
+ +
+ )}
{CHART_INTERVALS.map((item, i) => {