diff --git a/apps/design-system/registry/default/block/chart-composed-basic.tsx b/apps/design-system/registry/default/block/chart-composed-basic.tsx index 99778d63f438c..d9457c3d3082d 100644 --- a/apps/design-system/registry/default/block/chart-composed-basic.tsx +++ b/apps/design-system/registry/default/block/chart-composed-basic.tsx @@ -28,16 +28,38 @@ export default function ComposedChartBasic() { }, ] - const data = Array.from({ length: 46 }, (_, i) => { + const data = Array.from({ length: 40 }, (_, i) => { const date = new Date() - date.setMinutes(date.getMinutes() - i * 5) // Each point 5 minutes apart + date.setMinutes(date.getMinutes() - i * 3) // Each point 3 minutes apart + + const progress = i / 40 + const standard_score = Math.floor(55 + progress * 55 + (Math.random() - 0.5) * 12) + const performance = Math.floor(35 + progress * 35 + (Math.random() - 0.5) * 10) + const efficiency = Math.floor(25 + progress * 25 + (Math.random() - 0.5) * 12) return { timestamp: date.toISOString(), - standard_score: Math.floor(Math.random() * 100), + standard_score: Math.max(0, Math.min(100, standard_score)), + performance: Math.max(0, Math.min(100, performance)), + efficiency: Math.max(0, Math.min(100, efficiency)), } }).reverse() + const chartConfig = { + standard_score: { + label: 'Standard Score', + color: 'hsl(var(--brand-default))', + }, + performance: { + label: 'Performance', + color: 'hsl(var(--chart-2))', + }, + efficiency: { + label: 'Efficiency', + color: 'hsl(var(--chart-5))', + }, + } + useEffect(() => { setTimeout(() => { setIsLoading(false) @@ -50,10 +72,8 @@ export default function ComposedChartBasic() { Standard Bar Chart - - Standard Line Chart - - + +This troubleshooting guide is about rotating **Legacy anon, service_role API keys**. We are deprecating Legacy +, and recommend migrating to New API keys. To learn more about API +keys, refer to [the API documentation](/docs/guides/api/api-keys). + + + + + +Once the JWT secret is regenerated, all current API secrets will be immediately invalidated, and +all connections using them will be severed. You will need to deploy the new secrets for +connections to begin working again. You can avoid downtime by migrating to new API Keys. + + + Have you ever accidentally committed a service key to a public repo? Or maybe rotating keys is just something you regularly do for security compliance. Whatever the reason, here's how to rotate the keys for your Supabase project. -1. Go to the [API Settings page](/dashboard/project/_/settings/api-keys/new) in the Supabase Dashboard -2. Find the JWT Secrets section +If you haven’t migrated to asymmetric JWT signing keys: + +{/* supa-mdx-lint-disable Rule004ExcludeWords */} + +1. Go to [**Project Settings** → **JWT Keys**](/dashboard/project/_/settings/jwt) in the Supabase Dashboard +2. Navigate to the **Legacy JWT Secret** tab +3. Click on **Change Legacy Secret** + - Click on **Generate a random secret** to let Supabase decide the JWT secret. + - Click on **Create my own secret** to enter a custom JWT secret +4. You will see a Confirmation dialog. Read it through and confirm to proceed. -Screenshot 2023-12-27 at 08 39 41 +If you have migrated to new symmetric JWT signing keys: -3. Click the `Generate new secret` button and choose either a random secret, or custom if you'd like to supply one of your own. -4. NOTE: Once regenerated, all current API secrets will be immediately invalidated, and all connections using them will be severed. You will need to deploy the new secrets for connections to begin working again. -5. Confirm the changes in the warning that pops up by clicking `Generate New Secret` again. +1. Go to [**Project Settings** → **JWT Keys**](/dashboard/project/_/settings/jwt)s in the Supabase Dashboard +2. Navigate to the **JWT Signing Keys** tab. +3. Click on Rotate Keys. This will move the current key to “Previously used keys” +4. Select the three-dot icon (action icon) of your previously used key and click “Revoke”. If you do not “Revoke” the key, older keys will still be valid. -Screenshot 2023-12-27 at 08 39 59 +## Further readings -6. After confirming, the secret will be generated, and Supabase will start rolling that out across our services. Postgres will restart, the API gateways will be updated, etc. Once the process is complete, you will be able to see your new JWT secret as well as the new anon and service keys. +- [How to rotate the service role key](/docs/guides/api/api-keys#i-am-using-jwt-based-anon-key-in-a-mobile-desktop-or-cli-application-and-need-to-rotate-my-servicerole-jwt-secret) +- [What to do if a secret key or service_role has been leaked or compromised?](/docs/guides/api/api-keys#what-to-do-if-a-secret-key-or-servicerole-has-been-leaked-or-compromised) +- [JWT Signing Keys](/docs/guides/auth/signing-keys) diff --git a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx index fbbbd80df0fbf..a24b3e33bb4a9 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/CommandMenu.tsx @@ -17,6 +17,7 @@ import { useApiUrlCommand } from './ApiUrl' import { useProjectSwitchCommand, useConfigureOrganizationCommand } from './OrgProjectSwitcher' import { useSupportCommands } from './Support' import { orderCommandSectionsByPriority } from './ordering' +import { useContextSearchCommands } from './ContextSearchCommands' import { useCreateCommands } from './CreateCommands' export default function StudioCommandMenu() { @@ -40,6 +41,7 @@ export default function StudioCommandMenu() { useChangelogCommand({ enabled: IS_PLATFORM }) useThemeSwitcherCommands() useCreateCommands() + useContextSearchCommands() return ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx new file mode 100644 index 0000000000000..9cd507530deae --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchCommands.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useMemo } from 'react' +import { Database } from 'lucide-react' +import { Auth, EdgeFunctions, Storage } from 'icons' +import type { ICommand } from 'ui-patterns/CommandMenu' +import { + CommandHeader, + CommandInput, + CommandWrapper, + PageType, + useRegisterCommands, + useRegisterPage, + useSetPage, + useQuery, +} from 'ui-patterns/CommandMenu' +import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { orderCommandSectionsByPriority } from './ordering' +import { ContextSearchResults } from './ContextSearchResults' +import { useFlag, IS_PLATFORM } from 'common' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import type { SearchContextValue } from './SearchContext.types' + +interface SearchContextOption { + value: SearchContextValue + label: string + pageName: string + placeholder: string + icon: React.ComponentType> +} + +const SEARCH_CONTEXT_OPTIONS: SearchContextOption[] = [ + { + value: 'database-tables', + label: 'Database Tables', + pageName: 'Search Database Tables', + placeholder: 'Search database tables...', + icon: Database, + }, + { + value: 'auth-policies', + label: 'RLS Policies', + pageName: 'Search RLS Policies', + placeholder: 'Search rls policies...', + icon: Auth, + }, + { + value: 'edge-functions', + label: 'Edge Functions', + pageName: 'Search Edge Functions', + placeholder: 'Search edge functions...', + icon: EdgeFunctions, + }, + { + value: 'storage', + label: 'Storage', + pageName: 'Search Storage', + placeholder: 'Search buckets...', + icon: Storage, + }, +] + +function ContextSearchPage({ + context, + placeholder, +}: { + context: SearchContextValue + placeholder: string +}) { + const query = useQuery() + + return ( + + + + + + + ) +} + +export function useContextSearchCommands() { + const enableSearchEntitiesCommandMenu = useFlag('enableSearchEntitiesCommandMenu') + const { data: project } = useSelectedProjectQuery() + const setPage = useSetPage() + + const { + projectAuthAll: authEnabled, + projectEdgeFunctionAll: edgeFunctionsEnabled, + projectStorageAll: storageEnabled, + } = useIsFeatureEnabled(['project_auth:all', 'project_edge_function:all', 'project_storage:all']) + + const pageDefinitions = [ + { title: 'Search Database Tables', context: 'database-tables' as const }, + { title: 'Search RLS Policies', context: 'auth-policies' as const }, + { title: 'Search Edge Functions', context: 'edge-functions' as const }, + { title: 'Search Storage', context: 'storage' as const }, + ] + + // Register pages - pageDefinitions is constant, so hooks are called in consistent order + for (const { title, context } of pageDefinitions) { + const placeholder = + SEARCH_CONTEXT_OPTIONS.find((opt) => opt.value === context)?.placeholder ?? '' + // eslint-disable-next-line react-hooks/rules-of-hooks + useRegisterPage(title, { + type: PageType.Component, + component: () => , + }) + } + + const contextCommands = useMemo(() => { + return SEARCH_CONTEXT_OPTIONS.filter((option) => { + let isFeatureEnabled = false + switch (option.value) { + case 'database-tables': + isFeatureEnabled = true + break + case 'auth-policies': + isFeatureEnabled = authEnabled + break + case 'edge-functions': + isFeatureEnabled = edgeFunctionsEnabled + break + case 'storage': + isFeatureEnabled = storageEnabled + break + } + + if (!isFeatureEnabled) return false + + // If self-hosted, show if feature is enabled + if (!IS_PLATFORM) { + return true + } + + // only show when inside a project if not self-hosted + return !!project + }).map((option) => ({ + id: `search-${option.value}`, + name: `Search ${option.label}...`, + action: () => setPage(option.pageName), + icon: () => , + })) as ICommand[] + }, [setPage, authEnabled, edgeFunctionsEnabled, storageEnabled, project]) + + useRegisterCommands(COMMAND_MENU_SECTIONS.QUERY, contextCommands, { + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 3 }, + enabled: !IS_PLATFORM || (enableSearchEntitiesCommandMenu && !!project), + }) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx new file mode 100644 index 0000000000000..8a511334f9487 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.shared.tsx @@ -0,0 +1,111 @@ +'use client' + +import { CommandList_Shadcn_, cn } from 'ui' +import { ShimmeringLoader } from 'ui-patterns' +import { CommandItem } from 'ui-patterns/CommandMenu/internal/Command' +import { CommandGroup } from 'ui-patterns/CommandMenu/internal/CommandGroup' +import { TextHighlighter } from 'ui-patterns/CommandMenu' +import type { IRouteCommand, IActionCommand } from 'ui-patterns/CommandMenu/internal/types' + +export interface SearchResult { + id: string + name: string + description?: string +} + +export function SkeletonResults() { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+ +
+ + +
+
+ ))} +
+ ) +} + +interface EmptyStateProps { + icon: React.ComponentType> + label: string + query: string +} + +export function EmptyState({ icon: Icon, label, query }: EmptyStateProps) { + return ( +
+ +

+ {query ? `No results found for "${query}"` : `Type to search in ${label}`} +

+
+ ) +} + +interface ResultsListProps { + results: SearchResult[] + icon: React.ComponentType> + getIcon?: (result: SearchResult) => React.ComponentType> + onResultClick?: (result: SearchResult) => void + getRoute?: (result: SearchResult) => `/${string}` | `http${string}` + className?: string +} + +export function ResultsList({ + results, + icon: Icon, + getIcon, + onResultClick, + getRoute, + className, +}: ResultsListProps) { + const commands = results.map((result): IRouteCommand | IActionCommand => { + const ResultIcon = getIcon ? getIcon(result) : Icon + const baseCommand = { + id: result.id, + name: result.name, + value: result.description ? `${result.name} ${result.description}` : result.name, + icon: () => , + } + + if (getRoute) { + return { + ...baseCommand, + route: getRoute(result), + } as IRouteCommand + } + + return { + ...baseCommand, + action: () => onResultClick?.(result), + } as IActionCommand + }) + + return ( + + + {commands.map((command) => ( + +
+ {command.name} + {command.value && command.value !== command.name && ( +

+ {command.value.replace(command.name, '').trim()} +

+ )} +
+
+ ))} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.tsx new file mode 100644 index 0000000000000..30a19b6894f50 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/ContextSearchResults.tsx @@ -0,0 +1,119 @@ +'use client' + +import dynamic from 'next/dynamic' +import { Database } from 'lucide-react' +import { Auth, EdgeFunctions, Storage } from 'icons' +import type { SearchContextValue } from './SearchContext.types' +import { SkeletonResults, EmptyState } from './ContextSearchResults.shared' + +const TableSearchResults = dynamic( + () => import('./TableSearchResults').then((mod) => ({ default: mod.TableSearchResults })), + { + loading: () => , + ssr: false, + } +) + +const PolicySearchResults = dynamic( + () => import('./PolicySearchResults').then((mod) => ({ default: mod.PolicySearchResults })), + { + loading: () => , + ssr: false, + } +) + +const EdgeFunctionSearchResults = dynamic( + () => + import('./EdgeFunctionSearchResults').then((mod) => ({ + default: mod.EdgeFunctionSearchResults, + })), + { + loading: () => , + ssr: false, + } +) + +const StorageSearchResults = dynamic( + () => import('./StorageSearchResults').then((mod) => ({ default: mod.StorageSearchResults })), + { + loading: () => , + ssr: false, + } +) + +interface ContextSearchResultsProps { + context: SearchContextValue + query: string +} + +const CONTEXT_CONFIG: Record< + SearchContextValue, + { + icon: React.ComponentType> + label: string + requiresInput?: boolean // requires user input before showing results + } +> = { + 'database-tables': { + icon: Database, + label: 'Database Tables', + requiresInput: false, + }, + 'auth-policies': { + icon: Auth, + label: 'RLS Policies', + requiresInput: false, + }, + 'edge-functions': { + icon: EdgeFunctions, + label: 'Edge Functions', + requiresInput: false, + }, + storage: { + icon: Storage, + label: 'Storage', + requiresInput: false, + }, +} + +export function ContextSearchResults({ context, query }: ContextSearchResultsProps) { + const config = CONTEXT_CONFIG[context] + + if (context === 'database-tables') { + return ( +
+ +
+ ) + } + + if (context === 'auth-policies') { + return ( +
+ +
+ ) + } + + if (context === 'edge-functions') { + return ( +
+ +
+ ) + } + + if (context === 'storage') { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/EdgeFunctionSearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/EdgeFunctionSearchResults.tsx new file mode 100644 index 0000000000000..3a184435a7fe9 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/EdgeFunctionSearchResults.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useMemo } from 'react' +import { EdgeFunctions } from 'icons' +import { Loader2 } from 'lucide-react' +import { useParams } from 'common' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { + SkeletonResults, + EmptyState, + ResultsList, + type SearchResult, +} from './ContextSearchResults.shared' + +interface EdgeFunctionSearchResultsProps { + query: string +} + +export function EdgeFunctionSearchResults({ query }: EdgeFunctionSearchResultsProps) { + const { ref: projectRef } = useParams() + + const trimmedQuery = query.trim() + + const { + data: functions, + isLoading: isLoadingFunctions, + isError: isErrorFunctions, + } = useEdgeFunctionsQuery( + { + projectRef, + }, + { + enabled: !!projectRef, + } + ) + + const functionResults: SearchResult[] = useMemo(() => { + if (!functions) return [] + + const filtered = trimmedQuery + ? functions.filter((func) => { + const searchLower = trimmedQuery.toLowerCase() + const functionName = func.name?.toLowerCase() || '' + const functionSlug = func.slug?.toLowerCase() || '' + + return functionName.includes(searchLower) || functionSlug.includes(searchLower) + }) + : functions + + // Limit results for performance + return filtered.slice(0, 20).map((func) => { + const displayName = func.name || func.slug || 'Untitled Function' + const description = func.version ? `Version ${func.version}` : undefined + + return { + id: String(func.id), + name: displayName, + description, + } + }) + }, [functions, trimmedQuery]) + + const totalFunctions = functions?.length ?? 0 + + const renderFooter = () => ( +
+
+ {isLoadingFunctions ? ( + + Loading... + + ) : ( + + Total: {totalFunctions.toLocaleString()} function{totalFunctions !== 1 ? 's' : ''} + + )} +
+
+ ) + + if (isLoadingFunctions) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + if (isErrorFunctions) { + return ( +
+
+
+ +

Failed to load edge functions

+
+
+ {renderFooter()} +
+ ) + } + + if (functionResults.length === 0) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + return ( +
+
+ { + const func = functions?.find((f) => String(f.id) === result.id) + if (!func || !projectRef) return `/project/${projectRef}/functions` as `/${string}` + + return `/project/${projectRef}/functions/${func.slug}` as `/${string}` + }} + className="pb-9" + /> +
+ {renderFooter()} +
+ ) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx new file mode 100644 index 0000000000000..46bfd9c54dc32 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/PolicySearchResults.tsx @@ -0,0 +1,158 @@ +'use client' + +import { useMemo } from 'react' +import { Auth } from 'icons' +import { Loader2 } from 'lucide-react' +import { useParams } from 'common' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + SkeletonResults, + EmptyState, + ResultsList, + type SearchResult, +} from './ContextSearchResults.shared' + +interface PolicySearchResultsProps { + query: string +} + +export function PolicySearchResults({ query }: PolicySearchResultsProps) { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const trimmedQuery = query.trim() + + const { + data: policies, + isLoading: isLoadingPolicies, + isError: isErrorPolicies, + } = useDatabasePoliciesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + enabled: !!project?.ref, + } + ) + + const policyResults: SearchResult[] = useMemo(() => { + if (!policies) return [] + + const filtered = trimmedQuery + ? policies.filter((policy) => { + const searchLower = trimmedQuery.toLowerCase() + const policyName = policy.name?.toLowerCase() || '' + const tableName = policy.table?.toLowerCase() || '' + const schemaName = policy.schema?.toLowerCase() || '' + const fullTableName = `${schemaName}.${tableName}` + const command = policy.command?.toLowerCase() || '' + + return ( + policyName.includes(searchLower) || + tableName.includes(searchLower) || + schemaName.includes(searchLower) || + fullTableName.includes(searchLower) || + command.includes(searchLower) + ) + }) + : policies + + // Limit results for performance + return filtered.slice(0, 20).map((policy) => { + const displayName = policy.name || 'Untitled Policy' + const tableDisplay = + policy.schema && policy.schema !== 'public' + ? `${policy.schema}.${policy.table}` + : policy.table || 'unknown table' + const commandDisplay = policy.command || 'ALL' + const description = `${commandDisplay} on ${tableDisplay}` + + return { + id: String(policy.id), + name: displayName, + description, + } + }) + }, [policies, trimmedQuery]) + + const totalPolicies = policies?.length ?? 0 + + const renderFooter = () => ( +
+
+ {isLoadingPolicies ? ( + + Loading... + + ) : ( + + Total: {totalPolicies.toLocaleString()} polic{totalPolicies !== 1 ? 'ies' : 'y'} + + )} +
+
+ ) + + if (isLoadingPolicies) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + if (isErrorPolicies) { + return ( +
+
+
+ +

Failed to load policies

+
+
+ {renderFooter()} +
+ ) + } + + if (policyResults.length === 0) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + return ( +
+
+ { + const policy = policies?.find((p) => String(p.id) === result.id) + if (!policy || !projectRef) + return `/project/${projectRef}/auth/policies` as `/${string}` + + const params = new URLSearchParams() + params.set('edit', String(policy.id)) + if (policy.schema) { + params.set('schema', policy.schema) + } + return `/project/${projectRef}/auth/policies?${params.toString()}` as `/${string}` + }} + className="pb-9" + /> +
+ {renderFooter()} +
+ ) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/SearchContext.types.ts b/apps/studio/components/interfaces/App/CommandMenu/SearchContext.types.ts new file mode 100644 index 0000000000000..7521c396855a6 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/SearchContext.types.ts @@ -0,0 +1 @@ +export type SearchContextValue = 'database-tables' | 'auth-policies' | 'edge-functions' | 'storage' diff --git a/apps/studio/components/interfaces/App/CommandMenu/StorageSearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/StorageSearchResults.tsx new file mode 100644 index 0000000000000..ad61654641f4d --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/StorageSearchResults.tsx @@ -0,0 +1,314 @@ +'use client' + +import { useMemo, useCallback } from 'react' +import { Storage, FilesBucket, AnalyticsBucket as AnalyticsBucketIcon, VectorBucket } from 'icons' +import { Loader2 } from 'lucide-react' +import { useParams } from 'common' +import { useBucketsQuery, type Bucket } from 'data/storage/buckets-query' +import { + useAnalyticsBucketsQuery, + type AnalyticsBucket, +} from 'data/storage/analytics-buckets-query' +import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query' +import { + useIsAnalyticsBucketsEnabled, + useIsVectorBucketsEnabled, +} from 'data/config/project-storage-config-query' +import { + SkeletonResults, + EmptyState, + ResultsList, + type SearchResult, +} from './ContextSearchResults.shared' + +interface StorageSearchResultsProps { + query: string +} + +type ExtendedSearchResult = SearchResult & { + bucketType?: 'file' | 'analytics' | 'vector' + bucket?: unknown +} + +function filterBuckets( + buckets: T[] | null | undefined, + query: string, + filterFn: (bucket: T, searchLower: string) => boolean, + mapFn: (bucket: T) => ExtendedSearchResult +): ExtendedSearchResult[] { + if (!buckets) return [] + + const trimmedQuery = query.trim() + const filtered = trimmedQuery + ? buckets.filter((bucket) => filterFn(bucket, trimmedQuery.toLowerCase())) + : buckets + + return filtered.slice(0, 10).map(mapFn) +} + +export function StorageSearchResults({ query }: StorageSearchResultsProps) { + const { ref: projectRef } = useParams() + + const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef }) + const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef }) + + const { + data: fileBuckets, + isLoading: isLoadingFileBuckets, + isError: isErrorFileBuckets, + } = useBucketsQuery( + { + projectRef: projectRef ?? undefined, + }, + { + enabled: !!projectRef, + } + ) + + const { + data: analyticsBuckets, + isLoading: isLoadingAnalyticsBuckets, + isError: isErrorAnalyticsBuckets, + } = useAnalyticsBucketsQuery( + { + projectRef: projectRef ?? undefined, + }, + { + enabled: !!projectRef && isAnalyticsBucketsEnabled, + } + ) + + const { + data: vectorBucketsData, + isLoading: isLoadingVectorBuckets, + isError: isErrorVectorBuckets, + } = useVectorBucketsQuery( + { + projectRef: projectRef ?? undefined, + }, + { + enabled: !!projectRef && isVectorBucketsEnabled, + } + ) + + const vectorBuckets = useMemo(() => vectorBucketsData?.vectorBuckets ?? [], [vectorBucketsData]) + + const isLoading = + isLoadingFileBuckets || + (isAnalyticsBucketsEnabled && isLoadingAnalyticsBuckets) || + (isVectorBucketsEnabled && isLoadingVectorBuckets) + const isError = + isErrorFileBuckets || + (isAnalyticsBucketsEnabled && isErrorAnalyticsBuckets) || + (isVectorBucketsEnabled && isErrorVectorBuckets) + + const fileBucketResults: ExtendedSearchResult[] = useMemo(() => { + return filterBuckets( + fileBuckets, + query, + (bucket, searchLower) => { + const bucketName = bucket.name?.toLowerCase() || '' + const bucketId = bucket.id?.toLowerCase() || '' + return bucketName.includes(searchLower) || bucketId.includes(searchLower) + }, + (bucket) => { + const displayName = bucket.name || bucket.id || 'Untitled Bucket' + const visibility = bucket.public ? 'Public' : 'Private' + const description = `File bucket • ${visibility}` + + return { + id: `file-bucket-${bucket.id || bucket.name}`, + name: displayName, + description, + bucketType: 'file' as const, + bucket, + } + } + ) + }, [fileBuckets, query]) + + const analyticsBucketResults: ExtendedSearchResult[] = useMemo(() => { + return filterBuckets( + analyticsBuckets, + query, + (bucket, searchLower) => { + const bucketName = bucket.name?.toLowerCase() || '' + return bucketName.includes(searchLower) + }, + (bucket) => { + const displayName = bucket.name || 'Untitled Bucket' + const description = 'Analytics bucket' + + return { + id: `analytics-bucket-${bucket.name}`, + name: displayName, + description, + bucketType: 'analytics' as const, + bucket, + } + } + ) + }, [analyticsBuckets, query]) + + const vectorBucketResults: ExtendedSearchResult[] = useMemo(() => { + return filterBuckets( + vectorBuckets, + query, + (bucket, searchLower) => { + const bucketName = bucket.vectorBucketName?.toLowerCase() || '' + return bucketName.includes(searchLower) + }, + (bucket) => { + const displayName = bucket.vectorBucketName || 'Untitled Bucket' + const description = 'Vector bucket' + + return { + id: `vector-bucket-${bucket.vectorBucketName}`, + name: displayName, + description, + bucketType: 'vector' as const, + bucket, + } + } + ) + }, [vectorBuckets, query]) + + const allResults: ExtendedSearchResult[] = useMemo(() => { + const results = [fileBucketResults] + if (isAnalyticsBucketsEnabled) { + results.push(analyticsBucketResults) + } + if (isVectorBucketsEnabled) { + results.push(vectorBucketResults) + } + return results.flat().slice(0, 20) + }, [ + fileBucketResults, + analyticsBucketResults, + vectorBucketResults, + isAnalyticsBucketsEnabled, + isVectorBucketsEnabled, + ]) + + const getRoute = useCallback( + (result: SearchResult) => { + if (!projectRef) return '/storage/files' as `/${string}` + + const extendedResult = result as ExtendedSearchResult + + if (extendedResult.bucketType && extendedResult.bucket) { + const bucketType = extendedResult.bucketType + + if (bucketType === 'file') { + const fileBucket = extendedResult.bucket as Bucket + return `/project/${projectRef}/storage/files/buckets/${encodeURIComponent(fileBucket.name)}` as `/${string}` + } + + if (bucketType === 'analytics') { + const analyticsBucket = extendedResult.bucket as AnalyticsBucket + return `/project/${projectRef}/storage/analytics/buckets/${encodeURIComponent(analyticsBucket.name)}` as `/${string}` + } + + if (bucketType === 'vector') { + const vectorBucket = extendedResult.bucket as { vectorBucketName: string } + return `/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(vectorBucket.vectorBucketName)}` as `/${string}` + } + } + + return `/project/${projectRef}/storage/files` as `/${string}` + }, + [projectRef] + ) + + const totalBuckets = useMemo(() => { + let total = fileBuckets?.length ?? 0 + if (isAnalyticsBucketsEnabled) { + total += analyticsBuckets?.length ?? 0 + } + if (isVectorBucketsEnabled) { + total += vectorBuckets?.length ?? 0 + } + return total + }, [ + fileBuckets?.length, + analyticsBuckets?.length, + vectorBuckets?.length, + isAnalyticsBucketsEnabled, + isVectorBucketsEnabled, + ]) + + const getIcon = useCallback((result: SearchResult) => { + const extendedResult = result as ExtendedSearchResult + if (extendedResult.bucketType === 'file') return FilesBucket + if (extendedResult.bucketType === 'analytics') return AnalyticsBucketIcon + if (extendedResult.bucketType === 'vector') return VectorBucket + return Storage + }, []) + + const renderFooter = () => ( +
+
+ {isLoading ? ( + + Loading... + + ) : ( + + Total: {totalBuckets.toLocaleString()} bucket{totalBuckets !== 1 ? 's' : ''} + + )} +
+
+ ) + + if (isLoading) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + if (isError) { + return ( +
+
+
+ +

Failed to load storage buckets

+
+
+ {renderFooter()} +
+ ) + } + + if (allResults.length === 0) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + return ( +
+
+ +
+ {renderFooter()} +
+ ) +} diff --git a/apps/studio/components/interfaces/App/CommandMenu/TableSearchResults.tsx b/apps/studio/components/interfaces/App/CommandMenu/TableSearchResults.tsx new file mode 100644 index 0000000000000..6f2ba0845f053 --- /dev/null +++ b/apps/studio/components/interfaces/App/CommandMenu/TableSearchResults.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useMemo } from 'react' +import { Database, Loader2 } from 'lucide-react' +import { useParams } from 'common' +import { useTablesQuery } from 'data/tables/tables-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + SkeletonResults, + EmptyState, + ResultsList, + type SearchResult, +} from './ContextSearchResults.shared' + +interface TableSearchResultsProps { + query: string +} + +export function TableSearchResults({ query }: TableSearchResultsProps) { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const trimmedQuery = query.trim() + + const { + data: tables, + isLoading: isLoadingTables, + isError: isErrorTables, + } = useTablesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + includeColumns: false, + sortByProperty: 'name', + }, + { + enabled: !!project?.ref, + } + ) + + const tableResults: SearchResult[] = useMemo(() => { + if (!tables) return [] + + const filtered = trimmedQuery + ? tables.filter((table) => { + const searchLower = trimmedQuery.toLowerCase() + const tableName = table.name?.toLowerCase() || '' + const schemaName = table.schema?.toLowerCase() || '' + const fullName = `${schemaName}.${tableName}` + + return ( + tableName.includes(searchLower) || + schemaName.includes(searchLower) || + fullName.includes(searchLower) + ) + }) + : tables + + // Limit results for performance + return filtered.slice(0, 20).map((table) => { + const displayName = + table.schema && table.schema !== 'public' + ? `${table.schema}.${table.name}` + : table.name || 'Untitled Table' + + const description = table.comment + ? table.comment.length > 50 + ? `${table.comment.slice(0, 50)}...` + : table.comment + : undefined + + return { + id: String(table.id), + name: displayName, + description, + } + }) + }, [tables, trimmedQuery]) + + const totalTables = tables?.length ?? 0 + + const renderFooter = () => ( +
+
+ {isLoadingTables ? ( + + Loading... + + ) : ( + + Total: {totalTables.toLocaleString()} table{totalTables !== 1 ? 's' : ''} + + )} +
+
+ ) + + if (isLoadingTables) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + if (isErrorTables) { + return ( +
+
+
+ +

Failed to load tables

+
+
+ {renderFooter()} +
+ ) + } + + if (tableResults.length === 0) { + return ( +
+
+ +
+ {renderFooter()} +
+ ) + } + + return ( +
+
+ { + const table = tables?.find((t) => String(t.id) === result.id) + if (!table || !projectRef) return `/project/${projectRef}/editor` as `/${string}` + + const schemaParam = table.schema ? `?schema=${table.schema}` : '' + return `/project/${projectRef}/editor/${table.id}${schemaParam}` as `/${string}` + }} + className="pb-9" + /> +
+ {renderFooter()} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx index 333b66c677516..8fecb0b896f37 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/DeleteCronJob.tsx @@ -26,7 +26,7 @@ export const DeleteCronJob = ({ cronJob, visible, onClose, onDeleteStart }: Dele const { mutate: deleteDatabaseCronJob, isPending } = useDatabaseCronJobDeleteMutation({ onSuccess: () => { sendEvent({ - action: 'cron_job_deleted', + action: 'cron_job_removed', groups: { project: project?.ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) toast.success(`Successfully removed cron job ${cronJob.jobname}`) diff --git a/apps/studio/components/ui/Charts/Charts.constants.ts b/apps/studio/components/ui/Charts/Charts.constants.ts index 3cd62f075496c..425631bd170e1 100644 --- a/apps/studio/components/ui/Charts/Charts.constants.ts +++ b/apps/studio/components/ui/Charts/Charts.constants.ts @@ -15,8 +15,8 @@ export const CHART_COLORS = { const LIGHT_STACKED_CHART_COLORS = [ '#3ECF8E', - '#097c4f', '#DA760B', + '#097c4f', '#EDC35E', '#65BCD9', '#0063E8', @@ -24,6 +24,17 @@ const LIGHT_STACKED_CHART_COLORS = [ '#B616A6', ] +const LIGHT_STACKED_CHART_FILLS = [ + '#9FE8C7', + '#FFB885', + '#4BA67A', + '#F6D99F', + '#B2DCEC', + '#80B1F4', + '#EDC9FC', + '#DB8BD3', +] + const DARK_STACKED_CHART_COLORS = [ '#3ECF8E', '#A3FFC2', @@ -35,12 +46,24 @@ const DARK_STACKED_CHART_COLORS = [ '#B616A6', ] +const DARK_STACKED_CHART_FILLS = [ + '#2A5C3F', + '#1F3D2A', + '#5C3D0A', + '#5C5230', + '#2A3D45', + '#001F3D', + '#4A3D5C', + '#3D1F3A', +] + // Default to light mode colors, will be updated based on theme export let STACKED_CHART_COLORS = LIGHT_STACKED_CHART_COLORS - +export let STACKED_CHART_FILLS = LIGHT_STACKED_CHART_FILLS // Function to update colors based on theme export const updateStackedChartColors = (isDarkMode: boolean) => { STACKED_CHART_COLORS = isDarkMode ? DARK_STACKED_CHART_COLORS : LIGHT_STACKED_CHART_COLORS + STACKED_CHART_FILLS = isDarkMode ? DARK_STACKED_CHART_FILLS : LIGHT_STACKED_CHART_FILLS } // refer to packages/ui/radix-colors.js for full list of colors diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index a247be4eed376..708c5397f37ce 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -14,6 +14,7 @@ import { Tooltip, XAxis, YAxis, + Customized, } from 'recharts' import { CategoricalChartState } from 'recharts/types/chart/types' @@ -24,6 +25,7 @@ import { CHART_COLORS, DateTimeFormats, STACKED_CHART_COLORS, + STACKED_CHART_FILLS, updateStackedChartColors, } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' @@ -71,6 +73,22 @@ export interface ComposedChartProps extends CommonChartProps { showNewBadge?: boolean } +interface CustomizedDotProps { + formattedGraphicalItems?: Array<{ + props?: { + points?: Array<{ x: number; y: number }> + dataKey?: string + } + item?: { + props?: { + points?: Array<{ x: number; y: number }> + dataKey?: string + } + } + points?: Array<{ x: number; y: number }> + }> +} + export function ComposedChart({ chartId, data, @@ -120,7 +138,6 @@ export function ComposedChart({ const [_activePayload, setActivePayload] = useState(null) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = useState(null) - const [hoveredLabel, setHoveredLabel] = useState(null) const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) const [hiddenAttributes, setHiddenAttributes] = useState>(new Set()) const isDarkMode = resolvedTheme?.includes('dark') @@ -240,7 +257,9 @@ export function ComposedChart({ color: CHART_COLORS.REFERENCE_LINE, } - const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line') + const referenceLines = attributes.filter((attribute) => { + return attribute?.provider === 'reference-line' + }) const resolvedHighlightedLabel = getHeaderLabel() @@ -271,10 +290,15 @@ export function ComposedChart({ return { ...att, color: attribute?.color - ? resolvedTheme?.includes('dark') + ? isDarkMode ? attribute.color.dark : attribute.color.light : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length], + fill: attribute?.fill + ? isDarkMode + ? attribute.fill.dark + : attribute.fill.light + : STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length], } }) : [] @@ -417,24 +441,7 @@ export function ComposedChart({ minTickGap={3} key={xAxisKey} /> - - showTooltip && !showHighlightActions ? ( - - ) : null - } - /> + {chartStyle === 'bar' ? visibleAttributes.map((attribute) => ( a.attribute === attribute.name)?.stackId ?? '1'} - fill={attribute.color} + stackId="1" + fill={attribute.fill} + fillOpacity={1} stroke={attribute.color} radius={20} animationDuration={375} - fillOpacity={ - hoveredLabel && hoveredLabel !== attribute.name - ? 0.075 - : hoveredLabel === attribute.name - ? 0.3 - : 0.25 - } name={ attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name } dot={false} + activeDot={false} /> ))} {/* Max value, if available */} {maxAttribute && _showMaxValue && ( )} {referenceLines - .filter((line) => line.isReferenceLine) + .filter((line) => { + return line.isReferenceLine + }) .map((line) => ( @@ -508,6 +511,7 @@ export function ComposedChart({ /> ))} + {/* Selection highlight */} {showHighlightActions && ( )} + + showTooltip && !showHighlightActions ? ( + + ) : null + } + cursor={{ + stroke: isDarkMode ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)', + strokeWidth: 1, + }} + /> + { + const { formattedGraphicalItems } = props + if (!formattedGraphicalItems || focusDataIndex === null) return null + + return ( + + {formattedGraphicalItems.map((item, index: number) => { + const points = item.props?.points || item.item?.props?.points || item.points + const dataKey = item.props?.dataKey || item.item?.props?.dataKey + + if (!points || !points[focusDataIndex]) return null + + const point = points[focusDataIndex] + const attribute = visibleAttributes.find((a) => a.name === dataKey) + if (!attribute) return null + + return ( + + ) + })} + + ) + }} + /> { setHiddenAttributes((prev) => { if (options?.exclusive) { diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index dbfdc2ae9fcaa..746b61fef054b 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -43,6 +43,10 @@ export type MultiAttribute = { light: string dark: string } + fill?: { + light?: string + dark?: string + } statusCode?: string grantType?: string providerType?: string diff --git a/apps/studio/data/database-integrations/stripe/stripe-sync-install-mutation.ts b/apps/studio/data/database-integrations/stripe/stripe-sync-install-mutation.ts index a3157d3813844..de34616249161 100644 --- a/apps/studio/data/database-integrations/stripe/stripe-sync-install-mutation.ts +++ b/apps/studio/data/database-integrations/stripe/stripe-sync-install-mutation.ts @@ -63,7 +63,7 @@ export const useStripeSyncInstallMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables - track('integration_install_started', { + track('integration_install_submitted', { integrationName: 'stripe_sync_engine', }) diff --git a/apps/studio/data/database-integrations/stripe/stripe-sync-uninstall-mutation.ts b/apps/studio/data/database-integrations/stripe/stripe-sync-uninstall-mutation.ts index d9830e22482b0..2cd9e892bf026 100644 --- a/apps/studio/data/database-integrations/stripe/stripe-sync-uninstall-mutation.ts +++ b/apps/studio/data/database-integrations/stripe/stripe-sync-uninstall-mutation.ts @@ -60,7 +60,7 @@ export const useStripeSyncUninstallMutation = ({ async onSuccess(data, variables, context) { const { projectRef } = variables - track('integration_uninstall_started', { + track('integration_uninstall_submitted', { integrationName: 'stripe_sync_engine', }) diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index 82cc2a026e159..9bd34e8177f0b 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -52,6 +52,9 @@ function isThirdPartyError(frames: Sentry.StackFrame[] | undefined) { Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + }), // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, // [Ali] Filter out browser extensions and user scripts (FE-2094) diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx index eec16f0cf9c91..bdb1eed00d5b9 100644 --- a/apps/studio/pages/project/[ref]/merge.tsx +++ b/apps/studio/pages/project/[ref]/merge.tsx @@ -224,7 +224,7 @@ const MergePage: NextPageWithLayout = () => { // Track successful merge sendEvent({ - action: 'branch_merge_succeeded', + action: 'branch_merge_completed', properties: { branchType: currentBranch?.persistent ? 'persistent' : 'preview', }, diff --git a/apps/studio/sentry.edge.config.ts b/apps/studio/sentry.edge.config.ts index 1e69c24525d44..c5db7e27ad95c 100644 --- a/apps/studio/sentry.edge.config.ts +++ b/apps/studio/sentry.edge.config.ts @@ -7,6 +7,9 @@ import * as Sentry from '@sentry/nextjs' Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + }), // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, }) diff --git a/apps/studio/sentry.server.config.ts b/apps/studio/sentry.server.config.ts index fd089fb1ff008..dba6457bc5f2b 100644 --- a/apps/studio/sentry.server.config.ts +++ b/apps/studio/sentry.server.config.ts @@ -6,6 +6,9 @@ import * as Sentry from '@sentry/nextjs' Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + ...(process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT && { + environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, + }), // Setting this option to true will print useful information to the console while you're setting up Sentry. debug: false, ignoreErrors: [ diff --git a/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc b/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc index da32e70001377..aea3f09db38f6 100644 --- a/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc +++ b/apps/ui-library/registry/default/ai-editor-rules/writing-supabase-edge-functions.mdc @@ -11,7 +11,7 @@ You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **h 1. Try to use Web APIs and Deno’s core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws) 2. If you are reusing utility methods between Edge Functions, add them to `supabase/functions/_shared` and import using a relative path. Do NOT have cross dependencies between Edge Functions. -3. Do NOT use bare specifiers when importing dependecnies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`. +3. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either `npm:` or `jsr:`. For example, `@supabase/supabase-js` should be written as `npm:@supabase/supabase-js`. 4. For external imports, always define a version. For example, `npm:@express` should be written as `npm:express@4.18.2`. 5. For external dependencies, importing via `npm:` and `jsr:` is preferred. Minimize the use of imports from @`deno.land/x` , `esm.sh` and @`unpkg.com` . If you have a package from one of those CDNs, you can replace the CDN hostname with `npm:` specifier. 6. You can also use Node built-in APIs. You will need to import them using `node:` specifier. For example, to import Node process: `import process from "node:process". Use Node APIs when you find gaps in Deno APIs. diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 49627f12ee670..b0b87eb132961 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -3,8 +3,10 @@ * * Note that events are not emitted for users that have opted out of telemetry. * - * Original definitions located at: - * https://github.com/supabase/supabase/blob/master/packages/common/telemetry-constants.ts + * ## Naming conventions + * Event names and actions should use standardized past-tense verbs for data quality and consistency. + * Only use verbs already established in this file or in https://github.com/supabase/platform/blob/develop/shared/src/telemetry.ts + * Adding new verbs requires @growth-eng review to prevent data pollution. * * @module telemetry-frontend */ @@ -165,14 +167,14 @@ export interface CronJobUpdatedEvent { } /** - * Cron job deleted. + * Cron job removed. Previously: cron_job_deleted * * @group Events * @source studio * @page /dashboard/project/{ref}/integrations/cron/jobs */ -export interface CronJobDeletedEvent { - action: 'cron_job_deleted' +export interface CronJobRemovedEvent { + action: 'cron_job_removed' groups: TelemetryGroups } @@ -668,7 +670,7 @@ export interface AssistantEditInSqlEditorClickedEvent { * @source studio * @page /dashboard/project/{ref}/reports/{id} */ -export interface CustomReportAddSQLBlockClicked { +export interface CustomReportAddSQLBlockClickedEvent { action: 'custom_report_add_sql_block_clicked' groups: TelemetryGroups } @@ -1407,7 +1409,7 @@ export interface SupabaseUiCommandCopyButtonClickedEvent { * @source studio * @page /dashboard/org/{slug}/security */ -export interface OrganizationMfaEnforcementUpdated { +export interface OrganizationMfaEnforcementUpdatedEvent { action: 'organization_mfa_enforcement_updated' properties: { mfaEnforced: boolean @@ -1539,14 +1541,14 @@ export interface BranchMergeSubmittedEvent { } /** - * Triggered when a branch merge is successful. + * Triggered when a branch merge completes successfully. Previously: branch_merge_succeeded * * @group Events * @source studio * @page /dashboard/project/{ref}/merge */ -export interface BranchMergeSucceededEvent { - action: 'branch_merge_succeeded' +export interface BranchMergeCompletedEvent { + action: 'branch_merge_completed' properties: { /** * The type of branch being merged, e.g. preview, persistent @@ -2620,15 +2622,15 @@ export interface DashboardErrorCreatedEvent { } /** - * User successfully installed an integration via the integrations marketplace in the dashboard. - * Note: This excludes Wrappers and Postgres Extensions. + * User successfully completed installing an integration via the integrations marketplace in the dashboard. + * Note: This excludes Wrappers and Postgres Extensions. Previously: integration_installed * * @group Events * @source studio * @page /dashboard/project/{ref}/integrations/{integration_slug} */ -export interface IntegrationInstalledEvent { - action: 'integration_installed' +export interface IntegrationInstallCompletedEvent { + action: 'integration_install_completed' properties: { /** * The name of the integration installed @@ -2639,14 +2641,14 @@ export interface IntegrationInstalledEvent { } /** - * User started installing an integration via the integrations marketplace. + * User submitted an integration install via the integrations marketplace. Previously: integration_install_started * * @group Events * @source studio * @page /dashboard/project/{ref}/integrations/{integration_slug} */ -export interface IntegrationInstallStartedEvent { - action: 'integration_install_started' +export interface IntegrationInstallSubmittedEvent { + action: 'integration_install_submitted' properties: { /** * The name of the integration being installed @@ -2657,14 +2659,14 @@ export interface IntegrationInstallStartedEvent { } /** - * User started uninstalling an integration via the integrations marketplace. + * User submitted an integration uninstall via the integrations marketplace. Previously: integration_uninstall_started * * @group Events * @source studio * @page /dashboard/project/{ref}/integrations/{integration_slug} */ -export interface IntegrationUninstallStartedEvent { - action: 'integration_uninstall_started' +export interface IntegrationUninstallSubmittedEvent { + action: 'integration_uninstall_submitted' properties: { /** * The name of the integration being uninstalled @@ -2693,15 +2695,15 @@ export interface IntegrationInstallFailedEvent { } /** - * User uninstalled an integration via the integrations marketplace in the dashboard. - * Note: This excludes Wrappers and Postgres Extensions. + * User successfully completed uninstalling an integration via the integrations marketplace in the dashboard. + * Note: This excludes Wrappers and Postgres Extensions. Previously: integration_uninstalled * * @group Events * @source studio * @page /dashboard/project/{ref}/integrations/{integration_slug} */ -export interface IntegrationUninstalledEvent { - action: 'integration_uninstalled' +export interface IntegrationUninstallCompletedEvent { + action: 'integration_uninstall_completed' properties: { /** * The name of the integration installed @@ -2735,7 +2737,7 @@ export type TelemetryEvent = | ApiDocsCodeCopyButtonClickedEvent | CronJobCreatedEvent | CronJobUpdatedEvent - | CronJobDeletedEvent + | CronJobRemovedEvent | CronJobCreateClickedEvent | CronJobUpdateClickedEvent | CronJobDeleteClickedEvent @@ -2777,7 +2779,7 @@ export type TelemetryEvent = | HomepageDiscordButtonClickedEvent | HomepageCustomerStoryCardClickedEvent | HomepageProjectTemplateCardClickedEvent - | CustomReportAddSQLBlockClicked + | CustomReportAddSQLBlockClickedEvent | CustomReportAssistantSQLBlockAddedEvent | OpenSourceRepoCardClickedEvent | StartProjectButtonClickedEvent @@ -2814,7 +2816,7 @@ export type TelemetryEvent = | SupabaseUiCommandCopyButtonClickedEvent | SupportTicketSubmittedEvent | AiAssistantInSupportFormClickedEvent - | OrganizationMfaEnforcementUpdated + | OrganizationMfaEnforcementUpdatedEvent | ForeignDataWrapperCreatedEvent | StorageBucketCreatedEvent | BranchCreateButtonClickedEvent @@ -2822,7 +2824,7 @@ export type TelemetryEvent = | BranchCreateMergeRequestButtonClickedEvent | BranchCloseMergeRequestButtonClickedEvent | BranchMergeSubmittedEvent - | BranchMergeSucceededEvent + | BranchMergeCompletedEvent | BranchMergeFailedEvent | BranchUpdatedEvent | BranchReviewWithAssistantClickedEvent @@ -2869,9 +2871,9 @@ export type TelemetryEvent = | RequestUpgradeModalOpenedEvent | RequestUpgradeSubmittedEvent | DashboardErrorCreatedEvent - | IntegrationInstalledEvent - | IntegrationInstallStartedEvent - | IntegrationUninstallStartedEvent + | IntegrationInstallCompletedEvent + | IntegrationInstallSubmittedEvent + | IntegrationUninstallSubmittedEvent | IntegrationInstallFailedEvent - | IntegrationUninstalledEvent + | IntegrationUninstallCompletedEvent | RlsEventTriggerBannerCreateButtonClickedEvent diff --git a/packages/ui-patterns/src/Chart/charts/chart-line.tsx b/packages/ui-patterns/src/Chart/charts/chart-line.tsx index 2acf85578c495..b514fb97323ef 100644 --- a/packages/ui-patterns/src/Chart/charts/chart-line.tsx +++ b/packages/ui-patterns/src/Chart/charts/chart-line.tsx @@ -45,6 +45,7 @@ export type ChartHighlightAction = { export interface ChartLineProps { data: ChartLineTick[] dataKey: string + dataKeys?: string[] // Add this line config?: ChartConfig onLineClick?: (datum: ChartLineTick, tooltipData?: CategoricalChartState) => void DateTimeFormat?: string @@ -72,6 +73,7 @@ export interface ChartLineProps { export const ChartLine = ({ data, dataKey, + dataKeys, // Add this line config, onLineClick, DateTimeFormat = 'MMM D, YYYY, hh:mma', @@ -98,11 +100,14 @@ export const ChartLine = ({ return null } - const chartConfig: ChartConfig = config || { - [dataKey]: { - label: dataKey, - }, - } + const keysToRender = dataKeys || [dataKey] + + const chartConfig: ChartConfig = + config || + keysToRender.reduce((acc, key) => { + acc[key] = { label: key } + return acc + }, {} as ChartConfig) const showHighlightActions = showHighlightArea && @@ -209,14 +214,33 @@ export const ChartLine = ({ fillOpacity={0.2} /> )} - + {keysToRender.map((key, index) => { + const keyConfig = chartConfig[key] + const lineColor = + keyConfig?.color || + (keyConfig?.theme + ? isDarkMode + ? keyConfig.theme.dark + : keyConfig.theme.light + : color) + const baseOpacity = 0.2 + const opacityIncrement = 0.1 + const maxOpacity = 0.6 + const fillOpacity = Math.min(baseOpacity + index * opacityIncrement, maxOpacity) + + return ( + + ) + })} {data && data.length > 0 && ( diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx index 37b38e814500d..eb1b20d12a233 100644 --- a/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx +++ b/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx @@ -113,24 +113,26 @@ const CommandInput = forwardRef< }, [inputValue, imeComposing]) return ( - setImeComposing(true)} - onCompositionEnd={() => setImeComposing(false)} - className={cn( - 'flex h-11 w-full rounded-md bg-transparent px-2 py-7 outline-none', - 'focus:shadow-none focus:ring-transparent', - 'text-base text-foreground-light placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 border-0', - className - )} - {...props} - /> +
+ setImeComposing(true)} + onCompositionEnd={() => setImeComposing(false)} + className={cn( + 'flex h-11 w-full rounded-md bg-transparent px-2 py-7 outline-none', + 'focus:shadow-none focus:ring-transparent', + 'text-base text-foreground-light placeholder:text-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 border-0', + className + )} + {...props} + /> +
) })