diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 797164a05e753..fb766a922ffc7 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -50,7 +50,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { enabled: isExplorerEnabled, isNew: true, isPlatformOnly: true, - isDefaultOptIn: true, + isDefaultOptIn: false, getRoute: (ref?: string) => `/project/${ref}/explorer`, }, { diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx index fc042ed1de2dd..7e99d4b6323f5 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationTypeSelection.tsx @@ -21,7 +21,6 @@ import { } from '../useIsETLPrivateAlpha' import { DestinationType } from './DestinationPanel.types' import { ReadReplicasMovedCallout } from './ReadReplicasMovedCallout' -import { InlineLink } from '@/components/ui/InlineLink' interface DestinationTypeOption { value: DestinationType @@ -116,24 +115,13 @@ export const DestinationTypeSelection = () => { const selectedOption = options.find((option) => option.value === destinationType) - const stageDescription = - selectedOption?.stage === 'Public Alpha' ? ( - <> - In public alpha and may change.{' '} - - Leave feedback - - - ) : selectedOption?.stage === 'Early Access' ? ( - <> - In early access and may change.{' '} - - Leave feedback - - - ) : selectedOption?.stage === 'Deprecated' ? ( - 'This destination type is deprecated.' - ) : null + const STAGE_DESCRIPTIONS: Record, string> = { + 'Public Alpha': 'In public alpha and may change.', + 'Early Access': 'In early access and may change.', + Deprecated: 'This destination type is deprecated.', + } + + const stageDescription = selectedOption?.stage ? STAGE_DESCRIPTIONS[selectedOption.stage] : null const typeDescription = !editMode || stageDescription ? ( diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index 7e95410cedee6..501550521259a 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' -import { MoreVertical, Plus, Search, Workflow, X } from 'lucide-react' +import { MessageSquare, MoreVertical, Plus, Search, Workflow, X } from 'lucide-react' import Link from 'next/link' import { parseAsStringEnum, useQueryState } from 'nuqs' import { useEffect, useMemo, useRef, useState } from 'react' @@ -29,6 +29,7 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisablePipelinesDialog } from './DisablePipelinesDialog' import { EnablePipelinesModal } from './EnablePipelinesCallout' +import { PIPELINES_FEEDBACK_URL } from './Replication.constants' import { useIsETLBigQueryPrivateAlpha, useIsETLClickHousePrivateAlpha, @@ -243,6 +244,11 @@ export const Destinations = () => { + { // Lifecycle values may arrive with or without the proto enum prefix. const lifecycle = (pooler.lifecycleStatus?.status ?? '').replace(/^LIFECYCLE_/, '') + // The proto's two terminal states: QUARANTINED (gave up recovering, kept alive + // for forensics) and SHUTDOWN (durably down). There is no separate FAILED state. if (lifecycle === 'QUARANTINED' || lifecycle === 'SHUTDOWN') return 'unhealthy' if (lifecycle === 'STARTING') return 'coming_up' if (lifecycle === 'STOPPING') return 'going_down' @@ -66,7 +68,12 @@ export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => { return 'healthy' } -// Matches the status vocabulary of the read replica surfaces (getStatusLabel). +// Labels are drawn from the read replica status vocabulary (`getStatusLabel` in +// ReadReplicas.utils.ts) so both surfaces read the same way, but they are only a +// subset of it. The read replica labels also cover 'Failed', 'Restarting', +// 'Resizing' and 'Restoring', which have no one-to-one lifecycle equivalents; +// the closest to 'Failed' is QUARANTINED, surfaced as 'Unhealthy' alongside +// SHUTDOWN. export const HA_POOLER_STATUS_LABELS: Record = { healthy: 'Healthy', coming_up: 'Coming up', diff --git a/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx b/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx index 5c90e6a8f282d..66e91a9e00b14 100644 --- a/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx +++ b/apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx @@ -1,67 +1,238 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useForm, useWatch } from 'react-hook-form' import { + Button, Dialog, DialogContent, + DialogFooter, DialogHeader, DialogSection, DialogSectionSeparator, DialogTitle, + Form, + FormControl, + FormField, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import * as z from 'zod' -import { EXAMPLE_WORKER } from './workerSnippets' +import { RuntimeBadge } from './RuntimeBadge' +import { WORKER_DEPLOYABLE_RUNTIMES, WORKER_SIZES, WORKERS_REGION } from './Workers.constants' +import type { WorkerAccess } from './Workers.types' +import { formatSize, generateWorkerName } from './Workers.utils' import { WorkerSnippetTabs } from './WorkerSnippetTabs' +const FORM_ID = 'deploy-worker-form' + +const DeployWorkerFormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .regex(/^[a-z0-9-]+$/, 'Lowercase letters, numbers, and hyphens only'), + runtime: z.enum(WORKER_DEPLOYABLE_RUNTIMES), + size: z.enum(WORKER_SIZES), + access: z.enum(['private', 'public']), + instances: z + .union([z.literal(''), z.coerce.number().int().gte(1).lte(10)]) + .refine((value) => value !== '', 'Instances is required'), +}) + +type DeployWorkerFormValues = z.infer + +const DEFAULT_VALUES: DeployWorkerFormValues = { + name: '', + runtime: 'deno', + size: WORKER_SIZES[0], + access: 'private', + instances: 1, +} + +const ACCESS_OPTIONS: { value: WorkerAccess; label: string }[] = [ + { value: 'private', label: 'Private' }, + { value: 'public', label: 'Public' }, +] + interface DeployWorkerDialogProps { open: boolean onOpenChange: (open: boolean) => void } -const STEPS = [ - { - title: 'Scaffold the worker', - description: 'Creates supabase/workers// with an entrypoint for the runtime you pick.', - }, - { - title: 'Configure it', - description: 'Runtime, size, access, and instance count live in supabase/config.toml.', - }, - { - title: 'Push it', - description: 'Builds the image and schedules it on a microVM in US West.', - }, -] +export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => { + const form = useForm({ + mode: 'onBlur', + resolver: zodResolver(DeployWorkerFormSchema), + defaultValues: { ...DEFAULT_VALUES, name: generateWorkerName() }, + }) + + const [name, runtime, size, access, instances] = useWatch({ + control: form.control, + name: ['name', 'runtime', 'size', 'access', 'instances'], + }) + + return ( + + + + Deploy a worker + + + -export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => ( - - - - Deploy a worker - - - -

- Workers are deployed with the Supabase CLI. This dashboard is read-only during the private - alpha. -

-
    - {STEPS.map((step, index) => ( -
  1. - - {index + 1} - -
    -

    {step.title}

    -

    {step.description}

    -
    -
  2. - ))} -
-
- - - - - - -
-
-) + + + + + +
+ + ( + + + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + field.onChange( + Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber + ) + } + /> + + + )} + /> + + +
+ + + + + + + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx b/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx index 151050e8fd419..08f181338de2a 100644 --- a/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerSnippetTabs.tsx @@ -1,4 +1,5 @@ import { useParams } from 'common' +import { FileCode, Sparkles, Terminal, type LucideIcon } from 'lucide-react' import { useState } from 'react' import { cn } from 'ui' @@ -6,27 +7,31 @@ import { buildWorkerSnippets, type WorkerSnippetInput } from './workerSnippets' import CopyButton from '@/components/ui/CopyButton' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' -export type WorkerSnippetTab = 'config' | 'cli' | 'curl' | 'js' | 'python' +export type WorkerSnippetTab = 'ai' | 'config' | 'cli' | 'js' | 'python' const TAB_LABEL: Record = { + ai: 'AI Prompt', config: 'config.toml', cli: 'CLI', - curl: 'cURL', js: 'JavaScript', python: 'Python', } +const TAB_ICON: Record = { + ai: Sparkles, + config: FileCode, + cli: Terminal, + js: FileCode, + python: FileCode, +} + interface WorkerSnippetTabsProps { input: Omit tabs?: [WorkerSnippetTab, ...WorkerSnippetTab[]] className?: string } -export const WorkerSnippetTabs = ({ - input, - tabs = ['cli', 'curl'], - className, -}: WorkerSnippetTabsProps) => { +export const WorkerSnippetTabs = ({ input, tabs = ['cli'], className }: WorkerSnippetTabsProps) => { const { ref } = useParams() const [active, setActive] = useState(tabs[0]) @@ -37,9 +42,9 @@ export const WorkerSnippetTabs = ({ protocol: settings?.app_config?.protocol, }) const snippetByTab: Record = { + ai: snippets.aiPrompt, config: snippets.configToml, cli: snippets.cli, - curl: snippets.curl, js: snippets.javascript, python: snippets.python, } @@ -49,22 +54,26 @@ export const WorkerSnippetTabs = ({ return (
- {tabs.map((tab) => ( - - ))} + {tabs.map((tab) => { + const Icon = TAB_ICON[tab] + return ( + + ) + })}
@@ -75,9 +84,15 @@ export const WorkerSnippetTabs = ({ aria-label="Copy snippet" className="absolute right-2 top-2 text-foreground-lighter hover:text-foreground" /> -
-          {value}
-        
+ {activeTab === 'ai' ? ( +

+ {value} +

+ ) : ( +
+            {value}
+          
+ )}
) diff --git a/apps/studio/components/interfaces/Workers/Workers.constants.ts b/apps/studio/components/interfaces/Workers/Workers.constants.ts index 7c3b911d0f15d..f61b6e3e5a390 100644 --- a/apps/studio/components/interfaces/Workers/Workers.constants.ts +++ b/apps/studio/components/interfaces/Workers/Workers.constants.ts @@ -5,7 +5,62 @@ export const WORKERS_REGION = 'us-west-2' export const WORKERS_REGION_LABEL = 'US West (Oregon)' export const WORKERS_REGION_SHORT = 'US West' -// Workers answer on the project's own domain, alongside /functions/v1. +// Sizes are fixed at deploy time — matches the `size` values the API accepts. +export const WORKER_SIZES = ['2gb-1vcpu', '4gb-2vcpu'] as const + +export const WORKER_NAME_WORDS = [ + 'swift', + 'nimble', + 'brisk', + 'bold', + 'sleek', + 'vivid', + 'lucid', + 'crisp', + 'keen', + 'agile', + 'fierce', + 'bright', + 'orchid', + 'lotus', + 'iris', + 'dahlia', + 'tulip', + 'jasmine', + 'magnolia', + 'azalea', + 'poppy', + 'marigold', + 'hibiscus', + 'camellia', + 'synergy', + 'bloom', + 'scalable', + 'pivot', + 'paradigm', + 'holistic', + 'leverage', + 'stream', + 'viral', + 'turnkey', + 'quantum', + 'photon', + 'nebula', + 'comet', + 'orbit', + 'cosmos', + 'meteor', + 'pulsar', + 'quasar', + 'vector', + 'cipher', + 'nova', + 'zenith', + 'warp', + 'rocket', + 'stellar', +] as const + export const workerUrl = ({ endpoint, protocol = 'https', @@ -59,6 +114,8 @@ export const RUNTIMES: Record = { }, } +export const WORKER_DEPLOYABLE_RUNTIMES = ['node', 'deno', 'dockerfile'] as const + interface WorkerStateMeta { label: string dotClassName: string diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts index 275f0763f5c04..da791ba034949 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from 'vitest' -import { getWorkerStateMeta, workerUrl } from './Workers.constants' +import { getWorkerStateMeta, WORKER_NAME_WORDS, workerUrl } from './Workers.constants' import type { Worker } from './Workers.types' import { filterWorkers, formatResources, formatRuntime, formatSize, + generateWorkerName, getPage, isWorkersForbidden, isWorkersUnavailable, @@ -163,6 +164,19 @@ describe('workerUrl', () => { }) }) +describe('generateWorkerName', () => { + it('produces a name that already passes the CLI naming rules', () => { + const name = generateWorkerName() + expect(name).toMatch(/^worker-[a-z]+-\d{6}$/) + expect(WORKER_NAME_WORDS).toContain(name.split('-')[1]) + }) + + it('varies across calls', () => { + const names = new Set(Array.from({ length: 20 }, () => generateWorkerName())) + expect(names.size).toBeGreaterThan(1) + }) +}) + describe('getWorkerStateMeta', () => { it('labels every build state', () => { expect(getWorkerStateMeta(worker({ name: 'a', buildState: 'building' })).label).toBe('Building') diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.ts b/apps/studio/components/interfaces/Workers/Workers.utils.ts index d0f32d15edf26..ddbcae1ee2094 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.ts @@ -1,4 +1,4 @@ -import { RUNTIMES, type RuntimeMeta } from './Workers.constants' +import { RUNTIMES, WORKER_NAME_WORDS, type RuntimeMeta } from './Workers.constants' import type { Worker, WorkerAccess, WorkerBuildState } from './Workers.types' import { ResponseError } from '@/types' @@ -54,6 +54,13 @@ export const formatSize = (size: string): string => { export const formatResources = (worker: Worker): string => `${formatSize(worker.size)} · ${worker.declaredInstances} inst` +// Suggests a friendly, already-valid starting name so the deploy dialog isn't blank. +export const generateWorkerName = (): string => { + const word = WORKER_NAME_WORDS[Math.floor(Math.random() * WORKER_NAME_WORDS.length)] + const number = Math.floor(Math.random() * 900000) + 100000 + return `worker-${word}-${number}` +} + // A project outside the alpha allow-list gets a 404, not a 403. export const isWorkersUnavailable = (error: Error | null): boolean => error instanceof ResponseError && error.code === 404 diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts index 283d93e8bd707..7070d5f014db6 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.test.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.test.ts @@ -10,25 +10,21 @@ const input = (overrides: Partial[0]> = { }) describe('buildWorkerSnippets', () => { - it('points every call snippet at the worker URL', () => { - const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' })) + it('points the invoke examples at the worker URL', () => { + const { javascript, python } = buildWorkerSnippets(input({ name: 'embed' })) const url = 'https://abcdefgh.supabase.co/workers/v1/embed' - expect(curl).toContain(`'${url}'`) expect(javascript).toContain(`'${url}'`) expect(python).toContain(`"${url}"`) }) - it('leaves a placeholder URL until the project settings resolve', () => { - const { curl } = buildWorkerSnippets(input({ endpoint: undefined })) - expect(curl).toContain('[YOUR WORKER URL]') + it('leaves a placeholder invoke URL until the project settings resolve', () => { + const { javascript } = buildWorkerSnippets(input({ endpoint: undefined })) + expect(javascript).toContain('[YOUR WORKER URL]') }) - it('asks for the anon key on a public worker and the service role key on a private one', () => { - expect(buildWorkerSnippets(input({ access: 'public' })).curl).toContain('[YOUR ANON KEY]') - expect(buildWorkerSnippets(input({ access: 'private' })).curl).toContain( - '[YOUR SERVICE ROLE KEY]' - ) + it('asks for the anon key to invoke a public worker and the service role key for a private one', () => { + expect(buildWorkerSnippets(input({ access: 'public' })).javascript).toContain('[YOUR ANON KEY]') expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain( '[YOUR SERVICE ROLE KEY]' ) diff --git a/apps/studio/components/interfaces/Workers/workerSnippets.ts b/apps/studio/components/interfaces/Workers/workerSnippets.ts index cbcc6e2beb7b9..02499a1fcb0c3 100644 --- a/apps/studio/components/interfaces/Workers/workerSnippets.ts +++ b/apps/studio/components/interfaces/Workers/workerSnippets.ts @@ -1,4 +1,4 @@ -import { WORKERS_REGION, workerUrl } from './Workers.constants' +import { RUNTIMES, WORKERS_REGION, workerUrl } from './Workers.constants' import type { WorkerAccess } from './Workers.types' import { formatSize } from './Workers.utils' import { CLI_NAME } from '@/lib/constants/workers' @@ -14,9 +14,9 @@ export interface WorkerSnippetInput { } export interface WorkerSnippets { + aiPrompt: string configToml: string cli: string - curl: string javascript: string python: string } @@ -40,12 +40,13 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { const cli = [ `supabase ${CLI_NAME} new ${name} --runtime ${runtime}`, - `supabase ${CLI_NAME} push ${name}`, + // size comes from config.toml — push has no flag for it. Same for access: the CLI + // doesn't have a route to a private worker yet, so this always deploys as public. + `supabase ${CLI_NAME} push ${name} --instances ${input.instances}`, + ...(input.access === 'private' ? [`# note: the CLI can only deploy public workers today`] : []), ].join('\n') - const configToml = [ - `# supabase/config.toml`, - ``, + const configBlock = [ `[${CLI_NAME}.${name}]`, `runtime = "${runtime}"`, `size = "${input.size}" # ${formatSize(input.size)}`, @@ -54,16 +55,26 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { `# region is locked to ${WORKERS_REGION} at alpha`, ].join('\n') - const authHeader = - input.access === 'public' - ? ` -H 'Authorization: Bearer [YOUR ANON KEY]' \\` - : ` -H 'Authorization: Bearer [YOUR SERVICE ROLE KEY]' \\` + const configToml = [`# supabase/config.toml`, ``, configBlock].join('\n') + + const runtimeMeta = RUNTIMES[runtime] ?? RUNTIMES.node + // The entrypoint metadata is " " — the filename is the last token. + const entrypointFile = runtimeMeta.entrypoint.split(' ').pop() - const curl = [ - `curl -L -X POST '${url}' \\`, - authHeader, - ` -H 'Content-Type: application/json' \\`, - ` --data '{"name":"world"}'`, + const aiPrompt = [ + `Scaffold and deploy a Supabase Workers worker named "${name}" using the ${runtimeMeta.label} runtime:`, + ``, + `1. Create a supabase/${CLI_NAME}/${name}/ directory with a ${runtimeMeta.label} entrypoint (${entrypointFile}) that responds with "Hello, world!".`, + ``, + `2. Add this block to supabase/config.toml:`, + '```toml', + configBlock, + '```', + ``, + `3. Run \`supabase ${CLI_NAME} push ${name}\` to deploy it.`, + ...(input.access === 'private' + ? [``, `Note: the CLI can only deploy public workers today.`] + : []), ].join('\n') const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]' @@ -91,7 +102,7 @@ export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets { `print(res.json())`, ].join('\n') - return { configToml, cli, curl, javascript, python } + return { aiPrompt, configToml, cli, javascript, python } } export interface WorkerCliCommand { diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx index acdd6ac0b9862..8283641f5df94 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityLayout.tsx @@ -56,7 +56,6 @@ const ObservabilityLayoutContent = ({ }) }, [ addBanner, - dismissBanner, isInitialized, isDatabaseConnectionsBannerDismissed, previouslyToggled, diff --git a/apps/studio/components/layouts/ProjectLayout/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/index.test.tsx index 6c7b2601677ec..13090a00efd85 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.test.tsx @@ -81,6 +81,7 @@ vi.mock('common', () => ({ `project-integration-banner-dismissed-${ref}-${integrationSource}`, }, isFeatureEnabled: () => false, + useFlag: () => false, })) vi.mock('framer-motion', () => ({ diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 3e63092a140bc..d03fe84dd624f 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, mergeRefs, useFlag, useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' import { XIcon } from 'lucide-react' import Head from 'next/head' @@ -42,6 +42,7 @@ import { UnhealthyState } from './UnhealthyState' import { UpgradingState } from './UpgradingState' import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal' import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { BannerExplorer } from '@/components/ui/BannerStack/Banners/BannerExplorer' import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -152,6 +153,9 @@ export const ProjectLayout = forwardRef { + if (!isExplorerEnabled || !isLocalStorageReady || isExplorerBannerDismissed) return + + addBanner({ + id: 'explorer-banner', + priority: 2, + isDismissed: false, + content: , + }) + }, [addBanner, isExplorerEnabled, isExplorerBannerDismissed, isLocalStorageReady]) + useLayoutEffect(() => { const unregister = registerOpenMenu(() => { setMobileSheetContent( diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx index 4c95ece258544..90413237e613a 100644 --- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx +++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx @@ -9,6 +9,7 @@ export const BANNER_ID = { TOS_UPDATE: 'tos-update-banner', LOGS_ALL_DEPRECATION: 'logs-all-deprecation-banner', SELECT_26: 'select-2026-banner', + EXPLORER: 'explorer-banner', } as const export type BannerId = (typeof BANNER_ID)[keyof typeof BANNER_ID] diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx new file mode 100644 index 0000000000000..78120cc7b397b --- /dev/null +++ b/apps/studio/components/ui/BannerStack/Banners/BannerExplorer.tsx @@ -0,0 +1,84 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { AnimatePresence, motion } from 'framer-motion' +import { Badge, Button } from 'ui' + +import { BannerCard } from '../BannerCard' +import { useBannerStack } from '../BannerStackProvider' +import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' +import { useTrack } from '@/lib/telemetry/track' + +export const BannerExplorer = () => { + const track = useTrack() + const { dismissBanner } = useBannerStack() + const { selectFeaturePreview } = useFeaturePreviewModal() + + const [, setIsDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.EXPLORER_BANNER_DISMISSED, + false + ) + + return ( + { + setIsDismissed(true) + dismissBanner('explorer-banner') + track('explorer_banner_dismiss_button_clicked') + }} + > +
+
+ + Preview + + +
+ +

User growth

+

+ Track how your user base is trending. +

+
+ +
+

+ select + date_trunc('week', last_sign_in_at) as week, + count(distinct id) as active_users +

+ +
+ +
+
+

Explorer & Notebooks

+

+ New unified workspace for querying data and chatting with Assistant. +

+
+
+ +
+
+ + ) +} diff --git a/apps/studio/data/ha-admin/ha-cluster-cells-query.ts b/apps/studio/data/ha-admin/ha-cluster-cells-query.ts deleted file mode 100644 index d5571f502c348..0000000000000 --- a/apps/studio/data/ha-admin/ha-cluster-cells-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { z } from 'zod' - -import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin' -import { haAdminKeys } from './keys' -import { IS_PLATFORM } from '@/lib/constants' -import type { ResponseError } from '@/types' - -export type HaClusterCellsVariables = { projectRef?: string } -export type HaClusterCellsError = ResponseError - -// Every field is optional because proto3 JSON omits zero values. -const haClusterCellsResponseSchema = z.object({ names: z.array(z.string()).optional() }) - -async function getHaClusterCells({ projectRef }: HaClusterCellsVariables, signal?: AbortSignal) { - const data = await getHaAdmin(projectRef, 'cells', signal) - return parseHaAdminResponse(haClusterCellsResponseSchema, data) -} - -export type HaClusterCellsData = Awaited> - -export const haClusterCellsQueryOptions = ({ projectRef }: HaClusterCellsVariables) => - queryOptions({ - queryKey: haAdminKeys.cells(projectRef), - queryFn: ({ signal }) => getHaClusterCells({ projectRef }, signal), - enabled: IS_PLATFORM && typeof projectRef !== 'undefined', - }) diff --git a/apps/studio/data/ha-admin/ha-cluster-databases-query.ts b/apps/studio/data/ha-admin/ha-cluster-databases-query.ts deleted file mode 100644 index 15cfd3adfb13b..0000000000000 --- a/apps/studio/data/ha-admin/ha-cluster-databases-query.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { queryOptions } from '@tanstack/react-query' -import { z } from 'zod' - -import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin' -import { haAdminKeys } from './keys' -import { IS_PLATFORM } from '@/lib/constants' -import type { ResponseError } from '@/types' - -export type HaClusterDatabasesVariables = { projectRef?: string } -export type HaClusterDatabasesError = ResponseError - -// Every field is optional because proto3 JSON omits zero values. -const haClusterDatabasesResponseSchema = z.object({ names: z.array(z.string()).optional() }) - -async function getHaClusterDatabases( - { projectRef }: HaClusterDatabasesVariables, - signal?: AbortSignal -) { - const data = await getHaAdmin(projectRef, 'databases', signal) - return parseHaAdminResponse(haClusterDatabasesResponseSchema, data) -} - -export type HaClusterDatabasesData = Awaited> - -export const haClusterDatabasesQueryOptions = ({ projectRef }: HaClusterDatabasesVariables) => - queryOptions({ - queryKey: haAdminKeys.databases(projectRef), - queryFn: ({ signal }) => getHaClusterDatabases({ projectRef }, signal), - enabled: IS_PLATFORM && typeof projectRef !== 'undefined', - }) diff --git a/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts b/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts index b88b12cbc1431..bc6a23c9d8200 100644 --- a/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts +++ b/apps/studio/data/ha-admin/ha-cluster-poolers-query.ts @@ -28,6 +28,15 @@ const multipoolerSchema = z.object({ // 'SERVING' | 'DISABLED' | 'DRAINING' servingStatus: z.string().optional(), hostname: z.string().optional(), + // `PoolerLifecycleStatus` (multigres proto/clustermetadata.proto): + // 'STARTING' | 'ACTIVE' | 'STOPPING' | 'SHUTDOWN' | 'QUARANTINED', optionally + // prefixed with 'LIFECYCLE_'. 'LIFECYCLE_UNKNOWN' is the zero value, so it is + // omitted from JSON. There is no dedicated failure member — QUARANTINED is the + // terminal failure state (the pooler gave up recovering, e.g. a failed + // pg_rewind or backup restore, and is kept alive for forensics) and SHUTDOWN is + // "durably down". `getPoolerStatus` maps every member. An unrecognized value + // (a future enum member) falls through to the `servingStatus` check, and since + // SERVING is that enum's zero value it usually renders as Healthy until mapped. lifecycleStatus: z.object({ status: z.string().optional() }).optional(), routingState: z .object({ diff --git a/apps/studio/data/ha-admin/keys.ts b/apps/studio/data/ha-admin/keys.ts index 3193018650a30..d07096e90a580 100644 --- a/apps/studio/data/ha-admin/keys.ts +++ b/apps/studio/data/ha-admin/keys.ts @@ -1,7 +1,4 @@ export const haAdminKeys = { - cells: (projectRef: string | undefined) => ['projects', projectRef, 'ha-admin', 'cells'] as const, - databases: (projectRef: string | undefined) => - ['projects', projectRef, 'ha-admin', 'databases'] as const, poolers: (projectRef: string | undefined) => ['projects', projectRef, 'ha-admin', 'poolers'] as const, gateways: (projectRef: string | undefined) => diff --git a/apps/studio/pages/project/[ref]/workers/index.tsx b/apps/studio/pages/project/[ref]/workers/index.tsx index af939bb920928..73debf88890ce 100644 --- a/apps/studio/pages/project/[ref]/workers/index.tsx +++ b/apps/studio/pages/project/[ref]/workers/index.tsx @@ -70,7 +70,7 @@ const WorkersPage: NextPageWithLayout = () => { )} {isMissingPermission && } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index cf3e8259db9c1..374cdc1d794c9 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -116,6 +116,7 @@ export const LOCAL_STORAGE_KEYS = { `organization-marketplace-banner-dismissed-${orgSlug}-${managedBy}`, PROJECT_INTEGRATION_BANNER_DISMISSED: (ref: string, integrationSource: string) => `project-integration-banner-dismissed-${ref}-${integrationSource}`, + EXPLORER_BANNER_DISMISSED: `explorer-banner-dismissed`, TABLE_EDITOR_QUEUE_OPERATIONS_BANNER_DISMISSED: (ref: string) => `table-editor-queue-operations-banner-dismissed-${ref}`, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 335d13f4df51c..a25642186f1fe 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1530,6 +1530,28 @@ export interface DatabaseConnectionsBannerCtaButtonClickedEvent { groups: TelemetryGroups } +/** + * User clicked the dismiss button on the Explorer feature preview banner in studio project pages. + * + * @group Events + * @source studio + */ +export interface ExplorerBannerDismissButtonClickedEvent { + action: 'explorer_banner_dismiss_button_clicked' + groups: TelemetryGroups +} + +/** + * User clicked the CTA button on the Explorer feature preview banner in studio project pages. + * + * @group Events + * @source studio + */ +export interface ExplorerBannerCtaButtonClickedEvent { + action: 'explorer_banner_cta_button_clicked' + groups: TelemetryGroups +} + /** * User clicked a metric card PID in the Overview panel of the Database Connections observability page, selecting it in the activity table below. * @@ -3810,6 +3832,8 @@ export type TelemetryEvent = | DatabaseConnectionsBlockerViewClickedEvent | DatabaseConnectionsBannerDismissButtonClickedEvent | DatabaseConnectionsBannerCtaButtonClickedEvent + | ExplorerBannerDismissButtonClickedEvent + | ExplorerBannerCtaButtonClickedEvent | SessionTerminateButtonClickedEvent | SessionTerminateSubmittedEvent | QueryCancelButtonClickedEvent