diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index bf4a953771c47..2b63bec9a1de4 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -727,6 +727,7 @@ export const auth: NavMenuConstant = { name: 'Server-Side Rendering', url: '/guides/auth/server-side', items: [ + { name: 'Overview', url: '/guides/auth/server-side' }, { name: 'Creating a client', url: '/guides/auth/server-side/creating-a-client' }, { name: 'Migrating from Auth Helpers', diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 933fdd62b2b20..f5e66e5df5d3a 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -349,6 +349,35 @@ commands: - id: supabase-storage-cp title: supabase storage cp summary: Copy objects from src to dst path + description: | + Relies on standard uploads to move files between local and remote storage. [Not suitable for moving files over 6MB in size](https://supabase.com/docs/guides/storage/uploads/standard-uploads?queryGroups=language&language=js#uploading). + examples: + - id: upload-file-to-bucket + name: Upload file to bucket + code: supabase storage cp -r path/to/file ss:///bucket_name --experimental + response: | + Uploading: /path/to/file/example.txt => /bucket_name/example.txt + - id: Upload-file-to-folder + name: Upload file to folder + code: supabase storage cp -r path/to/local/file ss:///bucket_name/path/to/folder --experimental + response: | + Uploading: /path/to/file/example.txt => /bucket_name/path/to/folder/example.txt + - id: upload-entire-folder-to-bucket + name: Upload folder to bucket + code: supabase storage cp -r path/to/local/folder ss:///bucket_name --experimental + response: | + Uploading: /path/to/folder/example1.txt => /bucket_name/folder/example1.txt + Uploading: /path/to/folder/example2.txt => /bucket_name/folder/example2.txt + Uploading: /path/to/folder/example3.txt => /bucket_name/folder/example3.txt + ... + - id: download-entire-bucket + name: Download bucket + code: supabase storage cp -r ss://bucket_name path/to/local/folder --experimental + response: | + Downloading: bucket_name/example1.txt => path/to/local/folder/bucket_name/example1.txt + Downloading: bucket_name/example2.txt => path/to/local/folder/bucket_name/example2.txt + Downloading: bucket_name/example3.txt => path/to/local/folder/bucket_name/example3.txt + ... tags: [] links: [] usage: supabase storage cp [flags] diff --git a/apps/studio/components/interfaces/Linter/LintDetail.tsx b/apps/studio/components/interfaces/Linter/LintDetail.tsx index a88d3bb6b45db..3db030282567b 100644 --- a/apps/studio/components/interfaces/Linter/LintDetail.tsx +++ b/apps/studio/components/interfaces/Linter/LintDetail.tsx @@ -1,5 +1,4 @@ import Link from 'next/link' -import ReactMarkdown from 'react-markdown' import { createLintSummaryPrompt, lintInfoMap } from 'components/interfaces/Linter/Linter.utils' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' @@ -10,6 +9,7 @@ import { ExternalLink } from 'lucide-react' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AiIconAnimation, Button } from 'ui' +import { Markdown } from '../Markdown' import { EntityTypeIcon, LintCTA, LintEntity } from './Linter.utils' interface LintDetailProps { @@ -48,13 +48,13 @@ const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => {

Issue

- + {lint.detail.replace(/\\`/g, '`')} - +

Description

- + {lint.description.replace(/\\`/g, '`')} - +

Resolve

diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index b66f4913196c4..40f689d731ac0 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -300,6 +300,26 @@ export const lintInfoMap: LintInfo[] = [ docsLink: `${DOCS_URL}/guides/platform/upgrading`, category: 'security', }, + { + name: 'sensitive_columns_exposed', + title: 'Sensitive Columns Exposed', + icon: , + link: ({ projectRef, metadata }) => + `/project/${projectRef}/editor?schema=${metadata?.schema}&table=${metadata?.name}`, + linkText: 'View table', + docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0023_sensitive_columns_exposed`, + category: 'security', + }, + { + name: 'rls_policy_always_true', + title: 'RLS Policy Always True', + icon: , + link: ({ projectRef, metadata }) => + `/project/${projectRef}/auth/policies?schema=${metadata?.schema}&search=${metadata?.name}`, + linkText: 'View policies', + docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0024_permissive_rls_policy`, + category: 'security', + }, ] export const LintCTA = ({ diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index bb44df24c7402..2cace40ccb8ed 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -1,7 +1,6 @@ -import dayjs from 'dayjs' import { BookOpen, Check, ChevronDown, Copy, ExternalLink, X } from 'lucide-react' import Link from 'next/link' -import { ReactNode, useState } from 'react' +import { ReactNode, useEffect, useState } from 'react' import { IS_PLATFORM } from 'common' import Table from 'components/to-be-cleaned/Table' @@ -32,8 +31,7 @@ import { LogsWarning, LogTemplate } from './Logs.types' export interface LogsQueryPanelProps { templates?: LogTemplate[] - defaultFrom: string - defaultTo: string + value: DatePickerValue warnings: LogsWarning[] onSelectTemplate: (template: LogTemplate) => void onSelectSource: (source: string) => void @@ -51,8 +49,7 @@ function DropdownMenuItemContent({ name, desc }: { name: ReactNode; desc?: strin const LogsQueryPanel = ({ templates = [], - defaultFrom, - defaultTo, + value, warnings, onSelectTemplate, onSelectSource, @@ -77,26 +74,11 @@ const LogsQueryPanel = ({ }) .map(([, value]) => value) - function getDefaultDatePickerValue() { - if (defaultFrom && defaultTo) { - return { - to: defaultTo, - from: defaultFrom, - text: `${dayjs(defaultFrom).format('DD MMM, HH:mm')} - ${dayjs(defaultTo).format('DD MMM, HH:mm')}`, - isHelper: false, - } - } - return { - to: EXPLORER_DATEPICKER_HELPERS[0].calcTo(), - from: EXPLORER_DATEPICKER_HELPERS[0].calcFrom(), - text: EXPLORER_DATEPICKER_HELPERS[0].text, - isHelper: true, - } - } + const [selectedDatePickerValue, setSelectedDatePickerValue] = useState(value) - const [selectedDatePickerValue, setSelectedDatePickerValue] = useState( - getDefaultDatePickerValue() - ) + useEffect(() => { + setSelectedDatePickerValue(value) + }, [value.from, value.to, value.text, value.isHelper]) return (
diff --git a/apps/studio/components/interfaces/Settings/Logs/logsDateRange.ts b/apps/studio/components/interfaces/Settings/Logs/logsDateRange.ts new file mode 100644 index 0000000000000..3a811e81d90fa --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Logs/logsDateRange.ts @@ -0,0 +1,55 @@ +import dayjs from 'dayjs' + +import { EXPLORER_DATEPICKER_HELPERS, getDefaultHelper } from './Logs.constants' +import type { DatePickerValue } from './Logs.DatePickers' +import type { DatetimeHelper } from './Logs.types' + +export interface ResolvedLogDateRange { + from: string + to: string +} + +export interface ResolvedLogParams extends ResolvedLogDateRange { + sql: string +} + +const findHelper = (value: DatePickerValue, helpers: DatetimeHelper[]) => { + if (!value.text) return undefined + return helpers.find((helper) => helper.text === value.text) +} + +const ensureEnd = (candidate: string | undefined, now: dayjs.Dayjs) => { + if (candidate && candidate.length > 0) return candidate + return now.toISOString() +} + +export const resolveLogDateRange = ( + value: DatePickerValue, + helpers: DatetimeHelper[] = EXPLORER_DATEPICKER_HELPERS +): ResolvedLogDateRange => { + const now = dayjs() + if (value.isHelper) { + const matchedHelper = findHelper(value, helpers) ?? getDefaultHelper(helpers) + const from = matchedHelper?.calcFrom() ?? value.from ?? '' + const to = ensureEnd(matchedHelper?.calcTo() ?? value.to, now) + return { from, to } + } + + const defaultHelper = getDefaultHelper(helpers) + const from = value.from || defaultHelper.calcFrom() + const to = ensureEnd(value.to, now) + return { from, to } +} + +export const buildLogQueryParams = ( + value: DatePickerValue, + sql: string, + helpers: DatetimeHelper[] = EXPLORER_DATEPICKER_HELPERS +): ResolvedLogParams => { + const range = resolveLogDateRange(value, helpers) + return { + sql, + from: range.from, + to: range.to, + } +} diff --git a/apps/studio/components/ui/AlertError.tsx b/apps/studio/components/ui/AlertError.tsx index c37b2c79b7324..09cd64cfaf025 100644 --- a/apps/studio/components/ui/AlertError.tsx +++ b/apps/studio/components/ui/AlertError.tsx @@ -1,10 +1,11 @@ import { SupportCategories } from '@supabase/shared-types/out/constants' import { SupportLink } from 'components/interfaces/Support/SupportLink' -import { PropsWithChildren } from 'react' +import { PropsWithChildren, useEffect, useRef } from 'react' import { Admonition } from 'ui-patterns/admonition' import { Button } from 'ui' +import { useTrack } from 'lib/telemetry/track' export interface AlertErrorProps { projectRef?: string @@ -52,10 +53,22 @@ export const AlertError = ({ children, additionalActions, }: PropsWithChildren) => { + const track = useTrack() + const hasTrackedRef = useRef(false) + const formattedErrorMessage = error?.message?.includes('503') ? '503 Service Temporarily Unavailable' : error?.message + useEffect(() => { + if (!hasTrackedRef.current) { + hasTrackedRef.current = true + track('dashboard_error_created', { + source: 'admonition', + }) + } + }, [track]) + return ( { + runQueryCallbackRef.current = runQuery.callback + }, [runQuery.callback]) + const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0 const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault) @@ -134,7 +139,7 @@ const CodeEditor = ({ const selectedValue = (editorRef?.current as any) .getModel() .getValueInRange((editorRef?.current as any)?.getSelection()) - runQuery.callback(selectedValue || (editorRef?.current as any)?.getValue()) + runQueryCallbackRef.current(selectedValue || (editorRef?.current as any)?.getValue()) }, }) } diff --git a/apps/studio/lib/api/self-hosted/lints.ts b/apps/studio/lib/api/self-hosted/lints.ts index 8dbf43a691798..5b62974c09b14 100644 --- a/apps/studio/lib/api/self-hosted/lints.ts +++ b/apps/studio/lib/api/self-hosted/lints.ts @@ -1184,4 +1184,240 @@ where and ext.default_version is not null and ext.installed_version != ext.default_version order by - ext.name)`.trim() + ext.name) +union all +( +-- Detects tables exposed via API that contain columns with sensitive names +-- Inspired by patterns from security scanners that detect PII/credential exposure +with sensitive_patterns as ( + select unnest(array[ + -- Authentication & Credentials + 'password', 'passwd', 'pwd', 'passphrase', + 'secret', 'secret_key', 'private_key', 'api_key', 'apikey', + 'auth_key', 'token', 'jwt', 'access_token', 'refresh_token', + 'oauth_token', 'session_token', 'bearer_token', 'auth_code', + 'session_id', 'session_key', 'session_secret', + 'recovery_code', 'backup_code', 'verification_code', + 'otp', 'two_factor', '2fa_secret', '2fa_code', + -- Personal Identifiers + 'ssn', 'social_security', 'social_security_number', + 'driver_license', 'drivers_license', 'license_number', + 'passport_number', 'passport_id', 'national_id', 'tax_id', + -- Financial Information + 'credit_card', 'card_number', 'cvv', 'cvc', 'cvn', + 'bank_account', 'account_number', 'routing_number', + 'iban', 'swift_code', 'bic', + -- Health & Medical + 'health_record', 'medical_record', 'patient_id', + 'insurance_number', 'health_insurance', 'medical_insurance', + 'treatment', + -- Device Identifiers + 'mac_address', 'macaddr', 'imei', 'device_uuid', + -- Digital Keys & Certificates + 'pgp_key', 'gpg_key', 'ssh_key', 'certificate', + 'license_key', 'activation_key', + -- Biometric Data + 'facial_recognition' + ]) as pattern +), +exposed_tables as ( + select + n.nspname as schema_name, + c.relname as table_name, + c.oid as table_oid + from + pg_catalog.pg_class c + join pg_catalog.pg_namespace n + on c.relnamespace = n.oid + where + c.relkind = 'r' -- regular tables + and ( + pg_catalog.has_table_privilege('anon', c.oid, 'SELECT') + or pg_catalog.has_table_privilege('authenticated', c.oid, 'SELECT') + ) + and n.nspname = any(array(select trim(unnest(string_to_array(current_setting('pgrst.db_schemas', 't'), ','))))) + and n.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) + -- Only flag tables without RLS enabled + and not c.relrowsecurity +), +sensitive_columns as ( + select + et.schema_name, + et.table_name, + a.attname as column_name, + sp.pattern as matched_pattern + from + exposed_tables et + join pg_catalog.pg_attribute a + on a.attrelid = et.table_oid + and a.attnum > 0 + and not a.attisdropped + cross join sensitive_patterns sp + where + -- Match column name against sensitive patterns (case insensitive), allowing '-'/'_' variants + replace(lower(a.attname), '-', '_') = sp.pattern +) +select + 'sensitive_columns_exposed' as name, + 'Sensitive Columns Exposed' as title, + 'ERROR' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection.' as description, + format( + 'Table \`%s.%s\` is exposed via API without RLS and contains potentially sensitive column(s): %s. This may lead to data exposure.', + schema_name, + table_name, + string_agg(distinct column_name, ', ' order by column_name) + ) as detail, + '${DOCS_URL}/guides/database/database-linter?lint=0023_sensitive_columns_exposed' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'sensitive_columns', array_agg(distinct column_name order by column_name), + 'matched_patterns', array_agg(distinct matched_pattern order by matched_pattern) + ) as metadata, + format( + 'sensitive_columns_exposed_%s_%s', + schema_name, + table_name + ) as cache_key +from + sensitive_columns +group by + schema_name, + table_name +order by + schema_name, + table_name) +union all +( +-- Detects RLS policies that are overly permissive (e.g., USING (true), USING (1=1)) +-- These policies effectively disable row-level security while giving a false sense of security +with policies as ( + select + nsp.nspname as schema_name, + pb.tablename as table_name, + pc.relrowsecurity as is_rls_active, + pa.polname as policy_name, + pa.polpermissive as is_permissive, + pa.polroles as role_oids, + (select array_agg(r::regrole::text) from unnest(pa.polroles) as x(r)) as roles, + case pa.polcmd + when 'r' then 'SELECT' + when 'a' then 'INSERT' + when 'w' then 'UPDATE' + when 'd' then 'DELETE' + when '*' then 'ALL' + end as command, + pb.qual, + pb.with_check, + -- Normalize expressions by removing whitespace and lowercasing + replace(replace(replace(lower(coalesce(pb.qual, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_qual, + replace(replace(replace(lower(coalesce(pb.with_check, '')), ' ', ''), E'\n', ''), E'\t', '') as normalized_with_check + from + pg_catalog.pg_policy pa + join pg_catalog.pg_class pc + on pa.polrelid = pc.oid + join pg_catalog.pg_namespace nsp + on pc.relnamespace = nsp.oid + join pg_catalog.pg_policies pb + on pc.relname = pb.tablename + and nsp.nspname = pb.schemaname + and pa.polname = pb.policyname + where + pc.relkind = 'r' -- regular tables + and nsp.nspname not in ( + '_timescaledb_cache', '_timescaledb_catalog', '_timescaledb_config', '_timescaledb_internal', 'auth', 'cron', 'extensions', 'graphql', 'graphql_public', 'information_schema', 'net', 'pgmq', 'pgroonga', 'pgsodium', 'pgsodium_masks', 'pgtle', 'pgbouncer', 'pg_catalog', 'pgtle', 'realtime', 'repack', 'storage', 'supabase_functions', 'supabase_migrations', 'tiger', 'topology', 'vault' + ) +), +permissive_patterns as ( + select + p.*, + -- Check for always-true USING clause patterns + -- Note: SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE + case when ( + command in ('UPDATE', 'DELETE', 'ALL') + and ( + normalized_qual in ('true', '(true)', '1=1', '(1=1)') + -- Empty or null qual on permissive policy means allow all + or (qual is null and is_permissive) + ) + ) then true else false end as has_permissive_using, + -- Check for always-true WITH CHECK clause patterns + case when ( + normalized_with_check in ('true', '(true)', '1=1', '(1=1)') + -- Empty with_check on INSERT means allow all (INSERT has no USING to fall back on) + or (with_check is null and is_permissive and command = 'INSERT') + -- Empty with_check on UPDATE/ALL with permissive USING means allow all writes + or (with_check is null and is_permissive and command in ('UPDATE', 'ALL') + and normalized_qual in ('true', '(true)', '1=1', '(1=1)')) + ) then true else false end as has_permissive_with_check + from + policies p + where + -- Only check tables with RLS enabled (otherwise it's a different lint) + is_rls_active + -- Only check permissive policies (restrictive policies with true are less dangerous) + and is_permissive + -- Only flag policies that apply to anon or authenticated roles (or public/all roles) + and ( + role_oids = array[0::oid] -- public (all roles) + or exists ( + select 1 + from unnest(role_oids) as r + where r::regrole::text in ('anon', 'authenticated') + ) + ) +) +select + 'rls_policy_always_true' as name, + 'RLS Policy Always True' as title, + 'WARN' as level, + 'EXTERNAL' as facing, + array['SECURITY'] as categories, + 'Detects RLS policies that use overly permissive expressions like \`USING (true)\` or \`WITH CHECK (true)\` for UPDATE, DELETE, or INSERT operations. SELECT policies with \`USING (true)\` are intentionally excluded as this pattern is often used deliberately for public read access.' as description, + format( + 'Table \`%s.%s\` has an RLS policy \`%s\` for \`%s\` that allows unrestricted access%s. This effectively bypasses row-level security for %s.', + schema_name, + table_name, + policy_name, + command, + case + when has_permissive_using and has_permissive_with_check then ' (both USING and WITH CHECK are always true)' + when has_permissive_using then ' (USING clause is always true)' + when has_permissive_with_check then ' (WITH CHECK clause is always true)' + else '' + end, + array_to_string(roles, ', ') + ) as detail, + '${DOCS_URL}/guides/database/database-linter?lint=0024_permissive_rls_policy' as remediation, + jsonb_build_object( + 'schema', schema_name, + 'name', table_name, + 'type', 'table', + 'policy_name', policy_name, + 'command', command, + 'roles', roles, + 'qual', qual, + 'with_check', with_check, + 'permissive_using', has_permissive_using, + 'permissive_with_check', has_permissive_with_check + ) as metadata, + format( + 'rls_policy_always_true_%s_%s_%s', + schema_name, + table_name, + policy_name + ) as cache_key +from + permissive_patterns +where + has_permissive_using or has_permissive_with_check +order by + schema_name, + table_name, + policy_name)`.trim() diff --git a/apps/studio/lib/toast-errors.tsx b/apps/studio/lib/toast-errors.tsx new file mode 100644 index 0000000000000..6c8724046d4f6 --- /dev/null +++ b/apps/studio/lib/toast-errors.tsx @@ -0,0 +1,22 @@ +import { useEffect, useRef } from 'react' +import { useSonner } from 'sonner' +import { useTrack } from 'lib/telemetry/track' + +export const ToastErrorTracker = () => { + const track = useTrack() + const { toasts } = useSonner() + const trackRef = useRef(new Set()) + + useEffect(() => { + toasts.forEach((toast) => { + if (toast.type === 'error' && !trackRef.current.has(toast.id)) { + trackRef.current.add(toast.id) + track('dashboard_error_created', { + source: 'toast', + }) + } + }) + }, [toasts, track]) + + return null +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 47f66371bd92d..c5e43132a0d69 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -61,6 +61,7 @@ import { Telemetry } from 'lib/telemetry' import { AiAssistantStateContextProvider } from 'state/ai-assistant-state' import type { AppPropsWithLayout } from 'types' import { SonnerToaster, TooltipProvider } from 'ui' +import { ToastErrorTracker } from 'lib/toast-errors' dayjs.extend(customParseFormat) dayjs.extend(utc) diff --git a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx index 262ae747910bd..d3956122abd31 100644 --- a/apps/studio/pages/project/[ref]/logs/explorer/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/explorer/index.tsx @@ -9,14 +9,13 @@ import { toast } from 'sonner' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' import { + EXPLORER_DATEPICKER_HELPERS, LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, TEMPLATES, + getDefaultHelper, } from 'components/interfaces/Settings/Logs/Logs.constants' -import { - DatePickerToFrom, - LogTemplate, - LogsWarning, -} from 'components/interfaces/Settings/Logs/Logs.types' +import { LogData, LogTemplate, LogsWarning } from 'components/interfaces/Settings/Logs/Logs.types' +import { DatePickerValue } from 'components/interfaces/Settings/Logs/Logs.DatePickers' import { maybeShowUpgradePromptIfNotEntitled, useEditorHints, @@ -43,6 +42,10 @@ import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import type { LogSqlSnippets, NextPageWithLayout } from 'types' +import { + buildLogQueryParams, + resolveLogDateRange, +} from 'components/interfaces/Settings/Logs/logsDateRange' import { Button, Form, @@ -76,6 +79,22 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const editorRef = useRef() const [editorId] = useState(uuidv4()) const { timestampStart, timestampEnd, setTimeRange } = useLogsUrlState() + const defaultHelper = useMemo(() => getDefaultHelper(EXPLORER_DATEPICKER_HELPERS), []) + const initialDatePickerValue = useMemo(() => { + if (timestampStart && timestampEnd) { + return { from: timestampStart, to: timestampEnd, isHelper: false } + } + if (timestampStart) { + return { from: timestampStart, to: timestampEnd || '', isHelper: false } + } + return { + from: defaultHelper.calcFrom(), + to: defaultHelper.calcTo(), + isHelper: true, + text: defaultHelper.text, + } + }, [timestampStart, timestampEnd, defaultHelper]) + const [datePickerValue, setDatePickerValue] = useState(initialDatePickerValue) const { logsDefaultQuery } = useCustomContent(['logs:default_query']) const PLACEHOLDER_QUERY = IS_PLATFORM @@ -85,7 +104,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [editorValue, setEditorValue] = useState(PLACEHOLDER_QUERY) const [saveModalOpen, setSaveModalOpen] = useState(false) const [warnings, setWarnings] = useState([]) - const [selectedLog, setSelectedLog] = useState(null) + const [selectedLog, setSelectedLog] = useState(null) const [recentLogs, setRecentLogs] = useLocalStorage( `project-content-${projectRef}-recent-log-sql`, @@ -100,18 +119,28 @@ export const LogsExplorerPage: NextPageWithLayout = () => { type: 'log_sql', }) const query = content?.content.find((x) => x.id === queryId) + + const resolvedRange = useMemo(() => { + if (datePickerValue.isHelper) { + return resolveLogDateRange(datePickerValue) + } + if (timestampStart && timestampEnd) { + return { from: timestampStart, to: timestampEnd } + } + return resolveLogDateRange(datePickerValue) + }, [timestampStart, timestampEnd, datePickerValue]) + const { params, logData, error, isLoading: logsLoading, - changeQuery, - runQuery, + setParams, } = useLogsQuery( projectRef, { - iso_timestamp_start: timestampStart, - iso_timestamp_end: timestampEnd, + iso_timestamp_start: resolvedRange.from, + iso_timestamp_end: resolvedRange.to, }, true ) @@ -173,12 +202,27 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const handleRun = (value?: string | React.MouseEvent) => { const query = typeof value === 'string' ? value || editorValue : editorValue - - changeQuery(query) - runQuery() + const resolvedParams = buildLogQueryParams(datePickerValue, query) + + setParams((prev) => ({ + ...prev, + sql: resolvedParams.sql, + iso_timestamp_start: resolvedParams.from, + iso_timestamp_end: resolvedParams.to, + })) + if (!datePickerValue.isHelper) { + setTimeRange(resolvedParams.from, resolvedParams.to) + } else { + setTimeRange('', '') + } + const queryParams: Record = { ...router.query, q: query } + if (datePickerValue.isHelper) { + delete queryParams.its + delete queryParams.ite + } router.push({ pathname: router.pathname, - query: { ...router.query, q: query }, + query: queryParams, }) addRecentLogSqlSnippet({ sql: query }) } @@ -206,7 +250,12 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } } - const handleCreateQuery = async (values: any, { setSubmitting }: any) => { + type SaveQueryFormValues = { name: string; description?: string } + + const handleCreateQuery = async ( + values: SaveQueryFormValues, + { setSubmitting }: { setSubmitting: (value: boolean) => void } + ) => { if (!projectRef) return console.error('Project ref is required') if (!profile) return console.error('Profile is required') setSubmitting(true) @@ -253,17 +302,29 @@ export const LogsExplorerPage: NextPageWithLayout = () => { setSaveModalOpen(!saveModalOpen) } - const handleDateChange = ({ to, from }: DatePickerToFrom) => { + const handleDateChange = (value: DatePickerValue) => { + setDatePickerValue(value) + const resolvedRange = resolveLogDateRange(value) const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled( - from, + resolvedRange.from, entitledToAuditLogDays ) if (shouldShowUpgradePrompt) { - setShowUpgradePrompt(!showUpgradePrompt) + setShowUpgradePrompt(true) + return + } + + if (value.isHelper) { + setTimeRange('', '') } else { - setTimeRange(from || '', to || '') + setTimeRange(resolvedRange.from || '', resolvedRange.to || '') } + setParams((prev) => ({ + ...prev, + iso_timestamp_start: resolvedRange.from, + iso_timestamp_end: resolvedRange.to, + })) } useEffect(() => { @@ -272,6 +333,15 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } }, [q]) + useEffect(() => { + // prevents overwriting when the user selects a helper. + // without this, if the user selects "last 3 days" it would overwrite it with "last hour" + // its the simplest solution I could come up with - jordi + if (!initialDatePickerValue.isHelper) { + setDatePickerValue(initialDatePickerValue) + } + }, [initialDatePickerValue]) + useEffect(() => { let newWarnings = [] const start = timestampStart ? dayjs(timestampStart) : dayjs() @@ -297,10 +367,10 @@ export const LogsExplorerPage: NextPageWithLayout = () => { entitledToAuditLogDays ) if (shouldShowUpgradePrompt) { - setShowUpgradePrompt(!showUpgradePrompt) + setShowUpgradePrompt(true) } } - }, [timestampStart, entitledToAuditLogDays]) + }, [timestampStart, entitledToAuditLogDays, setShowUpgradePrompt]) return (
@@ -311,8 +381,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { > template.mode === 'custom')} @@ -342,7 +411,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => { error={error} projectRef={projectRef} onSelectedLogChange={setSelectedLog} - selectedLog={selectedLog} + selectedLog={selectedLog || undefined} />
@@ -362,7 +431,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
diff --git a/apps/studio/tests/features/logs/LogsQueryPanel.test.tsx b/apps/studio/tests/features/logs/LogsQueryPanel.test.tsx index 7e860a924e9a8..7fa7c5322d0c7 100644 --- a/apps/studio/tests/features/logs/LogsQueryPanel.test.tsx +++ b/apps/studio/tests/features/logs/LogsQueryPanel.test.tsx @@ -7,8 +7,7 @@ import { render } from 'tests/helpers' test('run and clear', async () => { render( {}} onSelectSource={() => {}} onSelectTemplate={() => {}} diff --git a/apps/studio/tests/unit/logs/logsDateRange.test.ts b/apps/studio/tests/unit/logs/logsDateRange.test.ts new file mode 100644 index 0000000000000..ba957379857c4 --- /dev/null +++ b/apps/studio/tests/unit/logs/logsDateRange.test.ts @@ -0,0 +1,86 @@ +import dayjs from 'dayjs' +import { describe, expect, it, vi } from 'vitest' + +import { + EXPLORER_DATEPICKER_HELPERS, + getDefaultHelper, +} from 'components/interfaces/Settings/Logs/Logs.constants' +import { + buildLogQueryParams, + resolveLogDateRange, +} from 'components/interfaces/Settings/Logs/logsDateRange' + +const lastHourHelper = getDefaultHelper(EXPLORER_DATEPICKER_HELPERS) + +describe('resolveLogDateRange', () => { + it('recomputes helper ranges up to current time', () => { + vi.useFakeTimers() + const firstNow = new Date('2025-01-01T12:00:00.000Z') + vi.setSystemTime(firstNow) + + const first = resolveLogDateRange({ + from: '', + to: '', + isHelper: true, + text: lastHourHelper.text, + }) + + expect(first.to).toBe(dayjs(firstNow).toISOString()) + expect(first.from).toBe(dayjs(firstNow).subtract(1, 'hour').toISOString()) + + const later = new Date('2025-01-01T13:10:00.000Z') + vi.setSystemTime(later) + + const second = resolveLogDateRange({ + from: '', + to: '', + isHelper: true, + text: lastHourHelper.text, + }) + + expect(second.to).toBe(dayjs(later).toISOString()) + expect(second.from).toBe(dayjs(later).subtract(1, 'hour').toISOString()) + expect(second.from).not.toBe(first.from) + + vi.useRealTimers() + }) + + it('uses provided range when not a helper', () => { + const range = resolveLogDateRange({ + from: '2025-01-01T10:00:00.000Z', + to: '2025-01-01T11:00:00.000Z', + isHelper: false, + }) + + expect(range.from).toBe('2025-01-01T10:00:00.000Z') + expect(range.to).toBe('2025-01-01T11:00:00.000Z') + }) +}) + +describe('buildLogQueryParams', () => { + it('returns sql with recomputed helper range for repeated runs', () => { + vi.useFakeTimers() + const now = new Date('2025-01-02T08:00:00.000Z') + vi.setSystemTime(now) + + const first = buildLogQueryParams( + { from: '', to: '', isHelper: true, text: lastHourHelper.text }, + 'select 1' + ) + + const later = new Date('2025-01-02T09:30:00.000Z') + vi.setSystemTime(later) + + const second = buildLogQueryParams( + { from: '', to: '', isHelper: true, text: lastHourHelper.text }, + 'select 1' + ) + + expect(first.sql).toBe('select 1') + expect(second.sql).toBe('select 1') + expect(first.from).not.toBe(second.from) + expect(second.to).toBe(dayjs(later).toISOString()) + + vi.useRealTimers() + }) +}) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 9b46543e1c64e..1f19a987c7449 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -2636,6 +2636,11 @@ module.exports = [ source: '/docs/guides/auth/auth-deep-dive/auth-row-level-security', destination: '/docs/guides/database/postgres/row-level-security', }, + { + permanent: true, + source: '/docs/guides/database/replication/etl-destinations', + destination: '/docs/guides/database/replication/replication-setup', + }, { permanent: true, source: '/docs/guides/auth/server-side-rendering', diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 3d8be8c9a00d9..f9a0eee2e98e3 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2601,6 +2601,24 @@ export interface RequestUpgradeSubmittedEvent { groups: Omit } +/** + * Triggered when a Studio error UI element is displayed (mounted). + * This includes error Admonitions and Toast notifications. + * + * @group Events + * @source studio + */ +export interface DashboardErrorCreatedEvent { + action: 'dashboard_error_created' + properties: { + /** + * Source of the error + */ + source?: 'admonition' | 'toast' + } + groups: TelemetryGroups +} + /** * User successfully installed an integration via the integrations marketplace in the dashboard. * Note: This excludes Wrappers and Postgres Extensions. @@ -2838,6 +2856,7 @@ export type TelemetryEvent = | QueryPerformanceAIExplanationButtonClickedEvent | RequestUpgradeModalOpenedEvent | RequestUpgradeSubmittedEvent + | DashboardErrorCreatedEvent | IntegrationInstalledEvent | IntegrationInstallStartedEvent | IntegrationUninstallStartedEvent diff --git a/packages/shared-data/pricing.ts b/packages/shared-data/pricing.ts index f522753bda80c..888d3cd7dcf5e 100644 --- a/packages/shared-data/pricing.ts +++ b/packages/shared-data/pricing.ts @@ -533,7 +533,7 @@ export const pricing: Pricing = { key: 'realtime.maxMessageSize', title: 'Max Message Size', plans: { - free: '250 KB', + free: '256 KB', pro: '3 MB', team: '3 MB', enterprise: 'Custom',