Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
enabled: isExplorerEnabled,
isNew: true,
isPlatformOnly: true,
isDefaultOptIn: true,
isDefaultOptIn: false,
getRoute: (ref?: string) => `/project/${ref}/explorer`,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
Leave feedback
</InlineLink>
</>
) : selectedOption?.stage === 'Early Access' ? (
<>
In early access and may change.{' '}
<InlineLink href="https://github.com/orgs/supabase/discussions/39416">
Leave feedback
</InlineLink>
</>
) : selectedOption?.stage === 'Deprecated' ? (
'This destination type is deprecated.'
) : null
const STAGE_DESCRIPTIONS: Record<NonNullable<DestinationTypeOption['stage']>, 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 ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -243,6 +244,11 @@ export const Destinations = () => {
</DropdownMenuContent>
</DropdownMenu>

<Button asChild variant="default" icon={<MessageSquare />}>
<a href={PIPELINES_FEEDBACK_URL} target="_blank" rel="noreferrer noopener">
Leave feedback
</a>
</Button>
<DocsButton href={`${DOCS_URL}/guides/database/replication`} />

<Shortcut
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ export enum PipelineStatusName {
STOPPING = 'stopping',
UNKNOWN = 'unknown',
}

export const PIPELINES_FEEDBACK_URL = 'https://github.com/orgs/supabase/discussions/39416'
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => {
// 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'
Expand All @@ -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<HaPoolerStatus, string> = {
healthy: 'Healthy',
coming_up: 'Coming up',
Expand Down
271 changes: 221 additions & 50 deletions apps/studio/components/interfaces/Workers/DeployWorkerDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DeployWorkerFormSchema>

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/<name>/ 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<DeployWorkerFormValues>({
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Deploy a worker</DialogTitle>
</DialogHeader>

<DialogSectionSeparator />

export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent size="large">
<DialogHeader>
<DialogTitle>Deploy a worker</DialogTitle>
</DialogHeader>

<DialogSection className="space-y-4">
<p className="text-sm text-foreground-light">
Workers are deployed with the Supabase CLI. This dashboard is read-only during the private
alpha.
</p>
<ol className="space-y-4">
{STEPS.map((step, index) => (
<li key={step.title} className="flex gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-strong text-xs text-foreground-light">
{index + 1}
</span>
<div className="space-y-0.5">
<p className="text-sm text-foreground">{step.title}</p>
<p className="text-sm text-foreground-lighter">{step.description}</p>
</div>
</li>
))}
</ol>
</DialogSection>

<DialogSectionSeparator />

<DialogSection>
<WorkerSnippetTabs input={EXAMPLE_WORKER} tabs={['cli', 'config']} />
</DialogSection>
</DialogContent>
</Dialog>
)
<Admonition
type="note"
title="This dashboard is read-only during the Private Alpha"
description={`Configure a worker below, then deploy it locally. Workers only deploy to ${WORKERS_REGION} during alpha.`}
className="border-x-0 border-y-0 rounded-none"
/>

<DialogSectionSeparator />

<DialogSection>
<Form {...form}>
<form id={FORM_ID} className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout name="name" label="Name">
<FormControl>
<Input {...field} placeholder="my-worker" />
</FormControl>
</FormItemLayout>
)}
/>

<FormField
control={form.control}
name="runtime"
render={({ field }) => (
<FormItemLayout name="runtime" label="Runtime">
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{WORKER_DEPLOYABLE_RUNTIMES.map((value) => (
<SelectItem key={value} value={value}>
<RuntimeBadge runtime={value} />
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>

<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItemLayout
name="size"
label="Size"
description="Fixed at deploy time and cannot be changed later"
>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{WORKER_SIZES.map((value) => (
<SelectItem key={value} value={value}>
{formatSize(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>

<FormField
control={form.control}
name="access"
render={({ field }) => (
<FormItemLayout
name="access"
label="Access"
description="Public workers accept requests with a publishable key"
>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{ACCESS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItemLayout>
)}
/>

<FormField
control={form.control}
name="instances"
render={({ field }) => (
<FormItemLayout name="instances" label="Instances" description="1 to 10">
<FormControl>
<Input
{...field}
type="number"
min={1}
max={10}
onChange={(e) =>
field.onChange(
Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber
)
}
/>
</FormControl>
</FormItemLayout>
)}
/>
</form>
</Form>
</DialogSection>

<DialogSectionSeparator />

<DialogSection>
<WorkerSnippetTabs
input={{
name: name ?? '',
runtime: runtime ?? DEFAULT_VALUES.runtime,
size: size ?? DEFAULT_VALUES.size,
access: access ?? DEFAULT_VALUES.access,
instances: typeof instances === 'number' ? instances : DEFAULT_VALUES.instances,
}}
tabs={['ai', 'cli', 'config']}
/>
</DialogSection>

<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
Loading
Loading