From 8cb4404065a3b45537e932fc677df4177f709152 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:48:52 +0100 Subject: [PATCH 01/12] fix: feature preview showing disabled features (#43339) fixes feature preview showing disabled features by flag. ## to test - go to feature previews - should not see unified logs --- .../FeaturePreview/FeaturePreviewContext.tsx | 3 ++- .../FeaturePreview/FeaturePreviewModal.tsx | 20 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 7fbd506af9fde..2b6eb4d64c9e0 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -82,8 +82,9 @@ export const useIsColumnLevelPrivilegesEnabled = () => { export const useUnifiedLogsPreview = () => { const { flags, onUpdateFlag } = useFeaturePreviewContext() + const unifiedLogsEnabled = useFlag('unifiedLogs') - const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] + const isEnabled = unifiedLogsEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS] const enable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, true) const disable = () => onUpdateFlag(LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, false) diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index faaeae557e0ae..ca9f986f09f67 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -65,15 +65,17 @@ export const FeaturePreviewModal = () => { ) const { flags, onUpdateFlag } = featurePreviewContext - const selectedFeature = - featurePreviews.find((preview) => preview.key === selectedFeatureKey) ?? featurePreviews[0] - const isSelectedFeatureEnabled = flags[selectedFeatureKey] + const allFeaturePreviews = ( + IS_PLATFORM ? featurePreviews : featurePreviews.filter((x) => !x.isPlatformOnly) + ).filter((x) => x.enabled) - const allFeaturePreviews = IS_PLATFORM - ? featurePreviews - : featurePreviews.filter((x) => !x.isPlatformOnly) + const selectedFeature = + allFeaturePreviews.find((preview) => preview.key === selectedFeatureKey) ?? + allFeaturePreviews[0] + const isSelectedFeatureEnabled = flags[selectedFeature?.key] const toggleFeature = () => { + if (!selectedFeature) return onUpdateFlag(selectedFeature.key, !isSelectedFeatureEnabled) sendEvent({ action: isSelectedFeatureEnabled ? 'feature_preview_disabled' : 'feature_preview_enabled', @@ -100,7 +102,7 @@ export const FeaturePreviewModal = () => { - {featurePreviews.length > 0 ? ( + {allFeaturePreviews.length > 0 ? (
@@ -113,7 +115,7 @@ export const FeaturePreviewModal = () => { onClick={() => selectFeaturePreview(feature.key)} className={cn( 'flex items-center justify-between p-4 border-b cursor-pointer bg transition', - selectedFeature.key === feature.key ? 'bg-surface-300' : 'bg-surface-100' + selectedFeature?.key === feature.key ? 'bg-surface-300' : 'bg-surface-100' )} >
@@ -152,7 +154,7 @@ export const FeaturePreviewModal = () => {
- {FEATURE_PREVIEW_KEY_TO_CONTENT[selectedFeature.key]} + {FEATURE_PREVIEW_KEY_TO_CONTENT[selectedFeature?.key ?? '']}
) : ( From 173a939cc31fa64bba068df3a3a27e278a44acfe Mon Sep 17 00:00:00 2001 From: Taryn King <49492414+tk1ng@users.noreply.github.com> Date: Fri, 6 Mar 2026 05:10:41 -0600 Subject: [PATCH 02/12] chore(docs): add circuit breaker error reference (#43362) ## 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? Small docs update to troubleshooting guide. Adds circuit breaker error to list of related errors that troubleshooting guide addresses. --- ...when-trying-to-connect-to-supabase-database-hwG0Dr.mdx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx b/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx index 43284c297b670..fa251d5016708 100644 --- a/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx +++ b/apps/docs/content/troubleshooting/error-connection-refused-when-trying-to-connect-to-supabase-database-hwG0Dr.mdx @@ -16,8 +16,12 @@ message = "connect ECONNREFUSED 1.2.3.4:5432" message = "psql: error: connection to server at \"db.xxxxxxxxxxxxxxxxxxxx.supabase.co\" (1.2.3.4), port 5432 failed: Connection refused Is the server running on that host and accepting TCP/IP connections?" --- -If you're not able to connect to the Supabase database and see the error `connect ECONNREFUSED 1.2.3.4:5432` or `psql: error: connection to server at "db.xxxxxxxxxxxxxxxxxxxx.supabase.co" (1.2.3.4), port 5432 failed: Connection refused -Is the server running on that host and accepting TCP/IP connections?`, this could be because there are banned IPs on your project caused by Fail2ban as it kicks in when attempting 2 wrong passwords in a row. +If you're not able to connect to the Supabase database and see one of the errors below, this could be because there are banned IPs on your project caused by Fail2ban as it kicks in when attempting 2 wrong passwords in a row. + +- `connect ECONNREFUSED 1.2.3.4:5432` +- `psql: error: connection to server at "db.xxxxxxxxxxxxxxxxxxxx.supabase.co" (1.2.3.4), port 5432 failed: Connection refused +Is the server running on that host and accepting TCP/IP connections?` +- `Circuit breaker open: Unable to establish connection to upstream database` These bans will clear after 30mins but you can unban the IPs using the Supabase CLI https://supabase.com/docs/guides/cli following the commands below. From 3bd3c3412e40e9a665bd8bf2ce2bc73c764a8394 Mon Sep 17 00:00:00 2001 From: Cam Michie <63932985+cameron-michie@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:21:21 +0000 Subject: [PATCH 03/12] Add name Cameron Michie to humans.txt (#43380) ## 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? Add my name to humans.txt ## What is the current behavior? n/a ## What is the new behavior? n/a ## Additional context n/a Co-authored-by: Cam Michie --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index f6ebc3f70d810..d62be0e15e0cc 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -37,6 +37,7 @@ Brendan Stephens Brent Newson Carel de Waal Cameron Blackwood +Cameron Michie Cemal Kılıç Chandana Anumula Charis Lam From e29f4ca6f30a45d3628d3bc21bc97318d5570e55 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Fri, 6 Mar 2026 09:05:25 -0300 Subject: [PATCH 04/12] chore: add entitlement check to observability log retention (#43469) ### Changes - Replace plan-based checks (`LOG_RETENTION`, `availableIn`) with `useCheckEntitlements('log.retention_days')` in the chart interval dropdown - `ChartIntervalDropdown` now calls the entitlements hook internally instead of receiving plan props - Remove `LOG_RETENTION` constant and `availableIn` from `CHART_INTERVALS` - Update consumer components (`ProjectUsage`, `ProjectUsageSection`, `ObservabilityOverview`) to remove plan prop passing ### Testing - Head to `/project/_/observability` with a Free plan org. - Click on the time selector "Last 7 days" is disabled, default is "Last 60 minutes", tooltip shows retention limit and upgrade link image - With a Pro Org and above the "Last 7 days" option should be enabled --- .../interfaces/Home/ProjectUsage.tsx | 16 +++++++----- .../HomeNew/ProjectUsageSection.tsx | 16 +++++++----- .../Observability/ObservabilityOverview.tsx | 4 --- .../ui/Logs/ChartIntervalDropdown.tsx | 25 +++++++++++-------- apps/studio/components/ui/Logs/logs.utils.ts | 11 -------- apps/studio/types/ui.ts | 2 -- 6 files changed, 34 insertions(+), 40 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectUsage.tsx b/apps/studio/components/interfaces/Home/ProjectUsage.tsx index 3422b71595799..71adca4495e70 100644 --- a/apps/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/apps/studio/components/interfaces/Home/ProjectUsage.tsx @@ -10,14 +10,14 @@ import { } from 'data/analytics/project-log-stats-query' import dayjs from 'dayjs' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Auth, Database, Realtime, Storage } from 'icons' import sumBy from 'lodash/sumBy' import Link from 'next/link' import { useRouter } from 'next/router' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Loading } from 'ui' type ChartIntervalKey = ProjectLogStatsVariables['interval'] @@ -32,12 +32,18 @@ const ProjectUsage = () => { 'project_storage:all', ]) - const { plan } = useCurrentOrgPlan() + const { getEntitlementMax } = useCheckEntitlements('log.retention_days') + const retentionDays = getEntitlementMax() - const DEFAULT_INTERVAL: ChartIntervalKey = plan?.id === 'free' ? '1hr' : '1day' + const DEFAULT_INTERVAL: ChartIntervalKey = + retentionDays !== undefined && retentionDays < 7 ? '1hr' : '1day' const [interval, setInterval] = useState(DEFAULT_INTERVAL) + useEffect(() => { + setInterval(retentionDays !== undefined && retentionDays < 7 ? '1hr' : '1day') + }, [retentionDays]) + const { data, isPending: isLoading } = useProjectLogStatsQuery({ projectRef, interval }) const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] @@ -94,8 +100,6 @@ const ProjectUsage = () => { setInterval(interval as ProjectLogStatsVariables['interval'])} - planId={plan?.id} - planName={plan?.name} organizationSlug={organization?.slug} dropdownAlign="start" tooltipSide="right" diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx index c50067a5ed33c..d642ce60f5a79 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -10,12 +10,12 @@ import { import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import dayjs from 'dayjs' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import Link from 'next/link' import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Card, CardContent, CardHeader, CardTitle, Loading } from 'ui' import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' @@ -55,11 +55,17 @@ export const ProjectUsageSection = () => { 'project_auth:all', 'project_storage:all', ]) - const { plan } = useCurrentOrgPlan() + const { getEntitlementMax } = useCheckEntitlements('log.retention_days') + const retentionDays = getEntitlementMax() - const DEFAULT_INTERVAL: ChartIntervalKey = plan?.id === 'free' ? '1hr' : '1day' + const DEFAULT_INTERVAL: ChartIntervalKey = + retentionDays !== undefined && retentionDays < 7 ? '1hr' : '1day' const [interval, setInterval] = useState(DEFAULT_INTERVAL) + useEffect(() => { + setInterval(retentionDays !== undefined && retentionDays < 7 ? '1hr' : '1day') + }, [retentionDays]) + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] const { datetimeFormat } = useMemo(() => { @@ -208,8 +214,6 @@ export const ProjectUsageSection = () => { setInterval(interval as ChartIntervalKey)} - planId={plan?.id} - planName={plan?.name} organizationSlug={organization?.slug} dropdownAlign="end" tooltipSide="left" diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx index 998916d89243b..c8235010cc1c3 100644 --- a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -5,7 +5,6 @@ import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import dayjs from 'dayjs' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { RefreshCw } from 'lucide-react' @@ -25,7 +24,6 @@ export const ObservabilityOverview = () => { const router = useRouter() const { ref: projectRef } = useParams() const { data: organization } = useSelectedOrganizationQuery() - const { plan } = useCurrentOrgPlan() const queryClient = useQueryClient() const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) @@ -156,8 +154,6 @@ export const ObservabilityOverview = () => { setInterval(interval as ChartIntervalKey)} - planId={plan?.id} - planName={plan?.name} organizationSlug={organization?.slug} dropdownAlign="end" tooltipSide="left" diff --git a/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx index 15728fc0f28c5..24b11d79c9016 100644 --- a/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx +++ b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx @@ -1,4 +1,5 @@ import { InlineLink } from 'components/ui/InlineLink' +import { useCheckEntitlements } from 'hooks/misc/useCheckEntitlements' import { ChevronDown } from 'lucide-react' import { Button, @@ -12,15 +13,17 @@ import { TooltipTrigger, } from 'ui' -import { CHART_INTERVALS, LOG_RETENTION } from './logs.utils' +import { CHART_INTERVALS } from './logs.utils' -type PlanId = keyof typeof LOG_RETENTION +function getDaysRequired(startValue: number, startUnit: string): number { + if (startUnit === 'day') return startValue + if (startUnit === 'hour') return startValue / 24 + return 0 +} interface ChartIntervalDropdownProps { value: string onChange: (value: string) => void - planId?: string - planName?: string organizationSlug?: string dropdownAlign?: 'start' | 'center' | 'end' tooltipSide?: 'left' | 'right' | 'top' | 'bottom' @@ -29,14 +32,14 @@ interface ChartIntervalDropdownProps { export const ChartIntervalDropdown = ({ value, onChange, - planId = 'free', - planName, organizationSlug, dropdownAlign = 'start', tooltipSide = 'right', }: ChartIntervalDropdownProps) => { const selectedInterval = CHART_INTERVALS.find((i) => i.key === value) || CHART_INTERVALS[1] - const normalizedPlanId = (planId && planId in LOG_RETENTION ? planId : 'free') as PlanId + + const { getEntitlementMax } = useCheckEntitlements('log.retention_days') + const retentionDays = getEntitlementMax() return ( @@ -48,10 +51,10 @@ export const ChartIntervalDropdown = ({ {CHART_INTERVALS.map((i) => { - const disabled = !i.availableIn?.includes(normalizedPlanId) + const daysRequired = getDaysRequired(i.startValue, i.startUnit) + const disabled = retentionDays !== undefined && daysRequired > retentionDays if (disabled) { - const retentionDuration = LOG_RETENTION[normalizedPlanId] return ( @@ -61,8 +64,8 @@ export const ChartIntervalDropdown = ({

- {planName} plan only includes up to {retentionDuration} day - {retentionDuration > 1 ? 's' : ''} of log retention + Your plan only includes up to {retentionDays} day + {retentionDays !== undefined && retentionDays > 1 ? 's' : ''} of log retention

{organizationSlug ? ( diff --git a/apps/studio/components/ui/Logs/logs.utils.ts b/apps/studio/components/ui/Logs/logs.utils.ts index b6eed6c53a4d1..5369cbe4c1475 100644 --- a/apps/studio/components/ui/Logs/logs.utils.ts +++ b/apps/studio/components/ui/Logs/logs.utils.ts @@ -1,13 +1,5 @@ import type { ChartIntervals } from 'types' -export const LOG_RETENTION = { - free: 1, - pro: 7, - team: 28, - enterprise: 90, - platform: 1, -} - export const CHART_INTERVALS: ChartIntervals[] = [ { key: '1hr', @@ -15,7 +7,6 @@ export const CHART_INTERVALS: ChartIntervals[] = [ startValue: 1, startUnit: 'hour', format: 'MMM D, h:mma', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], }, { key: '1day', @@ -23,7 +14,6 @@ export const CHART_INTERVALS: ChartIntervals[] = [ startValue: 24, startUnit: 'hour', format: 'MMM D, ha', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], }, { key: '7day', @@ -31,6 +21,5 @@ export const CHART_INTERVALS: ChartIntervals[] = [ startValue: 7, startUnit: 'day', format: 'MMM D', - availableIn: ['pro', 'team', 'enterprise'], }, ] diff --git a/apps/studio/types/ui.ts b/apps/studio/types/ui.ts index bd47d809ad5b1..f642cce1fc801 100644 --- a/apps/studio/types/ui.ts +++ b/apps/studio/types/ui.ts @@ -1,6 +1,5 @@ import type { PostgresColumn } from '@supabase/postgres-meta' import { ProjectLogStatsVariables } from 'data/analytics/project-log-stats-query' -import { PlanId } from 'data/subscriptions/types' export interface Notification { category: 'info' | 'error' | 'success' | 'loading' @@ -23,7 +22,6 @@ export interface ChartIntervals { startValue: number startUnit: 'minute' | 'hour' | 'day' format?: 'MMM D, h:mm:ssa' | 'MMM D, h:mma' | 'MMM D, ha' | 'MMM D' - availableIn?: PlanId[] } export interface VaultSecret { From 4b994fcb543db8e53c7955e1dde04329578a1fdf Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:24:37 +0100 Subject: [PATCH 05/12] feat: copy multiple logs (#43218) Allows users to select logs and copy their contents for debugging, or pass them on to assistant with one click. ## To test - go to logs or log explorer - select some logs - try copying as json, markdown, or sending it to assistant. --- .../interfaces/Settings/Logs/LogTable.tsx | 376 ++++++++++++------ .../Settings/Logs/Logs.utils.test.ts | 86 ++++ .../interfaces/Settings/Logs/Logs.utils.ts | 94 ++++- .../Settings/Logs/MultiSelectActionBar.tsx | 98 +++++ .../components/ui/AiAssistantDropdown.tsx | 24 +- .../project/[ref]/logs/explorer/index.tsx | 1 + apps/studio/styles/react-data-grid-logs.scss | 10 + .../tests/features/logs/LogTable.test.tsx | 42 +- .../features/logs/LogsPreviewer.test.tsx | 32 +- .../ui/src/components/shadcn/ui/checkbox.tsx | 2 +- 10 files changed, 606 insertions(+), 159 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 09c2fab36b391..194a175f8bd43 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,8 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM, useParams } from 'common' import { isEqual } from 'lodash' -import { Copy, Eye, EyeOff, Play } from 'lucide-react' -import { Key, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { Copy, Eye, EyeOff, Play, X as XIcon } from 'lucide-react' +import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Item, Menu, useContextMenu } from 'react-contexify' import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid' import { createPortal } from 'react-dom' @@ -10,6 +10,7 @@ import { toast } from 'sonner' import type { ResponseError } from 'types' import { Button, + Checkbox_Shadcn_, cn, copyToClipboard, ResizableHandle, @@ -24,11 +25,12 @@ import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewCol import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender' import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' import type { LogData, LogQueryError, QueryType } from './Logs.types' -import { isDefaultLogPreviewFormat } from './Logs.utils' +import { formatLogsAsJson, formatLogsAsMarkdown, isDefaultLogPreviewFormat } from './Logs.utils' import LogSelection from './LogSelection' import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer' import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' import { LogsTableEmptyState } from './LogsTableEmptyState' +import { MultiSelectActionBar } from './MultiSelectActionBar' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' import { useSelectedLog } from '@/hooks/analytics/useSelectedLog' @@ -56,6 +58,7 @@ interface Props { isSelectedLogLoading?: boolean selectedLogError?: LogQueryError | ResponseError onSelectedLogChange?: (log: LogData | null) => void + sqlQuery?: string } type LogMap = { [id: string]: LogData } @@ -84,6 +87,7 @@ export const LogTable = ({ isSelectedLogLoading, selectedLogError, onSelectedLogChange, + sqlQuery, }: Props) => { const { ref } = useParams() const { profile } = useProfile() @@ -91,8 +95,10 @@ export const LogTable = ({ const { show: showContextMenu } = useContextMenu() const [cellPosition, setCellPosition] = useState() - const [selectionOpen, setSelectionOpen] = useState(false) const [selectedRow, setSelectedRow] = useState(null) + const [selectedRows, setSelectedRows] = useState>(new Set()) + const [anchorRowId, setAnchorRowId] = useState(null) + const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null) const { can: canCreateLogQuery } = useAsyncCheckPermissions( PermissionAction.CREATE, @@ -105,14 +111,10 @@ export const LogTable = ({ const firstRow = data[0] - // move timestamp to the first column, if it exists function getFirstRow() { if (!firstRow) return {} - const { timestamp, ...rest } = firstRow - if (!timestamp) return firstRow - return { timestamp, ...rest } } @@ -124,6 +126,82 @@ export const LogTable = ({ const panelContentMaxSize = 60 const LOGS_EXPLORER_CONTEXT_MENU_ID = 'logs-explorer-context-menu' + + const getRowKey = useCallback( + (row: LogData): string => { + if (!hasId) return JSON.stringify(row) + return (row as LogData).id + }, + [hasId] + ) + + const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => { + const deduped = [...new Set(data)] as LogData[] + if (!hasId) return [deduped, {}] + const map = deduped.reduce((acc: LogMap, d: LogData) => { + acc[d.id] = d + return acc + }, {}) + return [deduped, map] + }, [data, hasId]) + + const logDataRows = useMemo(() => { + if (hasId && hasTimestamp) { + return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp) + } else { + return dedupedData + } + }, [dedupedData, hasId, hasTimestamp, logMap]) + + // Side panel is open only when a single row is selected via regular click (not multi-select) + const selectionOpen = Boolean((selectedLog || isSelectedLogLoading) && selectedRows.size === 0) + + const selectedRowsData = useMemo( + () => logDataRows.filter((r) => selectedRows.has(getRowKey(r))), + [logDataRows, selectedRows, getRowKey] + ) + + const checkboxColumn: Column = { + key: 'multi-select', + name: '', + width: 32, + maxWidth: 32, + minWidth: 32, + renderCell: ({ row }) => { + const key = getRowKey(row) + const toggle = () => { + const next = new Set(selectedRows) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + setSelectedRows(next) + setAnchorRowId(key) + if (next.size > 0) { + setSelectedRow(null) + onSelectedLogChange?.(null) + } + } + return ( +

{ + e.stopPropagation() + toggle() + }} + > + e.stopPropagation()} + onCheckedChange={toggle} + /> +
+ ) + }, + } + const DEFAULT_COLUMNS = columnNames.map((v: keyof LogData, idx) => { const column = `logs-column-${idx}` const result: Column = { @@ -148,7 +226,6 @@ export const LogTable = ({ }, minWidth: 128, } - return result }) @@ -161,25 +238,21 @@ export const LogTable = ({ case 'api': columns = DatabaseApiColumnRender break - case 'database': columns = DatabasePostgresColumnRender break - case 'fn_edge': columns = FunctionsEdgeColumnRender break case 'functions': columns = FunctionsLogsColumnRender break - case 'auth': columns = AuthColumnRenderer break case 'pg_cron': columns = DatabasePostgresColumnRender break - default: if (firstRow && isDefaultLogPreviewFormat(firstRow)) { columns = DefaultPreviewColumnRenderer @@ -190,37 +263,25 @@ export const LogTable = ({ } } - const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => { - const deduped = [...new Set(data)] as LogData[] - - if (!hasId) { - return [deduped, {}] - } - - const map = deduped.reduce((acc: LogMap, d: LogData) => { - acc[d.id] = d - return acc - }, {}) - - return [deduped, map] - }, [data, hasId]) - - const logDataRows = useMemo(() => { - if (hasId && hasTimestamp) { - return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp) - } else { - return dedupedData - } - }, [dedupedData, hasId, hasTimestamp, logMap]) + if (columns.length > 0) { + columns = [checkboxColumn, ...columns] + } const RowRenderer = useCallback<(key: Key, props: RenderRowProps) => ReactNode>( (key, props) => { const handleContextMenu = (e: React.MouseEvent) => { - if (columns.length > 0) { - setCellPosition({ row: props.row, column: columns[0] }) + const firstDataColumn = columns.find((c) => c.key !== 'multi-select') + if (firstDataColumn) { + setCellPosition({ row: props.row, column: firstDataColumn }) } showContextMenu(e, { id: LOGS_EXPLORER_CONTEXT_MENU_ID }) } + const handleClick = (e: React.MouseEvent) => { + // Check if clicking on the checkbox column - let that handler handle it + const target = e.target as HTMLElement + if (target.closest('[data-column-key="multi-select"]')) return + onRowClick(props.row) + } return ( ) }, @@ -250,6 +312,89 @@ export const LogTable = ({ }) } + function onRowClick(row: LogData) { + const key = getRowKey(row) + + // Regular single click — clear multi-select, open side panel + setSelectedRows(new Set()) + setAnchorRowId(key) + setSelectedRow(row) + onSelectedLogChange?.(row) + } + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSelectedRows(new Set()) + setAnchorRowId(null) + return + } + + // Arrow navigation only in single-select mode + if (!logDataRows.length || !selectedRow || selectedRows.size > 0) return + + const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow)) + if (currentIndex === -1) return + + if (event.key === 'ArrowUp' && currentIndex > 0) { + const prevRow = logDataRows[currentIndex - 1] + onRowClick(prevRow) + } else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) { + const nextRow = logDataRows[currentIndex + 1] + onRowClick(nextRow) + } + }, + [logDataRows, selectedRow, selectedRows, onRowClick] + ) + + useEffect(() => { + if (!isSelectedLogLoading && !selectedLog) { + setSelectedRow(null) + } + }, [selectedLog, isSelectedLogLoading]) + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown) + return () => { + window.removeEventListener('keydown', handleKeyDown) + } + }, [handleKeyDown]) + + useEffect(() => { + if (!isLoading && !selectedRow) { + const logData = data.find((x) => x.id === selectedLogId) + if (logData) setSelectedRow(logData) + } + }, [isLoading, data, selectedRow, selectedLogId]) + + // Clear multi-select when a new query starts loading + useEffect(() => { + if (isLoading) { + setSelectedRows(new Set()) + setAnchorRowId(null) + } + }, [isLoading]) + + // Copy feedback timeout + useEffect(() => { + if (!copiedFormat) return + const timer = setTimeout(() => setCopiedFormat(null), 2000) + return () => clearTimeout(timer) + }, [copiedFormat]) + + function handleCopySelectedRows(format: 'json' | 'markdown') { + const text = + format === 'json' + ? formatLogsAsJson(selectedRowsData) + : formatLogsAsMarkdown(selectedRowsData) + copyToClipboard(text, () => { + setCopiedFormat(format) + toast.success( + `Copied ${selectedRowsData.length} log${selectedRowsData.length !== 1 ? 's' : ''} as ${format.toUpperCase()}` + ) + }) + } + const LogsExplorerTableHeader = () => (
{ if (!error) return null - const childProps = { isCustomQuery: queryType ? false : true, error: error!, } - if ( typeof error === 'object' && error.error?.errors.find((err) => err.reason === 'resourcesExceeded') ) { return } - return (
@@ -339,55 +481,6 @@ export const LogTable = ({ else return } - function onRowClick(row: LogData) { - setSelectedRow(row) - onSelectedLogChange?.(row) - } - - // Keyboard navigation - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (!logDataRows.length || !selectedRow) return - - const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow)) - if (currentIndex === -1) return - - if (event.key === 'ArrowUp' && currentIndex > 0) { - const prevRow = logDataRows[currentIndex - 1] - onRowClick(prevRow) - } else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) { - const nextRow = logDataRows[currentIndex + 1] - onRowClick(nextRow) - } - }, - [logDataRows, selectedRow, onRowClick] - ) - - useEffect(() => { - if (selectedLog || isSelectedLogLoading) { - setSelectionOpen(true) - } - if (!isSelectedLogLoading && !selectedLog) { - setSelectedRow(null) - } - }, [selectedLog, isSelectedLogLoading]) - - useEffect(() => { - window.addEventListener('keydown', handleKeyDown) - return () => { - window.removeEventListener('keydown', handleKeyDown) - } - }, [handleKeyDown]) - - useEffect(() => { - if (!isLoading && !selectedRow) { - // [Joshen] Only want to run this once on a fresh session when log param is provided in URL - // Subsequently, selectedRow state is just controlled by the user's clicks on LogTable - const logData = data.find((x) => x.id === selectedLogId) - if (logData) setSelectedRow(logData) - } - }, [isLoading]) - if (!data) return null return ( @@ -400,46 +493,70 @@ export const LogTable = ({ maxSize={`${panelContentMaxSize}`} defaultSize={`${panelContentMaxSize}`} > - { - setCellPosition(row) - }} - onCellClick={(row) => { - onRowClick(row.row) - }} - columns={columns} - rowClass={(row: LogData) => { - return cn( - 'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer', - { - '!bg-surface-200 rdg-row--focused': isEqual(row, selectedRow), - } - ) - }} - rows={logDataRows} - rowKeyGetter={(r) => { - if (!hasId) return JSON.stringify(r) - const row = r as LogData - return row.id - }} - renderers={{ - renderRow: RowRenderer, - noRowsFallback: !isLoading ? ( - <> - {logDataRows.length === 0 && !error && } - {error && } - - ) : null, - }} - /> +
+
0 ? 40 : 0, + overflow: 'hidden', + transition: 'max-height 150ms ease', + }} + > + { + setSelectedRows(new Set()) + setAnchorRowId(null) + }} + /> +
+ { + setCellPosition(row) + }} + columns={columns} + rowClass={(row: LogData) => { + const key = getRowKey(row) + const isMultiSelected = selectedRows.has(key) + const isSingleSelected = isEqual(row, selectedRow) + return cn( + 'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer', + { + '!bg-surface-200 rdg-row--focused': isSingleSelected || isMultiSelected, + } + ) + }} + rows={logDataRows} + rowKeyGetter={(r) => { + if (!hasId) return JSON.stringify(r) + const row = r as LogData + return row.id + }} + renderers={{ + renderRow: RowRenderer, + noRowsFallback: !isLoading ? ( + // gridColumn: '1 / -1' makes the fallback span all CSS grid columns, + // including the checkbox column we prepend, so it fills the full width. +
+ {logDataRows.length === 0 && !error && } + {error && } +
+ ) : null, + }} + /> +
{typeof window !== 'undefined' && createPortal( @@ -466,7 +583,6 @@ export const LogTable = ({ projectRef={projectRef} onClose={() => { onSelectedLogChange?.(null) - setSelectionOpen(false) }} log={selectedLog} error={selectedLogError} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts new file mode 100644 index 0000000000000..8eb6de48d9573 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest' + +import type { LogData } from './Logs.types' +import { buildLogsPrompt, formatLogsAsJson, formatLogsAsMarkdown } from './Logs.utils' + +const createLog = (overrides: Partial = {}): LogData => ({ + id: 'test-id', + timestamp: 1621323232312, + event_message: 'test message', + ...overrides, +}) + +describe('Logs.utils', () => { + describe('formatLogsAsJson', () => { + test('formats single log as JSON', () => { + const rows: LogData[] = [createLog({ id: '1', event_message: 'test message' })] + const result = formatLogsAsJson(rows) + expect(result).toContain('"id": "1"') + expect(result).toContain('"event_message": "test message"') + }) + + test('formats multiple logs as JSON array', () => { + const rows: LogData[] = [ + createLog({ id: '1', event_message: 'first' }), + createLog({ id: '2', event_message: 'second' }), + ] + const result = formatLogsAsJson(rows) + expect(result).toContain('"id": "1"') + expect(result).toContain('"id": "2"') + }) + }) + + describe('formatLogsAsMarkdown', () => { + test('formats single log with timestamp', () => { + const rows: LogData[] = [ + createLog({ + id: '123', + timestamp: 1621323232312, + event_message: 'Test error', + status: '500', + }), + ] + const result = formatLogsAsMarkdown(rows) + expect(result).toContain('## Log 1') + expect(result).toContain('**Timestamp:**') + expect(result).toContain('**Message:** Test error') + expect(result).toContain('**Details:**') + }) + + test('formats multiple logs with separators', () => { + const rows: LogData[] = [ + createLog({ id: '1', event_message: 'first error' }), + createLog({ id: '2', event_message: 'second error' }), + ] + const result = formatLogsAsMarkdown(rows) + expect(result).toContain('## Log 1') + expect(result).toContain('## Log 2') + expect(result).toContain('---') + }) + }) + + describe('buildLogsPrompt', () => { + test('builds prompt with single log', () => { + const rows: LogData[] = [createLog({ id: '1', event_message: 'error occurred' })] + const result = buildLogsPrompt(rows) + expect(result).toContain('1 Supabase log entry') + expect(result).toContain('error occurred') + expect(result).toContain('What do these logs indicate') + }) + + test('builds prompt with multiple logs', () => { + const rows: LogData[] = [ + createLog({ id: '1', event_message: 'error 1' }), + createLog({ id: '2', event_message: 'error 2' }), + ] + const result = buildLogsPrompt(rows) + expect(result).toContain('2 Supabase log entries') + }) + + test('handles singular correctly', () => { + const rows: LogData[] = [createLog({ id: '1', event_message: 'single error' })] + const result = buildLogsPrompt(rows) + expect(result).toContain('1 Supabase log entry') + }) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index adb35fd6860d3..51a5df7bb82c1 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -1,12 +1,14 @@ import { useMonaco } from '@monaco-editor/react' +import { IS_PLATFORM } from 'common' +import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator' +import type { PlanId } from 'data/subscriptions/types' import dayjs, { Dayjs } from 'dayjs' import { get } from 'lodash' import uniqBy from 'lodash/uniqBy' import { useEffect } from 'react' -import { IS_PLATFORM } from 'common' -import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator' import logConstants from 'shared-data/logConstants' + import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants' import type { Filters, LogData, LogsEndpointParams } from './Logs.types' @@ -740,3 +742,91 @@ export function role(metadata: any) { return payload.role } + +export function formatLogsAsJson(rows: LogData[]): string { + return JSON.stringify(rows, null, 2) +} + +export function formatLogsAsMarkdown(rows: LogData[]): string { + return rows + .map((row, i) => { + const lines: string[] = [`## Log ${i + 1}`] + if (row.timestamp) { + const numTs = Number(row.timestamp) + let tsString: string + if (isFinite(numTs)) { + tsString = new Date(numTs / 1000).toISOString() + } else if (typeof row.timestamp === 'string') { + const d = new Date(row.timestamp) + tsString = isNaN(d.getTime()) ? row.timestamp : d.toISOString() + } else { + tsString = String(row.timestamp) + } + lines.push(`**Timestamp:** ${tsString}`) + } + if (row.event_message) { + lines.push(`**Message:** ${row.event_message}`) + } + const { id: _id, timestamp: _ts, event_message: _msg, ...rest } = row as any + if (Object.keys(rest).length > 0) { + lines.push('', '**Details:**', '```json', JSON.stringify(rest, null, 2), '```') + } + return lines.join('\n') + }) + .join('\n\n---\n\n') +} + +const QUERY_TYPE_LABELS: Record = { + api: 'API Gateway (Edge Network)', + database: 'Postgres Database', + functions: 'Edge Functions', + fn_edge: 'Edge Functions (edge runtime)', + auth: 'Auth', + realtime: 'Realtime', + storage: 'Storage', + supavisor: 'Supavisor (connection pooling)', + postgrest: 'PostgREST', + pg_upgrade: 'Postgres upgrade', + pg_cron: 'pg_cron', + pgbouncer: 'PgBouncer', + etl: 'ETL', +} + +const LOG_TABLE_TO_SERVICE_LABEL: Record = { + edge_logs: 'API Gateway (Edge Network)', + postgres_logs: 'Postgres Database', + function_logs: 'Edge Functions', + function_edge_logs: 'Edge Functions (edge runtime)', + auth_logs: 'Auth', + auth_audit_logs: 'Auth (audit)', + realtime_logs: 'Realtime', + storage_logs: 'Storage', + postgrest_logs: 'PostgREST', + supavisor_logs: 'Supavisor (connection pooling)', + pgbouncer_logs: 'PgBouncer', + pg_upgrade_logs: 'Postgres upgrade', + pg_cron_logs: 'pg_cron', + etl_replication_logs: 'ETL', +} + +function extractServiceLabelFromSql(sql: string): string | null { + const match = sql.match(/\bfrom\s+(\w+)/i) + const tableName = match?.[1] + return tableName ? LOG_TABLE_TO_SERVICE_LABEL[tableName] ?? null : null +} + +export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string { + const serviceLabel = + (queryType ? QUERY_TYPE_LABELS[queryType] : null) ?? + (sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null) + const serviceContext = serviceLabel ? ` from the **${serviceLabel}** service` : '' + const sqlContext = sqlQuery ? `\n\n**Query used:**\n\`\`\`sql\n${sqlQuery.trim()}\n\`\`\`` : '' + const header = `I have ${rows.length} Supabase log entr${rows.length === 1 ? 'y' : 'ies'}${serviceContext} I'd like help debugging:\n\n` + const body = formatLogsAsMarkdown(rows) + return ( + header + + body + + sqlContext + + '\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.' + ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx new file mode 100644 index 0000000000000..d5d9455c02e98 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx @@ -0,0 +1,98 @@ +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown' +import { Check, ChevronDown, Copy, X as XIcon } from 'lucide-react' +import { useMemo } from 'react' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' + +import type { LogData, QueryType } from './Logs.types' +import { buildLogsPrompt } from './Logs.utils' + +interface MultiSelectActionBarProps { + selectedRows: Set + selectedRowsData: LogData[] + copiedFormat: 'json' | 'markdown' | null + onCopy: (format: 'json' | 'markdown') => void + onClear: () => void + queryType?: QueryType + sqlQuery?: string +} + +export function MultiSelectActionBar({ + selectedRows, + selectedRowsData, + copiedFormat, + onCopy, + onClear, + queryType, + sqlQuery, +}: MultiSelectActionBarProps) { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + function handleOpenAiAssistant() { + const prompt = buildLogsPrompt(selectedRowsData, queryType, sqlQuery) + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ initialMessage: prompt }) + } + const count = selectedRows.size + if (count === 0) return null + + return ( +
+ + {count} row{count !== 1 ? 's' : ''} selected + + +
+ + + + + + onCopy('json')} className="gap-2 text-xs"> + + Copy as JSON + + onCopy('markdown')} className="gap-2 text-xs"> + + Copy as Markdown + + + + + buildLogsPrompt(selectedRowsData, queryType, sqlQuery)} + onOpenAssistant={handleOpenAiAssistant} + /> + +
+
+ ) +} diff --git a/apps/studio/components/ui/AiAssistantDropdown.tsx b/apps/studio/components/ui/AiAssistantDropdown.tsx index 9a44649f4f8bc..84e17b0e3965a 100644 --- a/apps/studio/components/ui/AiAssistantDropdown.tsx +++ b/apps/studio/components/ui/AiAssistantDropdown.tsx @@ -1,7 +1,7 @@ import { AiPromptCopiedEvent } from 'common/telemetry-constants' import { useTrack } from 'lib/telemetry/track' import { Check, ChevronDown, Copy } from 'lucide-react' -import { ComponentProps, useEffect, useState } from 'react' +import { ComponentProps, ReactNode, useEffect, useState } from 'react' import { AiIconAnimation, Button, @@ -10,6 +10,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, Tooltip, TooltipContent, @@ -18,6 +19,12 @@ import { type TelemetrySource = AiPromptCopiedEvent['properties']['source'] +export interface AiAssistantDropdownItem { + label: string + icon?: ReactNode + onClick: () => void +} + export interface AiAssistantDropdownProps { buildPrompt: () => string label: string @@ -30,6 +37,7 @@ export interface AiAssistantDropdownProps { loading?: boolean className?: string tooltip?: string + additionalDropdownItems?: AiAssistantDropdownItem[] } export function AiAssistantDropdown({ @@ -44,6 +52,7 @@ export function AiAssistantDropdown({ loading = false, className, tooltip, + additionalDropdownItems, }: AiAssistantDropdownProps) { const track = useTrack() const [showCopied, setShowCopied] = useState(false) @@ -95,11 +104,22 @@ export function AiAssistantDropdown({ icon={} /> - + {showCopied ? : } {showCopied ? 'Copied!' : 'Copy prompt'} + {additionalDropdownItems && additionalDropdownItems.length > 0 && ( + <> + + {additionalDropdownItems.map((item, i) => ( + + {item.icon} + {item.label} + + ))} + + )}
diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index f0d6a7c638791..df6375cc0532d 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -414,6 +414,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { projectRef={projectRef} onSelectedLogChange={setSelectedLog} selectedLog={selectedLog || undefined} + sqlQuery={editorValue} />
diff --git a/apps/studio/styles/react-data-grid-logs.scss b/apps/studio/styles/react-data-grid-logs.scss index 1ec5c499e83f1..5274dc6b14d6e 100644 --- a/apps/studio/styles/react-data-grid-logs.scss +++ b/apps/studio/styles/react-data-grid-logs.scss @@ -13,6 +13,11 @@ text-overflow: clip; } + .rdg-cell { + display: flex; + align-items: center; + } + .rdg-cell:first-child { @apply pl-5; } @@ -48,6 +53,11 @@ text-overflow: clip; } + .rdg-cell { + display: flex; + align-items: center; + } + .rdg-cell:first-child { @apply pl-5; } diff --git a/apps/studio/tests/features/logs/LogTable.test.tsx b/apps/studio/tests/features/logs/LogTable.test.tsx index d25554b3ad801..9ce8b87478a2a 100644 --- a/apps/studio/tests/features/logs/LogTable.test.tsx +++ b/apps/studio/tests/features/logs/LogTable.test.tsx @@ -6,24 +6,42 @@ import customParseFormat from 'dayjs/plugin/customParseFormat' import relativeTime from 'dayjs/plugin/relativeTime' import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' -import { beforeAll, expect, test, vi } from 'vitest' - -import { render } from '../../helpers' +import { customRender as render } from 'tests/lib/custom-render' +import { expect, test, vi } from 'vitest' dayjs.extend(customParseFormat) dayjs.extend(utc) dayjs.extend(timezone) dayjs.extend(relativeTime) -beforeAll(() => { - vi.mock('next/router', () => import('next-router-mock')) - vi.mock('nuqs', async () => { - let queryValue = 'example' - return { - useQueryState: () => [queryValue, (v: string) => (queryValue = v)], - } - }) -}) +vi.mock('next/router', () => import('next-router-mock')) + +vi.mock('react-data-grid', () => ({ + default: ({ columns, rows, renderers, role, headerRowHeight }: any) => ( +
+ {headerRowHeight !== 0 && ( +
+ {columns.map((col: any, colIdx: number) => ( +
+ {col.renderHeaderCell ? col.renderHeaderCell({}) : col.name} +
+ ))} +
+ )} + {rows.map((row: any, rowIdx: number) => ( +
+ {columns.map((col: any, colIdx: number) => ( +
+ {col.renderCell?.({ row, rowIdx, isCellSelected: false })} +
+ ))} +
+ ))} + {rows.length === 0 && renderers?.noRowsFallback} +
+ ), + Row: ({ row, ...props }: any) =>
, +})) const fakeMicroTimestamp = dayjs().unix() * 1000 diff --git a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx index 9f11eaefeeade..463f2351ab621 100644 --- a/apps/studio/tests/features/logs/LogsPreviewer.test.tsx +++ b/apps/studio/tests/features/logs/LogsPreviewer.test.tsx @@ -1,18 +1,30 @@ import { screen, waitFor } from '@testing-library/react' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import userEvent from '@testing-library/user-event' import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' import { - LogsPreviewer, calculateBarClickTimeRange, + LogsPreviewer, } from 'components/interfaces/Settings/Logs/LogsPreviewer' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import useLogsPreview from 'hooks/analytics/useLogsPreview' import { customRender, customRenderHook } from 'tests/lib/custom-render' -import userEvent from '@testing-library/user-event' +import { addAPIMock } from 'tests/lib/msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' -import useLogsPreview from 'hooks/analytics/useLogsPreview' import { LOGS_API_MOCKS } from './logs.mocks' -import { addAPIMock } from 'tests/lib/msw' + +vi.mock('components/interfaces/Settings/Logs/LogTable', () => ({ + LogTable: ({ data }: { data: any[] }) => ( +
+ {data.map((row) => ( +
+ {row.event_message} +
+ ))} +
+ ), +})) dayjs.extend(utc) @@ -80,15 +92,11 @@ test('useLogsPreview returns data from MSW', async () => { expect(result.current.logData).toEqual(LOGS_API_MOCKS.result) }) -test('LogsPreviewer renders the expected data from the API', async () => { +test('LogsPreviewer passes API data to LogTable', async () => { customRender( ) - await waitFor(() => { - expect(screen.getByRole('table')).toBeInTheDocument() - }) - const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message await waitFor(() => { diff --git a/packages/ui/src/components/shadcn/ui/checkbox.tsx b/packages/ui/src/components/shadcn/ui/checkbox.tsx index 09d634e25ba11..ecc4e38410b9f 100644 --- a/packages/ui/src/components/shadcn/ui/checkbox.tsx +++ b/packages/ui/src/components/shadcn/ui/checkbox.tsx @@ -23,7 +23,7 @@ const Checkbox = React.forwardRef< {...props} > - + )) From 5894e37b40080a7788e12725e5bdb91f1fe6afc2 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 6 Mar 2026 21:51:37 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[FE-2158]=20=E2=80=93=20feat(studio):=20A?= =?UTF-8?q?dd=20exposed=20tables=20config=20to=20Postgrest=20settings=20(#?= =?UTF-8?q?43280)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a feature flagged exposed tables config to postgrest settings: Screenshot 2026-03-02 at 17 04 13 To test: - make sure the existing (feature flag off) settings work as expected - make sure exposing and removing schemas works in the new mode - make sure exposing and removing tables works in the new mode - ideally try with a lot of tables to check search and infinite scroll work as expected - try exposing a schema without any tables (like `graphql_public`) and make sure it doesn't get removed randomly when editing tables - try with a table with custom permissions (for example `REVOKE SELECT ON public.posts FROM anon;`) and make sure the user is informed with the tooltip --------- Co-authored-by: Nick Babadzhanian <33933459+pgnickb@users.noreply.github.com> --- .../Settings/API/ExposedSchemaSelector.tsx | 143 +++++++ .../Settings/API/ExposedTableSelector.tsx | 305 +++++++++++++ .../Settings/API/PostgrestConfig.tsx | 401 ++++++++++++------ .../TableEditor/ApiAccessToggle.tsx | 133 +----- .../TableEditor/TableEditor.tsx | 1 + apps/studio/components/ui/SchemaSelector.tsx | 19 +- .../privileges/exposed-table-counts-query.ts | 62 +++ .../exposed-tables-infinite-query.ts | 91 ++++ apps/studio/data/privileges/keys.ts | 10 + apps/studio/data/privileges/privileges.sql.ts | 162 +++++++ .../update-exposed-tables-mutation.ts | 88 ++++ e2e/studio/features/api-access-toggle.spec.ts | 195 ++------- 12 files changed, 1198 insertions(+), 412 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx create mode 100644 apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx create mode 100644 apps/studio/data/privileges/exposed-table-counts-query.ts create mode 100644 apps/studio/data/privileges/exposed-tables-infinite-query.ts create mode 100644 apps/studio/data/privileges/privileges.sql.ts create mode 100644 apps/studio/data/privileges/update-exposed-tables-mutation.ts diff --git a/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx new file mode 100644 index 0000000000000..5b990eb7dc207 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ExposedSchemaSelector.tsx @@ -0,0 +1,143 @@ +import { Check, ChevronsUpDown } from 'lucide-react' +import { useMemo, useState } from 'react' +import { + Button, + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, +} from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { useSchemasQuery } from '@/data/database/schemas-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' +import { pluralize } from '@/lib/helpers' + +interface ExposedSchemaSelectorProps { + disabled?: boolean + selectedSchemas: string[] + onToggleSchema: (schema: string) => void +} + +export const ExposedSchemaSelector = ({ + disabled = false, + selectedSchemas, + onToggleSchema, +}: ExposedSchemaSelectorProps) => { + const [open, setOpen] = useState(false) + + const { data: project } = useSelectedProjectQuery() + + const { + data: allSchemas, + isPending, + isError, + isSuccess, + } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const schemas = useMemo( + () => + (allSchemas ?? []) + .filter((s) => { + if (s.name === 'graphql_public') return true + return !INTERNAL_SCHEMAS.includes(s.name) + }) + .sort((a, b) => a.name.localeCompare(b.name)), + [allSchemas] + ) + + const selectedSet = useMemo(() => new Set(selectedSchemas), [selectedSchemas]) + const selectedCount = schemas.filter((s) => selectedSet.has(s.name)).length + + return ( + + + + + + + + + + {isPending ? ( + <> +
+ +
+
+ +
+ + ) : isError ? ( +
+

Failed to retrieve schemas

+
+ ) : ( + <> + +

+ No schemas found +

+
+ 7 ? 'h-[210px]' : ''}> + {schemas.map((schema) => { + const isExposed = selectedSet.has(schema.name) + + return ( + { + onToggleSchema(schema.name) + }} + > +
+ {isExposed && } + {schema.name} +
+
+ ) + })} +
+ + )} +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx new file mode 100644 index 0000000000000..18371b96ad7b9 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/API/ExposedTableSelector.tsx @@ -0,0 +1,305 @@ +import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { useDebounce, useIntersectionObserver } from '@uidotdev/usehooks' +import { Check, ChevronsUpDown, CircleAlert, Info } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { + Button, + cn, + Command_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + ScrollArea, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + +import { exposedTableCountsQueryOptions } from '@/data/privileges/exposed-table-counts-query' +import { exposedTablesInfiniteQueryOptions } from '@/data/privileges/exposed-tables-infinite-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { pluralize } from '@/lib/helpers' + +interface ExposedTableSelectorProps { + className?: string + disabled?: boolean + selectedSchemas: string[] + pendingAddTableIds: number[] + pendingRemoveTableIds: number[] + onTogglePendingAdd: (tableId: number) => void + onTogglePendingRemove: (tableId: number) => void +} + +export const ExposedTableSelector = ({ + className, + disabled = false, + selectedSchemas, + pendingAddTableIds, + pendingRemoveTableIds, + onTogglePendingAdd, + onTogglePendingRemove, +}: ExposedTableSelectorProps) => { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 300) + + const { data: project } = useSelectedProjectQuery() + + const scrollRootRef = useRef(null) + const [sentinelRef, entry] = useIntersectionObserver({ + root: scrollRootRef.current, + threshold: 0, + rootMargin: '0px', + }) + + const { data: countsData, isPending: isCountsPending } = useQuery({ + ...exposedTableCountsQueryOptions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + selectedSchemas, + }), + placeholderData: keepPreviousData, + }) + const pendingCount = pendingAddTableIds.length + pendingRemoveTableIds.length + + const totalCount = countsData?.total_count ?? 0 + const grantsCount = countsData?.grants_count ?? 0 + + const { data, isPending, isError, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } = + useInfiniteQuery({ + ...exposedTablesInfiniteQueryOptions({ + projectRef: project?.ref, + connectionString: project?.connectionString, + search: search.length === 0 ? undefined : debouncedSearch || undefined, + }), + placeholderData: search.length > 0 ? keepPreviousData : undefined, + }) + + const tables = useMemo(() => data?.pages.flatMap((page) => page.tables) ?? [], [data?.pages]) + + const pendingAddSet = useMemo(() => new Set(pendingAddTableIds), [pendingAddTableIds]) + const pendingRemoveSet = useMemo(() => new Set(pendingRemoveTableIds), [pendingRemoveTableIds]) + + useEffect(() => { + if (!isPending && !isFetching && entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [entry?.isIntersecting, hasNextPage, isFetching, isFetchingNextPage, isPending, fetchNextPage]) + + return ( +
+ + + + + + + + + + {isPending ? ( + <> +
+ +
+
+ +
+ + ) : isError ? ( +
+

Failed to retrieve tables

+
+ ) : ( + <> + {search.length > 0 && tables.length === 0 && ( +

+ No tables found +

+ )} + 7 ? 'h-[210px]' : ''} + > + {tables.map((table) => { + const isSchemaExposed = selectedSchemas.includes(table.schema) + const hasPendingAdd = pendingAddSet.has(table.id) + const hasPendingRemove = pendingRemoveSet.has(table.id) + + const isCustomTable = table.status === 'custom' + const isGranted = table.status === 'granted' + + const isCustomNeutral = isCustomTable && !hasPendingAdd && !hasPendingRemove + const isExposed = + isSchemaExposed && + (isCustomTable + ? hasPendingAdd + : isGranted + ? !hasPendingRemove + : hasPendingAdd) + + const customGrantsTooltip = getCustomGrantsTooltip({ + hasPendingAdd, + hasPendingRemove, + }) + + return ( + { + if (!isSchemaExposed) return + + if (isCustomTable) { + if (hasPendingAdd) { + onTogglePendingAdd(table.id) + onTogglePendingRemove(table.id) + } else if (hasPendingRemove) { + onTogglePendingRemove(table.id) + onTogglePendingAdd(table.id) + } else { + onTogglePendingAdd(table.id) + } + return + } + + if (isGranted) { + onTogglePendingRemove(table.id) + } else { + onTogglePendingAdd(table.id) + } + }} + > +
+
+ {isExposed && } +
+ + {`${table.schema}.${table.name}`} + + +
+ {isCustomTable && ( + + +
+ +
+
+ + {customGrantsTooltip} + +
+ )} + {!isSchemaExposed && ( + + + + + + {`The schema "${table.schema}" must be exposed before enabling this table.`} + + + )} +
+
+
+ ) + })} +
+ {hasNextPage && ( +
+ +
+ )} + + + )} + + + + + +
+ ) +} + +const getCustomGrantsTooltip = ({ + hasPendingAdd, + hasPendingRemove, +}: { + hasPendingAdd: boolean + hasPendingRemove: boolean +}) => { + if (hasPendingAdd) { + return 'This table has custom grants. Saving will override them with standard Data API grants for anon, authenticated, and service_role. Select again to revoke all grants instead.' + } + + if (hasPendingRemove) { + return 'This table has custom grants. Saving will revoke all grants for anon, authenticated, and service_role. Select again to override with standard Data API grants instead.' + } + + return 'This table has custom grants. Select it to override with standard Data API grants for anon, authenticated, and service_role.' +} diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx index 5a1da6e630977..24df32ed98682 100644 --- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx +++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { indexOf } from 'lodash' import { Lock } from 'lucide-react' import Link from 'next/link' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -19,6 +19,7 @@ import { Input_Shadcn_, PrePostTab, Skeleton, + useWatch_Shadcn_, } from 'ui' import { GenericSkeletonLoader, PageSection, PageSectionContent } from 'ui-patterns' import { Admonition } from 'ui-patterns/admonition' @@ -32,16 +33,26 @@ import { } from 'ui-patterns/multi-select' import { z } from 'zod' +import { ExposedSchemaSelector } from './ExposedSchemaSelector' import { HardenAPIModal } from './HardenAPIModal' +import { ExposedTableSelector } from '@/components/interfaces/Settings/API/ExposedTableSelector' import { FormActions } from '@/components/ui/Forms/FormActions' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useProjectPostgrestConfigUpdateMutation } from '@/data/config/project-postgrest-config-update-mutation' import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useSchemasQuery } from '@/data/database/schemas-query' +import { privilegeKeys } from '@/data/privileges/keys' +import { useUpdateExposedTablesMutation } from '@/data/privileges/update-exposed-tables-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useDataApiGrantTogglesEnabled } from '@/hooks/misc/useDataApiGrantTogglesEnabled' +import useLatest from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' +import { noop } from '@/lib/void' +import type { ResponseError } from '@/types' const formSchema = z.object({ + // Fields for updatePostgrestConfig dbSchema: z.array(z.string()), dbExtraSearchPath: z.array(z.string()), maxRows: z.number().max(1000000, "Can't be more than 1,000,000"), @@ -51,11 +62,17 @@ const formSchema = z.object({ .max(1000, "Can't be more than 1000") .optional() .nullable(), + + // Fields for expose toggles + tableIdsToAdd: z.array(z.number()), + tableIdsToRemove: z.array(z.number()), }) export const PostgrestConfig = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() + const queryClient = useQueryClient() + const isApiGrantTogglesEnabled = useDataApiGrantTogglesEnabled() const [showModal, setShowModal] = useState(false) @@ -63,6 +80,7 @@ export const PostgrestConfig = () => { data: config, isError, isPending: isLoadingConfig, + isSuccess: isSuccessConfig, } = useProjectPostgrestConfigQuery({ projectRef }) const { data: extensions } = useDatabaseExtensionsQuery({ projectRef: project?.ref, @@ -77,27 +95,36 @@ export const PostgrestConfig = () => { connectionString: project?.connectionString, }) + const configDbSchemas = useMemo( + () => (config?.db_schema ? config.db_schema.split(',').map((x) => x.trim()) : []), + [config?.db_schema] + ) + const isLoading = isLoadingConfig || isLoadingSchemas - const { mutate: updatePostgrestConfig, isPending: isUpdating } = - useProjectPostgrestConfigUpdateMutation({ - onSuccess: () => { - toast.success('Successfully saved settings') - }, - }) + const schemas = useMemo( + () => + allSchemas + .filter((x) => !INTERNAL_SCHEMAS.some((schema) => schema === x.name)) + .map((x) => { + return { + id: x.id, + value: x.name, + name: x.name, + disabled: false, + } + }) ?? [], + [allSchemas] + ) + + const { mutateAsync: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation() + + const { mutateAsync: updateExposedTables } = useUpdateExposedTablesMutation() + + const [isUpdating, setIsUpdating] = useState(false) const formId = 'project-postgres-config' - const hiddenSchema = [ - 'auth', - 'pgbouncer', - 'hooks', - 'extensions', - 'vault', - 'storage', - 'realtime', - 'pgsodium', - 'pgsodium_masks', - ] + const { can: canUpdatePostgrestConfig, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'custom_config_postgrest') @@ -105,17 +132,19 @@ export const PostgrestConfig = () => { (extensions ?? []).find((ext) => ext.name === 'pg_graphql')?.installed_version !== null const defaultValues = useMemo(() => { - const dbSchema = config?.db_schema ? config?.db_schema.split(',').map((x) => x.trim()) : [] return { - dbSchema, + dbSchema: configDbSchemas, maxRows: config?.max_rows, + // TODO: only display schemas that exist in the db dbExtraSearchPath: (config?.db_extra_search_path ?? '') .split(',') .map((x) => x.trim()) - .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)), + .filter(Boolean), dbPool: config?.db_pool, + tableIdsToAdd: [] as number[], + tableIdsToRemove: [] as number[], } - }, [config, allSchemas]) + }, [config, configDbSchemas]) const form = useForm>({ resolver: zodResolver(formSchema), @@ -123,43 +152,81 @@ export const PostgrestConfig = () => { defaultValues, }) - const schemas = - allSchemas - .filter((x) => { - const find = indexOf(hiddenSchema, x.name) - if (find < 0) return x - }) - .map((x) => { - return { - id: x.id, - value: x.name, - name: x.name, - disabled: false, - } - }) ?? [] - const resetForm = useCallback(() => { form.reset({ ...defaultValues }) }, [form, defaultValues]) const onSubmit = async (values: z.infer) => { - if (!projectRef) return console.error('Project ref is required') // is this needed ? + if (!projectRef) return console.error('Project ref is required') + + setIsUpdating(true) + + try { + let dbSchema = values.dbSchema.join(',') + + if (isApiGrantTogglesEnabled) { + await updateExposedTables({ + projectRef, + connectionString: project?.connectionString, + tableIdsToAdd: values.tableIdsToAdd, + tableIdsToRemove: values.tableIdsToRemove, + }) + } - updatePostgrestConfig({ - projectRef, - dbSchema: values.dbSchema.join(', '), - maxRows: values.maxRows, - dbExtraSearchPath: values.dbExtraSearchPath.join(','), - dbPool: values.dbPool ? values.dbPool : null, - }) + await updatePostgrestConfig( + { + projectRef, + dbSchema, + maxRows: values.maxRows, + dbExtraSearchPath: values.dbExtraSearchPath.join(','), + dbPool: values.dbPool ? values.dbPool : null, + }, + { onError: noop } + ) + + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: privilegeKeys.exposedTablesInfinite(projectRef), + }), + queryClient.invalidateQueries({ + queryKey: privilegeKeys.exposedTableCounts(projectRef, watchedDbSchema), + }), + ]) + + toast.success('Successfully saved settings') + form.reset({ + dbSchema: dbSchema + .split(',') + .map((x) => x.trim()) + .filter(Boolean), + maxRows: values.maxRows, + dbExtraSearchPath: values.dbExtraSearchPath, + dbPool: values.dbPool, + tableIdsToAdd: [], + tableIdsToRemove: [], + }) + } catch (error) { + toast.error('Failed to save settings: ' + (error as ResponseError).message || 'Unknown error') + } finally { + setIsUpdating(false) + } } + const resetFormRef = useLatest(resetForm) + const isReady = isSuccessConfig && isSuccessSchemas useEffect(() => { - if (config && isSuccessSchemas) { - resetForm() + if (isReady) { + resetFormRef.current() } - }, [config, isSuccessSchemas, resetForm]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isReady]) + const watchedDbSchema = useWatch_Shadcn_({ control: form.control, name: 'dbSchema' }) + const watchedTableIdsToAdd = useWatch_Shadcn_({ control: form.control, name: 'tableIdsToAdd' }) + const watchedTableIdsToRemove = useWatch_Shadcn_({ + control: form.control, + name: 'tableIdsToRemove', + }) return ( @@ -176,88 +243,170 @@ export const PostgrestConfig = () => { ) : ( <> - - ( - - + + { + const current = form.getValues('dbSchema') + if (current.includes(schema)) { + form.setValue( + 'dbSchema', + current.filter((x) => x !== schema), + { shouldDirty: true } + ) + } else { + form.setValue('dbSchema', [...current, schema], { + shouldDirty: true, + }) + } + }} + /> + + + + { + const current = form.getValues('tableIdsToAdd') + if (current.includes(tableId)) { + form.setValue( + 'tableIdsToAdd', + current.filter((x) => x !== tableId), + { shouldDirty: true } + ) + } else { + form.setValue('tableIdsToAdd', [...current, tableId], { + shouldDirty: true, + }) + } + }} + onTogglePendingRemove={(tableId) => { + const current = form.getValues('tableIdsToRemove') + if (current.includes(tableId)) { + form.setValue( + 'tableIdsToRemove', + current.filter((x) => x !== tableId), + { shouldDirty: true } + ) + } else { + form.setValue('tableIdsToRemove', [...current, tableId], { + shouldDirty: true, + }) + } + }} + /> + + + {watchedDbSchema.length === 0 && ( + + )} + + ) : ( + + ( + + - {isLoadingSchemas ? ( -
- -
- ) : ( - - - - - {schemas.length <= 0 ? ( - - no - - ) : ( - schemas.map((x) => ( - - {x.name} + layout="flex-row-reverse" + > + {isLoadingSchemas ? ( +
+ +
+ ) : ( + + + + + {schemas.length <= 0 ? ( + + no - )) + ) : ( + schemas.map((x) => ( + + {x.name} + + )) + )} + + + + )} +
+ {!field.value.includes('public') && field.value.length > 0 && ( + +

+ You will not be able to query tables and views in the{' '} + public schema via + supabase-js or HTTP clients. +

+ {isGraphqlExtensionEnabled && ( + <> +

+ Tables in the{' '} + public schema + are still exposed over our GraphQL endpoints. +

+ + )} - - - + + } + /> )} - - {!field.value.includes('public') && field.value.length > 0 && ( - -

- You will not be able to query tables and views in the{' '} - public schema via - supabase-js or HTTP clients. -

- {isGraphqlExtensionEnabled && ( - <> -

- Tables in the{' '} - public schema are - still exposed over our GraphQL endpoints. -

- - - )} - - } - /> - )} -
- )} - /> -
+ + )} + /> + + )} = { - anon: 'Anonymous (anon)', - authenticated: 'Authenticated', -} - namespace ApiAccessToggleProps { type New = { type: 'new' @@ -255,10 +238,11 @@ export const useTableApiAccessHandlerWithHistory = ( } } -type ApiAccessToggleProps = { +type ApiAccessToggleComponentProps = { projectRef?: string schemaName?: string tableName?: string + isNewRecord: boolean handler: TableApiAccessHandlerWithHistoryReturn } @@ -266,10 +250,9 @@ export const ApiAccessToggle = ({ projectRef, schemaName, tableName, + isNewRecord, handler, -}: ApiAccessToggleProps): ReactNode => { - const [isPrivilegesPopoverOpen, setIsPrivilegesPopoverOpen] = useState(false) - +}: ApiAccessToggleComponentProps): ReactNode => { const isPending = handler.isPending const isError = handler.isError const isSchemaExposed = handler.data?.schemaExposed @@ -290,104 +273,33 @@ export const ApiAccessToggle = ({ } } - const handlePrivilegesChange = (role: ApiAccessRole) => (values: string[]) => { - if (!handler.isSuccess) return - if (!isSchemaExposed) return - if (!privileges) return - - handler.data?.setPrivileges((oldPrivileges) => { - return { - ...oldPrivileges, - [role]: values.filter(isApiPrivilegeType), - } - }) - } - - const totalAvailablePrivileges = API_ACCESS_ROLES.length * API_PRIVILEGE_TYPES.length - const totalSelectedPrivileges = Object.values(privileges ?? {}).reduce( - (sum, rolePrivileges) => sum + rolePrivileges.length, - 0 - ) - const hasPartialPrivileges = - totalSelectedPrivileges > 0 && totalSelectedPrivileges < totalAvailablePrivileges - return (
-
-

- Data API Access - - This controls which operations the anon{' '} - and authenticated roles - can perform on this table via the Data API. Unselected privileges are revoked from - these roles. - -

+
+
Data API Access

Allow this table to be queried via Supabase client libraries or the API directly

-
- - - - - - {!isDisabled && ( - <> -

Adjust API privileges per role

-
- {API_ACCESS_ROLES.map((role) => ( -
-

- {ROLE_LABELS[role]} -

- - - - - {API_PRIVILEGE_TYPES.map((privilege) => ( - - {privilege} - - ))} - - - -
- ))} -
- - )} -
-
+ {isNewRecord ? ( -
+ ) : ( + + )}
@@ -398,7 +310,6 @@ export const ApiAccessToggle = ({ isPending={isPending} isError={isError} isSchemaExposed={isSchemaExposed} - hasNonEmptyPrivileges={!!privileges ? hasNonEmptyPrivileges : undefined} />
) @@ -411,7 +322,6 @@ const SchemaExposureOptions = ({ isPending, isError, isSchemaExposed, - hasNonEmptyPrivileges, }: { projectRef?: string schemaName?: string @@ -419,7 +329,6 @@ const SchemaExposureOptions = ({ isPending: boolean isError: boolean isSchemaExposed?: boolean - hasNonEmptyPrivileges?: boolean }): ReactNode => { const { selectedDatabaseId } = useDatabaseSelectorStateSnapshot() @@ -459,7 +368,7 @@ const SchemaExposureOptions = ({ )} - {isSchemaExposed && apiUrl && hasNonEmptyPrivileges && ( + {isSchemaExposed && apiUrl && ( )} diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index d6871bc5b8518..0859b4f5dc864 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -1,25 +1,24 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Check, ChevronsUpDown, Plus } from 'lucide-react' -import { useState } from 'react' - import { useSchemasQuery } from 'data/database/schemas-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Check, ChevronsUpDown, Plus } from 'lucide-react' +import { useState } from 'react' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, + Command_Shadcn_, CommandEmpty_Shadcn_, CommandGroup_Shadcn_, CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, CommandSeparator_Shadcn_, - Command_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, ScrollArea, Skeleton, } from 'ui' @@ -29,7 +28,8 @@ interface SchemaSelectorProps { disabled?: boolean size?: 'tiny' | 'small' showError?: boolean - selectedSchemaName: string + selectedSchemaName?: string + placeholderLabel?: string supportSelectAll?: boolean excludedSchemas?: string[] onSelectSchema: (name: string) => void @@ -43,6 +43,7 @@ export const SchemaSelector = ({ size = 'tiny', showError = true, selectedSchemaName, + placeholderLabel = 'Choose a schema...', supportSelectAll = false, excludedSchemas = [], onSelectSchema, @@ -122,7 +123,7 @@ export const SchemaSelector = ({
) : (
-

Choose a schema...

+

{placeholderLabel}

)} @@ -158,7 +159,7 @@ export const SchemaSelector = ({ )} )} - {schemas?.map((schema) => ( + {schemas.map((schema) => ( { + if (!projectRef) throw new Error('projectRef is required') + if (!selectedSchemas) throw new Error('selectedSchemas is required') + + const sql = getExposedTableCountsSql({ selectedSchemas }) + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql, + queryKey: ['exposed-table-counts', selectedSchemas], + }, + signal + ) + + return result[0] as ExposedTableCountsResponse +} + +export type ExposedTableCountsData = Awaited> +export type ExposedTableCountsError = ResponseError + +export const exposedTableCountsQueryOptions = ( + { projectRef, connectionString, selectedSchemas }: ExposedTableCountsVariables, + { enabled = true }: { enabled?: boolean } = {} +) => { + return queryOptions({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query + queryKey: privilegeKeys.exposedTableCounts(projectRef, selectedSchemas), + queryFn: ({ signal }) => + getExposedTableCounts( + { + projectRef, + connectionString, + selectedSchemas, + }, + signal + ), + enabled: enabled && typeof projectRef !== 'undefined', + }) +} diff --git a/apps/studio/data/privileges/exposed-tables-infinite-query.ts b/apps/studio/data/privileges/exposed-tables-infinite-query.ts new file mode 100644 index 0000000000000..9b4fd1eebec37 --- /dev/null +++ b/apps/studio/data/privileges/exposed-tables-infinite-query.ts @@ -0,0 +1,91 @@ +import { infiniteQueryOptions } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' + +import { privilegeKeys } from './keys' +import { getExposedTablesSql } from './privileges.sql' + +export const EXPOSED_TABLES_PAGE_LIMIT = 50 + +export type ExposedTablesVariables = { + projectRef?: string + connectionString?: string | null + search?: string +} + +export type ExposedTable = { + id: number + schema: string + name: string + status: 'granted' | 'revoked' | 'custom' +} + +export type ExposedTablesResponse = { + total_count: number + tables: ExposedTable[] +} + +export async function getExposedTables( + { + projectRef, + connectionString, + search, + page = 0, + limit = EXPOSED_TABLES_PAGE_LIMIT, + }: ExposedTablesVariables & { page?: number; limit?: number }, + signal?: AbortSignal +): Promise { + if (!projectRef) throw new Error('projectRef is required') + + const offset = page * limit + + const sql = getExposedTablesSql({ search, offset, limit }) + + const { result } = await executeSql( + { + projectRef, + connectionString, + sql, + queryKey: ['exposed-tables', page], + }, + signal + ) + + return result[0] as ExposedTablesResponse +} + +export type ExposedTablesData = Awaited> +export type ExposedTablesError = ResponseError + +export const exposedTablesInfiniteQueryOptions = ( + { projectRef, connectionString, search }: ExposedTablesVariables, + { enabled = true }: { enabled?: boolean } = {} +) => { + return infiniteQueryOptions({ + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- connection string doesn't change the result of the query + queryKey: privilegeKeys.exposedTablesInfinite(projectRef, search), + queryFn: ({ signal, pageParam }) => + getExposedTables( + { + projectRef, + connectionString, + search, + page: pageParam, + }, + signal + ), + enabled: enabled && typeof projectRef !== 'undefined', + initialPageParam: 0, + getNextPageParam(lastPage, pages) { + const page = pages.length + const currentTotalCount = page * EXPOSED_TABLES_PAGE_LIMIT + const totalCount = lastPage.total_count ?? 0 + + if (currentTotalCount >= totalCount) { + return undefined + } + + return page + }, + }) +} diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 9e92f4ed95c39..328e39c325f00 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -3,4 +3,14 @@ export const privilegeKeys = { ['projects', projectRef, 'database', 'table-privileges'] as const, columnPrivilegesList: (projectRef: string | undefined) => ['projects', projectRef, 'database', 'column-privileges'] as const, + exposedTablesInfinite: (projectRef: string | undefined, search?: string) => + [ + 'projects', + projectRef, + 'privileges', + 'exposed-tables-infinite', + ...(search ? ([{ search }] as const) : []), + ] as const, + exposedTableCounts: (projectRef: string | undefined, selectedSchemas: string[]) => + ['projects', projectRef, 'privileges', 'exposed-table-counts', ...selectedSchemas] as const, } diff --git a/apps/studio/data/privileges/privileges.sql.ts b/apps/studio/data/privileges/privileges.sql.ts new file mode 100644 index 0000000000000..2f72c9699f25f --- /dev/null +++ b/apps/studio/data/privileges/privileges.sql.ts @@ -0,0 +1,162 @@ +import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' + +export const IGNORED_SCHEMAS = [...INTERNAL_SCHEMAS, 'pg_catalog'] + +const IGNORED_SCHEMAS_LIST = IGNORED_SCHEMAS.map((s) => `'${s}'`).join(', ') + +/** + * Builds the shared `table_privileges` and `table_grants` CTEs used by + * both the exposed-tables list query and the counts-only query. + * + * Returns SQL text meant to follow `WITH` (no leading `WITH` keyword). + * Callers that append additional CTEs should add a comma after interpolation. + */ +function getTableGrantsCTEs({ search }: { search?: string } = {}) { + return /* SQL */ ` + table_privileges as ( + select + c.oid::int as id, + n.nspname as schema_name, + c.relname as name, + c.relkind as kind, + + -- Anon Privileges + bool_or(pr.rolname = 'anon' and acl.privilege_type = 'SELECT') as anon_select, + bool_or(pr.rolname = 'anon' and acl.privilege_type = 'INSERT') as anon_insert, + bool_or(pr.rolname = 'anon' and acl.privilege_type = 'UPDATE') as anon_update, + bool_or(pr.rolname = 'anon' and acl.privilege_type = 'DELETE') as anon_delete, + + -- Authenticated Privileges + bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'SELECT') as auth_select, + bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'INSERT') as auth_insert, + bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'UPDATE') as auth_update, + bool_or(pr.rolname = 'authenticated' and acl.privilege_type = 'DELETE') as auth_delete, + + -- Service Role Privileges + bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'SELECT') as srv_select, + bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'INSERT') as srv_insert, + bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'UPDATE') as srv_update, + bool_or(pr.rolname = 'service_role' and acl.privilege_type = 'DELETE') as srv_delete + + from pg_class c + join pg_namespace n + on n.oid = c.relnamespace + left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl + on true + left join pg_roles pr + on pr.oid = acl.grantee + where c.relkind in ('r', 'p', 'v', 'm', 'f') + and n.nspname not in (${IGNORED_SCHEMAS_LIST}) + ${search ? `and (n.nspname || '.' || c.relname) ilike '%${search}%'` : ''} + group by c.oid, n.nspname, c.relname, c.relkind + ), + table_grants as ( + select + id, + schema_name, + name, + kind, + case + -- 1. Strict Granted: All 3 roles possess ALL 4 privileges + when ( + anon_select and anon_insert and anon_update and anon_delete and + auth_select and auth_insert and auth_update and auth_delete and + srv_select and srv_insert and srv_update and srv_delete + ) then 'granted' + + -- 2. Strict Revoked: NO role possesses ANY privilege + when not ( + anon_select or anon_insert or anon_update or anon_delete or + auth_select or auth_insert or auth_update or auth_delete or + srv_select or srv_insert or srv_update or srv_delete + ) then 'revoked' + + -- 3. Custom: Anything in between + else 'custom' + end as status + from table_privileges + ) + ` +} + +export function getExposedTablesSql({ + search, + offset, + limit, +}: { + search?: string + offset: number + limit: number +}) { + return /* SQL */ ` + with ${getTableGrantsCTEs({ search })} + select + (select count(*)::int from table_grants) as total_count, + coalesce( + ( + select jsonb_agg( + jsonb_build_object( + 'id', tg.id, + 'schema', tg.schema_name, + 'name', tg.name, + 'status', tg.status + ) + ) + from ( + select * + from table_grants + order by schema_name, name + offset ${offset} + limit ${limit} + ) tg + ), + '[]'::jsonb + ) as tables; + ` +} + +export function getExposedTableCountsSql({ selectedSchemas }: { selectedSchemas: string[] }) { + const schemasList = + selectedSchemas.length > 0 ? selectedSchemas.map((s) => `'${s}'`).join(', ') : "''" + + return /* SQL */ ` + with ${getTableGrantsCTEs()} + select + count(*)::int as total_count, + (count(*) filter (where status = 'granted' and schema_name in (${schemasList})))::int as grants_count + from table_grants + ` +} + +export function getExposedSchemasSql() { + return /* SQL */ ` + select coalesce( + ( + select jsonb_agg(distinct schema_name order by schema_name) + from ( + select n.nspname as schema_name + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + left join lateral aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) as acl on true + where c.relkind in ('r', 'p', 'v', 'm', 'f') + and n.nspname not in (${IGNORED_SCHEMAS_LIST}) + group by c.oid, n.nspname + having + bool_or( + pg_catalog.pg_get_userbyid(acl.grantee) = 'anon' + and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + ) + and bool_or( + pg_catalog.pg_get_userbyid(acl.grantee) = 'authenticated' + and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + ) + and bool_or( + pg_catalog.pg_get_userbyid(acl.grantee) = 'service_role' + and acl.privilege_type in ('SELECT', 'INSERT', 'UPDATE', 'DELETE') + ) + ) t + ), + '[]'::jsonb + ) as schemas; + ` +} diff --git a/apps/studio/data/privileges/update-exposed-tables-mutation.ts b/apps/studio/data/privileges/update-exposed-tables-mutation.ts new file mode 100644 index 0000000000000..47b576d0209d7 --- /dev/null +++ b/apps/studio/data/privileges/update-exposed-tables-mutation.ts @@ -0,0 +1,88 @@ +import { useMutation } from '@tanstack/react-query' +import { executeSql } from 'data/sql/execute-sql-query' +import { toast } from 'sonner' +import type { UseCustomMutationOptions } from 'types' + +import type { ConnectionVars } from '../common.types' +import { getExposedSchemasSql } from './privileges.sql' + +export type UpdateExposedTablesVariables = ConnectionVars & { + tableIdsToAdd: number[] + tableIdsToRemove: number[] +} + +const buildTablePrivilegesSql = (oids: number[], action: 'grant' | 'revoke') => { + if (oids.length === 0) return '' + + const privilegeClause = + action === 'grant' + ? 'grant select, insert, update, delete on table %I.%I to anon, authenticated, service_role' + : 'revoke all on table %I.%I from anon, authenticated, service_role' + + return /* SQL */ ` + do $$ + declare + relname name; + nspname name; + begin + for nspname, relname in + select n.nspname, c.relname + from pg_class c + join pg_namespace n on n.oid = c.relnamespace + where c.oid in (${oids.join(', ')}) + loop + execute format('${privilegeClause}', relname, nspname); + end loop; + end $$; + ` +} + +export async function updateExposedTables({ + projectRef, + connectionString, + tableIdsToAdd, + tableIdsToRemove, +}: UpdateExposedTablesVariables): Promise { + if (!projectRef) throw new Error('projectRef is required') + + const sqlParts: string[] = [] + + if (tableIdsToAdd.length > 0) { + sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant')) + } + + if (tableIdsToRemove.length > 0) { + sqlParts.push(buildTablePrivilegesSql(tableIdsToRemove, 'revoke')) + } + + sqlParts.push(getExposedSchemasSql()) + + const { result } = await executeSql({ + projectRef, + connectionString, + sql: sqlParts.join('\n'), + queryKey: ['update-exposed-tables'], + }) + + return (result[0] as { schemas: string[] }).schemas +} + +type UpdateExposedTablesData = Awaited> + +export const useUpdateExposedTablesMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars: UpdateExposedTablesVariables) => updateExposedTables(vars), + onError(error: Error) { + toast.error(`Failed to update table access: ${error.message}`) + }, + ...(onError ? { onError } : {}), + ...options, + }) +} diff --git a/e2e/studio/features/api-access-toggle.spec.ts b/e2e/studio/features/api-access-toggle.spec.ts index 85269eb6c2f47..a61d38463be2d 100644 --- a/e2e/studio/features/api-access-toggle.spec.ts +++ b/e2e/studio/features/api-access-toggle.spec.ts @@ -58,11 +58,10 @@ async function verifyTablePrivileges( /** * Locates the API access toggle switch for Data API Access. - * The switch is labeled by the nearby "Data API Access" text. + * Only present when creating or duplicating a table (not when editing). */ -function getApiAccessToggle(page: Page) { +function getApiAccessSwitch(page: Page) { const sidePanel = page.getByTestId('table-editor-side-panel') - // The switch is near the "Data API Access" label - get the section first, then find the switch const dataApiSection = sidePanel .locator('div') .filter({ hasText: 'Data API Access' }) @@ -71,23 +70,12 @@ function getApiAccessToggle(page: Page) { } /** - * Locates the settings button for granular privilege settings. + * Locates the "Manage access" link shown when editing an existing table. + * Links out to the API settings page. */ -function getPrivilegeSettingsButton(page: Page) { +function getManageAccessLink(page: Page) { const sidePanel = page.getByTestId('table-editor-side-panel') - return sidePanel.getByRole('button', { name: 'Configure API privileges' }) -} - -/** - * Gets the privilege selector combobox for a specific role in the privileges popover. - * The popover must already be open. - */ -function getRolePrivilegeSelector(page: Page, roleLabel: 'Anonymous (anon)' | 'Authenticated') { - // The popover is a dialog with structure: paragraph (role label) followed by combobox - // We find the paragraph with the role text, then get the adjacent combobox - const popoverContent = page.locator('[data-radix-popper-content-wrapper]') - // Get the paragraph containing the role label, then navigate to the sibling combobox - return popoverContent.getByText(roleLabel, { exact: true }).locator('..').getByRole('combobox') + return sidePanel.getByRole('link', { name: 'Manage access' }) } test.describe('API Access Toggle', () => { @@ -107,8 +95,8 @@ test.describe('API Access Toggle', () => { // Fill in table name await page.getByTestId('table-name-input').fill(tableName) - // Find and click the API access toggle to turn it off - const toggle = getApiAccessToggle(page) + // Verify the toggle is checked by default + const toggle = getApiAccessSwitch(page) await expect(toggle).toBeChecked() // Create the table @@ -155,8 +143,8 @@ test.describe('API Access Toggle', () => { // Fill in table name await page.getByTestId('table-name-input').fill(tableName) - // Find and click the API access toggle to turn it off - const toggle = getApiAccessToggle(page) + // Toggle API access off + const toggle = getApiAccessSwitch(page) await expect(toggle).toBeChecked() await toggle.click() await expect(toggle, 'Toggle should be unchecked after clicking').not.toBeChecked() @@ -195,7 +183,7 @@ test.describe('API Access Toggle', () => { }) }) - test('shows API access toggle when editing an existing table', async ({ page, ref }) => { + test('shows Manage access link when editing an existing table', async ({ page, ref }) => { const tableName = `${TABLE_NAME_PREFIX}_edit` // Create a table first @@ -207,138 +195,40 @@ test.describe('API Access Toggle', () => { await page.getByRole('button', { name: 'Save' }).click() await createPromise - // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) - // Dismiss toast to prevent it from blocking subsequent interactions await dismissToastsIfAny(page) - await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) + await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible() - // Verify table was created - await expect( - page.getByRole('button', { name: `View ${tableName}`, exact: true }), - 'Table should be visible after creation' - ).toBeVisible() - - // Verify default full privileges were granted - await verifyTablePrivileges('public', tableName, { - anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], - authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], - }) - - // Navigate back to table editor - let loadPromise = waitForTableToLoad(page, ref) + // Navigate back and open the edit panel + const loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise - // Click on the table to view it const navigationPromise = page.waitForURL(/\/editor\/\d+\?schema=public$/) await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() await navigationPromise - // Open edit table dialog via context menu await openTableContextMenu(page, tableName) await page.getByRole('menuitem', { name: 'Edit table' }).click() - - // Verify the side panel is open await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() - // Verify Data API Access section is visible + // Data API Access section is visible await expect( page.getByText('Data API Access'), 'Data API Access label should be visible in edit mode' ).toBeVisible() - // Verify the toggle is present - const toggle = getApiAccessToggle(page) - await expect(toggle, 'API Access toggle should be visible in edit mode').toBeVisible() - }) - - test('creates table with partial privileges and verifies correct grants', async ({ - page, - ref, - }) => { - const tableName = `${TABLE_NAME_PREFIX}_partial_grants` - - // Open new table dialog - await page.getByRole('button', { name: 'New table', exact: true }).click() - await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() - - // Fill in table name - await page.getByTestId('table-name-input').fill(tableName) - - // Open the privilege settings popover - const settingsButton = getPrivilegeSettingsButton(page) - await settingsButton.click() - - await expect(page.getByText('Adjust API privileges per role')).toBeVisible() - - // Modify anon privileges - leave only SELECT - const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)') - await anonSelector.click() - - // Click DELETE to toggle it off - await page.getByRole('option', { name: 'DELETE' }).click() - // Click UPDATE to toggle it off - await page.getByRole('option', { name: 'UPDATE' }).click() - await page.getByRole('option', { name: 'INSERT' }).click() - - // Close the dropdown by clicking the combobox again - await anonSelector.click() - - // Wait for dropdown to close - await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) - - // Modify authenticated privileges - remove DELETE and UPDATE (leave SELECT + INSERT) - const authSelector = getRolePrivilegeSelector(page, 'Authenticated') - await authSelector.click() - - // Remove all except SELECT - await page.getByRole('option', { name: 'DELETE' }).click() - await page.getByRole('option', { name: 'UPDATE' }).click() - - // Close the dropdown by clicking the combobox again - await authSelector.click() - - // Wait for dropdown to close - await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) - - // Close the popover by pressing Escape - await page.keyboard.press('Escape') - - // Create the table - const createTablePromise = createApiResponseWaiter( - page, - 'pg-meta', - ref, - 'query?key=table-create' - ) - await page.getByRole('button', { name: 'Save' }).click() - await createTablePromise - - // Wait for success toast which indicates all operations (including privilege updates) are complete - await expect( - page.getByText(`Table ${tableName} is good to go!`), - 'Success toast should appear after table creation' - ).toBeVisible({ timeout: 15000 }) - - await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) - - // Verify table was created + // In edit mode the panel shows a "Manage access" link instead of a toggle switch + const manageAccessLink = getManageAccessLink(page) await expect( - page.getByRole('button', { name: `View ${tableName}`, exact: true }), - 'Table should be visible after creation' + manageAccessLink, + 'Manage access link should be visible in edit mode' ).toBeVisible() - - // Verify partial grants - anon: SELECT; authenticated: SELECT, INSERT - await verifyTablePrivileges('public', tableName, { - anon: ['SELECT'], - authenticated: ['SELECT', 'INSERT'], - }) }) test('preserves API grants when editing non-privilege table properties', async ({ @@ -347,78 +237,54 @@ test.describe('API Access Toggle', () => { }) => { const tableName = `${TABLE_NAME_PREFIX}_preserve_grants` - // Step 1: Create a table with partial privileges (only SELECT and INSERT for anon) + // Step 1: Create a table with API access on (default — full grants) await page.getByRole('button', { name: 'New table', exact: true }).click() await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() - await page.getByTestId('table-name-input').fill(tableName) - // Open privilege settings and set partial privileges - const settingsButton = getPrivilegeSettingsButton(page) - await settingsButton.click() - await expect(page.getByText('Adjust API privileges per role')).toBeVisible() - - // Modify anon privileges - keep only SELECT and INSERT - const anonSelector = getRolePrivilegeSelector(page, 'Anonymous (anon)') - await anonSelector.click() - await page.getByRole('option', { name: 'DELETE' }).click() - await page.getByRole('option', { name: 'UPDATE' }).click() - await anonSelector.click() - await expect(page.getByRole('option', { name: 'DELETE' })).not.toBeVisible({ timeout: 2000 }) - - // Keep authenticated with full privileges - await page.keyboard.press('Escape') // Close popover + // Verify toggle is on by default + const toggle = getApiAccessSwitch(page) + await expect(toggle).toBeChecked() - // Create the table let createPromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-create') await page.getByRole('button', { name: 'Save' }).click() await createPromise - // Wait for success toast which indicates all operations (including privilege updates) are complete await expect( page.getByText(`Table ${tableName} is good to go!`), 'Success toast should appear after table creation' ).toBeVisible({ timeout: 15000 }) await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) + await expect(page.getByRole('button', { name: `View ${tableName}`, exact: true })).toBeVisible() - await expect( - page.getByRole('button', { name: `View ${tableName}`, exact: true }), - 'Table should be created' - ).toBeVisible() - - // Verify initial privileges before edit + // Verify full privileges were granted await verifyTablePrivileges('public', tableName, { - anon: ['SELECT', 'INSERT'], + anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) - // Navigate back to table editor + // Step 2: Navigate back and edit only the description let loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise - // Step 2: Edit the table's description (without touching privileges) await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click() await page.waitForURL(/\/editor\/\d+\?schema=public$/) await openTableContextMenu(page, tableName) await page.getByRole('menuitem', { name: 'Edit table' }).click() - await expect(page.getByTestId('table-editor-side-panel')).toBeVisible() - // Add a description without modifying privileges const descriptionInput = page .getByTestId('table-editor-side-panel') .getByPlaceholder('Optional') await descriptionInput.fill('Test description for grant preservation') - // Save the changes const updatePromise = createApiResponseWaiter(page, 'pg-meta', ref, 'query?key=table-update') await page.getByRole('button', { name: 'Save' }).click() await updatePromise - // Wait for success toast which indicates all operations are complete await expect( page.getByText(`Successfully updated ${tableName}!`), 'Success toast should appear after table update' @@ -426,18 +292,17 @@ test.describe('API Access Toggle', () => { await page.waitForSelector('[data-testid="table-editor-side-panel"]', { state: 'detached' }) - // Step 3: Verify the privileges remain unchanged after edit + // Step 3: Verify the full privileges are unchanged after the description edit await verifyTablePrivileges('public', tableName, { - anon: ['SELECT', 'INSERT'], + anon: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], authenticated: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'], }) - // Navigate back to table editor for cleanup + // Clean up loadPromise = waitForTableToLoad(page, ref) await page.goto(toUrl(`/project/${ref}/editor?schema=public`)) await loadPromise - // Clean up await deleteTable(page, ref, tableName) }) }) From 95283fafb56bcfc881cae32cad39971be89199de Mon Sep 17 00:00:00 2001 From: Kanishk Dudeja Date: Fri, 6 Mar 2026 18:54:47 +0530 Subject: [PATCH 07/12] fix(billing): remove duplicate tax ID entry for New Zealand (#43482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR removes a duplicate `nz_gst` entry from the tax ID types list. **Testing** Screenshot 2026-03-06 at 5 25 38 PM --- .../BillingSettings/BillingCustomerData/TaxID.constants.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts index b34098174b306..de562f4824a08 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/TaxID.constants.ts @@ -721,13 +721,6 @@ export const TAX_IDS: TaxId[] = [ placeholder: 'MK1234567890123', countryIso2: 'MK', }, - { - name: 'nz_gst', - type: 'nz_gst', - country: 'New Zealand', - placeholder: '123456789', - countryIso2: 'NZ', - }, { name: 'NO VAT', type: 'no_vat', From a25e116c505fbfa40aa0c65d8247627f1bfae0b4 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Fri, 6 Mar 2026 14:25:55 +0100 Subject: [PATCH 08/12] chore(Docs): Run format on docs folder to update files (#43463) From 98c34582b8c9c576b92ca6c5bb1e9937f7288428 Mon Sep 17 00:00:00 2001 From: hallidayo <22655069+Hallidayo@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:59:07 +0000 Subject: [PATCH 09/12] feat: add description to schema visualizer (#43406) ## 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? Studio > Database > Schema Visualizer ## What is the current behavior? If a table has a description value then the value does not show on the visualizer ## What is the new behavior? Screenshot 2026-03-04 at 19 09 47 ## Additional context Closes #36072 --- .../Database/Schemas/SchemaTableNode.tsx | 43 ++++++++++++------- .../Database/Schemas/Schemas.utils.ts | 4 ++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index effd0546cdc23..4881bfdca67ac 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -1,9 +1,8 @@ import { buildTableEditorUrl } from 'components/grid/SupabaseGrid.utils' -import { DiamondIcon, ExternalLink, Fingerprint, Hash, Key, Table2 } from 'lucide-react' +import { DiamondIcon, ExternalLink, Fingerprint, Hash, InfoIcon, Key, Table2 } from 'lucide-react' import Link from 'next/link' import { Handle, NodeProps } from 'reactflow' - -import { Button, cn } from 'ui' +import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' // ReactFlow is scaling everything by the factor of 2 export const TABLE_NODE_WIDTH = 320 @@ -15,6 +14,7 @@ export type TableNodeData = { name: string ref?: string isForeign: boolean + description: string columns: { id: string isPrimary: boolean @@ -67,19 +67,30 @@ export const TableNode = ({ {data.name}
- {!placeholder && ( - - )} +
+ {data.description && ( + + + + + {data.description} + + )} + + {!placeholder && ( + + )} +
{data.columns.map((column) => ( diff --git a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts index 0dc70a1f953cb..c8af180fed61c 100644 --- a/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts +++ b/apps/studio/components/interfaces/Database/Schemas/Schemas.utils.ts @@ -2,10 +2,12 @@ import dagre from '@dagrejs/dagre' import type { PostgresSchema, PostgresTable } from '@supabase/postgres-meta' import { uniqBy } from 'lodash' import { Edge, Node, Position } from 'reactflow' + import 'reactflow/dist/style.css' import { LOCAL_STORAGE_KEYS } from 'common' import { tryParseJson } from 'lib/helpers' + import { TABLE_NODE_ROW_HEIGHT, TABLE_NODE_WIDTH, TableNodeData } from './SchemaTableNode' const NODE_SEP = 25 @@ -41,6 +43,7 @@ export async function getGraphDataFromTables( ref, id: table.id, name: table.name, + description: table.comment ?? '', schema: table.schema, isForeign: false, columns, @@ -78,6 +81,7 @@ export async function getGraphDataFromTables( ref: ref!, schema: rel.target_table_schema, name: targetId, + description: '', isForeign: true, columns: [], } From ebec20f5428c0b6bf5f547d83c98b6e300cc7687 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Fri, 6 Mar 2026 11:17:57 -0300 Subject: [PATCH 10/12] chore: prevention of used leaked passwords entitlement (#43410) ### Changes - Replaces the isPaid plan-based check on the "Prevent use of leaked passwords" (PASSWORD_HIBP_ENABLED) setting with a proper entitlement check using the `password_hibp` entitlement key - Adds a new `useHasEntitlementAccess` hook that returns a reusable checker function for any entitlement key, backed by the same cached entitlements query ### Testing - Head to `/project/_/auth/providers?provider=Email` with an Org on the Free Plan - Assert that the "Prevent use of leaked passwords" toggle is disabled. - Head to `/project/_/auth/providers?provider=Email` with an Org on the Pro Plan - Assert that the "Prevent use of leaked passwords" toggle is enabled and can be toggled and saved. image --- .../AuthProvidersForm.types.ts | 4 +++- .../Auth/AuthProvidersForm/ProviderForm.tsx | 18 +++++++-------- .../Auth/AuthProvidersFormValidation.tsx | 2 +- .../studio/hooks/misc/useCheckEntitlements.ts | 22 ++++++++++++++++++- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts index af4bcc95c1988..32667230f548f 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.types.ts @@ -1,3 +1,5 @@ +import type { FeatureKey } from 'data/entitlements/entitlements-query' + export interface Enum { label: string value: string @@ -22,7 +24,7 @@ export interface Provider { descriptionOptional?: string units?: string isSecret?: boolean - isPaid?: boolean + entitlementKey?: FeatureKey link?: string } } diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 594b16b7d37b6..160a08f3cd20e 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -7,6 +7,7 @@ import { ResourceItem } from 'components/ui/Resource/ResourceItem' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useHasEntitlementAccess } from 'hooks/misc/useCheckEntitlements' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { BASE_PATH } from 'lib/constants' import { Check } from 'lucide-react' @@ -67,7 +68,7 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) ) } - const isFreePlan = organization?.plan.id === 'free' + const hasEntitlementAccess = useHasEntitlementAccess() const INITIAL_VALUES = (() => { const initialValues: { [x: string]: string | boolean } = {} @@ -201,18 +202,17 @@ export const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) ) } + const { entitlementKey } = provider.properties[x] + const hasAccess = + entitlementKey == null || hasEntitlementAccess(entitlementKey) const properties = { ...provider.properties[x], - description: - provider.properties[x].isPaid && isFreePlan - ? `${description} Only available on [Pro plan](/org/${organization.slug}/billing?panel=subscriptionPlan) and above.` - : description, + description: hasAccess + ? description + : `${description} Only available on [Pro plan](/org/${organization?.slug}/billing?panel=subscriptionPlan) and above.`, } - const isDisabledDueToPlan = properties.isPaid && isFreePlan const shouldDisable = - properties.type === 'boolean' - ? isDisabledDueToPlan && !values[x] - : isDisabledDueToPlan + properties.type === 'boolean' ? !hasAccess && !values[x] : !hasAccess return ( + IS_PLATFORM + ? entitlementsData?.entitlements.find((e) => e.feature.key === key)?.hasAccess ?? false + : true, + [entitlementsData] + ) +} + export function useCheckEntitlements( featureKey: FeatureKey, organizationSlug?: string, From f0b44ae087028151f1121053c5e4de2ac8f9e9e0 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 6 Mar 2026 22:39:04 +0800 Subject: [PATCH 11/12] Adjust header + project list for wildcard routes (#43387) ## Context Small adjustments to wildcard routes - Header logo to be consistent with other layouts - Before: image - After: image - Project wildcard route list view to be consistent with org/[slug] page - project list shouldn't take full height if there's no need to, scroll behaviour should be on the page - Before: image - After: image Co-authored-by: Ali Waseem --- .../Home/ProjectList/EmptyStates.tsx | 19 +++++------------ apps/studio/pages/org/[slug]/index.tsx | 2 +- .../pages/project/_/[[...routeSlug]].tsx | 21 +++++++++++-------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx index 60f4c422d2bc0..16a4c9e8df3bb 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/EmptyStates.tsx @@ -1,9 +1,7 @@ +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { BoxPlus } from 'icons' import { Plus } from 'lucide-react' import Link from 'next/link' - -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { BASE_PATH } from 'lib/constants' import { Button, Card, @@ -16,21 +14,14 @@ import { TableRow, } from 'ui' import { EmptyStatePresentational } from 'ui-patterns' + import { ShimmeringCard } from './ShimmeringCard' +import { HomeIcon } from '@/components/layouts/ProjectLayout/LayoutHeader/HomeIcon' export const Header = () => { return ( -
-
- - Supabase - -
+
+
) } diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index b223258fe7175..c0493aa977396 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -19,7 +19,7 @@ const ProjectsPage: NextPageWithLayout = () => { return ( - + {disableAccessMfa ? ( {
- - {organizations.length > 0 && ( - -
+ + +
+ {isLoadingOrganizations ? ( + + ) : (
@@ -103,11 +106,11 @@ const GenericProjectPage: NextPage = () => { ))} - -
- - )} - + )} + +
+
+ {isLoadingOrganizations ? ( ) : isErrorOrganizations ? ( From efa40ea0e4947947589588fe1f843d2621f260e1 Mon Sep 17 00:00:00 2001 From: Marouane Souda <61951643+marsou001@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:38:49 +0000 Subject: [PATCH 12/12] fix(studio): contrast ratio of JSON cell viewer colors in light mode (#40691) JSON column viewer looks fine on dark mode, but barely visible on light mode, especially when the value is of string type. There are no colors that can satisfy the 4.5:1 contrast ratio (minimum requirement for web and digital accessibility) on both white and black background, so I had to pick different classes for ight mode (dark mode is fine, I checked the colors against the dark background and the contrast is good). Even though the new colors may not look that contrasting to each other, I prioritized accessibility, and made sure they all complied with the 4.5:1 contrast ratio. Before: 112 After: 222 Fixes #40633 --------- Co-authored-by: Ali Waseem --- .../RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx index 32ccb94f0e5d9..7e28b3f2eec82 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/DrilldownViewer/DrilldownPane.tsx @@ -63,10 +63,12 @@ const DrilldownPane = ({ pane, jsonData, activeKey, onSelectKey = noop }: Drilld ))} {keysWithoutChildren.map((key: string) => (
-

{key}:

+

{key}:

{isNull(jsonData[key])