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={
+ <>
+ {
+ enableUnifiedLogs()
+ router.push(`/project/${ref}/logs`)
+ }}
+ >
+ Enable preview
+
+ }
+ 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
-
-
+
)
@@ -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 (
+
+ )
+
+ 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)
+ }}
+ />
+
+
+ {Component ? (
+
+ ) : (
+ {option.label}
+ )}
+
+
+ {isLoadingCounts ? (
+
+ ) : facetedValue?.has(option.value) ? (
+ formatCompactNumber(facetedValue.get(option.value) || 0)
+ ) : ['log_type', 'method', 'level'].includes(value) ? (
+ '0'
+ ) : null}
+
+ column?.setFilterValue([option.value])}
+ className={cn(
+ 'absolute inset-y-0 right-0 hidden font-normal text-muted-foreground backdrop-blur-sm hover:text-foreground group-hover:block',
+ 'rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
+ )}
+ >
+ only
+
+
+
+ )
+ })
+ )}
+
+
+ )
+}
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}
/>
) : (
@@ -415,6 +416,7 @@ const PricingComparisonTable = ({
})
}
size="tiny"
+ planId={plan.planId}
/>
) : (
{isUpgradablePlan && hasExistingOrganizations ? (
-
+
) : (
void
size?: ButtonProps['size']
+ planId: PlanId
}
-const UpgradePlan = ({ organizations = [], onClick, size = 'large' }: UpgradePlanProps) => {
+const UpgradePlan = ({ organizations = [], onClick, size = 'large', planId }: UpgradePlanProps) => {
const [open, setOpen] = useState(false)
const [value, setValue] = useState('')
@@ -142,7 +144,7 @@ const UpgradePlan = ({ organizations = [], onClick, size = 'large' }: UpgradePla
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index 692132e645371..a374533acc3af 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -10,6 +10,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',
UI_PREVIEW_CLS: 'supabase-ui-cls',
UI_PREVIEW_INLINE_EDITOR: 'supabase-ui-preview-inline-editor',
+ UI_PREVIEW_UNIFIED_LOGS: 'supabase-ui-preview-unified-logs',
UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown',
UI_PREVIEW_REALTIME_SETTINGS: 'supabase-ui-realtime-settings',
UI_PREVIEW_BRANCHING_2_0: 'supabase-ui-branching-2-0',
@@ -104,6 +105,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL,
LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR,
LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS,
+ LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
LOCAL_STORAGE_KEYS.BLOG_VIEW,
diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml
index 9aae4953dec35..449ffc5cf0423 100644
--- a/supa-mdx-lint/Rule003Spelling.toml
+++ b/supa-mdx-lint/Rule003Spelling.toml
@@ -320,6 +320,7 @@ allow_list = [
"ngrok",
"node-postgres",
"npm",
+ "pnpm",
"npmrc",
"npx",
"ns",