diff --git a/apps/docs/content/guides/local-development.mdx b/apps/docs/content/guides/local-development.mdx index 7ea2ec792e1f8..e8ba56d465491 100644 --- a/apps/docs/content/guides/local-development.mdx +++ b/apps/docs/content/guides/local-development.mdx @@ -27,6 +27,12 @@ Develop locally while running the Supabase stack on your machine. pnpm add supabase --save-dev --allow-build=supabase ``` + + + The `--allow-build=supabase` flag is required on pnpm version 10 or higher. If you're using an older version of pnpm, omit this flag. + + + ```sh diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index 8d87320e875ce..4433c5b389546 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx @@ -1,6 +1,13 @@ import { LOCAL_STORAGE_KEYS } from 'common' export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS, + name: 'New Logs Interface', + discussionsUrl: 'https://github.com/orgs/supabase/discussions/37234', + isNew: true, + isPlatformOnly: true, + }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_BRANCHING_2_0, name: 'Branching 2.0', diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 4e889871b8f81..0ab0b040922d6 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -90,6 +90,14 @@ export const useIsInlineEditorEnabled = () => { return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] } +export const useUnifiedLogsPreview = () => { + const { flags, onUpdateFlag } = useFeaturePreviewContext() + const isEnabled = 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) + return { isEnabled, enable, disable } +} + export const useIsRealtimeSettingsEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_REALTIME_SETTINGS] @@ -111,6 +119,7 @@ export const useFeaturePreviewModal = () => { const isRealtimeSettingsEnabled = useIsRealtimeSettingsFFEnabled() const gitlessBranchingEnabled = useFlag('gitlessBranching') const advisorRulesEnabled = useFlag('advisorRules') + const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') const selectedFeatureKeyFromQuery = featurePreviewModal?.trim() ?? null const showFeaturePreviewModal = selectedFeatureKeyFromQuery !== null @@ -125,19 +134,28 @@ export const useFeaturePreviewModal = () => { return gitlessBranchingEnabled case 'supabase-ui-advisor-rules': return advisorRulesEnabled + case 'supabase-ui-preview-unified-logs': + return isUnifiedLogsPreviewAvailable default: return true } }, - [isRealtimeSettingsEnabled, gitlessBranchingEnabled, advisorRulesEnabled] + [ + isRealtimeSettingsEnabled, + gitlessBranchingEnabled, + advisorRulesEnabled, + isUnifiedLogsPreviewAvailable, + ] ) - const selectedFeatureKey = !selectedFeatureKeyFromQuery - ? FEATURE_PREVIEWS.filter((feature) => isFeaturePreviewReleasedToPublic(feature))[0].key - : selectedFeatureKeyFromQuery + const selectedFeatureKey = ( + !selectedFeatureKeyFromQuery + ? FEATURE_PREVIEWS.filter((feature) => isFeaturePreviewReleasedToPublic(feature))[0].key + : selectedFeatureKeyFromQuery + ) as (typeof FEATURE_PREVIEWS)[number]['key'] const selectFeaturePreview = useCallback( - (featureKey: string) => { + (featureKey: (typeof FEATURE_PREVIEWS)[number]['key']) => { setFeaturePreviewModal(featureKey) }, [setFeaturePreviewModal] diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 51cb64d361e07..ba83954fe4b68 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -15,6 +15,7 @@ import { FEATURE_PREVIEWS } from './FeaturePreview.constants' import { useFeaturePreviewContext, useFeaturePreviewModal } from './FeaturePreviewContext' import { InlineEditorPreview } from './InlineEditorPreview' import { RealtimeSettingsPreview } from './RealtimeSettingsPreview' +import { UnifiedLogsPreview } from './UnifiedLogsPreview' const FEATURE_PREVIEW_KEY_TO_CONTENT: { [key: string]: ReactNode @@ -25,6 +26,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: { [LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: , + [LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS]: , } const FeaturePreviewModal = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/UnifiedLogsPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/UnifiedLogsPreview.tsx new file mode 100644 index 0000000000000..981d98ad42ec5 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/UnifiedLogsPreview.tsx @@ -0,0 +1,36 @@ +import { useParams } from 'common' +import { InlineLink } from 'components/ui/InlineLink' +import { BASE_PATH } from 'lib/constants' +import Image from 'next/image' + +export const UnifiedLogsPreview = () => { + const { ref } = useParams() + + return ( +
+ new-logs-preview +

+ Experience our enhanced logs interface with improved filtering, real-time updates, and a + unified view across all your services. Built for better performance and easier debugging. +

+
+

Enabling this preview will:

+
    +
  • + Replace the current logs interface on the{' '} + logs page with a unified view +
  • +
  • Provide enhanced filtering capabilities and real-time log streaming
  • +
  • Improve performance with optimized data loading and virtualization
  • +
  • Offer a more modern interface with better search and navigation
  • +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index a1c811a09adf9..9721137b0bb86 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -68,11 +68,13 @@ interface NewOrgFormProps { onPlanSelected: (plan: string) => void } +const plans = ['FREE', 'PRO', 'TEAM'] as const + const formSchema = z.object({ plan: z .string() .transform((val) => val.toUpperCase()) - .pipe(z.enum(['FREE', 'PRO', 'TEAM', 'ENTERPRISE'] as const)), + .pipe(z.enum(plans)), name: z.string().min(1), kind: z .string() @@ -149,9 +151,10 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr if (typeof name === 'string') updateForm('name', name) if (typeof kind === 'string') updateForm('kind', kind) - if (typeof plan === 'string') { - updateForm('plan', plan) - onPlanSelected(plan) + if (typeof plan === 'string' && plans.includes(plan.toUpperCase() as (typeof plans)[number])) { + const uppercasedPlan = plan.toUpperCase() as (typeof plans)[number] + updateForm('plan', uppercasedPlan) + onPlanSelected(uppercasedPlan) } if (typeof size === 'string') updateForm('size', size) if (typeof spend_cap === 'string') updateForm('spend_cap', spend_cap === 'true') @@ -251,8 +254,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr | 'tier_payg' | 'tier_pro' | 'tier_free' - | 'tier_team' - | 'tier_enterprise', + | 'tier_team', ...(formState.kind == 'COMPANY' ? { size: formState.size } : {}), payment_method: paymentMethodId, billing_name: dbTier === 'FREE' ? undefined : customerData?.billing_name, diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index bb7625b410a7f..e729bc0b73257 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -224,7 +224,6 @@ const ProjectLinks = () => { const { securityLints, errorLints } = useLints() const showWarehouse = useFlag('warehouse') - const showUnifiedLogs = useFlag('unifiedLogs') const activeRoute = router.pathname.split('/')[3] @@ -247,7 +246,7 @@ const ProjectLinks = () => { storage: storageEnabled, realtime: realtimeEnabled, }) - const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs }) + const otherRoutes = generateOtherRoutes(ref, project) const settingsRoutes = generateSettingsRoutes(ref, project) return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index a2ab7ee491291..d16449090a61e 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -3,7 +3,7 @@ import { User } from 'lucide-react' import { LEVELS } from 'components/ui/DataTable/DataTable.constants' import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types' -import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils' +import { getLevelColor } from 'components/ui/DataTable/DataTable.utils' import { cn } from 'ui' import { LOG_TYPES, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants' import { ColumnSchema } from './UnifiedLogs.schema' @@ -41,11 +41,8 @@ export const filterFields = [ value: 'status', type: 'checkbox', defaultOpen: true, - options: [ - { label: '2xx', value: 200 }, - { label: '4xx', value: 400 }, - { label: '4xx', value: 500 }, - ], // REMINDER: this is a placeholder to set the type in the client.tsx + options: [], + hasDynamicOptions: true, component: (props: Option) => { if (typeof props.value === 'boolean') return null if (typeof props.value === 'undefined') return null @@ -53,10 +50,6 @@ export const filterFields = [ const statusValue = String(props.value) const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS] - // Convert string status codes to numbers for HTTP status styling - const statusCode = typeof props.value === 'string' ? parseInt(props.value, 10) : props.value - const isHttpStatus = !isNaN(statusCode) && statusCode >= 100 && statusCode < 600 - return (
{statusValue} @@ -110,7 +103,9 @@ export const filterFields = [ value: 'host', type: 'checkbox', defaultOpen: false, - options: [], // Will be populated dynamically from facets + options: [], + hasDynamicOptions: true, + hasAsyncSearch: true, component: (props: Option) => { return ( @@ -123,8 +118,10 @@ export const filterFields = [ label: 'Pathname', value: 'pathname', type: 'checkbox', - defaultOpen: true, - options: [], // Will be populated dynamically from facets + defaultOpen: false, + options: [], + hasDynamicOptions: true, + hasAsyncSearch: true, component: (props: Option) => { return ( @@ -138,7 +135,9 @@ export const filterFields = [ value: 'auth_user', type: 'checkbox', defaultOpen: false, - options: [], // Will be populated dynamically from facets + options: [], + hasDynamicOptions: true, + hasAsyncSearch: true, component: (props: Option) => { return (
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index 4d9cba79b5172..984be6580c83f 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -459,7 +459,7 @@ const getSupabaseStorageLogsQuery = () => { /** * Combine all log sources to create the unified logs CTE */ -const getUnifiedLogsCTE = () => { +export const getUnifiedLogsCTE = () => { return ` WITH unified_logs AS ( ${getPostgrestLogsQuery()} @@ -510,39 +510,72 @@ ${finalWhere} * Get a count query for the total logs within the timeframe * Uses proper faceted search behavior where facets show "what would I get if I selected ONLY this option" */ -export const getLogsCountQuery = (search: QuerySearchParamsType): string => { - const { finalWhere } = buildQueryConditions(search) - // Helper function to build WHERE clause excluding a specific field - const buildFacetWhere = (excludeField: string): string => { - const conditions: string[] = [] +// Helper function to build WHERE clause excluding a specific field +const buildFacetWhere = (search: QuerySearchParamsType, excludeField: string): string => { + const conditions: string[] = [] - Object.entries(search).forEach(([key, value]) => { - if (key === excludeField) return // Skip the field we're getting facets for - if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params + Object.entries(search).forEach(([key, value]) => { + if (key === excludeField) return // Skip the field we're getting facets for + if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params - // Handle array filters (IN clause) - if (Array.isArray(value) && value.length > 0) { - conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`) - return - } + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`) + return + } - // Handle scalar values - if (value !== null && value !== undefined) { - if (['host', 'pathname'].includes(key)) { - conditions.push(`${key} LIKE '%${value}%'`) - } else { - conditions.push(`${key} = '${value}'`) - } + // Handle scalar values + if (value !== null && value !== undefined) { + if (['host', 'pathname'].includes(key)) { + conditions.push(`${key} LIKE '%${value}%'`) + } else { + conditions.push(`${key} = '${value}'`) } - }) + } + }) - return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' - } + return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' +} + +export const getFacetCountCTE = ({ + search, + facet, + facetSearch, +}: { + search: QuerySearchParamsType + facet: string + facetSearch?: string +}) => { + const MAX_FACETS_QUANTITY = 20 + + return ` +${facet}_count AS ( + SELECT '${facet}' as dimension, ${facet} as value, COUNT(*) as count + FROM unified_logs + ${buildFacetWhere(search, `${facet}`) || `WHERE ${facet} IS NOT NULL`} + ${buildFacetWhere(search, `${facet}`) ? ` AND ${facet} IS NOT NULL` : ''} + ${!!facetSearch ? `AND ${facet} LIKE '%${facetSearch}%'` : ''} + GROUP BY ${facet} + LIMIT ${MAX_FACETS_QUANTITY} +) +`.trim() +} + +export const getLogsCountQuery = (search: QuerySearchParamsType): string => { + const { finalWhere } = buildQueryConditions(search) // Create a count query using the same unified logs CTE const sql = ` -${getUnifiedLogsCTE()} +${getUnifiedLogsCTE()}, +${getFacetCountCTE({ search, facet: 'log_type' })}, +${getFacetCountCTE({ search, facet: 'method' })}, +${getFacetCountCTE({ search, facet: 'level' })}, +${getFacetCountCTE({ search, facet: 'status' })}, +${getFacetCountCTE({ search, facet: 'host' })}, +${getFacetCountCTE({ search, facet: 'pathname' })}, +${getFacetCountCTE({ search, facet: 'auth_user' })} + -- Get total count SELECT 'total' as dimension, 'all' as value, COUNT(*) as count FROM unified_logs @@ -551,65 +584,37 @@ ${finalWhere} UNION ALL -- Get counts by log_type (exclude log_type filter to avoid self-filtering) -SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('log_type') || 'WHERE log_type IS NOT NULL'} -${buildFacetWhere('log_type') ? ' AND log_type IS NOT NULL' : ''} -GROUP BY log_type +SELECT dimension, value, count from log_type_count UNION ALL -- Get counts by method (exclude method filter to avoid self-filtering) -SELECT 'method' as dimension, method as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('method') || 'WHERE method IS NOT NULL'} -${buildFacetWhere('method') ? ' AND method IS NOT NULL' : ''} -GROUP BY method +SELECT dimension, value, count from method_count UNION ALL -- Get counts by level (exclude level filter to avoid self-filtering) -SELECT 'level' as dimension, level as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('level') || 'WHERE level IS NOT NULL'} -${buildFacetWhere('level') ? ' AND level IS NOT NULL' : ''} -GROUP BY level +SELECT dimension, value, count from level_count UNION ALL -- Get counts by status (exclude status filter to avoid self-filtering) -SELECT 'status' as dimension, status as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('status') || 'WHERE status IS NOT NULL'} -${buildFacetWhere('status') ? ' AND status IS NOT NULL' : ''} -GROUP BY status +SELECT dimension, value, count from status_count UNION ALL -- Get counts by host (exclude host filter to avoid self-filtering) -SELECT 'host' as dimension, host as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('host') || 'WHERE host IS NOT NULL'} -${buildFacetWhere('host') ? ' AND host IS NOT NULL' : ''} -GROUP BY host +SELECT dimension, value, count from host_count UNION ALL -- Get counts by pathname (exclude pathname filter to avoid self-filtering) -SELECT 'pathname' as dimension, pathname as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('pathname') || 'WHERE pathname IS NOT NULL'} -${buildFacetWhere('pathname') ? ' AND pathname IS NOT NULL' : ''} -GROUP BY pathname +SELECT dimension, value, count from pathname_count UNION ALL -- Get counts by auth_user (exclude auth_user filter to avoid self-filtering) -SELECT 'auth_user' as dimension, auth_user as value, COUNT(*) as count -FROM unified_logs -${buildFacetWhere('auth_user') || 'WHERE auth_user IS NOT NULL'} -${buildFacetWhere('auth_user') ? ' AND auth_user IS NOT NULL' : ''} -GROUP BY auth_user +SELECT dimension, value, count from auth_user_count ` return sql diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index 36490a091ff59..6683e4db27c9a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -71,8 +71,6 @@ export const UnifiedLogs = () => { const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) const [rowSelection, setRowSelection] = useState(defaultRowSelection) - const [showBottomLogsPanel, setShowBottomLogsPanelState] = useState(false) - const [columnVisibility, setColumnVisibility] = useLocalStorageQuery( 'data-table-visibility', defaultColumnVisibility @@ -203,7 +201,11 @@ export const UnifiedLogs = () => { }, [isLoading, isFetching, flatData.length, table, selectedRowKey]) // REMINDER: this is currently needed for the cmdk search + // [Joshen] This is where facets are getting dynamically loaded // TODO: auto search via API when the user changes the filter instead of hardcoded + + // Will need to refactor this bit + // - Each facet just handles its own state, rather than getting passed down like this const filterFields = useMemo(() => { return defaultFilterFields.map((field) => { const facetsField = facets?.[field.value] @@ -289,6 +291,7 @@ export const UnifiedLogs = () => { rowSelection={rowSelection} columnOrder={columnOrder} columnVisibility={columnVisibility} + searchParameters={searchParameters} enableColumnOrdering={true} isFetching={isFetching} isLoading={isLoading} diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index f47831e49e34c..b9f02d6b4a0a1 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -1,14 +1,19 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ChevronRight, FilePlus, Plus } from 'lucide-react' +import { ChevronRight, CircleHelpIcon, FilePlus, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' import { IS_PLATFORM, useParams } from 'common' +import { + useFeaturePreviewModal, + useUnifiedLogsPreview, +} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { CreateWarehouseCollectionModal } from 'components/interfaces/DataWarehouse/CreateWarehouseCollection' import { WarehouseMenuItem } from 'components/interfaces/DataWarehouse/WarehouseMenuItem' import SavedQueriesItem from 'components/interfaces/Settings/Logs/Logs.SavedQueriesItem' import { LogsSidebarItem } from 'components/interfaces/Settings/Logs/SidebarV2/SidebarItem' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query' import { useWarehouseTenantQuery } from 'data/analytics/warehouse-tenant-query' import { useContentQuery } from 'data/content/content-query' @@ -17,6 +22,7 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useFlag } from 'hooks/ui/useFlag' import { + Badge, Button, Collapsible_Shadcn_, CollapsibleContent_Shadcn_, @@ -37,6 +43,7 @@ import { InnerSideMenuItem, } from 'ui-patterns/InnerSideMenu' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel' const SupaIcon = ({ className }: { className?: string }) => { return ( @@ -82,7 +89,11 @@ export function SidebarCollapsible({ export function LogsSidebarMenuV2() { const router = useRouter() const { ref } = useParams() as { ref: string } + const warehouseEnabled = useFlag('warehouse') + const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') + const { selectFeaturePreview } = useFeaturePreviewModal() + const { enable: enableUnifiedLogs } = useUnifiedLogsPreview() const [searchText, setSearchText] = useState('') const [createCollectionOpen, setCreateCollectionOpen] = useState(false) @@ -213,6 +224,36 @@ export function LogsSidebarMenuV2() { return (
+ {isUnifiedLogsPreviewAvailable && ( + Feature Preview} + actions={ + <> + + } + onClick={() => selectFeaturePreview('supabase-ui-preview-unified-logs')} + tooltip={{ content: { side: 'bottom', text: 'More information' } }} + /> + + } + /> + )} +
{ +export const generateOtherRoutes = (ref?: string, project?: Project, features?: {}): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` - const showUnifiedLogs = features?.unifiedLogs ?? false - return [ { key: 'advisors', @@ -149,16 +143,6 @@ export const generateOtherRoutes = ( icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/logs`), }, - ...(showUnifiedLogs - ? [ - { - key: 'unified-logs', - label: 'Unified Logs', - icon: , - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/unified-logs`), - }, - ] - : []), { key: 'api', label: 'API Docs', diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx deleted file mode 100644 index 27ba57f0ce085..0000000000000 --- a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { PropsWithChildren } from 'react' - -import ProjectLayout from '../ProjectLayout/ProjectLayout' - -const UnifiedLogsLayout = ({ children }: PropsWithChildren) => { - return {children} -} - -export default UnifiedLogsLayout diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts index 8bea320ad0f85..19d2b5d77f3ec 100644 --- a/apps/studio/components/ui/DataTable/DataTable.types.ts +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -50,6 +50,8 @@ export type Base = { * Defines if the command input is disabled for this field */ commandDisabled?: boolean + hasDynamicOptions?: boolean + hasAsyncSearch?: boolean } export type DataTableCheckboxFilterField = Base & Checkbox diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx index a514895a2b200..02d284968ae8e 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -6,6 +6,7 @@ import type { DataTableCheckboxFilterField } from '../DataTable.types' import { formatCompactNumber } from '../DataTable.utils' import { InputWithAddons } from '../primitives/InputWithAddons' import { useDataTable } from '../providers/DataTableProvider' +import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader' export function DataTableFilterCheckbox({ value: _value, @@ -34,31 +35,13 @@ export function DataTableFilterCheckbox({ const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] // REMINDER: if no options are defined, while fetching data, we should show a skeleton - if (isLoading && !filterOptions?.length) - return ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - -
- ))} -
- ) + if (isLoading && !filterOptions?.length) return // Show empty state when no original options are available (not due to search filtering) if (!options?.length) return ( -
-
-
- -
-
-

No options available

-

Try adjusting your filters

-
-
+
+

No options available

) @@ -77,9 +60,9 @@ export function DataTableFilterCheckbox({
{filterOptions.length === 0 && inputValue !== '' ? (
-
-

No results found

-

Try a different search term

+
+

No results found

+

Try a different search term

) : ( diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx new file mode 100644 index 0000000000000..79ad290266dfd --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx @@ -0,0 +1,136 @@ +import { useDebounce } from '@uidotdev/usehooks' +import { Loader2, Search } from 'lucide-react' +import { useState } from 'react' + +import { useParams } from 'common' +import { useUnifiedLogsFacetCountQuery } from 'data/logs/unified-logs-facet-count-query' +import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui' +import type { DataTableCheckboxFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { InputWithAddons } from '../primitives/InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterCheckboxAsync({ + value: _value, + options, + component: Component, +}: DataTableCheckboxFilterField) { + const value = _value as string + const [inputValue, setInputValue] = useState('') + + const { table, searchParameters, columnFilters, isLoadingCounts, getFacetedUniqueValues } = + useDataTable() + + // [Joshen] JFYI for simplicity currently, i'm adding UnifiedLogs logic into this file + // despite this supposedly being a reusable component - tbh really, this doesn't need to + // be reusable perhaps unless we plan for this to be used in another area of the dashboard + // but its too early to say for sure atm. + const { ref: projectRef } = useParams() + const debouncedSearch = useDebounce(inputValue, 700) + const { data: filterOptions = [], isFetching: isFetchingFacetCount } = + useUnifiedLogsFacetCountQuery( + { + projectRef, + search: searchParameters, + facet: value, + facetSearch: debouncedSearch, + }, + { + keepPreviousData: true, + enabled: debouncedSearch.length > 0, + initialData: debouncedSearch.length === 0 ? options : undefined, + } + ) + + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues() + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + if (!options?.length) + return ( +
+

No options available

+
+ ) + + return ( +
+ } + containerClassName="h-8 rounded" + value={inputValue} + trailing={isFetchingFacetCount ? : undefined} + onChange={(e) => setInputValue(e.target.value)} + /> + +
+ {filterOptions.length === 0 ? ( +
+
+

No results found

+

Try a different search term

+
+
+ ) : ( + filterOptions.map((option, index) => { + const checked = filters.includes(option.value) + + return ( +
+ { + const newValue = checked + ? [...(filters || []), option.value] + : filters?.filter((value) => option.value !== value) + column?.setFilterValue(newValue?.length ? newValue : undefined) + }} + /> + +
+ ) + }) + )} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx new file mode 100644 index 0000000000000..3c74d3ce87831 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from 'ui' + +export const DataTableFilterCheckboxLoader = () => { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx index 628ba8a49f94b..2f69d2272263c 100644 --- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx @@ -13,6 +13,8 @@ import { DataTableFilterTimerange } from './DataTableFilterTimerange' import { DateRangeDisabled } from '../DataTable.types' import { useDataTable } from '../providers/DataTableProvider' +import { DataTableFilterCheckboxAsync } from './DataTableFilterCheckboxAsync' +import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader' // FIXME: use @container (especially for the slider element) to restructure elements @@ -24,7 +26,7 @@ interface DataTableFilterControls { } export function DataTableFilterControls({ dateRangeDisabled }: DataTableFilterControls) { - const { filterFields } = useDataTable() + const { filterFields, isLoadingCounts } = useDataTable() return ( { switch (field.type) { case 'checkbox': { - return + // [Joshen] Loader here so that CheckboxAsync can retrieve the data + // immediately to be set in its react query state + if (field.hasDynamicOptions && isLoadingCounts) { + return + } else if (field.hasAsyncSearch) { + return + } else { + return + } } case 'slider': { return diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx index 86562753becf4..c81d299fe34a6 100644 --- a/apps/studio/components/ui/DataTable/FilterSideBar.tsx +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -1,4 +1,10 @@ -import { cn, ResizablePanel } from 'ui' +import { useRouter } from 'next/router' + +import { useParams } from 'common' +import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useFlag } from 'hooks/ui/useFlag' +import { Button, cn, ResizablePanel } from 'ui' +import { FeaturePreviewSidebarPanel } from '../FeaturePreviewSidebarPanel' import { DateRangeDisabled } from './DataTable.types' import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' import { DataTableResetButton } from './DataTableResetButton' @@ -9,8 +15,18 @@ interface FilterSideBarProps { } export function FilterSideBar({ dateRangeDisabled }: FilterSideBarProps) { + const router = useRouter() + const { ref } = useParams() const { table } = useDataTable() + const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs') + const { disable: disableUnifiedLogs } = useUnifiedLogsPreview() + + const handleGoBackToOldLogs = () => { + disableUnifiedLogs() + router.push(`/project/${ref}/logs/explorer`) + } + return ( -
+
-

Logs

-
{table.getState().columnFilters.length ? : null}
+

Logs

+ {table.getState().columnFilters.length ? : null}
+
+ {isUnifiedLogsPreviewAvailable && ( + + Switch back + + } + /> + )}
diff --git a/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx index 2848c0e3cc337..974dde4b67098 100644 --- a/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx +++ b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx @@ -31,7 +31,9 @@ export const InputWithAddons = forwardRef {trailing ? ( -
{trailing}
+
+ {trailing} +
) : null}
) diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx index ff1abe4f0cb1a..2515c748a59dd 100644 --- a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx +++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx @@ -9,6 +9,7 @@ import type { } from '@tanstack/react-table' import { createContext, ReactNode, useContext, useMemo } from 'react' +import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types' import { DataTableFilterField } from '../DataTable.types' import { ControlsProvider } from './ControlsProvider' @@ -23,6 +24,7 @@ interface DataTableStateContextType { columnVisibility: VisibilityState pagination: PaginationState enableColumnOrdering: boolean + searchParameters: QuerySearchParamsType } interface DataTableBaseContextType { @@ -59,6 +61,7 @@ export function DataTableProvider({ columnVisibility: props.columnVisibility ?? {}, pagination: props.pagination ?? { pageIndex: 0, pageSize: 10 }, enableColumnOrdering: props.enableColumnOrdering ?? false, + searchParameters: props.searchParameters ?? ({} as any), }), [props] ) diff --git a/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx b/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx new file mode 100644 index 0000000000000..b736f8092dfc2 --- /dev/null +++ b/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx @@ -0,0 +1,39 @@ +import { ReactNode } from 'react' +import { cn } from 'ui' + +interface FeaturePreviewSidebarPanelProps { + title: string + description: string + illustration?: ReactNode + actions?: ReactNode + className?: string +} + +export function FeaturePreviewSidebarPanel({ + title, + description, + illustration, + actions, + className, +}: FeaturePreviewSidebarPanelProps) { + return ( +
+ {illustration &&
{illustration}
} + +
+

{title}

+

{description}

+
+ + {actions &&
{actions}
} +
+ ) +} diff --git a/apps/studio/csp.js b/apps/studio/csp.js index f4ddabfcc7617..775afd742fa0a 100644 --- a/apps/studio/csp.js +++ b/apps/studio/csp.js @@ -41,7 +41,6 @@ const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' const STRIPE_JS_URL = 'https://js.stripe.com' const STRIPE_NETWORK_URL = 'https://*.stripe.network' const CLOUDFLARE_URL = 'https://www.cloudflare.com' -const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' const VERCEL_URL = 'https://vercel.com' const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' const GITHUB_API_URL = 'https://api.github.com' @@ -81,7 +80,6 @@ module.exports.getCSP = function getCSP() { STRIPE_SUBDOMAINS_URL, STRIPE_NETWORK_URL, CLOUDFLARE_URL, - ONE_ONE_ONE_ONE_URL, VERCEL_INSIGHTS_URL, GITHUB_API_URL, GITHUB_USER_CONTENT_URL, diff --git a/apps/studio/data/custom-domains/check-cname-mutation.ts b/apps/studio/data/custom-domains/check-cname-mutation.ts index 97c59858cb51a..183a53514d5d7 100644 --- a/apps/studio/data/custom-domains/check-cname-mutation.ts +++ b/apps/studio/data/custom-domains/check-cname-mutation.ts @@ -1,5 +1,6 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' -import { fetchHandler } from 'data/fetchers' +import { fetchHandler, handleError } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' import { toast } from 'sonner' import type { ResponseError } from 'types' @@ -22,19 +23,21 @@ export type CheckCNAMERecordResponse = { // [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98 export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) { - const res = await fetchHandler(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, { - method: 'GET', - headers: { accept: 'application/dns-json' }, - }) - const verification = (await res.json()) as CheckCNAMERecordResponse + try { + const res: CheckCNAMERecordResponse = await fetchHandler( + `${BASE_PATH}/api/check-cname?domain=${domain}` + ).then((res) => res.json()) - if (verification.Answer === undefined) { - throw new Error( - `Your CNAME record for ${domain} cannot be found - if you've just added the record, do check back in a bit.` - ) - } + if (res.Answer === undefined) { + throw new Error( + `Your CNAME record for ${domain} cannot be found - if you've just added the record, do check back in a bit.` + ) + } - return verification.Answer.some((x) => x.type === 5) + return res.Answer.some((x) => x.type === 5) + } catch (error) { + handleError(error) + } } type CheckCNAMERecordData = Awaited> diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 9c369ee550472..b2c3d4aaa6f34 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -6,7 +6,8 @@ import { API_URL } from 'lib/constants' import { getAccessToken } from 'lib/gotrue' import { uuidv4 } from 'lib/helpers' import { ResponseError } from 'types' -import type { paths } from './api' // generated from openapi-typescript +// generated from openapi-typescript +import type { paths } from './api' const DEFAULT_HEADERS = { Accept: 'application/json' } @@ -15,6 +16,7 @@ export const fetchHandler: typeof fetch = async (input, init) => { return await fetch(input, init) } catch (err: any) { if (err instanceof TypeError && err.message === 'Failed to fetch') { + console.error(err) throw new Error('Unable to reach the server. Please check your network or try again later.') } throw err diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts index a079740b2c89e..af62b1e311638 100644 --- a/apps/studio/data/logs/keys.ts +++ b/apps/studio/data/logs/keys.ts @@ -34,6 +34,21 @@ export const logsKeys = { 'chart-data', ...(searchParams ? [searchParams].filter(Boolean) : []), ] as const, + unifiedLogsFacetCount: ( + projectRef: string | undefined, + facet: string, + facetSearch: string | undefined, + searchParams: QuerySearchParamsType | undefined + ) => + [ + 'projects', + projectRef, + 'unified-logs', + 'count-data', + facet, + facetSearch, + ...(searchParams ? [searchParams].filter(Boolean) : []), + ] as const, serviceFlow: ( projectRef: string | undefined, searchParams: QuerySearchParamsType | undefined, diff --git a/apps/studio/data/logs/unified-logs-facet-count-query.ts b/apps/studio/data/logs/unified-logs-facet-count-query.ts new file mode 100644 index 0000000000000..dea0d5a885c69 --- /dev/null +++ b/apps/studio/data/logs/unified-logs-facet-count-query.ts @@ -0,0 +1,64 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { + getFacetCountCTE, + getUnifiedLogsCTE, +} from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries' +import { Option } from 'components/ui/DataTable/DataTable.types' +import { handleError, post } from 'data/fetchers' +import { ExecuteSqlError } from 'data/sql/execute-sql-query' +import { logsKeys } from './keys' +import { + getUnifiedLogsISOStartEnd, + UNIFIED_LOGS_QUERY_OPTIONS, + UnifiedLogsVariables, +} from './unified-logs-infinite-query' + +type UnifiedLogsFacetCountVariables = UnifiedLogsVariables & { + facet: string + facetSearch?: string +} + +export async function getUnifiedLogsFacetCount( + { projectRef, search, facet, facetSearch }: UnifiedLogsFacetCountVariables, + signal?: AbortSignal +) { + if (typeof projectRef === 'undefined') { + throw new Error('projectRef is required for getUnifiedLogsFacetCount') + } + + const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search) + const sql = ` +${getUnifiedLogsCTE()}, +${getFacetCountCTE({ search, facet, facetSearch })} +SELECT dimension, value, count from ${facet}_count; +`.trim() + const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { path: { ref: projectRef } }, + body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, sql }, + signal, + }) + + if (error) handleError(error) + return (data.result ?? []) as Option[] +} + +export type UnifiedLogsFacetCountData = Awaited> +export type UnifiedLogsFacetCountError = ExecuteSqlError + +export const useUnifiedLogsFacetCountQuery = ( + { projectRef, search, facet, facetSearch }: UnifiedLogsFacetCountVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search), + ({ signal }) => getUnifiedLogsFacetCount({ projectRef, search, facet, facetSearch }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...UNIFIED_LOGS_QUERY_OPTIONS, + ...options, + } + ) diff --git a/apps/studio/data/misc/get-default-region-query.ts b/apps/studio/data/misc/get-default-region-query.ts index 8dc7620fde6c5..3a304a0250f5e 100644 --- a/apps/studio/data/misc/get-default-region-query.ts +++ b/apps/studio/data/misc/get-default-region-query.ts @@ -37,7 +37,9 @@ export async function getDefaultRegionOption({ if (locLatLon === undefined) return undefined - const allRegions = cloudProvider === 'AWS' ? AWS_REGIONS_COORDINATES : FLY_REGIONS_COORDINATES + const isAWSProvider = ['AWS', 'AWS_K8S'].includes(cloudProvider) + + const allRegions = isAWSProvider ? AWS_REGIONS_COORDINATES : FLY_REGIONS_COORDINATES const locations = useRestrictedPool && restrictedPool ? Object.entries(allRegions) @@ -55,7 +57,7 @@ export async function getDefaultRegionOption({ const shortestDistance = Math.min(...distances) const closestRegion = Object.keys(locations)[distances.indexOf(shortestDistance)] - return cloudProvider === 'AWS' + return isAWSProvider ? AWS_REGIONS[closestRegion as keyof typeof AWS_REGIONS].displayName : FLY_REGIONS[closestRegion as keyof typeof FLY_REGIONS].displayName } catch (error) { diff --git a/apps/studio/lib/ai/bedrock.test.ts b/apps/studio/lib/ai/bedrock.test.ts deleted file mode 100644 index 292d0da28587b..0000000000000 --- a/apps/studio/lib/ai/bedrock.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { selectBedrockRegion, bedrockRegionMap } from './bedrock' - -describe('selectBedrockRegion', () => { - it('should return a valid region for a given routing key', async () => { - const region = await selectBedrockRegion('test-key') - const validRegions = Object.keys(bedrockRegionMap) - - expect(validRegions).toContain(region) - }) - - it('should return the same region for the same routing key', async () => { - const routingKey = 'consistent-key' - const region1 = await selectBedrockRegion(routingKey) - const region2 = await selectBedrockRegion(routingKey) - - expect(region1).toBe(region2) - }) - - it('should distribute different keys across regions', async () => { - const keys = Array.from({ length: 100 }, (_, i) => `key-${i}`) - const regions = await Promise.all(keys.map((key) => selectBedrockRegion(key))) - const uniqueRegions = new Set(regions) - const validRegions = Object.keys(bedrockRegionMap) - - // Should use all regions for 100 different keys - expect(uniqueRegions.size).toEqual(validRegions.length) - }) - - it('should distribute keys evenly across regions', async () => { - const numKeys = 3000 - const keys = Array.from({ length: numKeys }, (_, i) => `key-${i}`) - const regions = await Promise.all(keys.map((key) => selectBedrockRegion(key))) - const validRegions = Object.keys(bedrockRegionMap) - - // Count occurrences of each region - const regionCounts = regions.reduce>((acc, region) => { - acc[region] = (acc[region] ?? 0) + 1 - return acc - }, {}) - - const expectedCountPerRegion = numKeys / validRegions.length - const tolerance = expectedCountPerRegion * 0.2 // Allow 20% deviation - - // Each region should have roughly equal distribution - for (const count of Object.values(regionCounts)) { - expect(count).toBeGreaterThan(expectedCountPerRegion - tolerance) - expect(count).toBeLessThan(expectedCountPerRegion + tolerance) - } - }) - - it('should handle empty string', async () => { - const region = await selectBedrockRegion('') - const validRegions = Object.keys(bedrockRegionMap) - - expect(validRegions).toContain(region) - }) - - it('should handle special characters in routing key', async () => { - const region = await selectBedrockRegion('key-with-special-chars!@#$%') - const validRegions = Object.keys(bedrockRegionMap) - - expect(validRegions).toContain(region) - }) - - it('should return consistent results for unicode characters', async () => { - const routingKey = '🔑-unicode-key-测试' - const region1 = await selectBedrockRegion(routingKey) - const region2 = await selectBedrockRegion(routingKey) - - expect(region1).toBe(region2) - }) -}) diff --git a/apps/studio/lib/ai/bedrock.ts b/apps/studio/lib/ai/bedrock.ts index 0e28b1e9bc598..b32e15dedbf9a 100644 --- a/apps/studio/lib/ai/bedrock.ts +++ b/apps/studio/lib/ai/bedrock.ts @@ -2,6 +2,7 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' import { createCredentialChain, fromNodeProviderChain } from '@aws-sdk/credential-providers' import { CredentialsProviderError } from '@smithy/property-provider' import { awsCredentialsProvider } from '@vercel/functions/oidc' +import { selectWeightedKey } from './util' const credentialProvider = createCredentialChain( // Vercel OIDC provider will be used for staging/production @@ -34,45 +35,82 @@ async function vercelOidcProvider() { } } +export async function checkAwsCredentials() { + try { + const credentials = await credentialProvider() + return !!credentials + } catch (error) { + return false + } +} + export const bedrockRegionMap = { - us1: 'us-east-1', - us3: 'us-west-2', + use1: 'us-east-1', + use2: 'us-east-2', + usw2: 'us-west-2', + euc1: 'eu-central-1', } as const export type BedrockRegion = keyof typeof bedrockRegionMap -export const bedrockForRegion = (region: BedrockRegion) => - createAmazonBedrock({ - credentialProvider, - region: bedrockRegionMap[region], - }) +export const regionPrefixMap: Record = { + use1: 'us', + use2: 'us', + usw2: 'us', + euc1: 'eu', +} + +export type BedrockModel = + | 'anthropic.claude-3-7-sonnet-20250219-v1:0' + | 'anthropic.claude-3-5-haiku-20241022-v1:0' + +export type RegionWeights = Record /** - * Selects a region based on a routing key using a consistent hashing algorithm. + * Weights for distributing requests across Bedrock regions. + * Weights are proportional to our rate limits per model per region. + */ +const modelRegionWeights: Record = { + ['anthropic.claude-3-7-sonnet-20250219-v1:0']: { + use1: 40, + use2: 10, + usw2: 10, + euc1: 10, + }, + ['anthropic.claude-3-5-haiku-20241022-v1:0']: { + use1: 40, + use2: 0, + usw2: 40, + euc1: 0, + }, +} + +/** + * Creates a Bedrock client that routes requests to different regions + * based on a routing key. * - * Ensures that the same key always maps to the same region - * while distributing keys evenly across available regions. + * Used to load balance requests across multiple regions depending on + * their capacities. */ -export async function selectBedrockRegion(routingKey: string) { - const regions = Object.keys(bedrockRegionMap) as BedrockRegion[] - const encoder = new TextEncoder() - const data = encoder.encode(routingKey) - const hashBuffer = await crypto.subtle.digest('SHA-256', data) +export function createRoutedBedrock(routingKey?: string) { + return async (modelId: BedrockModel) => { + const regionWeights = modelRegionWeights[modelId] - // Use first 4 bytes (32 bit integer) - const hashInt = new DataView(hashBuffer).getUint32(0) + // Select the Bedrock region based on the routing key and the model + const bedrockRegion = routingKey + ? await selectWeightedKey(routingKey, regionWeights) + : // There's a few places where getModel is called without a routing key + // Will cause disproportionate load on use1 region + 'use1' - // Use modulo to map to available regions - const regionIndex = hashInt % regions.length + const bedrock = createAmazonBedrock({ + credentialProvider, + region: bedrockRegionMap[bedrockRegion], + }) - return regions[regionIndex] -} + // Cross-region models require the region prefix + const modelName = `${regionPrefixMap[bedrockRegion]}.${modelId}` -export async function checkAwsCredentials() { - try { - const credentials = await credentialProvider() - return !!credentials - } catch (error) { - return false + return bedrock(modelName) } } diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts index 2d7d2e276585c..3c76e67819359 100644 --- a/apps/studio/lib/ai/model.test.ts +++ b/apps/studio/lib/ai/model.test.ts @@ -7,10 +7,10 @@ vi.mock('@ai-sdk/openai', () => ({ openai: vi.fn(() => 'openai-model'), })) -vi.mock('./bedrock', () => ({ - bedrockForRegion: vi.fn(() => () => 'bedrock-model'), +vi.mock('./bedrock', async () => ({ + ...(await vi.importActual('./bedrock')), + createRoutedBedrock: vi.fn(() => () => 'bedrock-model'), checkAwsCredentials: vi.fn(), - selectBedrockRegion: vi.fn(() => 'us'), })) describe('getModel', () => { @@ -29,10 +29,7 @@ describe('getModel', () => { const { model, error } = await getModel() - console.log('Model:', model) - expect(model).toEqual('bedrock-model') - expect(bedrockModule.bedrockForRegion).toHaveBeenCalledWith('us1') expect(error).toBeUndefined() }) @@ -40,7 +37,7 @@ describe('getModel', () => { vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false) process.env.OPENAI_API_KEY = 'test-key' - const { model } = await getModel('test-key') + const { model } = await getModel() expect(model).toEqual('openai-model') expect(openai).toHaveBeenCalledWith('gpt-4.1-2025-04-14') diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts index e093e4702e9d0..b6afbf2d14165 100644 --- a/apps/studio/lib/ai/model.ts +++ b/apps/studio/lib/ai/model.ts @@ -1,23 +1,12 @@ import { openai } from '@ai-sdk/openai' import { LanguageModel } from 'ai' -import { - bedrockForRegion, - BedrockRegion, - checkAwsCredentials, - selectBedrockRegion, -} from './bedrock' - -export const regionMap = { - us1: 'us', - us2: 'us', - us3: 'us', - eu: 'eu', -} +import { checkAwsCredentials, createRoutedBedrock } from './bedrock' // Default behaviour here is to be throttled (e.g if this env var is not available, IS_THROTTLED should be true, unless specified 'false') const IS_THROTTLED = process.env.IS_THROTTLED !== 'false' -const PRO_MODEL = process.env.AI_PRO_MODEL ?? 'anthropic.claude-3-7-sonnet-20250219-v1:0' -const NORMAL_MODEL = process.env.AI_NORMAL_MODEL ?? 'anthropic.claude-3-5-haiku-20241022-v1:0' + +const BEDROCK_PRO_MODEL = 'anthropic.claude-3-7-sonnet-20250219-v1:0' +const BEDROCK_NORMAL_MODEL = 'anthropic.claude-3-5-haiku-20241022-v1:0' const OPENAI_MODEL = 'gpt-4.1-2025-04-14' export type ModelSuccess = { @@ -46,14 +35,11 @@ export async function getModel(routingKey?: string, isLimited?: boolean): Promis const hasOpenAIKey = !!process.env.OPENAI_API_KEY if (hasAwsCredentials) { - // Select the Bedrock region based on the routing key - const bedrockRegion: BedrockRegion = routingKey ? await selectBedrockRegion(routingKey) : 'us1' - const bedrock = bedrockForRegion(bedrockRegion) - const model = IS_THROTTLED || isLimited ? NORMAL_MODEL : PRO_MODEL - const modelName = `${regionMap[bedrockRegion]}.${model}` + const bedrockModel = IS_THROTTLED || isLimited ? BEDROCK_NORMAL_MODEL : BEDROCK_PRO_MODEL + const bedrock = createRoutedBedrock(routingKey) return { - model: bedrock(modelName), + model: await bedrock(bedrockModel), } } diff --git a/apps/studio/lib/ai/util.test.ts b/apps/studio/lib/ai/util.test.ts new file mode 100644 index 0000000000000..99cd00205da62 --- /dev/null +++ b/apps/studio/lib/ai/util.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest' +import { selectWeightedKey } from './util' + +describe('selectWeightedKey', () => { + it('should return a valid key from the weights object', async () => { + const weights = { a: 10, b: 20, c: 30 } + const result = await selectWeightedKey('test-input', weights) + + expect(Object.keys(weights)).toContain(result) + }) + + it('should return consistent results for the same input', async () => { + const weights = { region1: 40, region2: 10, region3: 20 } + const input = 'consistent-key' + + const result1 = await selectWeightedKey(input, weights) + const result2 = await selectWeightedKey(input, weights) + const result3 = await selectWeightedKey(input, weights) + + expect(result1).toBe(result2) + expect(result2).toBe(result3) + }) + + it('should distribute keys according to weights', async () => { + const weights = { a: 80, b: 10, c: 10 } + const numSamples = 10000 + const samples = Array.from({ length: numSamples }, (_, i) => `sample-${i}`) + + const results = await Promise.all(samples.map((sample) => selectWeightedKey(sample, weights))) + + const counts = results.reduce>((acc, key) => { + acc[key] = (acc[key] ?? 0) + 1 + return acc + }, {}) + + expect(counts.a / numSamples).toBeCloseTo(0.8, 1) + expect(counts.b / numSamples).toBeCloseTo(0.1, 1) + expect(counts.c / numSamples).toBeCloseTo(0.1, 1) + }) + + it('should handle equal weights', async () => { + const weights = { x: 25, y: 25, z: 25, w: 25 } + const numSamples = 8000 + const samples = Array.from({ length: numSamples }, (_, i) => `equal-${i}`) + + const results = await Promise.all(samples.map((sample) => selectWeightedKey(sample, weights))) + + const counts = results.reduce>((acc, key) => { + acc[key] = (acc[key] ?? 0) + 1 + return acc + }, {}) + + // Each key should get roughly 25% of the samples + Object.values(counts).forEach((count) => { + expect(count / numSamples).toBeCloseTo(0.25, 1) + }) + }) + + it('should handle single key', async () => { + const weights = { only: 100 } + const result = await selectWeightedKey('any-input', weights) + + expect(result).toBe('only') + }) + + it('should handle empty string input', async () => { + const weights = { a: 10, b: 20 } + const result = await selectWeightedKey('', weights) + + expect(Object.keys(weights)).toContain(result) + }) + + it('should handle unicode characters in input', async () => { + const weights = { option1: 50, option2: 50 } + const unicodeInput = '🔑-unicode-key-测试' + + const result1 = await selectWeightedKey(unicodeInput, weights) + const result2 = await selectWeightedKey(unicodeInput, weights) + + expect(result1).toBe(result2) + expect(Object.keys(weights)).toContain(result1) + }) +}) diff --git a/apps/studio/lib/ai/util.ts b/apps/studio/lib/ai/util.ts new file mode 100644 index 0000000000000..462d95d12bec3 --- /dev/null +++ b/apps/studio/lib/ai/util.ts @@ -0,0 +1,42 @@ +/** + * Selects a key from weighted choices using consistent hashing + * on an input string. + * + * The same input always returns the same key, with distribution + * proportional to the provided weights. + * + * @example + * const region = await selectWeightedKey('my-unique-id', { + * use1: 40, + * use2: 10, + * usw2: 10, + * euc1: 10, + * }) + * // Returns one of the keys based on the input and weights + */ +export async function selectWeightedKey( + input: string, + weights: Record +): Promise { + const keys = Object.keys(weights) as T[] + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + + // Use first 4 bytes (32 bit integer) + const hashInt = new DataView(hashBuffer).getUint32(0) + + const totalWeight = keys.reduce((sum, key) => sum + weights[key], 0) + + let cumulativeWeight = 0 + const targetWeight = hashInt % totalWeight + + for (const key of keys) { + cumulativeWeight += weights[key] + if (cumulativeWeight > targetWeight) { + return key + } + } + + return keys[0] +} diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index 37f16b6a41df5..34d405e60ed37 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -24,12 +24,16 @@ const HOSTED_SUPPORTED_API_URLS = [ '/ai/feedback/classify', '/get-ip-address', '/get-utc-time', + '/check-cname', '/edge-functions/test', '/edge-functions/body', ] export function middleware(request: NextRequest) { - if (IS_PLATFORM && !HOSTED_SUPPORTED_API_URLS.some((url) => request.url.endsWith(url))) { + if ( + IS_PLATFORM && + !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) + ) { return Response.json( { success: false, message: 'Endpoint not supported on hosted' }, { status: 404 } diff --git a/apps/studio/pages/api/check-cname.ts b/apps/studio/pages/api/check-cname.ts new file mode 100644 index 0000000000000..aad6be583b9d0 --- /dev/null +++ b/apps/studio/pages/api/check-cname.ts @@ -0,0 +1,21 @@ +import { CheckCNAMERecordResponse } from 'data/custom-domains/check-cname-mutation' +import { NextApiRequest, NextApiResponse } from 'next' + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const { domain } = req.query + + try { + const result: CheckCNAMERecordResponse = await fetch( + `https://cloudflare-dns.com/dns-query?name=${domain}&type=CNAME`, + { + method: 'GET', + headers: { Accept: 'application/dns-json' }, + } + ).then((res) => res.json()) + return res.status(200).json(result) + } catch (error: any) { + return res.status(400).json({ message: error.message }) + } +} + +export default handler diff --git a/apps/studio/pages/project/[ref]/logs/index.tsx b/apps/studio/pages/project/[ref]/logs/index.tsx index 1276db2782f35..4875b1f9545d5 100644 --- a/apps/studio/pages/project/[ref]/logs/index.tsx +++ b/apps/studio/pages/project/[ref]/logs/index.tsx @@ -2,14 +2,18 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { UnifiedLogs } from 'components/interfaces/UnifiedLogs/UnifiedLogs' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' +import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import type { NextPageWithLayout } from 'types' export const LogPage: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams() + const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const [lastVisitedLogsPage] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.LAST_VISITED_LOGS_PAGE, @@ -17,16 +21,51 @@ export const LogPage: NextPageWithLayout = () => { ) useEffect(() => { - router.replace(`/project/${ref}/logs/${lastVisitedLogsPage}`) - }, [router, lastVisitedLogsPage, ref]) + if (!isUnifiedLogsEnabled) { + router.replace(`/project/${ref}/logs/${lastVisitedLogsPage}`) + } + }, [router, lastVisitedLogsPage, ref, isUnifiedLogsEnabled]) - return null + // Handle redirects when unified logs preview flag changes + useEffect(() => { + // Only handle redirects if we're currently on a logs page + if (!router.asPath.includes('/logs')) return + + if (isUnifiedLogsEnabled) { + // If unified logs preview is enabled and we're not already on the main logs page + if (router.asPath !== `/project/${ref}/logs` && router.asPath.includes('/logs/')) { + router.push(`/project/${ref}/logs`) + } + } else if (!isUnifiedLogsEnabled) { + // If unified logs preview is disabled and admin flag is also off + // and we're on the main logs page, redirect to explorer + if (router.asPath === `/project/${ref}/logs`) { + router.push(`/project/${ref}/logs/explorer`) + } + } + }, [isUnifiedLogsEnabled, router, ref]) + + if (isUnifiedLogsEnabled) { + return ( + + + + + + ) + } + + return ( + + + {/* Empty placeholder - the useEffect will handle redirect */} +
+
+
+ ) } -LogPage.getLayout = (page) => ( - - {page} - -) +// Don't use getLayout since we're handling layouts conditionally within the component +LogPage.getLayout = (page) => page export default LogPage diff --git a/apps/studio/pages/project/[ref]/unified-logs.tsx b/apps/studio/pages/project/[ref]/unified-logs.tsx deleted file mode 100644 index dd752c26b8ead..0000000000000 --- a/apps/studio/pages/project/[ref]/unified-logs.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useRouter } from 'next/router' - -import { useParams } from 'common' -import { UnifiedLogs } from 'components/interfaces/UnifiedLogs/UnifiedLogs' -import DefaultLayout from 'components/layouts/DefaultLayout' -import UnifiedLogsLayout from 'components/layouts/UnifiedLogsLayout/UnifiedLogsLayout' -import { useFlag } from 'hooks/ui/useFlag' -import type { NextPageWithLayout } from 'types' - -export const LogPage: NextPageWithLayout = () => { - const router = useRouter() - const { ref } = useParams() - const unifiedLogsEnabled = useFlag('unifiedLogs') - - // Redirect if flag is disabled - if (unifiedLogsEnabled === false) { - router.push(`/project/${ref}/logs`) - } - - return -} - -LogPage.getLayout = (page) => ( - - {page} - -) - -export default LogPage diff --git a/apps/studio/public/img/previews/new-logs-preview.png b/apps/studio/public/img/previews/new-logs-preview.png new file mode 100644 index 0000000000000..a7c4f510a5026 Binary files /dev/null and b/apps/studio/public/img/previews/new-logs-preview.png differ diff --git a/apps/www/components/Pricing/PricingComparisonTable.tsx b/apps/www/components/Pricing/PricingComparisonTable.tsx index 873642c18f3f2..d7f0349c955ef 100644 --- a/apps/www/components/Pricing/PricingComparisonTable.tsx +++ b/apps/www/components/Pricing/PricingComparisonTable.tsx @@ -66,6 +66,7 @@ const MobileHeader = ({ }) } size="medium" + planId={selectedPlan.planId} /> ) : (