mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 19:35:06 +03:00
Merge remote-tracking branch 'origin/master' into dnywh/fix/unsaved-changes-blocker
This commit is contained in:
commit
fe6be67c9a
68 files changed
+1930
-422
No files matched your search
@@ -15,7 +15,8 @@ Supabase branches create separate environments that spin off from your main proj
|
||||
- **Preview Branches**: Preview branches are ephemeral and best suited for focused testing. They are automatically deleted when a PR is merged or closed.
|
||||
- **Persistent Branches**: Persistent branches are long-lived and recommended for environments like staging, QA, or development. They aren't automatically paused or deleted due to inactivity or when a PR is merged or closed.
|
||||
- **Managing Branches**: You can create, review, and merge branches either automatically via our [GitHub integration](/docs/guides/deployment/branching/github-integration) or directly [through the dashboard](/docs/guides/deployment/branching/dashboard) (currently in beta). All branches show up in the branches page in the dashboard, regardless of how they were created.
|
||||
- **Data-less**: New branches do not start with any data from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration.
|
||||
- **Cloned from your main project**: Each new branch is created as a clone of your main project. It starts with that project's Edge Functions deployed and the configuration set.
|
||||
- **Data-less by default**: By default, new branches do not start with any data or storage objects from your main project. This is meant to better protect your sensitive production data. To start your branches with data, you can use a [seed file](/docs/guides/deployment/branching/github-integration#seeding) if using the GitHub integration, or the [Include data](/docs/guides/deployment/branching/dashboard#include-production-data) option if you create the branch from the dashboard.
|
||||
|
||||
## Deploying to production
|
||||
|
||||
|
||||
@@ -41,6 +41,24 @@ Once you've enabled the feature, you can create a new branch:
|
||||
1. Click the arrows next to the branch name in the top menu bar. (The top menu bar has the format `YOUR_ORGANIZATION / YOUR_PROJECT / CURRENT_BRANCH_NAME`.)
|
||||
2. Click `Create branch`.
|
||||
|
||||
The new branch is a clone of your base project. It starts with the project's schema, Edge Functions, and the configuration set.
|
||||
|
||||
### Include production data
|
||||
|
||||
By default, a branch starts without any of your production data or storage objects. If your project has the Point-in-Time Recovery add-on, you can turn on **Include data** when you create the branch to copy your production data into it.
|
||||
|
||||
<Admonition type="caution">
|
||||
|
||||
A branch created with **Include data** holds a copy of your production data, treat it with the same care as production.
|
||||
|
||||
</Admonition>
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
A branch uses a larger disk and matches the compute size of your project, which increases its cost.
|
||||
|
||||
</Admonition>
|
||||
|
||||
## Making changes to a branch
|
||||
|
||||
Use the branch selector in the top bar to change to your branch. Any changes you make (including SQL run in the SQL editor, table editor changes, and configuration changes) are now made against the currently selected branch.
|
||||
|
||||
@@ -83,19 +83,25 @@ Enable the **Automatic branching** option in your GitHub Integration configurati
|
||||
|
||||
When a new branch is created in GitHub, a corresponding branch is created in Supabase. (You can enable the **Supabase changes only** option to only create Supabase branches when Supabase files change.)
|
||||
|
||||
Every Supabase branch, preview or persistent, is created as a clone of your base project. The new branch starts with the Edge Functions and configuration of that project. Its database schema is not cloned. Instead, it is built from the migrations you commit to your repository.
|
||||
|
||||
### Configuration
|
||||
|
||||
You can test configuration changes on your Preview Branch by configuring the `config.toml` file in your Supabase directory. See the [Configuration docs](/docs/guides/deployment/branching/configuration) for more information.
|
||||
|
||||
Your branch starts with the configuration of your base project. The settings in your `config.toml` file are applied on top of that clone.
|
||||
|
||||
A comment is added to your PR with the deployment status of your preview branch.
|
||||
|
||||
### Migrations
|
||||
|
||||
The migrations in the `migrations` subdirectory of your Supabase directory are automatically run.
|
||||
The migrations in the `migrations` subdirectory of your Supabase directory are automatically run when the branch is created. Each later commit runs only the migrations that haven't been applied yet.
|
||||
|
||||
If you want to rerun existing migrations, reset the branch from the Supabase dashboard to start from scratch. Note that existing data on your branch will also be dropped by a reset.
|
||||
|
||||
### Seeding
|
||||
|
||||
No production data is copied to your Preview branch. This is meant to protect your sensitive production data.
|
||||
Cloning your base project copies its Edge Functions and configuration, but not its data or storage objects. This is meant to protect your sensitive production data. Your branch starts with the tables your migrations create, and the only rows in them are the ones your seed files add.
|
||||
|
||||
You can seed your Preview Branch with sample data using the `seed.sql` file in your Supabase directory. See the [Seeding docs](/docs/guides/local-development/seeding-your-database) for more information.
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ You might want to roll back changes you've made in an earlier migration change.
|
||||
|
||||
To fix this, push the latest changes, then delete the preview branch in Supabase and reopen it.
|
||||
|
||||
The new preview branch is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost. This is equivalent to running `supabase db reset` locally. All migrations are rerun in sequential order.
|
||||
The new preview branch is a fresh clone of your base project and is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost.
|
||||
|
||||
To rerun migrations that your base project has already applied, reset the branch from the Supabase dashboard instead. A reset reruns all migrations in sequential order and drops existing data on the branch.
|
||||
|
||||
### Deployment failures
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ After completing the steps above, you should receive a Slack message whenever an
|
||||
|
||||
Migrations are run in sequential order. Each migration builds upon the previous one.
|
||||
|
||||
The preview branch has a record of which migrations have been applied, and only applies new migrations for each commit. This can create an issue when rolling back migrations.
|
||||
The preview branch inherits the migration history of your base project, so it only applies migrations that haven't been run yet. This can create an issue when rolling back migrations.
|
||||
|
||||
### Using ORM or custom seed scripts
|
||||
|
||||
@@ -237,7 +237,9 @@ You might want to roll back changes you've made in an earlier migration change.
|
||||
|
||||
To fix this, push the latest changes, then delete the preview branch in Supabase and reopen it.
|
||||
|
||||
The new preview branch is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost. This is equivalent to running `supabase db reset` locally. All migrations are rerun in sequential order.
|
||||
The new preview branch is a fresh clone of your base project and is reseeded from the `./supabase/seed.sql` file by default. Any additional data changes made on the old preview branch are lost.
|
||||
|
||||
To rerun migrations that your base project has already applied, reset the branch from the Supabase dashboard instead. A reset reruns all migrations in sequential order and drops existing data on the branch.
|
||||
|
||||
### Seeding behavior
|
||||
|
||||
|
||||
@@ -231,6 +231,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
### Project shell — `/workers/*`
|
||||
|
||||
- [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx`
|
||||
- [x] A `routes/project/$ref/workers/$name.tsx` ← `pages/project/[ref]/workers/[name].tsx`
|
||||
|
||||
### Project shell — `/functions/*`
|
||||
|
||||
|
||||
@@ -386,6 +386,8 @@ export const PGBOUNCER_ENABLED_BUT_NO_IPV4_ADDON_TEXT =
|
||||
'Purchase IPv4 add-on or use Shared Pooler if on a IPv4 network'
|
||||
export const IPV4_ADDON_TEXT = 'Connections are IPv4 proxied with IPv4 add-on'
|
||||
|
||||
export const CONNECTION_SOURCE_LOAD_BALANCER = 'load-balancer'
|
||||
|
||||
export type ConnectionStringMethod = 'direct' | 'transaction' | 'session'
|
||||
|
||||
export const connectionStringMethodOptions: Record<
|
||||
|
||||
@@ -126,12 +126,14 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={`connect-${field.id}`}
|
||||
>
|
||||
<Select
|
||||
value={String(value ?? '')}
|
||||
onValueChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`connect-${field.id}`}
|
||||
size="small"
|
||||
className="[&>span:first-child]:flex [&>span:first-child]:items-center [&>span:first-child]:gap-x-2"
|
||||
>
|
||||
@@ -167,6 +169,7 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={field.id}
|
||||
className="[&>div>label>span]:break-keep! [&>div>label>span]:text-balance"
|
||||
>
|
||||
<Switch
|
||||
@@ -185,12 +188,14 @@ export function ConnectConfigSection({
|
||||
layout="horizontal"
|
||||
label={field.label}
|
||||
description={field.description}
|
||||
name={`connect-${field.id}`}
|
||||
>
|
||||
<MultiSelector
|
||||
values={Array.isArray(value) ? value : []}
|
||||
onValuesChange={(v) => onFieldChange(field.id, v)}
|
||||
>
|
||||
<MultiSelectorTrigger
|
||||
id={`connect-${field.id}`}
|
||||
className="w-full"
|
||||
label="Select features"
|
||||
badgeLimit="wrap"
|
||||
|
||||
@@ -210,6 +210,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS
|
||||
const { ref } = useParams()
|
||||
const stepsContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const deploymentMode = useDeploymentMode()
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
const connectionStringPooler = useConnectionStringPooler(deploymentMode)
|
||||
|
||||
const { data: ipv4Addon } = useProjectAddonsQuery(
|
||||
@@ -227,6 +228,7 @@ export function ConnectStepsSection({ steps, state, projectKeys }: ConnectStepsS
|
||||
connectionMethod: state.connectionMethod,
|
||||
useSharedPooler: state.useSharedPooler,
|
||||
hasIpv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
})
|
||||
const showSessionPoolerNotice = shouldShowSessionPoolerNotice({
|
||||
isPlatform: deploymentMode.isPlatform,
|
||||
|
||||
@@ -28,14 +28,17 @@ export function shouldShowIpv4AddonNotice({
|
||||
connectionMethod,
|
||||
useSharedPooler,
|
||||
hasIpv4Addon,
|
||||
isHighAvailability,
|
||||
}: {
|
||||
isPlatform: boolean
|
||||
mode: ConnectMode
|
||||
connectionMethod: FieldValue
|
||||
useSharedPooler: FieldValue
|
||||
hasIpv4Addon: boolean
|
||||
isHighAvailability: boolean
|
||||
}): boolean {
|
||||
if (!isPlatform || mode !== 'direct' || hasIpv4Addon) return false
|
||||
// The IPv4 add-on does not apply to Multigres connections
|
||||
if (!isPlatform || mode !== 'direct' || hasIpv4Addon || isHighAvailability) return false
|
||||
return connectionMethod === 'direct' || (connectionMethod === 'transaction' && !useSharedPooler)
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,27 @@ export const buildJdbcString = (params: ConnectionParams) => {
|
||||
return `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}${extraParams}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a connection string's query params carry `sslmode=require` without
|
||||
* dropping params the URI already has (e.g. `options=reference%3D...` or
|
||||
* `sslnegotiation=direct`).
|
||||
*/
|
||||
export const withRequiredSslmode = (search: string) => {
|
||||
if (!search) return '?sslmode=require'
|
||||
if (search.includes('sslmode=')) return search
|
||||
return `${search}&sslmode=require`
|
||||
}
|
||||
|
||||
export const buildDotnetConnectionString = (params: ConnectionParams) => {
|
||||
// Multigres only accepts direct SSL negotiation; Npgsql (9+) spells it
|
||||
// `SSL Negotiation=Direct` and throws on the parameter in older versions,
|
||||
// so only emit it when the resolved URI carries the param.
|
||||
const sslNegotiation = params.search.includes('sslnegotiation=direct')
|
||||
? ';SSL Negotiation=Direct'
|
||||
: ''
|
||||
return `Host=${params.host};Port=${params.port};Database=${params.database};Username=${params.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true${sslNegotiation}`
|
||||
}
|
||||
|
||||
export const buildConnectionStringWithPassword = (
|
||||
connectionString: string,
|
||||
password: string
|
||||
|
||||
@@ -17,6 +17,18 @@ export const appendHighAvailabilitySslParams = (uri: string) =>
|
||||
? uri
|
||||
: appendConnectionStringParams(uri, HIGH_AVAILABILITY_SSL_PARAMS)
|
||||
|
||||
/**
|
||||
* The Multigres read-only load balancer listens on this port on the same host
|
||||
* as the primary database.
|
||||
*/
|
||||
export const HIGH_AVAILABILITY_LOAD_BALANCER_PORT = 5433
|
||||
|
||||
export const getHighAvailabilityLoadBalancerConnectionInfo = <
|
||||
T extends { db_port: number | string },
|
||||
>(
|
||||
connectionInfo: T
|
||||
): T => ({ ...connectionInfo, db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT })
|
||||
|
||||
type ConnectionStrings = {
|
||||
psql: string
|
||||
uri: string
|
||||
|
||||
@@ -55,6 +55,10 @@ func main() {
|
||||
},
|
||||
dotnet: {
|
||||
installCommands: [
|
||||
// SSL Negotiation=Direct in the generated connection string requires Npgsql 9.0+.
|
||||
// Concrete version: quoting a floating version breaks on Windows cmd, and
|
||||
// floating versions fail under Central Package Management (NU1011).
|
||||
'dotnet add package Npgsql --version 9.0.5',
|
||||
'dotnet add package Microsoft.Extensions.Configuration.Json --version YOUR_DOTNET_VERSION',
|
||||
],
|
||||
},
|
||||
|
||||
+5
@@ -132,6 +132,7 @@ describe('shouldShowIpv4AddonNotice', () => {
|
||||
connectionMethod: 'direct',
|
||||
useSharedPooler: false,
|
||||
hasIpv4Addon: false,
|
||||
isHighAvailability: false,
|
||||
}
|
||||
|
||||
test('returns true for a direct connection with no IPv4 addon', () => {
|
||||
@@ -169,6 +170,10 @@ describe('shouldShowIpv4AddonNotice', () => {
|
||||
test('returns false when self-hosted (not platform)', () => {
|
||||
expect(shouldShowIpv4AddonNotice({ ...BASE, isPlatform: false })).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for high-availability projects even with a direct connection and no addon', () => {
|
||||
expect(shouldShowIpv4AddonNotice({ ...BASE, isHighAvailability: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowSessionPoolerNotice', () => {
|
||||
|
||||
+48
@@ -4,6 +4,7 @@ import {
|
||||
appendConnectionStringParams,
|
||||
buildConnectionParameters,
|
||||
buildConnectionStringWithPassword,
|
||||
buildDotnetConnectionString,
|
||||
buildJdbcString,
|
||||
buildPsqlCommand,
|
||||
buildSafeConnectionString,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseConnectionParams,
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
withRequiredSslmode,
|
||||
} from '../ConnectionString.utils'
|
||||
|
||||
describe('parseConnectionParams', () => {
|
||||
@@ -236,6 +238,52 @@ describe('buildJdbcString', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRequiredSslmode', () => {
|
||||
test('returns ?sslmode=require for an empty search', () => {
|
||||
expect(withRequiredSslmode('')).toBe('?sslmode=require')
|
||||
})
|
||||
|
||||
test('leaves a search that already sets sslmode unchanged', () => {
|
||||
expect(withRequiredSslmode('?sslmode=require&sslnegotiation=direct')).toBe(
|
||||
'?sslmode=require&sslnegotiation=direct'
|
||||
)
|
||||
})
|
||||
|
||||
test('appends sslmode=require to existing params without it', () => {
|
||||
expect(withRequiredSslmode('?options=reference%3Dproj')).toBe(
|
||||
'?options=reference%3Dproj&sslmode=require'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildDotnetConnectionString', () => {
|
||||
const params = {
|
||||
host: 'db.proj.supabase.co',
|
||||
port: '5432',
|
||||
user: 'postgres',
|
||||
database: 'postgres',
|
||||
search: '',
|
||||
}
|
||||
|
||||
test('builds the base Npgsql string without SSL negotiation', () => {
|
||||
expect(buildDotnetConnectionString(params)).toBe(
|
||||
`Host=db.proj.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true`
|
||||
)
|
||||
})
|
||||
|
||||
test('appends SSL Negotiation=Direct when the URI requires direct negotiation', () => {
|
||||
expect(
|
||||
buildDotnetConnectionString({
|
||||
...params,
|
||||
port: '5433',
|
||||
search: '?sslmode=require&sslnegotiation=direct',
|
||||
})
|
||||
).toBe(
|
||||
`Host=db.proj.supabase.co;Port=5433;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true;SSL Negotiation=Direct`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildConnectionParameters', () => {
|
||||
test('produces host/port/database/user rows in display order', () => {
|
||||
expect(
|
||||
|
||||
+45
@@ -3,8 +3,11 @@ import { describe, expect, test } from 'vitest'
|
||||
import {
|
||||
appendHighAvailabilitySslParams,
|
||||
buildConnectionStringPooler,
|
||||
getConnectionStrings,
|
||||
getHighAvailabilityLoadBalancerConnectionInfo,
|
||||
getSelfHostedDirectStrings,
|
||||
getSelfHostedPoolerStrings,
|
||||
HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
||||
HIGH_AVAILABILITY_SSL_PARAMS,
|
||||
} from '../DatabaseSettings.utils'
|
||||
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
||||
@@ -239,3 +242,45 @@ describe('appendHighAvailabilitySslParams', () => {
|
||||
expect(appendHighAvailabilitySslParams('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getHighAvailabilityLoadBalancerConnectionInfo', () => {
|
||||
test('swaps the port for the load balancer port and preserves the other fields', () => {
|
||||
const connectionInfo = {
|
||||
db_user: 'postgres',
|
||||
db_port: 5432,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
}
|
||||
|
||||
expect(getHighAvailabilityLoadBalancerConnectionInfo(connectionInfo)).toEqual({
|
||||
db_user: 'postgres',
|
||||
db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
})
|
||||
})
|
||||
|
||||
test('builds a read-only load balancer connection string on port 5433 with SSL params', () => {
|
||||
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo({
|
||||
db_user: 'postgres',
|
||||
db_port: 5432,
|
||||
db_host: 'db.proj.supabase.co',
|
||||
db_name: 'postgres',
|
||||
})
|
||||
|
||||
const result = buildConnectionStringPooler({
|
||||
deploymentMode: platform,
|
||||
connectionInfo: loadBalancerInfo,
|
||||
connectionStringsShared: getConnectionStrings({
|
||||
connectionInfo: loadBalancerInfo,
|
||||
metadata: { projectRef: 'proj' },
|
||||
}),
|
||||
ipv4Addon: false,
|
||||
isHighAvailability: true,
|
||||
})
|
||||
|
||||
expect(result.direct).toBe(
|
||||
`postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5433/postgres?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -7,8 +7,11 @@ vi.mock('common', () => ({
|
||||
useParams: () => ({ ref: 'test-ref' }),
|
||||
}))
|
||||
|
||||
const readReplicasMock: {
|
||||
data: Array<{ identifier: string; region: string; inserted_at: string }>
|
||||
} = { data: [] }
|
||||
vi.mock('@/data/read-replicas/replicas-query', () => ({
|
||||
useReadReplicasQuery: () => ({ data: [] }),
|
||||
useReadReplicasQuery: () => readReplicasMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
|
||||
@@ -498,9 +501,67 @@ describe('useConnectState', () => {
|
||||
// ============================================================================
|
||||
|
||||
describe('high availability projects', () => {
|
||||
const PRIMARY_DATABASE = {
|
||||
identifier: 'test-ref',
|
||||
region: 'ap-southeast-1',
|
||||
inserted_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
const REPLICA_DATABASE = {
|
||||
identifier: 'test-ref-rr-abcdefgh',
|
||||
region: 'ap-southeast-1',
|
||||
inserted_at: '2024-01-02T00:00:00Z',
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(false)
|
||||
readReplicasMock.data = []
|
||||
})
|
||||
|
||||
test('should offer only the primary and the load balancer as sources for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
|
||||
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionSource')
|
||||
expect(options).toEqual([
|
||||
{ value: 'test-ref', label: 'Primary database' },
|
||||
{ value: 'load-balancer', label: 'Replica (read-only)' },
|
||||
])
|
||||
})
|
||||
|
||||
test('should keep replicas as sources and omit the load balancer for non-HA projects', () => {
|
||||
readReplicasMock.data = [PRIMARY_DATABASE, REPLICA_DATABASE]
|
||||
|
||||
const { result } = renderHook(() => useConnectState({ mode: 'direct' }))
|
||||
|
||||
const options = result.current.getFieldOptions('connectionSource')
|
||||
expect(options.map((o) => o.value)).toEqual(['test-ref', 'test-ref-rr-abcdefgh'])
|
||||
expect(options.some((o) => o.value === 'load-balancer')).toBe(false)
|
||||
})
|
||||
|
||||
test('should coerce a replica connection source to the primary for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionSource: 'test-ref-rr-abcdefgh' })
|
||||
)
|
||||
|
||||
expect(result.current.state.connectionSource).toBe('test-ref')
|
||||
})
|
||||
|
||||
test('should preserve the load balancer as a valid connection source for HA projects', async () => {
|
||||
const { useIsHighAvailability } = await import('@/hooks/misc/useSelectedProject')
|
||||
vi.mocked(useIsHighAvailability).mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useConnectState({ mode: 'direct', connectionSource: 'load-balancer' })
|
||||
)
|
||||
|
||||
expect(result.current.state.connectionSource).toBe('load-balancer')
|
||||
})
|
||||
|
||||
test('should hide connectionMethod field for HA projects', async () => {
|
||||
|
||||
+19
-108
@@ -1,19 +1,17 @@
|
||||
import { useParams } from 'common'
|
||||
import { Check, KeyRound } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { buildConnectionStringPooler, getConnectionStrings } from '../../../DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
DATABASE_CONNECTION_TYPES,
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
import type {
|
||||
ConnectionStringPooler,
|
||||
DeploymentMode,
|
||||
StepContentProps,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.types'
|
||||
import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
|
||||
@@ -28,114 +26,14 @@ import {
|
||||
resolveConnectionString,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
import { useConnectionStringDatabases } from '@/components/interfaces/ConnectSheet/useConnectionStringDatabases'
|
||||
import { ResetDbPasswordDialog } from '@/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPasswordDialog'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
import { useTrack } from '@/lib/telemetry/track'
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
// Multigres has no pooler, so the pooler config endpoints don't apply
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
// Memoized so the per-database pooler bag (consumed by resolveConnectionString
|
||||
// downstream) keeps a stable identity across renders. Without this the inner
|
||||
// pluckObjectFields/getConnectionStrings calls would mint fresh objects every
|
||||
// render and ripple through the resolveConnectionString useMemo below.
|
||||
return useMemo(() => {
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
return Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo,
|
||||
connectionStringsShared,
|
||||
connectionStringsDedicated,
|
||||
ipv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
}, [
|
||||
databases,
|
||||
pgbouncerConfig,
|
||||
supavisorConfig,
|
||||
allowPgBouncerSelection,
|
||||
ipv4Addon,
|
||||
projectRef,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
])
|
||||
}
|
||||
|
||||
const CONNECTION_METHOD_TO_TELEMETRY: Record<
|
||||
ConnectionStringMethod,
|
||||
'direct' | 'transaction_pooler' | 'session_pooler'
|
||||
@@ -156,6 +54,8 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
const [temporaryDatabasePassword, setTemporaryDatabasePassword] = useState('')
|
||||
|
||||
const connectionSource = state.connectionSource
|
||||
const isLoadBalancerSelected =
|
||||
isHighAvailability && connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
@@ -239,17 +139,19 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
const showPasswordPlaceholder = connectionString.includes(PASSWORD_PLACEHOLDER)
|
||||
const showSelfHostedDirectNotice = deploymentMode.isSelfHosted && connectionMethod === 'direct'
|
||||
const showPoolerTitle = deploymentMode.isPlatform && !!poolerBadge && !isHighAvailability
|
||||
const titleBadge = isLoadBalancerSelected ? 'Read-only' : poolerBadge
|
||||
const showTitleBadge = isLoadBalancerSelected || showPoolerTitle
|
||||
const showResetInTitle =
|
||||
deploymentMode.isPlatform && showPasswordPlaceholder && !temporaryDatabasePassword
|
||||
const showStringTitleRow = showPoolerTitle || showResetInTitle
|
||||
const showStringTitleRow = showTitleBadge || showResetInTitle
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="overflow-hidden rounded-lg border bg-surface-75">
|
||||
{showStringTitleRow && (
|
||||
<div className="flex items-center justify-between gap-2 border-b bg-surface-100 py-2 pl-4 pr-2">
|
||||
{showPoolerTitle ? (
|
||||
<span className="text-xs text-foreground-light">{poolerBadge}</span>
|
||||
{showTitleBadge ? (
|
||||
<span className="text-xs text-foreground-light">{titleBadge}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
@@ -282,6 +184,15 @@ function DirectConnectionContent({ state, deploymentMode }: StepContentProps) {
|
||||
)}
|
||||
</div>
|
||||
{showPasswordPlaceholder && <PasswordEncodingNote />}
|
||||
{/* Persistent live region so screen readers announce the read-only state
|
||||
when the load balancer is selected */}
|
||||
<p
|
||||
role="status"
|
||||
className={cn('text-sm text-foreground-lighter', !isLoadBalancerSelected && 'sr-only')}
|
||||
>
|
||||
{isLoadBalancerSelected &&
|
||||
'Replica connections are read-only. Connect to the primary database for writes.'}
|
||||
</p>
|
||||
{showSelfHostedDirectNotice && (
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Manually{' '}
|
||||
|
||||
+46
-4
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
import { MultipleCodeBlock } from 'ui-patterns/MultipleCodeBlock'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
type ConnectionStringMethod,
|
||||
type DatabaseConnectionType,
|
||||
} from '@/components/interfaces/ConnectSheet/Connect.constants'
|
||||
@@ -10,12 +12,16 @@ import type { StepContentProps } from '@/components/interfaces/ConnectSheet/Conn
|
||||
import { ConnectionParameters } from '@/components/interfaces/ConnectSheet/ConnectionParameters'
|
||||
import {
|
||||
buildConnectionParameters,
|
||||
buildDotnetConnectionString,
|
||||
buildSafeConnectionString,
|
||||
parseConnectionParams,
|
||||
PASSWORD_PLACEHOLDER,
|
||||
resolveConnectionString,
|
||||
withRequiredSslmode,
|
||||
} from '@/components/interfaces/ConnectSheet/ConnectionString.utils'
|
||||
import { PasswordEncodingNote } from '@/components/interfaces/ConnectSheet/PasswordEncodingNote'
|
||||
import { useConnectionStringDatabases } from '@/components/interfaces/ConnectSheet/useConnectionStringDatabases'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
type DirectFilesConfig = {
|
||||
files: {
|
||||
@@ -28,11 +34,20 @@ type DirectFilesConfig = {
|
||||
passwordInUrl?: boolean
|
||||
}
|
||||
|
||||
function DirectFilesContent({ state, connectionStringPooler }: StepContentProps) {
|
||||
function DirectFilesContent({ state, deploymentMode }: StepContentProps) {
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const connectionSource = state.connectionSource
|
||||
const isLoadBalancerSelected =
|
||||
isHighAvailability && connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri'
|
||||
const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct'
|
||||
const useSharedPooler = Boolean(state.useSharedPooler)
|
||||
|
||||
const connectionStrings = useConnectionStringDatabases(deploymentMode)
|
||||
const connectionStringPooler =
|
||||
connectionStrings[connectionSource as keyof typeof connectionStrings]
|
||||
|
||||
const resolvedConnectionString = useMemo(
|
||||
() =>
|
||||
resolveConnectionString({
|
||||
@@ -125,7 +140,7 @@ func main() {
|
||||
language: 'json',
|
||||
code: `{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=${connectionParams.host};Database=${connectionParams.database};Username=${connectionParams.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true"
|
||||
"DefaultConnection": "${buildDotnetConnectionString(connectionParams)}"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
@@ -180,7 +195,7 @@ PORT = os.getenv("port")
|
||||
DBNAME = os.getenv("dbname")
|
||||
|
||||
# Construct the SQLAlchemy connection string
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}?sslmode=require"
|
||||
DATABASE_URL = f"postgresql+psycopg2://{USER}:{PASSWORD}@{HOST}:{PORT}/{DBNAME}${withRequiredSslmode(connectionParams.search)}"
|
||||
|
||||
# Create the SQLAlchemy engine
|
||||
engine = create_engine(DATABASE_URL)
|
||||
@@ -235,10 +250,37 @@ except Exception as e:
|
||||
return null
|
||||
}
|
||||
|
||||
const codeBlock = (
|
||||
<MultipleCodeBlock
|
||||
files={config.files}
|
||||
value={activeFile}
|
||||
onValueChange={setActiveFile}
|
||||
className={isLoadBalancerSelected ? 'rounded-none border-0' : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<MultipleCodeBlock files={config.files} value={activeFile} onValueChange={setActiveFile} />
|
||||
{isLoadBalancerSelected ? (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="flex items-center border-b bg-surface-100 py-2 pl-4 pr-2">
|
||||
<span className="text-xs text-foreground-light">Read-only</span>
|
||||
</div>
|
||||
{codeBlock}
|
||||
</div>
|
||||
) : (
|
||||
codeBlock
|
||||
)}
|
||||
{config.passwordInUrl && <PasswordEncodingNote />}
|
||||
{/* Persistent live region so screen readers announce the read-only state
|
||||
when the load balancer is selected */}
|
||||
<p
|
||||
role="status"
|
||||
className={cn('text-sm text-foreground-lighter', !isLoadBalancerSelected && 'sr-only')}
|
||||
>
|
||||
{isLoadBalancerSelected &&
|
||||
'Replica connections are read-only. Connect to the primary database for writes.'}
|
||||
</p>
|
||||
<ConnectionParameters parameters={buildConnectionParameters(connectionParams)} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
|
||||
|
||||
import {
|
||||
CONNECTION_SOURCE_LOAD_BALANCER,
|
||||
connectionStringMethodOptions,
|
||||
DATABASE_CONNECTION_TYPES,
|
||||
FRAMEWORKS,
|
||||
@@ -50,11 +51,15 @@ function getFieldOptionsFromSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
}: {
|
||||
source: string
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
deploymentMode: DeploymentMode
|
||||
isHighAvailability: boolean
|
||||
projectRef?: string
|
||||
}): FieldOption[] {
|
||||
switch (source) {
|
||||
case 'frameworks':
|
||||
@@ -135,15 +140,23 @@ function getFieldOptionsFromSource({
|
||||
}))
|
||||
}
|
||||
|
||||
case 'connectionSources':
|
||||
return databases.map((db) => {
|
||||
case 'connectionSources': {
|
||||
const options = databases.map((db) => {
|
||||
const region = formatDatabaseRegion(db?.region ?? '')
|
||||
const id = formatDatabaseID(db.identifier ?? '')
|
||||
const label = db.identifier.includes('-rr-')
|
||||
? `Read Replica (${region} - ${id})`
|
||||
: 'Primary Database'
|
||||
? `Read replica (${region} - ${id})`
|
||||
: 'Primary database'
|
||||
return { value: db.identifier, label }
|
||||
})
|
||||
if (!isHighAvailability) return options
|
||||
// Multigres replicas are only reachable through the read-only load
|
||||
// balancer, so the sources are the primary and the load balancer.
|
||||
return [
|
||||
...options.filter((option) => option.value === projectRef),
|
||||
{ value: CONNECTION_SOURCE_LOAD_BALANCER, label: 'Replica (read-only)' },
|
||||
]
|
||||
}
|
||||
|
||||
case 'connectionTypes':
|
||||
return DATABASE_CONNECTION_TYPES.map((t) => ({
|
||||
@@ -185,11 +198,15 @@ function resolveFieldOptionsWithSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
}: {
|
||||
field: ResolvedField
|
||||
state: ConnectState
|
||||
databases: Database[]
|
||||
deploymentMode: DeploymentMode
|
||||
isHighAvailability: boolean
|
||||
projectRef?: string
|
||||
}): FieldOption[] {
|
||||
// If already resolved (from conditional resolution)
|
||||
if (field.resolvedOptions.length > 0) {
|
||||
@@ -204,6 +221,8 @@ function resolveFieldOptionsWithSource({
|
||||
state,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -361,12 +380,21 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
|
||||
// Multigres has no pooler, so pooler-flavored selections restored from the
|
||||
// URL or localStorage (shared across projects) must never leak into an HA
|
||||
// project — every consumer sees the direct connection method.
|
||||
const resolvedState = useMemo(
|
||||
() =>
|
||||
isHighAvailability ? { ...state, connectionMethod: 'direct', useSharedPooler: false } : state,
|
||||
[state, isHighAvailability]
|
||||
)
|
||||
// project — every consumer sees the direct connection method. Likewise a
|
||||
// stale connection source (e.g. a replica identifier restored from the URL)
|
||||
// falls back to the primary, since HA sources are only the primary and the
|
||||
// load balancer.
|
||||
const resolvedState = useMemo(() => {
|
||||
if (!isHighAvailability) return state
|
||||
|
||||
const next: ConnectState = { ...state, connectionMethod: 'direct', useSharedPooler: false }
|
||||
const hasValidSource =
|
||||
state.connectionSource === undefined ||
|
||||
state.connectionSource === projectRef ||
|
||||
state.connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
|
||||
if (!hasValidSource) next.connectionSource = projectRef ?? '_'
|
||||
return next
|
||||
}, [state, isHighAvailability, projectRef])
|
||||
|
||||
const activeFields = useMemo(() => {
|
||||
let fields = getActiveFields(connectSchema, resolvedState)
|
||||
@@ -394,9 +422,11 @@ export function useConnectState(initialState?: Partial<ConnectState>): UseConnec
|
||||
state: resolvedState,
|
||||
databases,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
projectRef,
|
||||
})
|
||||
},
|
||||
[activeFields, resolvedState, databases, deploymentMode]
|
||||
[activeFields, resolvedState, databases, deploymentMode, isHighAvailability, projectRef]
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { CONNECTION_SOURCE_LOAD_BALANCER } from './Connect.constants'
|
||||
import type { DeploymentMode } from './Connect.types'
|
||||
import {
|
||||
buildConnectionStringPooler,
|
||||
getConnectionStrings,
|
||||
getHighAvailabilityLoadBalancerConnectionInfo,
|
||||
} from './DatabaseSettings.utils'
|
||||
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
|
||||
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
|
||||
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
||||
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
|
||||
import { pluckObjectFields } from '@/lib/helpers'
|
||||
|
||||
/**
|
||||
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
|
||||
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
|
||||
*
|
||||
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
|
||||
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
|
||||
*/
|
||||
export const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
|
||||
const isHighAvailability = useIsHighAvailability()
|
||||
|
||||
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
|
||||
// Multigres has no pooler, so the pooler config endpoints don't apply
|
||||
const { data: pgbouncerConfig } = usePgbouncerConfigQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: supavisorConfig } = useSupavisorConfigurationQuery(
|
||||
{ projectRef },
|
||||
{ enabled: !isHighAvailability }
|
||||
)
|
||||
const { data: addons } = useProjectAddonsQuery({ projectRef })
|
||||
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
|
||||
|
||||
// Memoized so the per-database pooler bag (consumed by resolveConnectionString
|
||||
// downstream) keeps a stable identity across renders. Without this the inner
|
||||
// pluckObjectFields/getConnectionStrings calls would mint fresh objects every
|
||||
// render and ripple through the resolveConnectionString useMemo below.
|
||||
return useMemo(() => {
|
||||
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
|
||||
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
|
||||
|
||||
const connectionStringsByIdentifier = Object.fromEntries(
|
||||
databases.map((db) => {
|
||||
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
|
||||
const poolingConfigurationShared = supavisorConfig?.find(
|
||||
(x) => x.identifier === db.identifier
|
||||
)
|
||||
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
|
||||
|
||||
const connectionStringsShared = getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationShared?.connection_string ?? '',
|
||||
db_host: poolingConfigurationShared?.db_host ?? '',
|
||||
db_name: poolingConfigurationShared?.db_name ?? '',
|
||||
db_port: poolingConfigurationShared?.db_port ?? 0,
|
||||
db_user: poolingConfigurationShared?.db_user ?? '',
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
|
||||
const connectionStringsDedicated =
|
||||
poolingConfigurationDedicated !== undefined
|
||||
? getConnectionStrings({
|
||||
connectionInfo,
|
||||
poolingInfo: {
|
||||
connectionString: poolingConfigurationDedicated.connection_string.replace(
|
||||
projectRef ?? '_',
|
||||
db.identifier
|
||||
),
|
||||
db_host: poolingConfigurationDedicated.db_host,
|
||||
db_name: poolingConfigurationDedicated.db_name,
|
||||
db_port: poolingConfigurationDedicated.db_port,
|
||||
db_user: poolingConfigurationDedicated.db_user,
|
||||
},
|
||||
metadata: { projectRef: db.identifier },
|
||||
})
|
||||
: undefined
|
||||
|
||||
return [
|
||||
db.identifier,
|
||||
buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo,
|
||||
connectionStringsShared,
|
||||
connectionStringsDedicated,
|
||||
ipv4Addon: !!ipv4Addon,
|
||||
isHighAvailability,
|
||||
}),
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
// The Multigres load balancer is not a database row — it shares the
|
||||
// primary's host on a dedicated read-only port.
|
||||
const primaryDatabase = isHighAvailability
|
||||
? databases.find((db) => db.identifier === projectRef)
|
||||
: undefined
|
||||
if (primaryDatabase) {
|
||||
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo(
|
||||
pluckObjectFields(primaryDatabase, DB_FIELDS)
|
||||
)
|
||||
connectionStringsByIdentifier[CONNECTION_SOURCE_LOAD_BALANCER] = buildConnectionStringPooler({
|
||||
deploymentMode,
|
||||
connectionInfo: loadBalancerInfo,
|
||||
connectionStringsShared: getConnectionStrings({
|
||||
connectionInfo: loadBalancerInfo,
|
||||
metadata: { projectRef },
|
||||
}),
|
||||
ipv4Addon: false,
|
||||
isHighAvailability,
|
||||
})
|
||||
}
|
||||
|
||||
return connectionStringsByIdentifier
|
||||
}, [
|
||||
databases,
|
||||
pgbouncerConfig,
|
||||
supavisorConfig,
|
||||
allowPgBouncerSelection,
|
||||
ipv4Addon,
|
||||
projectRef,
|
||||
deploymentMode,
|
||||
isHighAvailability,
|
||||
])
|
||||
}
|
||||
+11
-5
@@ -28,6 +28,11 @@ import {
|
||||
} from './DestinationForm.constants'
|
||||
import { type DestinationPanelSchemaType } from './DestinationForm.schema'
|
||||
|
||||
const INVALIDATED_SLOT_BEHAVIOR_LABELS = {
|
||||
error: 'Block startup',
|
||||
recreate: 'Recreate slot',
|
||||
}
|
||||
|
||||
export const AdvancedSettings = ({
|
||||
type,
|
||||
form,
|
||||
@@ -153,16 +158,18 @@ export const AdvancedSettings = ({
|
||||
>
|
||||
<FormControl>
|
||||
<Select value={field.value ?? 'error'} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="capitalize">{field.value ?? 'error'}</SelectTrigger>
|
||||
<SelectTrigger>
|
||||
{INVALIDATED_SLOT_BEHAVIOR_LABELS[field.value ?? 'error']}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="error" className="[&>span]:top-2.5">
|
||||
<p>Error</p>
|
||||
<p>Block startup</p>
|
||||
<p className="text-foreground-lighter">
|
||||
Blocks startup for manual recovery.
|
||||
</p>
|
||||
</SelectItem>
|
||||
<SelectItem value="recreate" className="[&>span]:top-2.5">
|
||||
<p>Recreate</p>
|
||||
<p>Recreate slot</p>
|
||||
<p className="text-foreground-lighter">
|
||||
Replaces destination tables and runs a new, billable initial sync.
|
||||
</p>
|
||||
@@ -222,7 +229,7 @@ export const AdvancedSettings = ({
|
||||
</div>
|
||||
}
|
||||
layout="horizontal"
|
||||
description="How old query results can be while BigQuery applies ongoing changes."
|
||||
description="Set the maximum age of query results while BigQuery applies ongoing changes, or leave blank for the freshest results."
|
||||
>
|
||||
<FormControl>
|
||||
<InputGroup>
|
||||
@@ -233,7 +240,6 @@ export const AdvancedSettings = ({
|
||||
step={1}
|
||||
value={field.value ?? ''}
|
||||
onChange={handleNumberChange(field)}
|
||||
placeholder="Default: None (Freshest results)"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>minutes</InputGroupText>
|
||||
|
||||
+7
-7
@@ -21,9 +21,9 @@ type AnalyticsBucketValidationOptions = {
|
||||
|
||||
// Fields that are always required regardless of the namespace / access key selections.
|
||||
const ANALYTICS_BUCKET_REQUIRED_FIELDS: AnalyticsBucketValidationIssue[] = [
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
]
|
||||
|
||||
export const getAnalyticsBucketValidationIssues = (
|
||||
@@ -43,8 +43,8 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
if (!hasValidNamespace) {
|
||||
issues.push(
|
||||
isCreatingNewNamespace
|
||||
? { path: 'newNamespaceName', message: 'Namespace name is required' }
|
||||
: { path: 'namespace', message: 'Namespace is required' }
|
||||
? { path: 'newNamespaceName', message: 'Namespace name is required.' }
|
||||
: { path: 'namespace', message: 'Namespace is required.' }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
data.s3SecretAccessKey?.trim().length &&
|
||||
!data.s3AccessKeyId?.trim().length
|
||||
) {
|
||||
issues.push({ path: 's3AccessKeyId', message: 'S3 access key ID is required' })
|
||||
issues.push({ path: 's3AccessKeyId', message: 'S3 access key ID is required.' })
|
||||
}
|
||||
|
||||
const currentS3AccessKeyId = data.s3AccessKeyId?.trim()
|
||||
@@ -69,7 +69,7 @@ export const getAnalyticsBucketValidationIssues = (
|
||||
(!options.secretsOptional || hasChangedStoredS3AccessKey) &&
|
||||
!data.s3SecretAccessKey?.trim().length
|
||||
) {
|
||||
issues.push({ path: 's3SecretAccessKey', message: 'S3 secret access key is required' })
|
||||
issues.push({ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' })
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
+3
-3
@@ -7,11 +7,11 @@ export const BigQueryFormSchema = z.object({
|
||||
connectionPoolSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Connection pool size must be greater than 0')
|
||||
.min(1, 'Connection pool size must be greater than 0.')
|
||||
.optional(),
|
||||
maxStalenessMins: z
|
||||
.number()
|
||||
.int('Maximum staleness must be a whole number of minutes')
|
||||
.min(0, 'Maximum staleness must be 0 or greater')
|
||||
.int('Maximum staleness must be a whole number of minutes.')
|
||||
.min(0, 'Maximum staleness must be 0 or greater.')
|
||||
.optional(),
|
||||
})
|
||||
+4
-4
@@ -7,14 +7,14 @@ export type BigQueryValidationIssue = {
|
||||
message: string
|
||||
}
|
||||
|
||||
export const BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE = 'Service account key must be valid JSON'
|
||||
export const BIGQUERY_SERVICE_ACCOUNT_JSON_MESSAGE = 'Service account key must be valid JSON.'
|
||||
|
||||
const BIGQUERY_REQUIRED_FIELDS: {
|
||||
path: Exclude<BigQueryFieldPath, 'serviceAccountKey'>
|
||||
message: string
|
||||
}[] = [
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
]
|
||||
|
||||
const isValidJsonString = (value: string) => {
|
||||
@@ -39,7 +39,7 @@ export const getBigQueryValidationIssues = (
|
||||
|
||||
if (!serviceAccountKey) {
|
||||
if (!secretsOptional) {
|
||||
issues.push({ path: 'serviceAccountKey', message: 'Service account key is required' })
|
||||
issues.push({ path: 'serviceAccountKey', message: 'Service account key is required.' })
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Form } from 'ui'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
|
||||
import { BigQueryFields } from './Fields'
|
||||
import { customRender } from '@/tests/lib/custom-render'
|
||||
|
||||
const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => {
|
||||
const form = useForm<DestinationPanelSchemaType>({
|
||||
defaultValues: { projectId: '', datasetId: '', serviceAccountKey },
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<BigQueryFields form={form} editMode={false} />
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BigQueryFields', () => {
|
||||
it('imports a JSON file into an editable service account key field', async () => {
|
||||
const { container } = customRender(<TestForm />)
|
||||
const contents = '{"type":"service_account"}'
|
||||
const file = new File([contents], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
expect(fileInput).not.toBeNull()
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'{"type": "service_account", "project_id": "...", ...}'
|
||||
)
|
||||
await waitFor(() => expect(textarea).toHaveValue(contents))
|
||||
|
||||
fireEvent.change(textarea, { target: { value: `${contents}\n` } })
|
||||
expect(textarea).toHaveValue(`${contents}\n`)
|
||||
expect(textarea).toHaveClass('max-h-[calc(13lh+1rem)]')
|
||||
})
|
||||
|
||||
it('imports a dropped JSON file into the service account key field', async () => {
|
||||
customRender(<TestForm />)
|
||||
const contents = '{"type":"service_account"}'
|
||||
const file = new File([contents], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue(contents) })
|
||||
|
||||
const textarea = screen.getByPlaceholderText(
|
||||
'{"type": "service_account", "project_id": "...", ...}'
|
||||
)
|
||||
fireEvent.drop(textarea.closest('div')!, {
|
||||
dataTransfer: { files: [file] },
|
||||
})
|
||||
|
||||
await waitFor(() => expect(textarea).toHaveValue(contents))
|
||||
})
|
||||
|
||||
it('rejects an oversized file without replacing the existing key', async () => {
|
||||
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
|
||||
const file = new File(['x'.repeat(5001)], 'service-account.json', {
|
||||
type: 'application/json',
|
||||
})
|
||||
const readFile = vi.fn()
|
||||
Object.defineProperty(file, 'text', { value: readFile })
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
expect(
|
||||
await screen.findByText('Service account key must be 5,000 characters or fewer.')
|
||||
).toBeInTheDocument()
|
||||
expect(readFile).not.toHaveBeenCalled()
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves the existing key when the selected file cannot be read', async () => {
|
||||
const { container } = customRender(<TestForm serviceAccountKey="existing-key" />)
|
||||
const file = new File(['{}'], 'service-account.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: vi.fn().mockRejectedValue(new Error('File read failed')),
|
||||
})
|
||||
|
||||
const fileInput = container.querySelector<HTMLInputElement>('input[type="file"]')
|
||||
fireEvent.change(fileInput!, { target: { files: [file] } })
|
||||
|
||||
expect(await screen.findByText('Could not read the selected JSON file.')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('existing-key')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+130
-16
@@ -1,10 +1,54 @@
|
||||
import { Upload } from 'lucide-react'
|
||||
import { useRef, useState, type ChangeEvent, type DragEvent } from 'react'
|
||||
import type { UseFormReturn } from 'react-hook-form'
|
||||
import { FormControl, FormField, Input, TextArea } from 'ui'
|
||||
import { Button, cn, FormControl, FormField, Input, TextArea } from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
|
||||
import { STORED_SECRET_PLACEHOLDER } from '../DestinationForm.constants'
|
||||
import type { DestinationPanelSchemaType } from '../DestinationForm.schema'
|
||||
|
||||
const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000
|
||||
|
||||
const readServiceAccountFile = async (
|
||||
file: File,
|
||||
form: UseFormReturn<DestinationPanelSchemaType>,
|
||||
isCurrentRequest: () => boolean
|
||||
) => {
|
||||
if (file.size > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
|
||||
if (isCurrentRequest()) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Service account key must be 5,000 characters or fewer.',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const contents = await file.text()
|
||||
if (!isCurrentRequest()) return
|
||||
|
||||
if (contents.length > MAX_SERVICE_ACCOUNT_KEY_LENGTH) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Service account key must be 5,000 characters or fewer.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
form.setValue('serviceAccountKey', contents, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
form.clearErrors('serviceAccountKey')
|
||||
} catch {
|
||||
if (isCurrentRequest()) {
|
||||
form.setError('serviceAccountKey', {
|
||||
message: 'Could not read the selected JSON file.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BigQueryFields = ({
|
||||
form,
|
||||
editMode,
|
||||
@@ -12,6 +56,41 @@ export const BigQueryFields = ({
|
||||
form: UseFormReturn<DestinationPanelSchemaType>
|
||||
editMode: boolean
|
||||
}) => {
|
||||
const serviceAccountFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileReadRequestIdRef = useRef(0)
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false)
|
||||
|
||||
const handleServiceAccountFile = async (file: File | undefined) => {
|
||||
if (!file) return
|
||||
const requestId = ++fileReadRequestIdRef.current
|
||||
await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current)
|
||||
}
|
||||
|
||||
const handleServiceAccountFileInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
await handleServiceAccountFile(file)
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setIsDraggingFile(false)
|
||||
await handleServiceAccountFile(event.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-6 p-5">
|
||||
<p className="text-sm font-medium text-foreground">BigQuery settings</p>
|
||||
@@ -57,23 +136,58 @@ export const BigQueryFields = ({
|
||||
label="Service account key"
|
||||
description={
|
||||
editMode
|
||||
? 'Stored credentials are hidden. Enter new credentials to replace them.'
|
||||
: 'Service account credentials JSON for authenticating with BigQuery'
|
||||
? 'Stored credentials are hidden. Paste or upload new credentials to replace them.'
|
||||
: 'Paste or upload your service account credentials JSON file for authenticating with BigQuery.'
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<TextArea
|
||||
{...field}
|
||||
rows={5}
|
||||
maxLength={5000}
|
||||
placeholder={
|
||||
editMode
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: '{"type": "service_account", "project_id": "...", ...}'
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className="relative"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className={cn('space-y-2 transition-opacity', isDraggingFile && 'opacity-40')}>
|
||||
<FormControl>
|
||||
<TextArea
|
||||
{...field}
|
||||
onChange={(event) => {
|
||||
fileReadRequestIdRef.current += 1
|
||||
field.onChange(event)
|
||||
}}
|
||||
rows={5}
|
||||
maxLength={MAX_SERVICE_ACCOUNT_KEY_LENGTH}
|
||||
placeholder={
|
||||
editMode
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: '{"type": "service_account", "project_id": "...", ...}'
|
||||
}
|
||||
className="max-h-[calc(13lh+1rem)] font-mono text-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<input
|
||||
ref={serviceAccountFileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="hidden"
|
||||
onChange={handleServiceAccountFileInputChange}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="tiny"
|
||||
icon={<Upload size={14} />}
|
||||
onClick={() => serviceAccountFileInputRef.current?.click()}
|
||||
>
|
||||
Upload JSON file
|
||||
</Button>
|
||||
</div>
|
||||
{isDraggingFile ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-md ring-2 ring-brand ring-offset-2 ring-offset-background"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
|
||||
+6
-6
@@ -64,26 +64,26 @@ export const getClickHouseValidationIssues = (
|
||||
const issues: ClickHouseValidationIssue[] = []
|
||||
|
||||
if (!data.clickhouseUrl?.length) {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'URL is required' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'URL is required.' })
|
||||
} else {
|
||||
let parsed: URL | undefined
|
||||
|
||||
try {
|
||||
parsed = new URL(data.clickhouseUrl)
|
||||
} catch {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must be a valid URL' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must be a valid URL.' })
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
if (parsed.protocol !== 'https:') {
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must use https://' })
|
||||
issues.push({ path: 'clickhouseUrl', message: 'ClickHouse URL must use HTTPS.' })
|
||||
} else {
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '')
|
||||
|
||||
if (isClickHouseHostInternal(host)) {
|
||||
issues.push({
|
||||
path: 'clickhouseUrl',
|
||||
message: 'ClickHouse URL must not target an internal address',
|
||||
message: 'ClickHouse URL must not target an internal address.',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,11 +91,11 @@ export const getClickHouseValidationIssues = (
|
||||
}
|
||||
|
||||
if (!data.clickhouseUser?.length) {
|
||||
issues.push({ path: 'clickhouseUser', message: 'User is required' })
|
||||
issues.push({ path: 'clickhouseUser', message: 'User is required.' })
|
||||
}
|
||||
|
||||
if (!data.clickhouseDatabase?.length) {
|
||||
issues.push({ path: 'clickhouseDatabase', message: 'Database is required' })
|
||||
issues.push({ path: 'clickhouseDatabase', message: 'Database is required.' })
|
||||
}
|
||||
|
||||
return issues
|
||||
|
||||
+7
-7
@@ -7,8 +7,8 @@ import { DuckLakeFormSchema } from './DuckLake/DuckLake.schema'
|
||||
import { SnowflakeFormSchema } from './Snowflake/Snowflake.schema'
|
||||
|
||||
const CommonFormSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
publicationName: z.string().min(1, 'Publication is required'),
|
||||
name: z.string().min(1, 'Name is required.'),
|
||||
publicationName: z.string().min(1, 'Publication is required.'),
|
||||
tableSyncCopyMode: z.enum([
|
||||
'include_all_tables',
|
||||
'skip_all_tables',
|
||||
@@ -18,18 +18,18 @@ const CommonFormSchema = z.object({
|
||||
tableSyncCopyTableIds: z.array(z.string()),
|
||||
maxFillMs: z
|
||||
.number()
|
||||
.int('Batch wait time must be a whole number of milliseconds')
|
||||
.min(0, 'Batch wait time must be 0 or greater')
|
||||
.int('Batch wait time must be a whole number of milliseconds.')
|
||||
.min(0, 'Batch wait time must be 0 or greater.')
|
||||
.optional(),
|
||||
maxTableSyncWorkers: z
|
||||
.number()
|
||||
.min(1, 'Max table sync workers must be greater than 0')
|
||||
.int('Max table sync workers must be a whole number')
|
||||
.min(1, 'Max table sync workers must be greater than 0.')
|
||||
.int('Max table sync workers must be a whole number.')
|
||||
.optional(),
|
||||
maxCopyConnectionsPerTable: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Max copy connections per table must be greater than 0')
|
||||
.min(1, 'Max copy connections per table must be greater than 0.')
|
||||
.optional(),
|
||||
invalidatedSlotBehavior: z.enum(['error', 'recreate']).optional(),
|
||||
})
|
||||
|
||||
+52
-52
@@ -306,12 +306,12 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -363,12 +363,12 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -386,19 +386,19 @@ describe('DestinationForm.utils DuckLake', () => {
|
||||
expect(issues).toEqual([
|
||||
{
|
||||
path: 'ducklakeCatalogUrl',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeDataPath',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeS3Endpoint',
|
||||
message: 'S3 endpoint must not contain the protocol scheme',
|
||||
message: 'S3 endpoint must not contain the protocol scheme.',
|
||||
},
|
||||
{
|
||||
path: 'ducklakeMetadataSchema',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores.',
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -487,9 +487,9 @@ describe('DestinationForm.utils DuckLake (Use Supabase)', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required' },
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required.' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required.' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -574,11 +574,11 @@ describe('DestinationForm.utils Snowflake', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required' },
|
||||
{ path: 'snowflakeUser', message: 'User is required' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required' },
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required.' },
|
||||
{ path: 'snowflakeUser', message: 'User is required.' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required.' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required.' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -653,9 +653,9 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'URL is required' },
|
||||
{ path: 'clickhouseUser', message: 'User is required' },
|
||||
{ path: 'clickhouseDatabase', message: 'Database is required' },
|
||||
{ path: 'clickhouseUrl', message: 'URL is required.' },
|
||||
{ path: 'clickhouseUser', message: 'User is required.' },
|
||||
{ path: 'clickhouseDatabase', message: 'Database is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -666,7 +666,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseUser: 'default',
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([{ path: 'clickhouseUrl', message: 'ClickHouse URL must use https://' }])
|
||||
).toEqual([{ path: 'clickhouseUrl', message: 'ClickHouse URL must use HTTPS.' }])
|
||||
|
||||
expect(
|
||||
getClickHouseValidationIssues({
|
||||
@@ -675,7 +675,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' },
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -711,7 +711,7 @@ describe('DestinationForm.utils ClickHouse', () => {
|
||||
clickhouseDatabase: 'analytics',
|
||||
})
|
||||
).toEqual([
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address' },
|
||||
{ path: 'clickhouseUrl', message: 'ClickHouse URL must not target an internal address.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -739,9 +739,9 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required' },
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -753,9 +753,9 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'projectId', message: 'Project ID is required' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required' },
|
||||
{ path: 'projectId', message: 'Project ID is required.' },
|
||||
{ path: 'datasetId', message: 'Dataset ID is required.' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -777,7 +777,7 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -802,7 +802,7 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -817,7 +817,7 @@ describe('DestinationForm.utils BigQuery', () => {
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON' },
|
||||
{ path: 'serviceAccountKey', message: 'Service account key must be valid JSON.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -847,11 +847,11 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'namespace', message: 'Namespace is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'namespace', message: 'Namespace is required.' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -866,11 +866,11 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
})
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 'warehouseName', message: 'Bucket is required' },
|
||||
{ path: 's3Region', message: 'S3 region is required' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'namespace', message: 'Namespace is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'warehouseName', message: 'Bucket is required.' },
|
||||
{ path: 's3Region', message: 'S3 region is required.' },
|
||||
{ path: 's3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'namespace', message: 'Namespace is required.' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -884,7 +884,7 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
s3SecretAccessKey: 'secret',
|
||||
})
|
||||
|
||||
expect(issues).toEqual([{ path: 'newNamespaceName', message: 'Namespace name is required' }])
|
||||
expect(issues).toEqual([{ path: 'newNamespaceName', message: 'Namespace name is required.' }])
|
||||
})
|
||||
|
||||
it('skips the S3 secret when creating a new access key', () => {
|
||||
@@ -946,7 +946,7 @@ describe('DestinationForm.utils Analytics Bucket', () => {
|
||||
)
|
||||
|
||||
expect(issues).toEqual([
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 's3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -172,7 +172,7 @@ export const buildTableSyncCopyConfig = ({
|
||||
if (mode === 'include_all_tables' || mode === 'skip_all_tables') return { type: mode }
|
||||
|
||||
if (selectedTableIds.length === 0) {
|
||||
throw new Error('Select at least one table for the initial sync')
|
||||
throw new Error('Select at least one table for the initial sync.')
|
||||
}
|
||||
|
||||
const tableIds = selectedTableIds.map(Number)
|
||||
@@ -343,7 +343,7 @@ export const buildDestinationConfigForValidation = ({
|
||||
} else if (selectedType === 'ClickHouse') {
|
||||
return { clickHouse: buildClickHouseConfig(data) }
|
||||
} else {
|
||||
throw new Error('Invalid destination type')
|
||||
throw new Error('Invalid destination type.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -14,7 +14,11 @@ export const DestinationNameInput = ({ form }: DestinationNameInputProps) => {
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout label="Name" layout="horizontal">
|
||||
<FormItemLayout
|
||||
label="Name"
|
||||
layout="horizontal"
|
||||
description="Used to identify this pipeline in Supabase."
|
||||
>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ export const DuckLakeFormSchema = z.object({
|
||||
ducklakePoolSize: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1, 'Pool size must be greater than 0')
|
||||
.max(6, 'Pool size must be 6 or less')
|
||||
.min(1, 'Pool size must be greater than 0.')
|
||||
.max(6, 'Pool size must be 6 or less.')
|
||||
.optional(),
|
||||
ducklakeS3AccessKeyId: z.string().optional(),
|
||||
ducklakeS3SecretAccessKey: z.string().optional(),
|
||||
|
||||
+15
-15
@@ -55,18 +55,18 @@ type DucklakeValidationData = Pick<
|
||||
// Required fields per mode. "Use Supabase" only needs project refs + a bucket; the catalog URL and
|
||||
// S3 credentials are resolved by the platform API.
|
||||
const DUCKLAKE_SUPABASE_REQUIRED_FIELDS: DucklakeValidationIssue[] = [
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required' },
|
||||
{ path: 'ducklakeCatalogProjectRef', message: 'Catalog project is required.' },
|
||||
{ path: 'ducklakeStorageProjectRef', message: 'Storage project is required.' },
|
||||
{ path: 'ducklakeStorageBucket', message: 'Bucket is required.' },
|
||||
]
|
||||
|
||||
const DUCKLAKE_CUSTOM_REQUIRED_FIELDS: DucklakeValidationIssue[] = [
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required' },
|
||||
{ path: 'ducklakeCatalogUrl', message: 'Catalog URL is required.' },
|
||||
{ path: 'ducklakeDataPath', message: 'Data path is required.' },
|
||||
{ path: 'ducklakeS3AccessKeyId', message: 'S3 access key ID is required.' },
|
||||
{ path: 'ducklakeS3SecretAccessKey', message: 'S3 secret access key is required.' },
|
||||
{ path: 'ducklakeS3Region', message: 'S3 region is required.' },
|
||||
{ path: 'ducklakeS3Endpoint', message: 'S3 endpoint is required.' },
|
||||
]
|
||||
|
||||
const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set<DucklakeFieldPath>([
|
||||
@@ -79,7 +79,7 @@ const DUCKLAKE_CUSTOM_SECRET_FIELDS = new Set<DucklakeFieldPath>([
|
||||
const METADATA_SCHEMA_PATTERN = /^[A-Za-z0-9_]+$/
|
||||
const METADATA_SCHEMA_ISSUE: DucklakeValidationIssue = {
|
||||
path: 'ducklakeMetadataSchema',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores',
|
||||
message: 'DuckLake metadata schema must contain only letters, numbers, and underscores.',
|
||||
}
|
||||
|
||||
const getMissingRequiredFieldIssues = (
|
||||
@@ -113,7 +113,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3AccessKeyId',
|
||||
message: 'S3 access key ID is required',
|
||||
message: 'S3 access key ID is required.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3SecretAccessKey',
|
||||
message: 'S3 secret access key is required',
|
||||
message: 'S3 secret access key is required.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeCatalogUrl',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL',
|
||||
message: 'DuckLake catalog URL must be a PostgreSQL-compatible URL.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeDataPath',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://',
|
||||
message: 'DuckLake data path must start with s3:// and cannot contain file://.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export const getDucklakeValidationIssues = (
|
||||
) {
|
||||
issues.push({
|
||||
path: 'ducklakeS3Endpoint',
|
||||
message: 'S3 endpoint must not contain the protocol scheme',
|
||||
message: 'S3 endpoint must not contain the protocol scheme.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ const DuckLakeSupabaseFields = ({ form }: { form: UseFormReturn<DestinationPanel
|
||||
const name = newBucketName.trim()
|
||||
if (!name || !ducklakeStorageProjectRef) return
|
||||
if (name.includes('/')) {
|
||||
return toast.error('Bucket name cannot contain "/"')
|
||||
return toast.error('Bucket name cannot contain "/".')
|
||||
}
|
||||
|
||||
createBucket({
|
||||
|
||||
+5
-5
@@ -23,11 +23,11 @@ export type SnowflakeValidationIssue = {
|
||||
}
|
||||
|
||||
const SNOWFLAKE_REQUIRED_FIELDS: { path: SnowflakeFieldPath; message: string }[] = [
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required' },
|
||||
{ path: 'snowflakeUser', message: 'User is required' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required' },
|
||||
{ path: 'snowflakeAccountId', message: 'Account ID is required.' },
|
||||
{ path: 'snowflakeUser', message: 'User is required.' },
|
||||
{ path: 'snowflakePrivateKey', message: 'Private key is required.' },
|
||||
{ path: 'snowflakeDatabase', message: 'Database is required.' },
|
||||
{ path: 'snowflakeSchema', message: 'Schema is required.' },
|
||||
]
|
||||
|
||||
export const getSnowflakeValidationIssues = (
|
||||
|
||||
+2
-2
@@ -85,7 +85,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
|
||||
// Helper function to handle namespace creation if needed
|
||||
const resolveNamespace = async (data: z.infer<typeof FormSchema>) => {
|
||||
if (data.namespace === CREATE_NEW_NAMESPACE) {
|
||||
if (!data.newNamespaceName) throw new Error('New namespace name is required')
|
||||
if (!data.newNamespaceName) throw new Error('New namespace name is required.')
|
||||
|
||||
await createNamespace({
|
||||
projectRef,
|
||||
@@ -224,7 +224,7 @@ export const useDestinationForm = ({ selectedType }: { selectedType: Destination
|
||||
resolveNamespace,
|
||||
})
|
||||
|
||||
if (!destinationConfig) throw new Error('Destination configuration is missing')
|
||||
if (!destinationConfig) throw new Error('Destination configuration is missing.')
|
||||
|
||||
const shouldSendBatch =
|
||||
!editMode || data.maxFillMs !== (existingBatch?.max_fill_ms ?? DEFAULT_MAX_FILL_MS)
|
||||
|
||||
+9
-4
@@ -76,10 +76,10 @@ describe('DestinationTypeSelection', () => {
|
||||
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('renders the Pipelines group with BigQuery when the flag is enabled', async () => {
|
||||
test('groups destinations by release stage', async () => {
|
||||
mockBigQueryEnabled.mockReturnValue(true)
|
||||
mockIcebergEnabled.mockReturnValue(false)
|
||||
mockDucklakeEnabled.mockReturnValue(false)
|
||||
mockIcebergEnabled.mockReturnValue(true)
|
||||
mockDucklakeEnabled.mockReturnValue(true)
|
||||
mockSnowflakeEnabled.mockReturnValue(false)
|
||||
mockClickHouseEnabled.mockReturnValue(false)
|
||||
addBackgroundMocks()
|
||||
@@ -88,8 +88,13 @@ describe('DestinationTypeSelection', () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole('combobox'))
|
||||
|
||||
expect(await screen.findByText('Pipelines')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Public Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Early Access')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated')).toBeInTheDocument()
|
||||
expect(screen.getByText('BigQuery')).toBeInTheDocument()
|
||||
expect(screen.getByText('DuckLake')).toBeInTheDocument()
|
||||
expect(screen.getByText('Analytics Bucket')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pipelines')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('hides destinations behind disabled feature flags', async () => {
|
||||
|
||||
+70
-69
@@ -1,11 +1,11 @@
|
||||
import { parseAsInteger, parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import {
|
||||
Badge,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
} from 'ui'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
@@ -30,13 +30,9 @@ interface DestinationTypeOption {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const STAGE_BADGE_VARIANT: Record<
|
||||
NonNullable<DestinationTypeOption['stage']>,
|
||||
'warning' | 'destructive' | 'default'
|
||||
> = {
|
||||
'Early Access': 'warning',
|
||||
Deprecated: 'destructive',
|
||||
'Public Alpha': 'default',
|
||||
interface DestinationTypeGroup {
|
||||
label: NonNullable<DestinationTypeOption['stage']>
|
||||
options: DestinationTypeOption[]
|
||||
}
|
||||
|
||||
export const DestinationTypeSelection = () => {
|
||||
@@ -72,46 +68,64 @@ export const DestinationTypeSelection = () => {
|
||||
const isOptionVisible = (value: DestinationType, hasAccess: boolean) =>
|
||||
editMode ? destinationType === value : hasAccess
|
||||
|
||||
const options: DestinationTypeOption[] = (
|
||||
[
|
||||
{
|
||||
value: 'Analytics Bucket',
|
||||
label: 'Analytics Bucket',
|
||||
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
|
||||
stage: 'Deprecated',
|
||||
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
|
||||
},
|
||||
{
|
||||
value: 'BigQuery',
|
||||
label: 'BigQuery',
|
||||
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
|
||||
stage: 'Public Alpha',
|
||||
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
|
||||
},
|
||||
{
|
||||
value: 'DuckLake',
|
||||
label: 'DuckLake',
|
||||
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
|
||||
},
|
||||
{
|
||||
value: 'Snowflake',
|
||||
label: 'Snowflake',
|
||||
description:
|
||||
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
|
||||
},
|
||||
{
|
||||
value: 'ClickHouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
|
||||
},
|
||||
] satisfies DestinationTypeOption[]
|
||||
).filter((option) => option.enabled)
|
||||
const groups: DestinationTypeGroup[] = [
|
||||
{
|
||||
label: 'Public Alpha',
|
||||
options: [
|
||||
{
|
||||
value: 'BigQuery',
|
||||
label: 'BigQuery',
|
||||
description: "Replicate changes to Google Cloud's data warehouse for analytics and BI",
|
||||
stage: 'Public Alpha',
|
||||
enabled: isOptionVisible('BigQuery', etlEnableBigQuery),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Early Access',
|
||||
options: [
|
||||
{
|
||||
value: 'DuckLake',
|
||||
label: 'DuckLake',
|
||||
description: 'Replicate changes to a DuckLake catalog backed by S3-compatible storage',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('DuckLake', etlEnableDucklake),
|
||||
},
|
||||
{
|
||||
value: 'Snowflake',
|
||||
label: 'Snowflake',
|
||||
description:
|
||||
'Replicate changes to Snowflake for warehouse analytics and downstream data workflows',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('Snowflake', etlEnableSnowflake),
|
||||
},
|
||||
{
|
||||
value: 'ClickHouse',
|
||||
label: 'ClickHouse',
|
||||
description: 'Stream changes to a ClickHouse cluster for fast columnar analytics',
|
||||
stage: 'Early Access',
|
||||
enabled: isOptionVisible('ClickHouse', etlEnableClickHouse),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Deprecated',
|
||||
options: [
|
||||
{
|
||||
value: 'Analytics Bucket',
|
||||
label: 'Analytics Bucket',
|
||||
description: 'Write Apache Iceberg tables to Supabase Storage for analytics workflows',
|
||||
stage: 'Deprecated',
|
||||
enabled: isOptionVisible('Analytics Bucket', etlEnableIceberg),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const visibleGroups = groups
|
||||
.map((group) => ({ ...group, options: group.options.filter((option) => option.enabled) }))
|
||||
.filter((group) => group.options.length > 0)
|
||||
const options = visibleGroups.flatMap((group) => group.options)
|
||||
|
||||
const selectedOption = options.find((option) => option.value === destinationType)
|
||||
|
||||
@@ -154,24 +168,18 @@ export const DestinationTypeSelection = () => {
|
||||
size={20}
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
{selectedOption.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[selectedOption.stage]}>
|
||||
{selectedOption.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-foreground">{selectedOption.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-foreground-lighter">Select a destination type</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
{options.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Pipelines</SelectLabel>
|
||||
{options.map((option) => (
|
||||
{visibleGroups.map((group, index) => (
|
||||
<SelectGroup key={group.label}>
|
||||
{index > 0 && <SelectSeparator />}
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="py-2">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<DestinationIcon
|
||||
@@ -180,14 +188,7 @@ export const DestinationTypeSelection = () => {
|
||||
className="shrink-0 text-foreground-light"
|
||||
/>
|
||||
<div className="flex flex-col gap-y-0.5">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
{option.stage && (
|
||||
<Badge variant={STAGE_BADGE_VARIANT[option.stage]}>
|
||||
{option.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-foreground">{option.label}</span>
|
||||
<span className="text-xs text-foreground-lighter">
|
||||
{option.description}
|
||||
</span>
|
||||
@@ -196,7 +197,7 @@ export const DestinationTypeSelection = () => {
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItemLayout>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
Trash,
|
||||
} from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
@@ -108,6 +108,7 @@ export const ExplorerNotebookTab = () => {
|
||||
const [skipMutatingCells, setSkipMutatingCells] = useState(false)
|
||||
const queryCellRefs = useRef(new Map<string, QueryEditorHandle>())
|
||||
const savedContentRef = useRef<typeof content>(undefined)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { mutate: updateNotebook, isPending: isUpdating } = useUpsertNotebookMutation({
|
||||
onSuccess: () => {
|
||||
@@ -322,6 +323,17 @@ export const ExplorerNotebookTab = () => {
|
||||
snap.insertCellAfter({ id: notebookId, cellId: lastCellId, cell })
|
||||
}
|
||||
|
||||
const scrollToBottomIfPending = useEffectEvent(() => {
|
||||
if (!id || snap.pendingScrollToBottom !== id || !scrollContainerRef.current) return
|
||||
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
})
|
||||
snap.clearPendingScrollToBottom()
|
||||
})
|
||||
|
||||
useEffect(() => scrollToBottomIfPending(), [id, snap.pendingScrollToBottom, content])
|
||||
|
||||
if (isNotFound) {
|
||||
return (
|
||||
<div className="p-4 h-full bg-surface-100">
|
||||
@@ -354,18 +366,12 @@ export const ExplorerNotebookTab = () => {
|
||||
<ExplorerToolbarAction
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
loading={isCreating}
|
||||
disabled={cells.length === 0}
|
||||
tooltip={cells.length === 0 ? 'Add a cell to the notebook to analyze it' : undefined}
|
||||
onClick={handleClickAnalyze}
|
||||
>
|
||||
Analyze
|
||||
</ExplorerToolbarAction>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Run notebook"
|
||||
icon={<Play />}
|
||||
tooltip="Run notebook"
|
||||
loading={isRunningNotebook}
|
||||
disabled={queryCellIds.length === 0}
|
||||
onClick={handleRunNotebook}
|
||||
/>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Save changes"
|
||||
icon={<Save />}
|
||||
@@ -397,10 +403,20 @@ export const ExplorerNotebookTab = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ExplorerToolbarActions>
|
||||
<ExplorerToolbarAction
|
||||
aria-label="Run notebook"
|
||||
icon={<Play />}
|
||||
tooltip="Run notebook"
|
||||
loading={isRunningNotebook}
|
||||
disabled={queryCellIds.length === 0}
|
||||
onClick={handleRunNotebook}
|
||||
>
|
||||
Run
|
||||
</ExplorerToolbarAction>
|
||||
</ExplorerToolbarActions>
|
||||
</ExplorerToolbar>
|
||||
|
||||
<div className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
|
||||
<div ref={scrollContainerRef} className="w-full mx-auto flex-grow min-h-0 overflow-y-auto">
|
||||
<div className="p-4 pb-10">
|
||||
{cells.length === 0 && (
|
||||
<EmptyStatePresentational
|
||||
|
||||
@@ -1,25 +1,69 @@
|
||||
import { useParams } from 'common'
|
||||
import { Loader2, SquareCode } from 'lucide-react'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
|
||||
import { Check, Keyboard, Loader2, MoreVertical, Save, SquareCode } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useContext, useEffect, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Button,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { ExplorerToolbarAction } from './ExplorerToolbar'
|
||||
import { useCreateNotebook } from './hooks'
|
||||
import { QueryEditor, type ExplorerQueryModel } from './QueryEditor'
|
||||
import { type QueryDisplay, type QueryResult } from './types'
|
||||
import { createQueryCellSkeleton } from './utils'
|
||||
import { getNotebook } from '@/data/content/notebooks/notebook-query'
|
||||
import { useNotebooksInfiniteQuery } from '@/data/content/notebooks/notebooks-infinite-query'
|
||||
import { toQuerySourceBinding } from '@/data/query-sources/query-source-registry'
|
||||
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
||||
import { explorerQueryState, useExplorerQueryStateSnapshot } from '@/state/explorer-query'
|
||||
import { useNotebooksStateSnapshot } from '@/state/notebooks/notebooks-state'
|
||||
import { useControlledRoleImpersonationState } from '@/state/role-impersonation-state'
|
||||
import { createTabId, TabsStateContext } from '@/state/tabs'
|
||||
|
||||
/** Query-tab lifecycle adapter around the shared QueryEditor. */
|
||||
export const ExplorerQueryTab = () => {
|
||||
const { id, ref } = useParams()
|
||||
const router = useRouter()
|
||||
const { id, ref } = useParams()
|
||||
const tabs = useContext(TabsStateContext)
|
||||
const querySnap = useExplorerQueryStateSnapshot()
|
||||
|
||||
const { createNotebook } = useCreateNotebook()
|
||||
const notebooksSnap = useNotebooksStateSnapshot()
|
||||
|
||||
const [isIntellisenseEnabled, setIsIntellisenseEnabled] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
|
||||
true
|
||||
)
|
||||
|
||||
const [restoredQueryKey, setRestoredQueryKey] = useState<string>()
|
||||
const [showQuery, setShowQuery] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 500)
|
||||
|
||||
const { data: notebooksData, isPending } = useNotebooksInfiniteQuery({
|
||||
projectRef: ref,
|
||||
limit: 100,
|
||||
name: search.length === 0 ? search : debouncedSearch,
|
||||
})
|
||||
const notebooks = useMemo(() => {
|
||||
const items = notebooksData?.pages.flatMap((page) => page.content) ?? []
|
||||
return items
|
||||
}, [notebooksData?.pages])
|
||||
|
||||
const stateDraft = id ? querySnap.drafts[id] : undefined
|
||||
const draft = stateDraft?.projectRef === ref ? stateDraft : undefined
|
||||
@@ -94,6 +138,32 @@ export const ExplorerQueryTab = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const onAddToNewNotebook = () => {
|
||||
createNotebook({
|
||||
cells: [createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql })],
|
||||
})
|
||||
}
|
||||
|
||||
const onAddToExistingNotebook = async (notebookId: string) => {
|
||||
if (!ref) return
|
||||
try {
|
||||
if (!notebooksSnap.notebooks[notebookId]?.notebook.content) {
|
||||
const notebook = await getNotebook({ projectRef: ref, id: notebookId })
|
||||
notebooksSnap.setNotebook({ projectRef: ref, notebook })
|
||||
}
|
||||
|
||||
notebooksSnap.insertCellAfter({
|
||||
id: notebookId,
|
||||
cell: createQueryCellSkeleton({ title: draft.name, sql: draft.uncheckedSql }),
|
||||
})
|
||||
notebooksSnap.requestScrollToBottom(notebookId)
|
||||
|
||||
router.push(`/project/${ref}/explorer/notebook/${notebookId}`)
|
||||
} catch (error) {
|
||||
toast.error('Failed to add query to notebook')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryEditor
|
||||
id={id}
|
||||
@@ -128,6 +198,75 @@ export const ExplorerQueryTab = () => {
|
||||
persistTab()
|
||||
explorerQueryState.setDisplay({ id, display })
|
||||
}}
|
||||
toolbarActions={
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ExplorerToolbarAction icon={<Save />} tooltip="Save query" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-52" align="end">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Add to existing notebook</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="p-0">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
placeholder="Search notebooks..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{isPending ? (
|
||||
<div className="flex flex-col p-1 gap-y-1">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
</div>
|
||||
) : !notebooks?.length ? (
|
||||
<p className="text-xs text-center text-foreground-lighter py-3">
|
||||
No notebooks found
|
||||
</p>
|
||||
) : null}
|
||||
{notebooks?.map((notebook) => (
|
||||
<CommandItem
|
||||
key={notebook.id}
|
||||
value={notebook.id}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => onAddToExistingNotebook(notebook.id)}
|
||||
>
|
||||
{notebook.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onClick={onAddToNewNotebook}>
|
||||
Create a new notebook
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ExplorerToolbarAction icon={<MoreVertical />} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
className="justify-between"
|
||||
onClick={() => setIsIntellisenseEnabled(!isIntellisenseEnabled)}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Keyboard size={14} />
|
||||
<span>Intellisense enabled</span>
|
||||
</div>
|
||||
{isIntellisenseEnabled && <Check className="text-brand" size={16} />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -366,13 +366,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
return (
|
||||
<>
|
||||
<Shell className={cn(variant === 'embedded' && 'mx-auto max-w-6xl', className)}>
|
||||
<ExplorerToolbar>
|
||||
<ExplorerToolbar className={cn(variant === 'viewport' && 'px-4')}>
|
||||
<ExplorerToolbarIcon>
|
||||
<CodeSquare size={14} />
|
||||
</ExplorerToolbarIcon>
|
||||
<ExplorerToolbarTitle onSaveTitle={onTitleChange}>{title}</ExplorerToolbarTitle>
|
||||
<ExplorerToolbarActions>
|
||||
{toolbarActions}
|
||||
{onSourceChange && (
|
||||
<QuerySourceMenu
|
||||
disabled={pendingProposal !== null}
|
||||
@@ -386,6 +385,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
roleImpersonationState={roleImpersonationState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{display && onDisplayChange && (
|
||||
<DisplaySettingsButton
|
||||
result={result}
|
||||
@@ -401,9 +401,12 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
|
||||
tooltip={showQuery ? 'Hide query' : 'Show query'}
|
||||
onClick={() => onShowQueryChange(!showQuery)}
|
||||
/>
|
||||
|
||||
{toolbarActions}
|
||||
|
||||
<ExplorerToolbarAction
|
||||
loading={isExecuting}
|
||||
icon={<Play />}
|
||||
loading={isExecuting}
|
||||
tooltip={hasSelection ? 'Run selected query' : 'Run query'}
|
||||
disabled={
|
||||
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Column } from 'react-data-grid'
|
||||
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import type { LogData } from '../Logs.types'
|
||||
import { RowLayout, SeverityFormatter, TextFormatter } from '../LogsFormatters'
|
||||
|
||||
export const WorkersLogsColumnRender: Column<LogData>[] = [
|
||||
{
|
||||
name: 'workers-logs-first-column',
|
||||
key: 'workers-logs-first-column',
|
||||
renderHeaderCell: () => null,
|
||||
renderCell: (props) => (
|
||||
<RowLayout>
|
||||
<TimestampInfo utcTimestamp={props.row.timestamp!} />
|
||||
{props.row.severity_text ? (
|
||||
<SeverityFormatter value={props.row.severity_text as string} />
|
||||
) : null}
|
||||
<TextFormatter className="w-full" value={props.row.event_message} />
|
||||
</RowLayout>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -69,6 +69,7 @@ interface Props {
|
||||
selectedLogError?: LogQueryError | ResponseError
|
||||
onSelectedLogChange?: (log: LogData | null) => void
|
||||
sqlQuery?: string
|
||||
columnRenderers?: Column<LogData>[]
|
||||
}
|
||||
type LogMap = { [id: string]: LogData }
|
||||
|
||||
@@ -98,6 +99,7 @@ export const LogTable = ({
|
||||
selectedLogError,
|
||||
onSelectedLogChange,
|
||||
sqlQuery,
|
||||
columnRenderers,
|
||||
}: Props) => {
|
||||
const { ref } = useParams()
|
||||
const { profile } = useProfile()
|
||||
@@ -245,7 +247,9 @@ export const LogTable = ({
|
||||
|
||||
let columns = DEFAULT_COLUMNS
|
||||
|
||||
if (!queryType) {
|
||||
if (columnRenderers) {
|
||||
columns = columnRenderers
|
||||
} else if (!queryType) {
|
||||
columns
|
||||
} else {
|
||||
switch (queryType) {
|
||||
@@ -616,7 +620,7 @@ export const LogTable = ({
|
||||
'data-grid--logs-explorer': !queryType,
|
||||
})}
|
||||
rowHeight={40}
|
||||
headerRowHeight={queryType ? 0 : 28}
|
||||
headerRowHeight={queryType || columnRenderers ? 0 : 28}
|
||||
columns={columns}
|
||||
rowClass={(row: LogData) => {
|
||||
const key = getRowKey(row)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { Container, Package } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { parseAsStringEnum, useQueryState } from 'nuqs'
|
||||
import {
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
Button,
|
||||
Card,
|
||||
NavMenu,
|
||||
NavMenuItem,
|
||||
} from 'ui'
|
||||
import { PageBreadcrumbs } from 'ui-patterns/PageBreadcrumbs'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderIcon,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import { PageNav } from 'ui-patterns/PageNav'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { RuntimeBadge } from '../RuntimeBadge'
|
||||
import { WorkerStatePill } from '../WorkerStatePill'
|
||||
import { WorkerLogsTab } from './WorkerLogsTab'
|
||||
import { WorkerOverviewTab } from './WorkerOverviewTab'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import type { WorkerLogStream } from '@/data/workers/worker-logs-query'
|
||||
import { workerQueryOptions } from '@/data/workers/worker-query'
|
||||
import { PRODUCT_NAME } from '@/lib/constants/workers'
|
||||
|
||||
type WorkerTab = 'overview' | 'invocations' | 'logs' | 'activity'
|
||||
const WORKER_TABS: WorkerTab[] = ['overview', 'invocations', 'logs', 'activity']
|
||||
|
||||
const TAB_LABEL: Record<WorkerTab, string> = {
|
||||
overview: 'Overview',
|
||||
invocations: 'Invocations',
|
||||
logs: 'Logs',
|
||||
activity: 'Activity',
|
||||
}
|
||||
|
||||
const TAB_STREAM: Partial<Record<WorkerTab, WorkerLogStream>> = {
|
||||
invocations: 'requests',
|
||||
logs: 'output',
|
||||
activity: 'builds',
|
||||
}
|
||||
|
||||
export const WorkerDetail = () => {
|
||||
const { ref: projectRef, name: workerName } = useParams()
|
||||
|
||||
const [tab, setTab] = useQueryState(
|
||||
'tab',
|
||||
parseAsStringEnum<WorkerTab>(WORKER_TABS)
|
||||
.withDefault('overview')
|
||||
.withOptions({ history: 'push' })
|
||||
)
|
||||
|
||||
const {
|
||||
data: worker,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
} = useQuery(workerQueryOptions({ projectRef, name: workerName }))
|
||||
|
||||
if (!projectRef) return null
|
||||
if (isPending) return <GenericSkeletonLoader className="p-6" />
|
||||
if (isError) return <AlertError error={error} subject="Failed to retrieve worker" />
|
||||
|
||||
if (!worker) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Worker "{workerName}" does not exist in this project
|
||||
</p>
|
||||
<Button asChild variant="default">
|
||||
<Link href={`/project/${projectRef}/workers`}>Back to {PRODUCT_NAME}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stream = TAB_STREAM[tab]
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-full flex flex-col items-stretch">
|
||||
<PageBreadcrumbs>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={`/project/${projectRef}/workers`}>{PRODUCT_NAME}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{worker.name}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</PageBreadcrumbs>
|
||||
|
||||
<PageHeader className="py-4 [&>div]:px-4 [&>div]:xl:px-4">
|
||||
<PageHeaderMeta className="px-0 xl:px-0">
|
||||
<PageHeaderIcon>
|
||||
<Card className="flex h-14 w-14 shrink-0 items-center justify-center">
|
||||
<Container className="h-5 w-5" />
|
||||
</Card>
|
||||
</PageHeaderIcon>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>{worker.name}</PageHeaderTitle>
|
||||
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<WorkerStatePill worker={worker} />
|
||||
<RuntimeBadge runtime={worker.runtime} />
|
||||
{worker.imageVersion !== undefined && (
|
||||
<span className="flex items-center gap-2 text-foreground-light">
|
||||
<Package size={14} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
Image {worker.imageVersion}
|
||||
</span>
|
||||
)}
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
<PageNav>
|
||||
<NavMenu>
|
||||
{WORKER_TABS.map((item) => (
|
||||
<NavMenuItem key={item} active={tab === item}>
|
||||
<button type="button" tabIndex={0} onClick={() => setTab(item)}>
|
||||
{TAB_LABEL[item]}
|
||||
</button>
|
||||
</NavMenuItem>
|
||||
))}
|
||||
</NavMenu>
|
||||
</PageNav>
|
||||
|
||||
{tab === 'overview' && <WorkerOverviewTab worker={worker} />}
|
||||
{stream !== undefined && (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<WorkerLogsTab key={stream} workerName={worker.name} stream={stream} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { WorkerCommandLine } from '../WorkerCommandLine'
|
||||
import { WorkersLogsColumnRender } from '@/components/interfaces/Settings/Logs/LogColumnRenderers/WorkersLogsColumnRender'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { LogTable } from '@/components/interfaces/Settings/Logs/LogTable'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import {
|
||||
WORKER_LOG_STREAM_LABEL,
|
||||
workerLogsQueryOptions,
|
||||
type WorkerLogStream,
|
||||
} from '@/data/workers/worker-logs-query'
|
||||
import { CLI_NAME } from '@/lib/constants/workers'
|
||||
|
||||
interface WorkerLogsTabProps {
|
||||
workerName: string
|
||||
stream: WorkerLogStream
|
||||
}
|
||||
|
||||
export const WorkerLogsTab = ({ workerName, stream }: WorkerLogsTabProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [selectedLog, setSelectedLog] = useState<LogData | null>(null)
|
||||
|
||||
const {
|
||||
data: logs,
|
||||
error,
|
||||
isPending,
|
||||
isError,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(workerLogsQueryOptions({ projectRef, name: workerName, stream }))
|
||||
|
||||
const label = WORKER_LOG_STREAM_LABEL[stream].toLowerCase()
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<div className="flex items-center justify-end border-b border-default px-4 py-2">
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<RefreshCw />}
|
||||
loading={isFetching}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="p-4">
|
||||
<AlertError error={error} subject="Failed to retrieve worker logs" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && isPending && (
|
||||
<div className="p-4">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isError && !isPending && (
|
||||
<div className="relative flex flex-1 flex-col grow overflow-auto">
|
||||
<LogTable
|
||||
projectRef={projectRef ?? ''}
|
||||
columnRenderers={WorkersLogsColumnRender}
|
||||
data={logs ?? []}
|
||||
isLoading={isPending}
|
||||
showHeader={false}
|
||||
showHistogramToggle={false}
|
||||
selectedLog={selectedLog ?? undefined}
|
||||
onSelectedLogChange={(log) => setSelectedLog(log)}
|
||||
EmptyState={
|
||||
<div className="mx-auto max-w-md space-y-3 py-16 text-center">
|
||||
<p className="text-sm text-foreground">No {label} in the last 24 hours</p>
|
||||
<p className="text-sm text-foreground-lighter">
|
||||
Follow them from the Supabase CLI while you wait for traffic.
|
||||
</p>
|
||||
<div className="pt-1 text-left">
|
||||
<WorkerCommandLine command={`supabase ${CLI_NAME} logs ${workerName} --follow`} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Badge } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionDescription,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { RuntimeBadge } from '../RuntimeBadge'
|
||||
import { WorkerCommandLine } from '../WorkerCommandLine'
|
||||
import { LISTENING_PORT, WORKERS_REGION_LABEL } from '../Workers.constants'
|
||||
import type { Worker } from '../Workers.types'
|
||||
import { formatSize, getRuntimeMeta } from '../Workers.utils'
|
||||
import { buildWorkerCliCommands } from '../workerSnippets'
|
||||
import { WorkerSnippetTabs } from '../WorkerSnippetTabs'
|
||||
import { CLI_NAME } from '@/lib/constants/workers'
|
||||
|
||||
interface WorkerOverviewTabProps {
|
||||
worker: Worker
|
||||
}
|
||||
|
||||
const InstanceCount = ({
|
||||
label,
|
||||
value,
|
||||
tooltip,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
tooltip: ReactNode
|
||||
}) => (
|
||||
<div className="flex flex-col gap-1 px-5 py-4">
|
||||
<span className="flex items-center gap-1.5 text-sm text-foreground-light">
|
||||
{label}
|
||||
<InfoTooltip side="top" className="max-w-56">
|
||||
{tooltip}
|
||||
</InfoTooltip>
|
||||
</span>
|
||||
<span className="text-2xl tabular-nums text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const SettingsRow = ({
|
||||
label,
|
||||
children,
|
||||
isFirst,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
isFirst?: boolean
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
isFirst ? '' : 'border-t border-default'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm text-foreground-light">{label}</span>
|
||||
<span className="text-sm text-foreground">{children}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const WorkerOverviewTab = ({ worker }: WorkerOverviewTabProps) => {
|
||||
const runtime = getRuntimeMeta(worker.runtime)
|
||||
const commands = buildWorkerCliCommands(worker.name)
|
||||
|
||||
return (
|
||||
<PageContainer size="small">
|
||||
{worker.buildState === 'failed' && (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Admonition type="destructive" title="This worker failed to build">
|
||||
<div className="space-y-3">
|
||||
<p>{worker.stateReason ?? 'The build did not complete.'}</p>
|
||||
<WorkerCommandLine
|
||||
comment="Redeploy after fixing the build"
|
||||
command={`supabase ${CLI_NAME} push ${worker.name}`}
|
||||
/>
|
||||
</div>
|
||||
</Admonition>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
|
||||
{worker.instancesError !== undefined && (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<Admonition type="warning" title="Instances reported an error">
|
||||
{worker.instancesError}
|
||||
</Admonition>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)}
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Instances</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
{worker.instances === undefined ? (
|
||||
<p className="text-sm text-foreground-light">
|
||||
No instances are running for this worker yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-foreground-light">
|
||||
<span className="tabular-nums text-foreground">{worker.instances.ready}</span> of{' '}
|
||||
<span className="tabular-nums text-foreground">{worker.instances.declared}</span>{' '}
|
||||
instances ready
|
||||
</p>
|
||||
<div className="grid grid-cols-2 divide-x divide-y rounded-md border border-default bg-surface-100 sm:grid-cols-4 sm:divide-y-0">
|
||||
<InstanceCount
|
||||
label="Instances"
|
||||
value={worker.instances.declared}
|
||||
tooltip="The number of instances you configured for this worker."
|
||||
/>
|
||||
<InstanceCount
|
||||
label="Live"
|
||||
value={worker.instances.live}
|
||||
tooltip="Instances currently running."
|
||||
/>
|
||||
<InstanceCount
|
||||
label="Ready"
|
||||
value={worker.instances.ready}
|
||||
tooltip="Instances passing health checks and serving requests."
|
||||
/>
|
||||
<InstanceCount
|
||||
label="Stale"
|
||||
value={worker.instances.stale}
|
||||
tooltip="Instances from a previous deployment, being replaced."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Container</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
The runtime image and entrypoint resolved for this worker.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<div className="rounded-md border border-default bg-surface-100">
|
||||
<SettingsRow label="Runtime" isFirst>
|
||||
<RuntimeBadge runtime={worker.runtime} />
|
||||
</SettingsRow>
|
||||
{worker.imageVersion !== undefined && (
|
||||
<SettingsRow label="Image version">
|
||||
<span className="font-mono text-xs text-foreground-light">
|
||||
{worker.imageVersion}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
{runtime !== undefined && (
|
||||
<SettingsRow label="Base image">
|
||||
<span className="font-mono text-xs text-foreground-light">{runtime.baseImage}</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
{runtime !== undefined && (
|
||||
<SettingsRow label="Entrypoint">
|
||||
<span className="font-mono text-xs text-foreground-light">
|
||||
{runtime.entrypoint}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
)}
|
||||
<SettingsRow label="Listening port">
|
||||
<span className="font-mono text-xs text-foreground-light">
|
||||
$PORT → {LISTENING_PORT}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Resources</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<div className="rounded-md border border-default bg-surface-100">
|
||||
<SettingsRow label="Size" isFirst>
|
||||
{formatSize(worker.size)}
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Instances">{worker.declaredInstances}</SettingsRow>
|
||||
<SettingsRow label="Access">
|
||||
{worker.access === 'public' ? (
|
||||
<Badge variant="success">Public</Badge>
|
||||
) : (
|
||||
<Badge>Private</Badge>
|
||||
)}
|
||||
</SettingsRow>
|
||||
<SettingsRow label="Region">
|
||||
<span className="text-foreground-light">
|
||||
{WORKERS_REGION_LABEL} <span className="text-foreground-lighter">(locked)</span>
|
||||
</span>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>How to call</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Call the worker over its gateway URL. Pass your project API key as a bearer token.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<WorkerSnippetTabs
|
||||
input={{
|
||||
name: worker.name,
|
||||
runtime: worker.runtime,
|
||||
size: worker.size,
|
||||
access: worker.access,
|
||||
instances: worker.declaredInstances,
|
||||
}}
|
||||
tabs={['cli', 'js', 'python']}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Develop locally</PageSectionTitle>
|
||||
<PageSectionDescription>
|
||||
Manage this worker from the Supabase CLI.
|
||||
</PageSectionDescription>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<div className="space-y-4 rounded-md border border-default bg-surface-100 p-4">
|
||||
{commands.map((command) => (
|
||||
<WorkerCommandLine
|
||||
key={command.command}
|
||||
comment={command.comment}
|
||||
command={command.command}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
@@ -114,7 +114,6 @@ export function buildWorkerCliCommands(name: string): WorkerCliCommand[] {
|
||||
const slug = safeName(name)
|
||||
return [
|
||||
{ comment: 'Recreate the source locally', command: `supabase ${CLI_NAME} pull ${slug}` },
|
||||
{ comment: 'Run it locally on a port', command: `supabase ${CLI_NAME} serve ${slug}` },
|
||||
{ comment: 'Deploy a new version', command: `supabase ${CLI_NAME} push ${slug}` },
|
||||
{ comment: 'Stream logs', command: `supabase ${CLI_NAME} logs ${slug} --follow` },
|
||||
{ comment: 'Delete the worker', command: `supabase ${CLI_NAME} delete ${slug}` },
|
||||
|
||||
@@ -121,6 +121,41 @@ describe('applyNotebookOperations', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('resets the insert offset for an anchor cell once it gets moved', () => {
|
||||
// cell-1 gets an insert right after it, then cell-1 itself moves after cell-3. A later
|
||||
// insert anchored on cell-1 must land right after its *new* position — the first insert
|
||||
// stayed behind at cell-1's old spot, so it shouldn't count toward this offset anymore.
|
||||
const notebook: NotebookWire = {
|
||||
schema_version: 1,
|
||||
cells: [
|
||||
{ _tag: 'markdown_cell', _id: 'cell-1', text: '1' },
|
||||
{ _tag: 'markdown_cell', _id: 'cell-2', text: '2' },
|
||||
{ _tag: 'markdown_cell', _id: 'cell-3', text: '3' },
|
||||
{ _tag: 'markdown_cell', _id: 'cell-4', text: '4' },
|
||||
],
|
||||
}
|
||||
const ops: NotebookOperation[] = [
|
||||
{
|
||||
_tag: 'insert_cell',
|
||||
after_cell_id: 'cell-1',
|
||||
cell: { _tag: 'markdown_cell', text: 'first' },
|
||||
},
|
||||
{ _tag: 'move_cell', cell_id: 'cell-1', after_cell_id: 'cell-3' },
|
||||
{
|
||||
_tag: 'insert_cell',
|
||||
after_cell_id: 'cell-1',
|
||||
cell: { _tag: 'markdown_cell', text: 'second' },
|
||||
},
|
||||
]
|
||||
|
||||
const result = applyNotebookOperations(notebook, ops)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
const texts = result.notebook.cells.map((cell) => ('text' in cell ? cell.text : undefined))
|
||||
expect(texts).toEqual(['first', '2', '3', '1', 'second', '4'])
|
||||
})
|
||||
|
||||
it('resolves a move anchored on another moved cell using its new position', () => {
|
||||
// cell-1 moves after cell-3 first, landing at [cell-2, cell-3, cell-1]; cell-2 then moves
|
||||
// after cell-1's *new* position, giving [cell-3, cell-1, cell-2].
|
||||
|
||||
@@ -256,6 +256,9 @@ export function deriveNotebookDiff(
|
||||
}
|
||||
|
||||
entries.splice(found.index, 1)
|
||||
// Cells already inserted after this anchor stay behind at its old position, so a
|
||||
// later insert anchored on it should start counting from its new position again.
|
||||
insertedAfter.delete(operation.cell_id)
|
||||
const error = insertAfter(operation.after_cell_id, {
|
||||
_tag: 'moved',
|
||||
cell: found.cell,
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function getProjectDetail(
|
||||
* To prevent odd side effects like pg-meta queries failing or the likes, we wake up the project proactively and wait for it
|
||||
* to be back online before returning the project details.
|
||||
*/
|
||||
if (data?.is_hibernating && !skipWake) {
|
||||
if (data?.status === 'ACTIVE_HEALTHY' && data?.is_hibernating && !skipWake) {
|
||||
// In case project was scaled down, explicitly wake it up before continuing to return the project details
|
||||
const { error: errorWaking, data: wakeResponse } = await post('/platform/projects/{ref}/wake', {
|
||||
params: { path: { ref } },
|
||||
|
||||
@@ -2,4 +2,6 @@ export const workersKeys = {
|
||||
list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const,
|
||||
detail: (projectRef: string | undefined, name: string | undefined) =>
|
||||
['projects', projectRef, 'worker', name, 'detail'] as const,
|
||||
logs: (projectRef: string | undefined, name: string | undefined, stream: string) =>
|
||||
['projects', projectRef, 'worker', name, 'logs', stream] as const,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseWorkerLogRows, workerLogsSql } from './worker-logs-query'
|
||||
|
||||
describe('workerLogsSql', () => {
|
||||
it('reads one worker stream, newest first', () => {
|
||||
expect(workerLogsSql('embed', 'output')).toBe(
|
||||
"select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes['worker'] = 'embed' and log_attributes['source'] = 'worker_guest_logs' order by timestamp desc limit 100"
|
||||
)
|
||||
})
|
||||
|
||||
it('names the right stream for each tab', () => {
|
||||
expect(workerLogsSql('embed', 'requests')).toContain("'worker_ingress_logs'")
|
||||
expect(workerLogsSql('embed', 'builds')).toContain("'worker_api_logs'")
|
||||
})
|
||||
|
||||
it('escapes a worker name rather than interpolating it raw', () => {
|
||||
expect(workerLogsSql("embed' or '1'='1", 'output')).toContain(
|
||||
"log_attributes['worker'] = 'embed'' or ''1''=''1'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorkerLogRows', () => {
|
||||
it('shapes rows for the logs table with a numeric timestamp', () => {
|
||||
const [row] = parseWorkerLogRows([
|
||||
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: 'ERROR', message: 'boom' },
|
||||
])
|
||||
expect(row.id).toBe('a')
|
||||
expect(row.event_message).toBe('boom')
|
||||
expect(row.severity_text).toBe('ERROR')
|
||||
expect(typeof row.timestamp).toBe('number')
|
||||
expect(Number.isFinite(row.timestamp)).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to empty strings for null severity and message', () => {
|
||||
const [row] = parseWorkerLogRows([
|
||||
{ id: 'a', timestamp: '2026-08-24T10:00:00.000000', severity: null, message: null },
|
||||
])
|
||||
expect(row.severity_text).toBe('')
|
||||
expect(row.event_message).toBe('')
|
||||
})
|
||||
|
||||
it('returns an empty array for a missing result', () => {
|
||||
expect(parseWorkerLogRows(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a malformed row rather than passing bad data to the table', () => {
|
||||
expect(() => parseWorkerLogRows([{ timestamp: '2026-08-24T10:00:00.000000' }])).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { workersKeys } from './keys'
|
||||
import type { LogData } from '@/components/interfaces/Settings/Logs/Logs.types'
|
||||
import { otelTimestampToMicros } from '@/components/interfaces/Settings/Logs/Logs.utils.otel'
|
||||
import { executeAnalyticsSql } from '@/data/logs/execute-analytics-sql'
|
||||
import { logsAllEndpointUrl } from '@/data/logs/logs-endpoint'
|
||||
import { analyticsLiteral, safeSql } from '@/data/logs/safe-analytics-sql'
|
||||
import { IS_PLATFORM } from '@/lib/constants'
|
||||
|
||||
export const WORKER_LOG_SOURCES = {
|
||||
requests: 'worker_ingress_logs',
|
||||
output: 'worker_guest_logs',
|
||||
builds: 'worker_api_logs',
|
||||
} as const
|
||||
|
||||
export type WorkerLogStream = keyof typeof WORKER_LOG_SOURCES
|
||||
|
||||
export const WORKER_LOG_STREAM_LABEL: Record<WorkerLogStream, string> = {
|
||||
requests: 'Invocations',
|
||||
output: 'Logs',
|
||||
builds: 'Activity',
|
||||
}
|
||||
|
||||
// Both are read from `log_attributes` rather than the endpoint's own `source` column:
|
||||
// that column is derived from a mapping which does not currently classify worker rows.
|
||||
const WORKER_NAME_KEY = 'worker'
|
||||
const STREAM_KEY = 'source'
|
||||
|
||||
const LOOKBACK_HOURS = 24
|
||||
const LOG_LIMIT = 100
|
||||
|
||||
const workerLogRowSchema = z.object({
|
||||
id: z.string(),
|
||||
timestamp: z.union([z.string(), z.number()]),
|
||||
severity: z.string().nullish(),
|
||||
message: z.string().nullish(),
|
||||
})
|
||||
|
||||
export type WorkerLogsVariables = {
|
||||
projectRef?: string
|
||||
name?: string
|
||||
stream: WorkerLogStream
|
||||
}
|
||||
|
||||
export const workerLogsSql = (name: string, stream: WorkerLogStream) =>
|
||||
safeSql`select id, timestamp, severity_text as severity, event_message as message from logs where log_attributes[${analyticsLiteral(WORKER_NAME_KEY)}] = ${analyticsLiteral(name)} and log_attributes[${analyticsLiteral(STREAM_KEY)}] = ${analyticsLiteral(WORKER_LOG_SOURCES[stream])} order by timestamp desc limit ${analyticsLiteral(LOG_LIMIT)}`
|
||||
|
||||
export const parseWorkerLogRows = (result: unknown): LogData[] =>
|
||||
z
|
||||
.array(workerLogRowSchema)
|
||||
.parse(result ?? [])
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
timestamp: otelTimestampToMicros(row.timestamp),
|
||||
event_message: row.message ?? '',
|
||||
severity_text: row.severity ?? '',
|
||||
}))
|
||||
|
||||
async function getWorkerLogs(
|
||||
{ projectRef, name, stream }: WorkerLogsVariables,
|
||||
signal?: AbortSignal
|
||||
): Promise<LogData[]> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
if (!name) throw new Error('name is required')
|
||||
|
||||
const end = dayjs()
|
||||
const start = end.subtract(LOOKBACK_HOURS, 'hour')
|
||||
|
||||
const data = await executeAnalyticsSql({
|
||||
projectRef,
|
||||
endpoint: logsAllEndpointUrl(true),
|
||||
sql: workerLogsSql(name, stream),
|
||||
iso_timestamp_start: start.toISOString(),
|
||||
iso_timestamp_end: end.toISOString(),
|
||||
signal,
|
||||
})
|
||||
|
||||
return parseWorkerLogRows(data?.result)
|
||||
}
|
||||
|
||||
export const workerLogsQueryOptions = ({ projectRef, name, stream }: WorkerLogsVariables) =>
|
||||
queryOptions({
|
||||
queryKey: workersKeys.logs(projectRef, name, stream),
|
||||
queryFn: ({ signal }) => getWorkerLogs({ projectRef, name, stream }, signal),
|
||||
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
|
||||
})
|
||||
@@ -91,6 +91,7 @@ describe('getAIDetails', () => {
|
||||
hasAccessToAdvanceModel: false,
|
||||
hasHipaaAddon: false,
|
||||
orgId: 1,
|
||||
orgSlug: ORG_SLUG,
|
||||
planId: 'pro',
|
||||
region: 'us-east-1',
|
||||
isSensitive: false,
|
||||
@@ -189,6 +190,7 @@ describe('getAIDetails', () => {
|
||||
expect(result.aiOptInLevel).toBe('disabled')
|
||||
expect(result.hasAccessToAdvanceModel).toBe(false)
|
||||
expect(result.orgId).toBeUndefined()
|
||||
expect(result.orgSlug).toBeUndefined()
|
||||
expect(result.planId).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -216,6 +218,7 @@ describe('getAIDetails', () => {
|
||||
|
||||
expect(result.aiOptInLevel).toBe('disabled')
|
||||
expect(result.orgId).toBeUndefined()
|
||||
expect(result.orgSlug).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the most restrictive posture when project detail is unavailable', async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AIDetails = {
|
||||
hasAccessToAdvanceModel: boolean
|
||||
hasHipaaAddon: boolean | undefined
|
||||
orgId: number | undefined
|
||||
orgSlug: string | undefined
|
||||
planId: string | undefined
|
||||
region: string | undefined
|
||||
isSensitive: boolean | null | undefined
|
||||
@@ -56,6 +57,7 @@ export const getAIDetails = async ({
|
||||
// Undefined rather than false so isTracingAllowed fails closed
|
||||
hasHipaaAddon: undefined,
|
||||
orgId: undefined,
|
||||
orgSlug: undefined,
|
||||
planId: undefined,
|
||||
region,
|
||||
isSensitive,
|
||||
@@ -72,6 +74,7 @@ export const getAIDetails = async ({
|
||||
hasAccessToAdvanceModel: advanceModelAccess.hasAccess,
|
||||
hasHipaaAddon,
|
||||
orgId: selectedOrg.id,
|
||||
orgSlug: selectedOrg.slug,
|
||||
planId: selectedOrg.plan.id,
|
||||
region,
|
||||
isSensitive,
|
||||
|
||||
@@ -39,6 +39,7 @@ export async function generateAssistantResponse({
|
||||
supportMode,
|
||||
userId,
|
||||
orgId,
|
||||
orgSlug,
|
||||
planId,
|
||||
includesLogsSnippets,
|
||||
isExplorerEnabled,
|
||||
@@ -60,6 +61,7 @@ export async function generateAssistantResponse({
|
||||
supportMode?: boolean
|
||||
userId?: string
|
||||
orgId?: number
|
||||
orgSlug?: string
|
||||
planId?: string
|
||||
/** Whether any user message in the conversation attached a logs (ClickHouse) query. */
|
||||
includesLogsSnippets?: boolean
|
||||
@@ -171,6 +173,7 @@ export async function generateAssistantResponse({
|
||||
aiOptInLevel,
|
||||
userId,
|
||||
orgId,
|
||||
orgSlug,
|
||||
planId,
|
||||
requestedModel,
|
||||
gitBranch: process.env.VERCEL_GIT_COMMIT_REF,
|
||||
|
||||
@@ -1,47 +1,2 @@
|
||||
export const PRODUCT_NAME = 'Workers'
|
||||
export const CLI_NAME = 'workers'
|
||||
|
||||
export const WORKERS_CLI_DEPLOY = `supabase ${CLI_NAME} push`
|
||||
|
||||
export const WORKERS_SKILL_MARKDOWN = `# Supabase ${PRODUCT_NAME} — agent skill
|
||||
|
||||
Deploy and manage backend workers (microVMs) that run next to a Supabase project's Postgres.
|
||||
|
||||
## Deploy from config.toml
|
||||
|
||||
Add a block to \`supabase/config.toml\`:
|
||||
|
||||
\`\`\`toml
|
||||
[${CLI_NAME}.embed]
|
||||
runtime = "python" # node | deno | bun | python | dockerfile
|
||||
size = "2gb-1vcpu" # 2gb-1vcpu | 4gb-2vcpu — fixed at deploy
|
||||
access = "public" # public | private
|
||||
instances = 1 # 1..10 per deploy; 100 cap per project
|
||||
secrets = ["OPENAI_API_KEY"] # names only — values live in the Secrets API
|
||||
\`\`\`
|
||||
|
||||
Then run:
|
||||
|
||||
\`\`\`bash
|
||||
supabase ${CLI_NAME} new embed --runtime python
|
||||
supabase ${CLI_NAME} push embed
|
||||
\`\`\`
|
||||
|
||||
Config precedence: \`--flag\` > \`config.toml\` > interactive prompt > default.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Workers under \`supabase/${CLI_NAME}/<name>/\` are auto-discovered; the folder name is the slug.
|
||||
- \`entrypoint\` is inferred from the runtime (node index.js, deno run main.ts, python main.py, Dockerfile CMD).
|
||||
- \`region\` is locked to us-west-2 at alpha — do not set it.
|
||||
- Sizes are fixed at deploy time. To change size, delete the worker and redeploy.
|
||||
|
||||
## Manage
|
||||
|
||||
- \`supabase ${CLI_NAME} list\`
|
||||
- \`supabase ${CLI_NAME} status <name>\`
|
||||
- \`supabase ${CLI_NAME} logs <name> --follow\`
|
||||
- \`supabase ${CLI_NAME} stop <name>\` # scale to zero (reversible)
|
||||
- \`supabase ${CLI_NAME} start <name>\`
|
||||
- \`supabase ${CLI_NAME} delete <name>\`
|
||||
`
|
||||
@@ -96,7 +96,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
|
||||
messages: rawMessages,
|
||||
projectRef,
|
||||
connectionString,
|
||||
orgSlug,
|
||||
orgSlug: rawOrgSlug,
|
||||
chatId,
|
||||
chatName,
|
||||
model: rawRequestedModel,
|
||||
@@ -126,6 +126,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
|
||||
let projectIsSensitive: boolean | null | undefined
|
||||
let projectRegion: string | undefined
|
||||
let orgId: number | undefined
|
||||
let orgSlug: string | undefined
|
||||
let planId: string | undefined
|
||||
|
||||
if (!IS_PLATFORM) {
|
||||
@@ -133,14 +134,15 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
|
||||
hasAccessToAdvanceModel = true
|
||||
}
|
||||
|
||||
if (IS_PLATFORM && orgSlug && authorization && projectRef) {
|
||||
if (IS_PLATFORM && rawOrgSlug && authorization && projectRef) {
|
||||
try {
|
||||
const aiDetails = await getAIDetails({ orgSlug, projectRef, authorization })
|
||||
const aiDetails = await getAIDetails({ orgSlug: rawOrgSlug, projectRef, authorization })
|
||||
|
||||
aiOptInLevel = aiDetails.aiOptInLevel
|
||||
hasAccessToAdvanceModel = aiDetails.hasAccessToAdvanceModel
|
||||
orgHasHipaaAddon = aiDetails.hasHipaaAddon
|
||||
orgId = aiDetails.orgId
|
||||
orgSlug = aiDetails.orgSlug
|
||||
planId = aiDetails.planId
|
||||
projectIsSensitive = aiDetails.isSensitive
|
||||
projectRegion = aiDetails.region
|
||||
@@ -234,6 +236,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw
|
||||
supportMode,
|
||||
userId,
|
||||
orgId,
|
||||
orgSlug,
|
||||
planId,
|
||||
includesLogsSnippets,
|
||||
isExplorerEnabled: explorerEnabled,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { WorkerDetail } from '@/components/interfaces/Workers/WorkerDetail/WorkerDetail'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { WorkersLayout } from '@/components/layouts/WorkersLayout/WorkersLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const WorkerDetailPage: NextPageWithLayout = () => <WorkerDetail />
|
||||
|
||||
WorkerDetailPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<WorkersLayout title="Worker">{page}</WorkersLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default WorkerDetailPage
|
||||
@@ -100,6 +100,7 @@ import { Route as ApiPlatformProfileIndexRouteImport } from './routes/api/platfo
|
||||
import { Route as ApiPlatformOrganizationsIndexRouteImport } from './routes/api/platform/organizations/index'
|
||||
import { Route as AppOrgSlugIndexRouteImport } from './routes/_app/org/$slug/index'
|
||||
import { Route as AppAccountTokensIndexRouteImport } from './routes/_app/account/tokens/index'
|
||||
import { Route as ProjectRefWorkersNameRouteImport } from './routes/project/$ref/workers/$name'
|
||||
import { Route as ProjectRefStorageS3RouteImport } from './routes/project/$ref/storage/s3'
|
||||
import { Route as ProjectRefSqlTemplatesRouteImport } from './routes/project/$ref/sql/templates'
|
||||
import { Route as ProjectRefSqlExamplesRouteImport } from './routes/project/$ref/sql/examples'
|
||||
@@ -793,6 +794,11 @@ const AppAccountTokensIndexRoute = AppAccountTokensIndexRouteImport.update({
|
||||
path: '/tokens/',
|
||||
getParentRoute: () => AppAccountRoute,
|
||||
} as any)
|
||||
const ProjectRefWorkersNameRoute = ProjectRefWorkersNameRouteImport.update({
|
||||
id: '/$name',
|
||||
path: '/$name',
|
||||
getParentRoute: () => ProjectRefWorkersRoute,
|
||||
} as any)
|
||||
const ProjectRefStorageS3Route = ProjectRefStorageS3RouteImport.update({
|
||||
id: '/s3',
|
||||
path: '/s3',
|
||||
@@ -2270,6 +2276,7 @@ export interface FileRoutesByFullPath {
|
||||
'/project/$ref/sql/examples': typeof ProjectRefSqlExamplesRoute
|
||||
'/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute
|
||||
'/project/$ref/storage/s3': typeof ProjectRefStorageS3Route
|
||||
'/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute
|
||||
'/account/tokens/': typeof AppAccountTokensIndexRoute
|
||||
'/org/$slug/': typeof AppOrgSlugIndexRoute
|
||||
'/api/platform/organizations/': typeof ApiPlatformOrganizationsIndexRoute
|
||||
@@ -2572,6 +2579,7 @@ export interface FileRoutesByTo {
|
||||
'/project/$ref/sql/examples': typeof ProjectRefSqlExamplesRoute
|
||||
'/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute
|
||||
'/project/$ref/storage/s3': typeof ProjectRefStorageS3Route
|
||||
'/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute
|
||||
'/account/tokens': typeof AppAccountTokensIndexRoute
|
||||
'/org/$slug': typeof AppOrgSlugIndexRoute
|
||||
'/api/platform/organizations': typeof ApiPlatformOrganizationsIndexRoute
|
||||
@@ -2891,6 +2899,7 @@ export interface FileRoutesById {
|
||||
'/project/$ref/sql/examples': typeof ProjectRefSqlExamplesRoute
|
||||
'/project/$ref/sql/templates': typeof ProjectRefSqlTemplatesRoute
|
||||
'/project/$ref/storage/s3': typeof ProjectRefStorageS3Route
|
||||
'/project/$ref/workers/$name': typeof ProjectRefWorkersNameRoute
|
||||
'/_app/account/tokens/': typeof AppAccountTokensIndexRoute
|
||||
'/_app/org/$slug/': typeof AppOrgSlugIndexRoute
|
||||
'/api/platform/organizations/': typeof ApiPlatformOrganizationsIndexRoute
|
||||
@@ -3209,6 +3218,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/sql/examples'
|
||||
| '/project/$ref/sql/templates'
|
||||
| '/project/$ref/storage/s3'
|
||||
| '/project/$ref/workers/$name'
|
||||
| '/account/tokens/'
|
||||
| '/org/$slug/'
|
||||
| '/api/platform/organizations/'
|
||||
@@ -3511,6 +3521,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/sql/examples'
|
||||
| '/project/$ref/sql/templates'
|
||||
| '/project/$ref/storage/s3'
|
||||
| '/project/$ref/workers/$name'
|
||||
| '/account/tokens'
|
||||
| '/org/$slug'
|
||||
| '/api/platform/organizations'
|
||||
@@ -3829,6 +3840,7 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/sql/examples'
|
||||
| '/project/$ref/sql/templates'
|
||||
| '/project/$ref/storage/s3'
|
||||
| '/project/$ref/workers/$name'
|
||||
| '/_app/account/tokens/'
|
||||
| '/_app/org/$slug/'
|
||||
| '/api/platform/organizations/'
|
||||
@@ -4732,6 +4744,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppAccountTokensIndexRouteImport
|
||||
parentRoute: typeof AppAccountRoute
|
||||
}
|
||||
'/project/$ref/workers/$name': {
|
||||
id: '/project/$ref/workers/$name'
|
||||
path: '/$name'
|
||||
fullPath: '/project/$ref/workers/$name'
|
||||
preLoaderRoute: typeof ProjectRefWorkersNameRouteImport
|
||||
parentRoute: typeof ProjectRefWorkersRoute
|
||||
}
|
||||
'/project/$ref/storage/s3': {
|
||||
id: '/project/$ref/storage/s3'
|
||||
path: '/s3'
|
||||
@@ -6928,10 +6947,12 @@ const ProjectRefStorageRouteWithChildren =
|
||||
ProjectRefStorageRoute._addFileChildren(ProjectRefStorageRouteChildren)
|
||||
|
||||
interface ProjectRefWorkersRouteChildren {
|
||||
ProjectRefWorkersNameRoute: typeof ProjectRefWorkersNameRoute
|
||||
ProjectRefWorkersIndexRoute: typeof ProjectRefWorkersIndexRoute
|
||||
}
|
||||
|
||||
const ProjectRefWorkersRouteChildren: ProjectRefWorkersRouteChildren = {
|
||||
ProjectRefWorkersNameRoute: ProjectRefWorkersNameRoute,
|
||||
ProjectRefWorkersIndexRoute: ProjectRefWorkersIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import WorkerDetailPage from '@/pages/project/[ref]/workers/[name]'
|
||||
|
||||
export const Route = createFileRoute('/project/$ref/workers/$name')({
|
||||
component: WorkerDetailRoute,
|
||||
staticData: {
|
||||
workersLayoutTitle: 'Worker',
|
||||
},
|
||||
})
|
||||
|
||||
function WorkerDetailRoute() {
|
||||
return <WorkerDetailPage dehydratedState={undefined} />
|
||||
}
|
||||
@@ -25,6 +25,13 @@ export const notebooksState = proxy({
|
||||
cellLocalState: proxyMap<string, NotebookCellLocalState>([]),
|
||||
/** Session-only conflicts where an assistant changed the server while local edits remain. */
|
||||
serverDivergedWhileDirty: proxyMap<string, 'updated' | 'deleted'>([]),
|
||||
/**
|
||||
* Id of the notebook the tab should scroll to the bottom of once rendered —
|
||||
* set by a surface that adds a cell to a notebook it's about to navigate to
|
||||
* (e.g. "Add to existing notebook" from a query tab), so the newly added
|
||||
* cell lands in view.
|
||||
*/
|
||||
pendingScrollToBottom: undefined as string | undefined,
|
||||
|
||||
/**
|
||||
* Load notebook into the Valtio store. No-ops if already present.
|
||||
@@ -242,6 +249,14 @@ export const notebooksState = proxy({
|
||||
},
|
||||
|
||||
addNeedsSaving: (id: string) => notebooksState.needsSaving.set(id, true),
|
||||
|
||||
requestScrollToBottom: (id: string) => {
|
||||
notebooksState.pendingScrollToBottom = id
|
||||
},
|
||||
|
||||
clearPendingScrollToBottom: () => {
|
||||
notebooksState.pendingScrollToBottom = undefined
|
||||
},
|
||||
})
|
||||
|
||||
export const getNotebooksStateSnapshot = () => snapshot(notebooksState)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./PublicityLogos.tsx', import.meta.url), 'utf8')
|
||||
|
||||
describe('PublicityLogos', () => {
|
||||
test('uses unique IDs for inline SVG references', () => {
|
||||
const ids = [...source.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1])
|
||||
const referencedIds = [...source.matchAll(/url\(#([^)]+)\)/g)].map((match) => match[1])
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(referencedIds.length).toBeGreaterThan(0)
|
||||
|
||||
for (const referencedId of referencedIds) {
|
||||
expect(ids).toContain(referencedId)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -20,11 +20,16 @@ export function OnePasswordLogo(props: ComponentProps<'svg'>) {
|
||||
export function BetasharesLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 149 60" {...props}>
|
||||
<g fill="currentColor" fillRule="evenodd" clipPath="url(#a)" clipRule="evenodd">
|
||||
<g
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipPath="url(#betashares-logo-clip)"
|
||||
clipRule="evenodd"
|
||||
>
|
||||
<path d="M36.341 30.003c0 7.091-5.748 12.84-12.84 12.84-7.09 0-12.84-5.749-12.84-12.84s5.75-12.84 12.84-12.84c7.092 0 12.84 5.749 12.84 12.84m-15.725-5.14a.085.085 0 0 0-.128.075v10.139a.086.086 0 0 0 .128.074l8.78-5.07a.086.086 0 0 0 0-.148zm1.532-.784c-.078 0-.107.105-.038.145h-.002l7.044 4.067c.07.04.146-.037.107-.105l-2.294-3.99a.23.23 0 0 0-.201-.117zM19.042 34.09c.04.07.146.042.146-.038v-8.103c0-.08-.105-.106-.146-.038l-2.284 3.973a.23.23 0 0 0 0 .233zm10.102-2.362c.07-.04.147.037.107.105l-.001.002-2.286 3.973a.23.23 0 0 1-.202.117h-4.597c-.08 0-.107-.105-.04-.145zm2.437-2.114a.78.78 0 0 1 0 .774l-3.706 6.446a.78.78 0 0 1-.674.39h-7.407a.78.78 0 0 1-.673-.39l-3.706-6.446a.78.78 0 0 1 0-.774l3.706-6.446a.78.78 0 0 1 .673-.39h7.407a.78.78 0 0 1 .674.39zM43.308 32.33c0-1.697 1.253-3.045 3.037-3.045s3.038 1.348 3.038 3.045-1.275 3.066-3.038 3.066c-1.764 0-3.038-1.37-3.038-3.066m3.568 5.17c2.89 0 4.865-2.288 4.865-5.17s-1.976-5.15-4.865-5.15c-1.402 0-2.782.634-3.505 1.758V22.95h-2.358v14.304h2.23v-1.758c.68 1.288 2.168 2.003 3.633 2.003zm7.97-6.336c.362-1.308 1.338-2.125 2.783-2.125s2.465.817 2.805 2.125zM57.8 37.56c2.082 0 3.91-.879 4.823-2.84v-.042c-.638.041-1.593-.389-1.976-.899l-.02.02c-.383 1.023-1.402 1.78-2.783 1.78-1.657 0-2.91-1.044-3.123-2.739h7.966c.382-3.085-1.657-5.722-5.036-5.722-2.996 0-5.163 2.33-5.163 5.21s2.21 5.232 5.312 5.232m5.388-8.112h1.38v7.806h2.381v-7.806h1.509v-2.044h-1.509v-3.148h-2.38v3.148h-1.381zm8.06 2.882c0-1.697 1.253-3.045 3.037-3.045s3.06 1.369 3.06 3.045-1.254 3.066-3.06 3.066-3.038-1.37-3.038-3.066m2.528 5.15c1.381 0 2.89-.675 3.569-1.942v1.716h2.294v-9.85h-2.294v1.716c-.68-1.266-2.188-1.941-3.569-1.941-2.91 0-4.887 2.288-4.887 5.15 0 2.86 1.976 5.149 4.887 5.149zm11.263.081c2.338 0 3.93-1.43 3.93-3.29s-1.487-2.677-3.059-2.984l-.957-.184c-.85-.184-1.487-.409-1.487-1.062 0-.572.552-1.103 1.424-1.103.956 0 1.7.47 1.932 1.471h.02c.404-.47 1.361-.838 1.997-.756l.021-.021c-.383-1.39-1.763-2.533-3.91-2.533-2.146 0-3.782 1.39-3.782 3.066 0 1.512 1.02 2.411 3.08 2.84l.999.204c.786.184 1.402.43 1.402 1.145 0 .717-.552 1.288-1.593 1.288-1.274 0-2.188-.817-2.358-1.84l-.021-.02c-.404.47-1.36.817-1.997.736l-.02.02c.382 1.84 2.082 3.025 4.376 3.025zm5.077-.307h2.359v-5.599c0-1.43 1.02-2.37 2.422-2.37 1.401 0 2.294.899 2.294 2.288v5.681h2.358v-6.518c0-2.084-1.657-3.576-3.846-3.576-1.296 0-2.57.572-3.228 1.736V22.95h-2.359zm12.922-4.924c0-1.697 1.253-3.045 3.037-3.045s3.06 1.369 3.06 3.045-1.253 3.066-3.06 3.066c-1.806 0-3.037-1.37-3.037-3.066m2.528 5.15c1.381 0 2.89-.675 3.569-1.942v1.716h2.294v-9.85h-2.294v1.716c-.679-1.266-2.188-1.941-3.569-1.941-2.91 0-4.887 2.288-4.887 5.15 0 2.86 1.976 5.149 4.887 5.149zm7.716-7.378v7.152h2.358v-4.372c0-1.716.531-3.31 2.252-3.31.51 0 .956.122 1.189.306h.021c.021-.633.574-1.553 1.126-1.9v-.041c-.383-.429-1.062-.694-1.976-.694-1.296 0-2.21.777-2.677 1.839v-1.676h-2.295v2.698zm8.647 1.062c.36-1.308 1.338-2.125 2.782-2.125 1.445 0 2.465.817 2.805 2.125zm2.953 6.396c2.082 0 3.909-.879 4.823-2.84v-.042c-.638.041-1.593-.389-1.976-.899l-.02.02c-.383 1.023-1.402 1.78-2.783 1.78-1.656 0-2.91-1.044-3.123-2.739h7.966c.382-3.085-1.657-5.722-5.036-5.722-2.996 0-5.162 2.33-5.162 5.21s2.209 5.232 5.311 5.232m9.863.001c2.337 0 3.93-1.43 3.93-3.29s-1.487-2.677-3.06-2.984l-.956-.184c-.85-.184-1.488-.409-1.488-1.062 0-.572.552-1.103 1.424-1.103.957 0 1.7.47 1.933 1.471h.02c.404-.47 1.36-.838 1.997-.756l.02-.021c-.382-1.39-1.763-2.533-3.909-2.533s-3.782 1.39-3.782 3.066c0 1.512 1.02 2.411 3.08 2.84l.998.204c.787.184 1.402.43 1.402 1.145 0 .717-.552 1.288-1.593 1.288-1.274 0-2.188-.817-2.358-1.84l-.021-.02c-.403.47-1.36.817-1.996.736l-.021.02c.383 1.84 2.082 3.025 4.377 3.025z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="betashares-logo-clip">
|
||||
<path fill="#fff" d="M10.59 17.04h128.13v25.92H10.59z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -85,7 +90,7 @@ export function BrthrsLogo(props: ComponentProps<'svg'>) {
|
||||
export function ChatbaseLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 148 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<g fill="currentColor" clipPath="url(#chatbase-logo-clip)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M17.702 16.5h13.385c3.905 0 7.071 3.105 7.071 6.936v13.128c0 3.83-3.166 6.936-7.071 6.936H17.702c-3.906 0-7.072-3.105-7.072-6.936V23.436c0-3.83 3.166-6.936 7.072-6.936m9.817 11.51h4.452q-.127-1.484-.718-2.633a6 6 0 0 0-1.563-1.935 6.8 6.8 0 0 0-2.257-1.205q-1.287-.411-2.818-.411-2.178 0-3.946.94-1.767.931-2.81 2.758-1.041 1.818-1.041 4.476 0 2.641 1.018 4.468 1.025 1.826 2.786 2.766 1.768.94 3.993.94 1.776 0 3.118-.536t2.249-1.414a6.7 6.7 0 0 0 1.413-1.927q.497-1.05.576-2.06l-4.451-.03q-.08.513-.308.924-.222.405-.576.692-.355.288-.837.443a3.7 3.7 0 0 1-1.09.147q-1.065 0-1.822-.49-.758-.496-1.16-1.475-.396-.987-.395-2.448 0-1.383.387-2.37.394-.987 1.152-1.515.766-.529 1.87-.529.631 0 1.129.18.497.17.845.496.354.32.552.762.205.443.253.987"
|
||||
@@ -94,7 +99,7 @@ export function ChatbaseLogo(props: ComponentProps<'svg'>) {
|
||||
<path d="M59.893 27.327h-2.5a3.6 3.6 0 0 0-.537-1.364 3.9 3.9 0 0 0-.962-1 4.2 4.2 0 0 0-1.274-.62 5.1 5.1 0 0 0-1.482-.209q-1.418 0-2.54.69-1.113.69-1.763 2.022-.64 1.334-.64 3.255 0 1.935.64 3.27.65 1.332 1.771 2.014 1.123.681 2.524.682.777 0 1.474-.202.705-.21 1.274-.612.57-.403.962-.984.4-.588.552-1.348l2.5.008a6.3 6.3 0 0 1-.777 2.154 6 6 0 0 1-1.466 1.689 6.7 6.7 0 0 1-2.035 1.092 7.8 7.8 0 0 1-2.5.388q-2.13 0-3.798-.977-1.666-.984-2.628-2.812-.954-1.829-.954-4.363 0-2.541.962-4.362.96-1.829 2.628-2.805 1.667-.983 3.79-.983 1.306-.001 2.436.364 1.138.355 2.043 1.053a5.9 5.9 0 0 1 1.498 1.69q.593.99.801 2.27M63.705 30.968v7.067h-2.396V22.166h2.364v5.904h.152q.433-.96 1.322-1.526t2.324-.566q1.266 0 2.21.504a3.46 3.46 0 0 1 1.475 1.503q.53.992.53 2.48v7.57h-2.397v-7.291q0-1.31-.697-2.03-.697-.729-1.939-.729-.85 0-1.522.349a2.56 2.56 0 0 0-1.05 1.023q-.375.666-.376 1.611M77.11 38.298q-1.17 0-2.115-.418a3.53 3.53 0 0 1-1.499-1.232q-.544-.806-.544-1.976 0-1.007.4-1.658a2.9 2.9 0 0 1 1.082-1.03 5.8 5.8 0 0 1 1.522-.574q.842-.194 1.715-.294l1.795-.202q.69-.085 1.001-.27.313-.187.313-.605v-.054q0-1.015-.593-1.573-.585-.558-1.747-.558-1.21 0-1.907.519-.69.51-.953 1.139l-2.252-.496q.4-1.085 1.17-1.751a4.8 4.8 0 0 1 1.787-.976 7.2 7.2 0 0 1 2.123-.31 7.7 7.7 0 0 1 1.563.17q.832.163 1.554.605.729.441 1.194 1.262.465.814.465 2.116v7.903h-2.34v-1.627h-.096q-.232.45-.697.883-.465.435-1.194.72-.729.288-1.747.287m.521-1.86q.993 0 1.698-.379.714-.38 1.082-.992.377-.62.377-1.325v-1.534q-.129.124-.497.233a8 8 0 0 1-.825.178l-.906.132q-.44.054-.737.093-.697.084-1.274.286-.569.202-.913.581-.337.372-.337.992 0 .86.657 1.302.657.434 1.675.434M90.766 26.134v1.86h-6.722v-1.86zm-4.92-2.852h2.396V34.54q0 .675.208 1.015.21.333.537.458.337.116.73.116.288 0 .504-.04.216-.038.337-.061l.432 1.914a4 4 0 0 1-.593.155 5 5 0 0 1-.961.093 4.3 4.3 0 0 1-1.763-.326 3 3 0 0 1-1.322-1.053q-.505-.713-.505-1.79zM92.518 38.035V22.166h2.396v5.897h.144q.21-.372.601-.86.393-.489 1.09-.853.698-.372 1.843-.372 1.49 0 2.66.729 1.17.727 1.835 2.1.673 1.371.673 3.3 0 1.93-.665 3.309-.665 1.372-1.827 2.115-1.162.736-2.652.736-1.122 0-1.835-.364-.705-.365-1.106-.852a6.4 6.4 0 0 1-.617-.868h-.2v1.852zm2.348-5.951q0 1.255.377 2.2.376.946 1.09 1.48.713.527 1.746.527 1.074 0 1.795-.55.722-.558 1.089-1.51.377-.954.377-2.147 0-1.178-.368-2.115-.361-.938-1.09-1.48-.72-.543-1.803-.543-1.041 0-1.763.52-.713.519-1.081 1.449-.369.93-.369 2.169M108.678 38.298q-1.17 0-2.115-.418a3.54 3.54 0 0 1-1.499-1.232q-.544-.806-.544-1.976 0-1.007.4-1.658a2.9 2.9 0 0 1 1.082-1.03 5.8 5.8 0 0 1 1.522-.574q.842-.194 1.715-.294l1.795-.202q.689-.085 1.001-.27.313-.187.313-.605v-.054q0-1.015-.593-1.573-.585-.558-1.747-.558-1.21 0-1.907.519-.689.51-.953 1.139l-2.252-.496q.4-1.085 1.17-1.751a4.8 4.8 0 0 1 1.787-.976 7.2 7.2 0 0 1 2.123-.31q.737 0 1.563.17.833.163 1.554.605.729.441 1.194 1.262.464.814.465 2.116v7.903h-2.34v-1.627h-.096q-.233.45-.697.883-.465.435-1.194.72-.729.288-1.747.287m.521-1.86q.993 0 1.699-.379.713-.38 1.081-.992a2.5 2.5 0 0 0 .377-1.325v-1.534q-.129.124-.497.233-.36.1-.825.178l-.906.132q-.44.054-.737.093a6 6 0 0 0-1.274.286 2.2 2.2 0 0 0-.913.581q-.337.372-.337.992 0 .86.657 1.302.657.434 1.675.434M125.811 29.04l-2.171.371a2.5 2.5 0 0 0-.433-.767 2.14 2.14 0 0 0-.785-.596q-.498-.233-1.242-.233-1.018 0-1.699.442-.681.433-.681 1.123 0 .597.457.961t1.474.597l1.955.434q1.699.38 2.532 1.17.834.789.833 2.053 0 1.07-.641 1.906-.633.829-1.77 1.302-1.13.472-2.62.472-2.068 0-3.374-.852-1.306-.86-1.602-2.44l2.315-.342q.217.876.89 1.325.672.442 1.754.442 1.178 0 1.883-.473.706-.48.706-1.17a1.2 1.2 0 0 0-.433-.937q-.425-.38-1.306-.574l-2.084-.441q-1.722-.38-2.547-1.21-.818-.828-.818-2.099 0-1.053.609-1.844.609-.79 1.683-1.232 1.074-.45 2.46-.45 1.995 0 3.141.838a3.9 3.9 0 0 1 1.514 2.223M132.636 38.275q-1.82 0-3.133-.751a5.16 5.16 0 0 1-2.019-2.131q-.705-1.38-.705-3.231 0-1.83.705-3.223.713-1.395 1.987-2.178 1.282-.782 2.997-.782 1.041 0 2.019.333.977.333 1.754 1.046.778.712 1.226 1.852.45 1.13.449 2.75v.822h-9.783v-1.736h7.435q0-.915-.384-1.62a2.86 2.86 0 0 0-1.082-1.123q-.689-.41-1.618-.41-1.01 0-1.763.48a3.26 3.26 0 0 0-1.154 1.24 3.5 3.5 0 0 0-.401 1.65v1.356q0 1.193.433 2.03.441.837 1.226 1.278.785.434 1.835.434a4 4 0 0 0 1.242-.186q.561-.193.969-.573.41-.38.625-.937l2.268.395a3.94 3.94 0 0 1-.978 1.697q-.696.72-1.754 1.123-1.05.395-2.396.395" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="chatbase-logo-clip">
|
||||
<path fill="#fff" d="M10.63 16.5h127.286v27H10.63z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -105,14 +110,14 @@ export function ChatbaseLogo(props: ComponentProps<'svg'>) {
|
||||
export function CoComLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 92 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<g clipPath="url(#cocom-logo-clip)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M81.632 29.3c0-5.052-2.43-10.113-6.03-13.59-3.068-2.967-7.152-5.09-11.425-5.534-3.876-.397-7.76 0-11.327 1.633a20 20 0 0 0-8.218 6.876 20.2 20.2 0 0 0-2.979 6.76c-.363 1.575-.476 3.24-.69 4.847-.092.673-.164 1.35-.31 2.011a10.74 10.74 0 0 1-6.701 7.713c-4.633 1.702-9.942-.266-12.528-4.422a10.702 10.702 0 0 1 7.495-16.233c1.298-.195 2.62-.15 3.902.135.789.174 1.556.436 2.287.782.098.047.524.32.593.283.069-.036.12-.225.152-.28l3.775-6.909.535-.978a20.9 20.9 0 0 0-16.248-1.345 20.18 20.18 0 0 0-12.593 12.763 19.866 19.866 0 0 0 10.306 24 21.8 21.8 0 0 0 8.178 2.036 20.03 20.03 0 0 0 15.113-5.796c.364-.364.727-.749 1.065-1.142 3.637 4.197 9.131 6.96 14.728 6.997 3.153.07 6.283-.55 9.17-1.819a20.26 20.26 0 0 0 8.048-6.581A19.95 19.95 0 0 0 81.632 29.3m-9.433 1.72a10.777 10.777 0 0 1-11.265 9.67 10.774 10.774 0 1 1 11.265-9.67"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="cocom-logo-clip">
|
||||
<path fill="#fff" d="M10.364 10h71.273v40H10.364z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -211,14 +216,14 @@ export function GoodtapeLogo(props: ComponentProps<'svg'>) {
|
||||
export function GopuffLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 95 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<g clipPath="url(#gopuff-logo-clip)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M61.88 23.943v7.021c0 1.817-.595 2.898-1.994 2.898-1.391 0-1.994-1.08-1.994-2.898v-7.021h-3.968v7.288c0 3.817 2.383 5.393 5.956 5.393 2.95 0 5.955-1.576 5.955-5.393v-7.288zm-30.204-.28c-4.095 0-6.812 2.572-6.812 6.572 0 4.012 2.877 6.39 6.577 6.39 3.413 0 6.725-2.072 6.725-6.605 0-3.74-2.59-6.357-6.49-6.357m-.12 10.219c-1.694 0-2.604-1.602-2.604-3.732 0-1.843.723-3.732 2.616-3.732 1.794 0 2.516 1.889 2.516 3.719.007 2.24-.977 3.745-2.529 3.745M22.04 23.07c-1.332.886-1.68 2.24-1.68 2.24-.722-.885-2.214-1.589-3.96-1.589-2.918 0-5.97 2.534-5.97 6.533 0 3.55 2.577 6.279 5.97 6.279 1.404 0 2.355-.339 3.11-1.068v.267c0 2.254-1.431 3.263-3.312 3.263-1.84 0-3.138-.593-3.947-1.042l-.783 2.93c1.057.535 3.151 1.056 4.83 1.056 1.881 0 3.775-.352 5.16-1.518 1.445-1.237 1.94-3.198 1.94-5.575v-5.49c.027-1.903.723-3.238 1.58-4.182 0-.007-2.938-2.104-2.938-2.104M17.129 33.92c-1.693 0-2.604-1.602-2.604-3.732 0-1.843.723-3.732 2.617-3.732 1.78.013 2.502 1.889 2.502 3.719 0 2.24-.97 3.745-2.515 3.745m28.92-10.258c-4.25 0-6.565 2.742-6.565 6.8v11.203h3.962V35.53c.709.716 1.84 1.114 3.352 1.114 2.837 0 5.801-2.124 5.801-6.644 0-3.973-2.69-6.337-6.55-6.337m-.014 10.219c-1.693 0-2.603-1.602-2.603-3.732 0-1.843.723-3.732 2.617-3.732 1.793 0 2.516 1.889 2.516 3.719-.02 2.24-.984 3.745-2.53 3.745m38.382-12.584v-2.957c-.549-.156-1.231-.267-2.094-.267-1.158 0-2.53.326-3.527 1.238-1.13 1.029-1.606 2.677-1.606 4.149v.462h-4.095v-.625c-.06-2.996 2.951-2.124 3.299-2.013v-2.957c-.555-.156-1.238-.267-2.101-.267-1.158 0-2.53.326-3.526 1.238-1.131 1.029-1.606 2.67-1.606 4.149v.462h-1.693v2.846h1.693v9.588c0 2.631-1.88 2.254-2.717 2.124l-.422 2.846a5 5 0 0 0 1.693.312c1.794.014 3.065-.52 3.921-1.335 1.071-1.029 1.48-2.507 1.48-3.973l.026-9.555h4.062v9.588c.06 2.774-2.168 2.208-2.717 2.123l-.421 2.846a5 5 0 0 0 1.692.313c1.794.013 3.065-.521 3.922-1.335 1.07-1.03 1.472-2.508 1.472-3.973l.026-9.556h3.252v-2.826h-3.299v-.632c.04-3.029 2.938-2.11 3.286-2.013"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="gopuff-logo-clip">
|
||||
<path fill="#fff" d="M10.43 18.061h74v23.878h-74z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -293,13 +298,13 @@ export function KikiLogo(props: ComponentProps<'svg'>) {
|
||||
export function LangchainLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 164 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<g fill="currentColor" clipPath="url(#langchain-logo-clip)">
|
||||
<path d="M47.027 24.422c-.634 0-1.23.245-1.676.691L43.55 26.91a2.36 2.36 0 0 0-.673 1.963c.064.53.297 1.007.673 1.381.26.259.546.423.894.537q.027.157.028.316c0 .477-.186.924-.522 1.259l-.11.11a3.9 3.9 0 0 1-1.558-.957c-.602-.6-1-1.358-1.15-2.19l-.02-.116-.092.074q-.093.075-.175.158l-1.802 1.797a2.363 2.363 0 0 0 1.676 4.034c.608 0 1.213-.23 1.675-.691l1.802-1.797a2.363 2.363 0 0 0 0-3.343 2.34 2.34 0 0 0-.872-.55 1.942 1.942 0 0 1 .552-1.705 4.1 4.1 0 0 1 1.59.99c.6.6.997 1.356 1.15 2.19l.02.116.092-.074q.092-.075.177-.16l1.801-1.796a2.362 2.362 0 0 0-1.676-4.034z" />
|
||||
<path d="M46.061 17.85H22.484C15.766 17.85 10.3 23.3 10.3 30s5.466 12.15 12.183 12.15h23.578c6.718 0 12.184-5.45 12.184-12.15S52.779 17.85 46.06 17.85M34.431 36.9c-.388.08-.825.094-1.122-.216-.11.252-.366.12-.563.087l-.052.146c-.656.044-1.148-.624-1.462-1.128-.62-.336-1.326-.54-1.961-.89-.037.578.09 1.294-.462 1.667-.028 1.112 1.686.132 1.843.96-.121.014-.257-.02-.354.074-.447.434-.96-.328-1.475-.013-.692.347-.762.632-1.618.703-.048-.071-.028-.119.012-.162.241-.28.258-.609.67-.728-.424-.067-.779.166-1.136.351-.464.19-.46-.428-1.174.033-.08-.065-.042-.123.004-.173.181-.22.42-.254.69-.241-1.325-.737-1.949.9-2.56.086-.185.048-.254.214-.37.33-.1-.109-.024-.242-.02-.37-.119-.056-.27-.083-.234-.274-.234-.079-.398.06-.572.191-.157-.121.106-.299.155-.425.14-.243.46-.05.621-.225.46-.261 1.103.163 1.63.091.405.051.907-.364.703-.779-.433-.552-.357-1.276-.366-1.936-.054-.386-.981-.877-1.25-1.292-.33-.374-.589-.808-.847-1.235-.932-1.8-.639-4.111-1.813-5.782-.531.293-1.223.154-1.681-.238-.248.224-.259.519-.278.83-.593-.59-.52-1.709-.045-2.367.194-.26.425-.474.683-.663.059-.042.078-.083.077-.149.47-2.104 3.669-1.699 4.68-.208.733.918.955 2.132 1.787 2.983 1.12 1.223 2.395 2.3 3.428 3.6.978 1.19 1.676 2.585 2.285 3.99.248.467.25 1.04.619 1.438.181.24 1.068.896.876 1.128.11.24.94.572.652.805m15.54-7.18-1.802 1.798a4.15 4.15 0 0 1-1.717 1.031l-.033.01-.012.032a4.1 4.1 0 0 1-.947 1.46l-1.802 1.797a4.14 4.14 0 0 1-2.944 1.215 4.15 4.15 0 0 1-2.945-7.089l1.802-1.796a4.1 4.1 0 0 1 1.714-1.028l.033-.01.012-.032c.21-.551.529-1.045.95-1.465l1.801-1.797a4.14 4.14 0 0 1 2.945-1.215c1.113 0 2.159.431 2.945 1.215a4.12 4.12 0 0 1 1.218 2.937c0 1.11-.432 2.154-1.218 2.936z" />
|
||||
<path d="M27.102 34.724c-.158.615-.21 1.661-1.011 1.692-.066.355.246.488.531.374.282-.129.416.102.51.332.436.063 1.079-.145 1.103-.659-.65-.373-.85-1.083-1.132-1.74zM69.532 24.219h-2.14v12.983h9.18v-2.068h-7.04zM87.331 37.202h2.14v-.023h.029l.004-.096c0-.027.008-.287-.031-.688v-4.832c0-1.818 1.325-2.645 2.557-2.645 1.325 0 1.97.714 1.97 2.183v6.1h2.14v-6.384c0-2.426-1.54-3.933-4.02-3.933-1.054 0-1.994.3-2.733.868l-.02-.69h-2.033v10.14zM104.851 27.807c-.744-.604-1.7-.923-2.779-.923-2.886 0-4.68 1.99-4.68 5.194s1.794 5.212 4.68 5.212c1.018 0 1.927-.277 2.642-.804-.061 1.572-1.046 2.505-2.659 2.505-1.358 0-2.125-.432-2.283-1.284l-.02-.106-2.08.635.02.085c.35 1.693 1.934 2.703 4.238 2.703 1.563 0 2.789-.425 3.645-1.265.863-.847 1.3-2.067 1.3-3.626v-9.07h-1.98l-.043.744zm-.134 4.36c0 1.935-.942 3.09-2.521 3.09-1.693 0-2.663-1.158-2.663-3.178s.971-3.161 2.663-3.161c1.541 0 2.506 1.15 2.521 3zM117.421 33.085c-.444 1.435-1.583 2.226-3.207 2.226-2.322 0-3.766-1.763-3.766-4.601s1.457-4.601 3.802-4.601c1.623 0 2.558.636 3.033 2.061l.222.667 2.021-.949-.189-.534c-.77-2.168-2.547-3.314-5.14-3.314-1.762 0-3.258.637-4.326 1.84-1.057 1.19-1.616 2.86-1.616 4.83 0 4.052 2.338 6.67 5.959 6.67 2.551 0 4.527-1.334 5.286-3.57l.193-.572-2.076-.79-.198.638zM125.414 26.884c-1.022 0-1.932.28-2.646.813v-4.723h-2.14v14.229h2.14v-5.639c0-1.83 1.325-2.663 2.558-2.663 1.324 0 1.969.715 1.969 2.184v6.119h2.141V30.8c0-2.378-1.578-3.914-4.021-3.914zM141.946 22.668c-.795 0-1.372.578-1.372 1.373 0 .794.577 1.372 1.372 1.372s1.372-.578 1.372-1.372-.577-1.373-1.372-1.373M149.568 26.884c-1.054 0-1.994.3-2.732.868l-.02-.69h-2.033v10.14h2.141v-5.639c0-1.818 1.325-2.645 2.557-2.645 1.325 0 1.969.714 1.969 2.183v6.1h2.141v-6.384c0-2.426-1.541-3.933-4.021-3.933zM142.986 27.062h-2.13v5.027a6 6 0 0 0-2.073-1.117v-.512c0-2.239-1.47-3.576-3.933-3.576-2 0-3.497.94-4.106 2.577l-.164.441 1.715 1.265.295-.767c.388-1.012 1.107-1.483 2.26-1.483 1.154 0 1.793.555 1.793 1.65v.056l-.208-.006c-2.291-.036-3.963.5-4.968 1.591-1.029 1.117-.939 2.37-.925 2.509l.009.096h.01c.16 1.562 1.524 2.565 3.507 2.565 1.092 0 2.101-.304 2.864-.86l.008.683h1.843v-3.172l-.04-.029c-.26-.19-.715-.437-1.398-.523a4 4 0 0 0-.608-.037h-.096v.292c0 .67-.77 1.614-2.485 1.614-1.266 0-1.455-.532-1.455-.85v-.032c.01-.142.07-.49.384-.815.4-.413 1.299-.896 3.315-.865 1.46.023 2.58.43 3.33 1.213.92.96 1.093 2.275 1.125 2.79v.415h2.129v-10.14zM81.797 26.84c-2 0-3.496.94-4.106 2.576l-.164.442 1.716 1.265.294-.767c.388-1.012 1.107-1.483 2.26-1.483 1.154 0 1.793.555 1.793 1.65v.256l-2.205.389c-2.584.457-3.894 1.562-3.894 3.282 0 1.721 1.383 2.884 3.523 2.884 1.093 0 2.101-.304 2.864-.86l.009.683h1.842v-6.74c0-2.24-1.47-3.577-3.932-3.577m1.793 5.945v.903c0 .671-.772 1.615-2.486 1.615-1.266 0-1.454-.532-1.454-.85 0-.282 0-.942 2.242-1.358z" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="langchain-logo-clip">
|
||||
<path fill="#fff" d="M10.3 17.76h143.291v24.48H10.3z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -453,7 +458,7 @@ export function PikaLogo(props: ComponentProps<'svg'>) {
|
||||
export function PwcLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 113 60" {...props}>
|
||||
<g clipPath="url(#a)">
|
||||
<g clipPath="url(#pwc-logo-clip)">
|
||||
<mask
|
||||
id="b"
|
||||
width="96"
|
||||
@@ -486,7 +491,7 @@ export function PwcLogo(props: ComponentProps<'svg'>) {
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="pwc-logo-clip">
|
||||
<path fill="#fff" d="M10.43 18.5h92v27h-92z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -570,7 +575,7 @@ export function SubmagicLogo(props: ComponentProps<'svg'>) {
|
||||
export function TempoLogo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 191 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<g fill="currentColor" clipPath="url(#tempo-logo-clip)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M19.46 8.274a8.69 8.69 0 0 0-8.691 8.69v26.072c0 4.8 3.89 8.69 8.69 8.69h26.072c4.799 0 8.69-3.89 8.69-8.69V16.965c0-4.8-3.891-8.69-8.69-8.69zm24.084 12.932 1.987-2.793H19.459l1.987 2.793h9.543l-.021 22.422 1.419 1.863 1.418-1.863.021-22.422zM27.659 38.897l2.364 2.794V22.137H22.11l1.655 2.328h3.905zm7.093 2.794 2.355-2.794V24.465h4.12l1.655-2.328h-8.13z"
|
||||
@@ -579,7 +584,7 @@ export function TempoLogo(props: ComponentProps<'svg'>) {
|
||||
<path d="M73.77 42.601v-21.52h-7.98V17.66h19.703v3.42h-7.981v21.521zM93.816 42.922c-5.63 0-9.3-4.133-9.3-9.478 0-5.416 3.706-9.513 9.264-9.513 5.38 0 8.765 4.24 8.765 9.085v1.675h-14.43c.463 3.492 2.815 5.024 5.7 5.024 2.922 0 4.811-1.782 5.381-3.385l2.744 1.46c-.784 2.032-3.278 5.132-8.124 5.132m-5.594-11.224H98.84c-.036-2.28-1.96-4.632-5.096-4.632-2.707 0-4.917 1.604-5.522 4.632M106.284 42.601v-18.35h3.563v2.316c.855-1.603 2.779-2.636 4.988-2.636 2.459 0 4.454 1.14 5.666 2.85.926-1.211 2.743-2.85 5.879-2.85 4.24 0 7.233 3.064 7.233 6.912v11.758h-3.563V31.698c0-2.743-1.746-4.418-4.133-4.418-2.459 0-4.205 1.817-4.205 4.418v10.903h-3.563V31.698c0-2.743-1.71-4.418-4.133-4.418-2.459 0-4.169 1.817-4.169 4.418v10.903zM137.879 50.76V24.251h3.563v2.352c1.176-1.568 3.385-2.672 6.129-2.672 4.988 0 8.943 3.563 8.943 9.513 0 5.523-3.884 9.478-8.943 9.478-2.744 0-5.06-1.176-6.129-2.744V50.76zm3.492-17.316c0 3.35 2.209 6.164 5.772 6.164s5.772-2.78 5.772-6.164c0-3.42-2.209-6.2-5.772-6.2s-5.772 2.815-5.772 6.2M169.036 42.922c-5.63 0-9.656-3.99-9.656-9.478s4.062-9.513 9.656-9.513 9.656 4.026 9.656 9.513c0 5.451-4.027 9.478-9.656 9.478m-6.057-9.478c0 3.634 2.672 6.2 6.057 6.2 3.42 0 6.057-2.566 6.057-6.2s-2.637-6.2-6.057-6.2-6.057 2.566-6.057 6.2" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="tempo-logo-clip">
|
||||
<path fill="#fff" d="M10.769 8.274h169.462v43.452H10.769z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
@@ -601,11 +606,11 @@ export function UdioLogo(props: ComponentProps<'svg'>) {
|
||||
export function V0Logo(props: ComponentProps<'svg'>) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 101 60" {...props}>
|
||||
<g fill="currentColor" clipPath="url(#a)">
|
||||
<g fill="currentColor" clipPath="url(#v0-logo-clip)">
|
||||
<path d="M57.015 10h19.054c7.726 0 13.99 6.263 13.99 13.99V42.16h-7.827V23.99a7 7 0 0 0-.022-.555L63.158 42.158q.096.003.193.003h18.882v7.391H63.35c-7.726 0-14.162-6.324-14.162-14.05V17.378h7.827v18.124q0 .523.08 1.032l19.472-19.135a6 6 0 0 0-.498-.02H57.015zM37.765 48.191 10.228 17.375h11.078l16.168 18.093V17.375h8.26V45.15c0 4.192-5.176 6.168-7.969 3.042" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="a">
|
||||
<clipPath id="v0-logo-clip">
|
||||
<path fill="#fff" d="M10.228 10h80v40h-80z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
@@ -75,6 +75,10 @@ const rewrites = [
|
||||
source: '/.well-known/security.txt',
|
||||
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/.well-known/security.txt`,
|
||||
},
|
||||
{
|
||||
source: '/openapi.json',
|
||||
destination: 'https://api.supabase.com/api/v1-json',
|
||||
},
|
||||
{ source: '/feed.xml', destination: `/rss.xml` },
|
||||
]
|
||||
|
||||
|
||||
@@ -151,7 +151,10 @@ Deno.serve(async (req: Request) => {
|
||||
// Using a common Import Map for all functions
|
||||
// to use a scope 'deno.json' it must be dinamically resolved base on the 'service_name'
|
||||
const importMapPath = `/home/deno/functions/deno.jsonc`
|
||||
const envVarsObj = Deno.env.toObject()
|
||||
// SUPABASE_FUNCTION_SLUG is listed after the container env snapshot so
|
||||
// nothing in it can shadow the value, and it is per-request because only this
|
||||
// worker knows which function the request resolved to.
|
||||
const envVarsObj = { ...Deno.env.toObject(), SUPABASE_FUNCTION_SLUG: service_name }
|
||||
const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]])
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
|
||||
import { cn, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
|
||||
import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
|
||||
|
||||
interface MultipleCodeBlockFile {
|
||||
@@ -12,6 +12,7 @@ interface MultipleCodeBlockProps {
|
||||
files: MultipleCodeBlockFile[]
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const languageAliases: Record<string, CodeBlockLang> = {
|
||||
@@ -91,7 +92,12 @@ const resolveLanguage = (language: string | undefined, name: string): CodeBlockL
|
||||
return inferLanguageFromName(name) ?? 'js'
|
||||
}
|
||||
|
||||
export const MultipleCodeBlock = ({ files, value, onValueChange }: MultipleCodeBlockProps) => {
|
||||
export const MultipleCodeBlock = ({
|
||||
files,
|
||||
value,
|
||||
onValueChange,
|
||||
className,
|
||||
}: MultipleCodeBlockProps) => {
|
||||
if (!files?.length) {
|
||||
return null
|
||||
}
|
||||
@@ -128,7 +134,7 @@ export const MultipleCodeBlock = ({ files, value, onValueChange }: MultipleCodeB
|
||||
<Tabs
|
||||
value={activeValue}
|
||||
onValueChange={handleValueChange}
|
||||
className="border rounded-lg gap-0 space-y-0 overflow-hidden"
|
||||
className={cn('border rounded-lg gap-0 space-y-0 overflow-hidden', className)}
|
||||
>
|
||||
<TabsList className="bg-surface-75 px-5 gap-5 overflow-x-auto border-0 border-b">
|
||||
{files.map((file) => (
|
||||
|
||||
Reference in new issue
Block a user