merge master into stepped create

This commit is contained in:
Danny White committed 2026-08-31 14:52:55 +10:00
commit c73c2fca21
99 files changed
+2659 -1733

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
name: Studio Dead Code (knip)
on:
push:
branches:
- master
paths:
- 'apps/studio/**'
- 'knip.jsonc'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/studio-knip.yml'
# No `branches` filter: stacked PRs target a sibling branch, not master, and
# the gate should still run on them.
pull_request:
paths:
- 'apps/studio/**'
- 'knip.jsonc'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/studio-knip.yml'
# Cancel old builds on new commit for same workflow + branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
knip:
# Uses larger hosted runner as it significantly decreases build times
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
NODE_OPTIONS: --max-old-space-size=6144
steps:
# No sparse-checkout: knip resolves the whole pnpm workspace graph, so it
# needs every workspace's package.json present even when scoped to studio.
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install deps
run: pnpm install --frozen-lockfile
# `symbols` keeps the readable table in the job log, `github-actions` adds
# the same findings as inline PR annotations.
- name: Run knip
run: pnpm knip --workspace apps/studio --no-progress --reporter symbols --reporter github-actions
@@ -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
+1
View File
@@ -75,6 +75,7 @@ Older Studio code predates some of these conventions. For new or modified code,
## Defaults that differ here
- **ESLint warnings are ratcheted in CI**: the per-rule occurrence count must not increase, so a new `any`, unresolved `exhaustive-deps` warning, or default export fails the build even though it's "only a warning". Check locally with `pnpm --filter studio run lint:ratchet`.
- **Dead files and deps are gated in CI** by knip (`pnpm knip --workspace apps/studio` locally). Framework-convention files nothing imports (routes, Vercel functions, TanStack Start files) belong in `knip.jsonc` under `workspaces["apps/studio"].entry`, not `ignore` — an ignored file's imports aren't traced, so anything only it uses gets reported as dead. There's no inline `knip-ignore` comment; the only per-file opt-out is config. In a PR stack, a file whose first consumer lands in a later PR fails the gate on the earlier one — either add it in the same PR as its first use, or add it to `workspaces["apps/studio"].ignore` with a `used from #NNN` comment and remove it in that PR.
- **Clipboard**: `copyToClipboard` from `'ui'`, and never `await` anything before calling it (Safari requires the write inside the user gesture; lint-enforced) — pass a Promise as the argument instead.
- **`useParams()` comes from `'common'`**, not `next/navigation` — it camelCases keys and returns `string | undefined`.
- **Permissions**: `useAsyncCheckPermissions` from `hooks/misc/useCheckPermissions` (returns `can: true` when self-hosted).
+1
View File
@@ -232,6 +232,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/*`
@@ -1,68 +0,0 @@
import { AlertTitle } from '@ui/components/shadcn/ui/alert'
import { AlertCircle } from 'lucide-react'
import Link from 'next/link'
import { Alert, AlertDescription, Button } from 'ui'
import { DOCS_URL } from '@/lib/constants'
interface CPUWarningsProps {
hasAccessToComputeSizes: boolean
upgradeUrl: string
severity?: 'warning' | 'critical' | null
}
export const CPUWarnings = ({
hasAccessToComputeSizes,
upgradeUrl,
severity,
}: CPUWarningsProps) => {
if (severity === 'warning') {
return (
<Alert variant="warning">
<AlertCircle />
<AlertTitle>Your max CPU usage has exceeded 80%</AlertTitle>
<AlertDescription>
High CPU usage could result in slower queries, disruption of daily back up routines, and
in rare cases, your instance may become unresponsive. If you need more resources, consider
upgrading to a larger compute add-on.
</AlertDescription>
<div className="mt-3 flex items-center space-x-2">
<Button asChild variant="default">
<Link href={`${DOCS_URL}/guides/troubleshooting/high-cpu-usage`}>Learn more</Link>
</Button>
<Button asChild variant="warning">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</div>
</Alert>
)
}
if (severity === 'critical') {
return (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Your max CPU usage has reached 100%</AlertTitle>
<AlertDescription>
High CPU usage could result in slower queries, disruption of daily back up routines, and
in rare cases, your instance may become unresponsive. If you need more resources, consider
upgrading to a larger compute add-on.
</AlertDescription>
<div className="mt-3 flex items-center space-x-2">
<Button asChild variant="default">
<Link href={`${DOCS_URL}/guides/troubleshooting/high-cpu-usage`}>Learn more</Link>
</Button>
<Button asChild variant="danger">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</div>
</Alert>
)
}
return null
}
@@ -1,118 +0,0 @@
import Link from 'next/link'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
// [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth
interface DiskIOBandwidthWarningsProps {
hasAccessToComputeSizes: boolean
hasLatest: boolean
upgradeUrl: string
currentBillingCycleSelected: boolean
latestIoBudgetConsumption: number
highestIoBudgetConsumption: number
}
export const DiskIOBandwidthWarnings = ({
hasAccessToComputeSizes,
hasLatest,
currentBillingCycleSelected,
upgradeUrl,
latestIoBudgetConsumption,
highestIoBudgetConsumption,
}: DiskIOBandwidthWarningsProps) => {
if (hasLatest && latestIoBudgetConsumption >= 100) {
return (
<Admonition
type="destructive"
title="Your Disk IO Budget has been used up"
description={
<>
<p className="mb-4">
Your workload has used up all your Disk IO Budget and is now running at the baseline
performance. If you need consistent disk performance, consider upgrading to a larger
compute add-on.
</p>
<Button asChild variant="danger">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</>
}
/>
)
}
if (hasLatest && latestIoBudgetConsumption >= 80) {
return (
<Admonition
type="destructive"
title="You are close to running out of Disk IO Budget"
description={
<>
<p className="mb-4">
Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO Budget. If you
use up all your Disk IO Budget, your instance will reverted to baseline performance.
If you need consistent disk performance, consider upgrading to a larger compute
add-on.
</p>
<Button asChild variant="danger">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</>
}
/>
)
}
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 100) {
return (
<Admonition
type="warning"
title="You ran out of IO Budget at least once"
description={
<>
<p className="mb-4">
Your workload has used up all your Disk IO Budget and reverted to baseline performance
at least once during this billing cycle. If you need consistent disk performance,
consider upgrading to a larger compute add-on.
</p>
<Button asChild variant="warning">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</>
}
/>
)
}
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 80) {
return (
<Admonition
type="warning"
title="You were close to using all your IO Budget at least once"
description={
<>
<p className="mb-4">
Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO budget during
this billing cycle. If you use up all your Disk IO Budget, your instance will reverted
to baseline performance. If you need consistent disk performance, consider upgrading
to a larger compute add-on.
</p>
<Button asChild variant="warning">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</>
}
/>
)
}
return null
}
@@ -1,68 +0,0 @@
import { AlertTitle } from '@ui/components/shadcn/ui/alert'
import { AlertCircle } from 'lucide-react'
import Link from 'next/link'
import { Alert, AlertDescription, Button } from 'ui'
import { DOCS_URL } from '@/lib/constants'
interface RAMWarningsProps {
hasAccessToComputeSizes: boolean
upgradeUrl: string
severity?: 'warning' | 'critical' | null
}
export const RAMWarnings = ({
hasAccessToComputeSizes,
upgradeUrl,
severity,
}: RAMWarningsProps) => {
if (severity === 'warning') {
return (
<Alert variant="warning">
<AlertCircle />
<AlertTitle>Your memory usage has exceeded 80%</AlertTitle>
<AlertDescription>
High memory usage could result in overall degraded performance, and in rare cases, your
instance may become unresponsive. If you need more resources, consider upgrading to a
larger compute add-on.
</AlertDescription>
<div className="mt-3 flex items-center space-x-2">
<Button asChild variant="default">
<Link href={`${DOCS_URL}/guides/troubleshooting/exhaust-ram`}>Learn more</Link>
</Button>
<Button asChild variant="warning">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</div>
</Alert>
)
}
if (severity === 'critical') {
return (
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Your memory usage has reached 100%</AlertTitle>
<AlertDescription>
High memory usage could result in overall degraded performance, and in rare cases, your
instance may become unresponsive. If you need more resources, consider upgrading to a
larger compute add-on.
</AlertDescription>
<div className="mt-3 flex items-center space-x-2">
<Button asChild variant="default">
<Link href={`${DOCS_URL}/guides/troubleshooting/high-cpu-usage`}>Learn more</Link>
</Button>
<Button asChild variant="danger">
<Link href={upgradeUrl}>
{hasAccessToComputeSizes ? 'Change compute add-on' : 'Upgrade project'}
</Link>
</Button>
</div>
</Alert>
)
}
return null
}
@@ -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',
],
},
@@ -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', () => {
@@ -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(
@@ -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 () => {
@@ -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{' '}
@@ -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,
])
}
@@ -1,42 +0,0 @@
import { AnimatePresence, motion } from 'framer-motion'
interface FormFooterChangeBadgeProps {
formState: {
dirtyFields: Record<string, boolean>
}
}
export const FormFooterChangeBadge = ({ formState }: FormFooterChangeBadgeProps) => {
return (
<AnimatePresence mode="wait">
{Object.keys(formState.dirtyFields).length > 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
>
<motion.div
animate={{
scale: [1, 1.05, 1],
transition: { duration: 0.3 },
}}
>
<p className="text-sm text-foreground-lighter">
<motion.span
key={Object.keys(formState.dirtyFields).length}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1 }}
>
{Object.keys(formState.dirtyFields).length === 1
? '1 change to review'
: `${Object.keys(formState.dirtyFields).length} changes to review`}
</motion.span>
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -274,7 +274,7 @@ export const AdvancedSettings = ({
)
}
layout="horizontal"
description="Leave blank for freshest results. When set, the maximum age of query results 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>
@@ -1,14 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
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 = () => {
const TestForm = ({ serviceAccountKey = '' }: { serviceAccountKey?: string }) => {
const form = useForm<DestinationPanelSchemaType>({
defaultValues: { serviceAccountKey: '' } as DestinationPanelSchemaType,
defaultValues: { projectId: '', datasetId: '', serviceAccountKey },
})
return (
@@ -20,7 +21,7 @@ const TestForm = () => {
describe('BigQueryFields', () => {
it('imports a JSON file into an editable service account key field', async () => {
const { container } = render(<TestForm />)
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) })
@@ -40,7 +41,7 @@ describe('BigQueryFields', () => {
})
it('imports a dropped JSON file into the service account key field', async () => {
render(<TestForm />)
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) })
@@ -54,4 +55,36 @@ describe('BigQueryFields', () => {
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()
})
})
@@ -15,10 +15,21 @@ const MAX_SERVICE_ACCOUNT_KEY_LENGTH = 5000
const readServiceAccountFile = async (
file: File,
form: UseFormReturn<DestinationPanelSchemaType>
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.',
@@ -33,9 +44,11 @@ const readServiceAccountFile = async (
})
form.clearErrors('serviceAccountKey')
} catch {
form.setError('serviceAccountKey', {
message: 'Could not read the selected JSON file.',
})
if (isCurrentRequest()) {
form.setError('serviceAccountKey', {
message: 'Could not read the selected JSON file.',
})
}
}
}
@@ -49,11 +62,13 @@ export const BigQueryFields = ({
className?: string
}) => {
const serviceAccountFileInputRef = useRef<HTMLInputElement>(null)
const fileReadRequestIdRef = useRef(0)
const [isDraggingFile, setIsDraggingFile] = useState(false)
const handleServiceAccountFile = async (file: File | undefined) => {
if (!file) return
await readServiceAccountFile(file, form)
const requestId = ++fileReadRequestIdRef.current
await readServiceAccountFile(file, form, () => requestId === fileReadRequestIdRef.current)
}
const handleServiceAccountFileInputChange = async (event: ChangeEvent<HTMLInputElement>) => {
@@ -140,6 +155,10 @@ export const BigQueryFields = ({
<FormControl>
<TextArea
{...field}
onChange={(event) => {
fileReadRequestIdRef.current += 1
field.onChange(event)
}}
rows={5}
maxLength={MAX_SERVICE_ACCOUNT_KEY_LENGTH}
placeholder={
@@ -76,7 +76,7 @@ export const getClickHouseValidationIssues = (
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, '')
@@ -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({
@@ -269,7 +269,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)
@@ -76,10 +76,10 @@ describe('DestinationTypeSelection', () => {
expect(await screen.findByText('Select a destination type')).toBeInTheDocument()
})
test('renders 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,7 +88,12 @@ describe('DestinationTypeSelection', () => {
fireEvent.click(await screen.findByRole('combobox'))
expect(await screen.findByText('BigQuery')).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()
})
@@ -1,56 +0,0 @@
import { Background, ColorMode, ReactFlow, ReactFlowProvider } from '@xyflow/react'
import { useTheme } from 'next-themes'
import { PrimaryDatabaseNode, ReplicationNode } from './Nodes'
import '@xyflow/react/dist/style.css'
import { SmoothstepEdge } from './Edges'
export const EmptyReplicationDiagram = () => {
return (
<ReactFlowProvider>
<ReplicationDiagramContent />
</ReactFlowProvider>
)
}
const nodeTypes = {
primary: PrimaryDatabaseNode,
replication: ReplicationNode,
}
const edgeTypes = { smoothstep: SmoothstepEdge }
const ReplicationDiagramContent = () => {
const { resolvedTheme } = useTheme()
const backgroundPatternColor =
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
return (
<div className="nowheel relative h-[350px] w-full border border-muted rounded-md overflow-hidden">
<ReactFlow
// FIXME: https://github.com/xyflow/xyflow/issues/4876
colorMode={'' as unknown as ColorMode}
fitView
fitViewOptions={{ minZoom: 0.8, maxZoom: 0.9 }}
className="bg"
zoomOnPinch={false}
zoomOnScroll={false}
nodesDraggable={false}
nodesConnectable={false}
zoomOnDoubleClick={false}
edgesFocusable={false}
edgesReconnectable={false}
defaultNodes={[]}
defaultEdges={[]}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
>
<Background color={backgroundPatternColor} />
</ReactFlow>
</div>
)
}
@@ -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>
</>
}
/>
)
}
@@ -2,7 +2,7 @@ import { useMonaco } from '@monaco-editor/react'
import { acceptUntrustedSql, untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useFlag } from 'common'
import { CodeSquare, Eye, EyeOff, Play } from 'lucide-react'
import type { editor as monacoEditor } from 'monaco-editor'
import type { editor as monacoEditor, Selection } from 'monaco-editor'
import {
forwardRef,
useEffect,
@@ -46,6 +46,7 @@ import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefin
import { ResizableAIWidget } from '@/components/ui/AIEditor/ResizableAIWidget'
import { getEditorSelectionParts, type EditorSelection } from '@/components/ui/AIEditor/utils'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { getEditorValueOrSelection } from '@/components/ui/CodeEditor/CodeEditor.utils'
import { DiffEditor } from '@/components/ui/DiffEditor'
import {
type DatabaseSourceParameters,
@@ -195,6 +196,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
const [promptState, setPromptState] = useState<(EditorSelection & { isOpen: boolean }) | null>(
null
)
const [hasSelection, setHasSelection] = useState(false)
const dialect = query._tag === 'logs' ? 'clickhouse' : 'postgres'
const { requestCompletion, isCompletionLoading } = useQueryEditorAi({ dialect })
@@ -237,13 +239,6 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
const isExecuting = isExecutingSql || isExecutingLogs
const isBusy = isLoadingProject || isResolvingDatabase || isExecuting
/**
* The user's run gesture, and therefore the promotion point for this query's SQL. The
* raw text comes straight off the editor, so it is (re)branded untrusted here — the
* editor boundary — and promoted in the same handler. Which pair of helpers applies is
* decided by `query._tag`, the same discriminant that picks the execution endpoint, so
* Postgres SQL cannot reach the analytics wire or vice versa.
*/
const handleRunQuery = async ({
rawSql = sql,
shouldForce = false,
@@ -371,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}
@@ -391,6 +385,7 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
roleImpersonationState={roleImpersonationState}
/>
)}
{display && onDisplayChange && (
<DisplaySettingsButton
result={result}
@@ -406,16 +401,23 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
tooltip={showQuery ? 'Hide query' : 'Show query'}
onClick={() => onShowQueryChange(!showQuery)}
/>
{toolbarActions}
<ExplorerToolbarAction
loading={isExecuting}
icon={<Play />}
tooltip="Run query"
loading={isExecuting}
tooltip={hasSelection ? 'Run selected query' : 'Run query'}
disabled={
isBusy || pendingProposal !== null || isRunDisabled || sql.trim().length === 0
}
onClick={() => handleRunQuery()}
onClick={() => {
const editorInstance = editorInstanceRef.current
const rawSql = editorInstance ? getEditorValueOrSelection(editorInstance) : sql
handleRunQuery({ rawSql })
}}
>
Run
{hasSelection ? 'Run selected' : 'Run'}
</ExplorerToolbarAction>
</ExplorerToolbarActions>
</ExplorerToolbar>
@@ -447,7 +449,10 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
placeholderClassName="top-[13px]"
className={variant === 'embedded' ? 'h-44' : undefined}
actions={{
runQuery: { enabled: !isRunDisabled, callback: handleRunQuery },
runQuery: {
enabled: !isRunDisabled,
callback: (rawSql: string) => handleRunQuery({ rawSql }),
},
}}
options={{
minimap: { enabled: false },
@@ -458,6 +463,23 @@ export const QueryEditor = forwardRef<QueryEditorHandle, QueryEditorProps>(funct
editor.onDidBlurEditorWidget(() => onSqlCommitRef.current?.(sqlRef.current))
editorInstanceRef.current = editor
const updateHasSelection = (selection: Selection | null | undefined) => {
const noSelection =
!selection ||
(selection.startLineNumber === selection.endLineNumber &&
selection.startColumn === selection.endColumn)
setHasSelection(!noSelection)
}
// A remount (e.g. toggling "Show query" off then on) creates a fresh
// editor with no listener history, so `hasSelection` must be read from
// this instance directly rather than left at whatever the previous
// editor instance last reported.
updateHasSelection(editor.getSelection())
editor.onDidChangeCursorSelection(({ selection }) =>
updateHasSelection(selection)
)
editor.addAction({
id: 'generate-sql',
label: 'Generate SQL',
@@ -1,6 +1,7 @@
import { act, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { HttpResponse } from 'msw'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ExplorerQueryTab } from '../ExplorerQueryTab'
@@ -14,6 +15,8 @@ import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
const testContext = vi.hoisted(() => ({
flags: { otelLegacyLogs: true } as Record<string, boolean>,
params: { ref: 'default', id: 'query-test' } as { ref?: string; id?: string },
/** Simulated editor selection — the mocked CodeEditor's fake editor reads this. */
selectedText: undefined as string | undefined,
}))
vi.mock('common', async (importOriginal) => {
@@ -30,16 +33,51 @@ vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({
CodeEditor: ({
value,
onInputChange,
onMount,
}: {
value: string
onInputChange?: (value: string | undefined) => void
}) => (
<textarea
aria-label="SQL editor"
value={value}
onChange={(e) => onInputChange?.(e.target.value)}
/>
),
onMount?: (editor: any, monaco: any) => void
}) => {
// Kept fresh via a ref (rather than closed over) so the fake editor's
// `getValue` reflects edits made after mount, same as the real editor would.
const valueRef = useRef(value)
valueRef.current = value
useEffect(() => {
const hasSelection = testContext.selectedText !== undefined
// Matches this instance's selection at the moment it mounts — a fresh editor
// instance (e.g. after "Show query" remounts it) has no memory of a prior
// instance's selection, so this must be read fresh rather than carried over.
const selection = hasSelection
? { startLineNumber: 1, endLineNumber: 2, startColumn: 1, endColumn: 5 }
: null
onMount?.(
{
getValue: () => valueRef.current,
getSelection: () => selection,
getModel: () => ({ getValueInRange: () => testContext.selectedText }),
onDidBlurEditorWidget: () => () => {},
// Intentionally never invoked here — the real editor only fires this on a
// subsequent user-driven selection change, not eagerly on mount. Tests that
// need to simulate a live selection change should call this directly.
onDidChangeCursorSelection: () => ({ dispose: () => {} }),
addAction: () => {},
},
{ KeyMod: { CtrlCmd: 1, Shift: 2 }, KeyCode: { KeyK: 3, Enter: 4 } }
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<textarea
aria-label="SQL editor"
value={value}
onChange={(e) => onInputChange?.(e.target.value)}
/>
)
},
}))
vi.mock('../QueryEditor/QuerySourceMenu', () => ({
@@ -132,6 +170,7 @@ beforeEach(() => {
setupSqlEditorMocks()
testContext.flags.otelLegacyLogs = true
testContext.params = { ref: 'default', id: 'query-test' }
testContext.selectedText = undefined
explorerQueryState.removeDraft({ id: 'query-test', projectRef: 'default' })
})
@@ -389,4 +428,75 @@ describe('QueryTab execution', () => {
expect(executedQueries[0]).toContain('create table foo (id int)')
expect(executedQueries[0]).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;')
})
it('runs only the selected text, not the full editor content, when there is an active selection', async () => {
createDraft({ _tag: 'database' }, 'select 1;\nselect 2;')
testContext.selectedText = 'select 2;'
// Background prefetches (intellisense keywords/functions/schemas/table-columns, event
// triggers) hit this same generic pg-meta query endpoint, distinguished from an actual
// run by their non-empty `key` search param — an executed query's `key` is `''`.
const executedQueries: string[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const key = new URL(request.url).searchParams.get('key')
if (key !== '') return HttpResponse.json([])
const { query } = (await request.json()) as { query: string }
executedQueries.push(query)
return HttpResponse.json([])
},
})
renderQueryTab()
const runButton = await screen.findByRole('button', { name: 'Run selected' })
await waitFor(() => expect(runButton).toBeEnabled())
await userEvent.click(runButton)
await waitFor(() => expect(executedQueries).toHaveLength(1))
expect(executedQueries[0]).toContain('select 2')
expect(executedQueries[0]).not.toContain('select 1')
})
it('drops the stale "Run selected" state once the query panel is hidden and shown again', async () => {
createDraft({ _tag: 'database' }, 'select 1;\nselect 2;')
testContext.selectedText = 'select 2;'
const executedQueries: string[] = []
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const key = new URL(request.url).searchParams.get('key')
if (key !== '') return HttpResponse.json([])
const { query } = (await request.json()) as { query: string }
executedQueries.push(query)
return HttpResponse.json([])
},
})
renderQueryTab()
expect(await screen.findByRole('button', { name: 'Run selected' })).toBeInTheDocument()
// Hiding the query panel unmounts CodeEditor entirely. Simulate the selection being
// gone by the time it's shown again (a fresh editor instance has no selection yet).
const hideQueryButton = document.querySelector('.lucide-eye-off')?.closest('button')
expect(hideQueryButton).toBeInstanceOf(HTMLButtonElement)
await userEvent.click(hideQueryButton as HTMLButtonElement)
testContext.selectedText = undefined
const showQueryButton = document.querySelector('.lucide-eye')?.closest('button')
expect(showQueryButton).toBeInstanceOf(HTMLButtonElement)
await userEvent.click(showQueryButton as HTMLButtonElement)
const runButton = await screen.findByRole('button', { name: 'Run' })
expect(screen.queryByRole('button', { name: 'Run selected' })).not.toBeInTheDocument()
await userEvent.click(runButton)
await waitFor(() => expect(executedQueries).toHaveLength(1))
expect(executedQueries[0]).toContain('select 1')
expect(executedQueries[0]).toContain('select 2')
})
})
@@ -1,127 +0,0 @@
import { ChevronDown } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
cn,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
} from 'ui'
import { getHasInstalledObject } from '@/components/layouts/IntegrationsLayout/Integrations.utils'
import PartnerIcon from '@/components/ui/PartnerIcon'
import { useIntegrationsQuery } from '@/data/integrations/integrations-query'
import type { IntegrationName } from '@/data/integrations/integrations.types'
import { useOrganizationsQuery } from '@/data/organizations/organizations-query'
import type { Organization } from '@/types'
export interface OrganizationPickerProps {
integrationName: IntegrationName
configurationId?: string
selectedOrg: Organization | null
onSelectedOrgChange: (organization: Organization) => void
disabled?: boolean
}
const OrganizationPicker = ({
integrationName,
configurationId,
selectedOrg,
onSelectedOrgChange,
disabled,
}: OrganizationPickerProps) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLButtonElement>(null)
const { data: integrationData } = useIntegrationsQuery()
const { data: organizationsData, isPending: isLoadingOrganization } = useOrganizationsQuery()
const installed = useMemo(
() =>
integrationData && organizationsData
? getHasInstalledObject({
integrationName,
integrationData,
organizationsData,
installationId: configurationId,
})
: {},
[configurationId, integrationData, integrationName, organizationsData]
)
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="default"
size="medium"
block
className="justify-start"
loading={isLoadingOrganization}
disabled={disabled}
iconRight={
<span className="grow flex justify-end">
<ChevronDown />
</span>
}
>
<div className="flex gap-2">
<span className={cn('truncate', !selectedOrg && 'text-foreground-light')}>
{selectedOrg?.name ? selectedOrg?.name : 'Choose an organization'}
</span>
{selectedOrg && configurationId && installed[selectedOrg.slug] && (
<Badge>Integration Installed</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-full" side="bottom" align="center" sameWidthAsTrigger>
<Command>
<CommandInput placeholder="Search organizations..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{organizationsData?.map((org) => {
return (
<CommandItem
value={org.slug}
key={org.slug}
className="flex gap-2 items-center"
onSelect={(slug) => {
const org = organizationsData?.find(
(org) => org.slug.toLowerCase() === slug.toLowerCase()
)
if (org) {
onSelectedOrgChange(org)
}
setOpen(false)
}}
>
<PartnerIcon organization={org} />
<span className="truncate">{org.name}</span>{' '}
{configurationId && installed[org.slug] && (
<Badge className="flex-none!">Integration Installed</Badge>
)}
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
)
}
export default OrganizationPicker
@@ -14,7 +14,7 @@ import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter'
import { ServiceHealthTable } from './ServiceHealthTable'
import { useSlowQueriesCount } from './useSlowQueriesCount'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import {
buildUnifiedLogsUrl,
type UnifiedLogType,
@@ -1,183 +0,0 @@
import { Loader2 } from 'lucide-react'
import { AiIconAnimation, Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import type { ClassifiedQuery } from '../QueryInsightsHealth/QueryInsightsHealth.types'
import { ISSUE_DOT_COLORS, ISSUE_ICONS } from './QueryInsightsTable.constants'
import { formatDuration, getColumnName, getTableName } from './QueryInsightsTable.utils'
interface QueryInsightsTableRowProps {
item: ClassifiedQuery
onRowClick?: () => void
onGoToLogs?: () => void
onCreateIndex?: () => void
onExplain?: () => void
onAiSuggestedFix?: () => void
isExplainLoading?: boolean
}
export const QueryInsightsTableRow = ({
item,
onRowClick,
onGoToLogs,
onCreateIndex,
onExplain,
onAiSuggestedFix,
isExplainLoading,
}: QueryInsightsTableRowProps) => {
const IssueIcon = item.issueType ? ISSUE_ICONS[item.issueType] : null
return (
<div
className="flex items-center gap-4 px-6 py-4 border-b hover:bg-surface-100 cursor-pointer group"
onClick={onRowClick}
>
{item.issueType && IssueIcon && (
<div
className={cn(
'h-6 w-6 rounded-full shrink-0 border flex items-center justify-center',
ISSUE_DOT_COLORS[item.issueType]?.border,
ISSUE_DOT_COLORS[item.issueType]?.background
)}
>
<IssueIcon size={14} className={ISSUE_DOT_COLORS[item.issueType].color} />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-foreground line-clamp-1">
<span className="text-foreground">{item.queryType ?? '–'}</span>
{getTableName(item.query) && (
<>
{' '}
<span className="text-foreground-lighter">in</span> {getTableName(item.query)}
</>
)}
{getColumnName(item.query) && (
<>
<span className="text-foreground-lighter">,</span> {getColumnName(item.query)}
</>
)}
</p>
<p
className={cn(
'text-xs mt-0.5 font-mono line-clamp-1',
item.issueType === 'error' && 'text-destructive-600',
item.issueType === 'index' && 'text-warning-600',
item.issueType === 'slow' && 'text-foreground-lighter'
)}
>
{item.hint}
</p>
</div>
<div className="flex items-stretch divide-x divide-border shrink-0 tabular-nums">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col items-end pr-4 cursor-default">
<span
className={cn(
'text-sm font-mono leading-snug',
item.mean_time >= 1000 && 'text-destructive-600'
)}
>
{formatDuration(item.mean_time)}
</span>
<span className="text-[10px] text-foreground-muted uppercase tracking-wide leading-snug">
avg
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-center">
Average execution time per call. High mean time means individual runs are slow —
directly felt by users.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col items-end px-4 cursor-default">
<span className="text-sm font-mono leading-snug">
{item.prop_total_time.toFixed(1)}%
</span>
<span className="text-[10px] text-foreground-muted uppercase tracking-wide leading-snug">
of db
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-center">
Percentage of total database execution time. Fixing high-impact queries has the biggest
overall effect on your database.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex flex-col items-end pl-4 cursor-default">
<span className="text-sm font-mono leading-snug">{item.calls.toLocaleString()}</span>
<span className="text-[10px] text-foreground-muted uppercase tracking-wide leading-snug">
calls
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px] text-center">
Number of times this query ran in the selected time window.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2 shrink-0 justify-end w-[260px]">
<Button
variant="default"
size="tiny"
onClick={(e) => {
e.stopPropagation()
onGoToLogs?.()
}}
>
Go to Logs
</Button>
{(item.issueType === 'index' || item.issueType === 'slow') && (
<Button
variant="default"
size="tiny"
icon={isExplainLoading ? <Loader2 size={12} className="animate-spin" /> : undefined}
onClick={(e) => {
e.stopPropagation()
onExplain?.()
}}
>
Explain
</Button>
)}
{item.issueType === 'index' && (
<Button
variant="primary"
size="tiny"
onClick={(e) => {
e.stopPropagation()
onCreateIndex?.()
}}
>
Create Index
</Button>
)}
{(item.issueType === 'error' || item.issueType === 'slow') && (
<Button
variant="default"
size="tiny"
icon={<AiIconAnimation size={14} />}
onClick={(e) => {
e.stopPropagation()
onAiSuggestedFix?.()
}}
>
Fix with AI
</Button>
)}
</div>
</div>
)
}
@@ -19,4 +19,3 @@ export const ReportPadding = ({
</div>
)
}
export default ReportPadding
@@ -0,0 +1,71 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RestartServerButton } from './RestartServerButton'
import { customRender } from '@/tests/lib/custom-render'
const {
mockUseAsyncCheckPermissions,
mockUseFlag,
mockUseIsFeatureEnabled,
mockUseSelectedProjectQuery,
} = vi.hoisted(() => ({
mockUseAsyncCheckPermissions: vi.fn(),
mockUseFlag: vi.fn(),
mockUseIsFeatureEnabled: vi.fn(),
mockUseSelectedProjectQuery: vi.fn(),
}))
vi.mock('common', async (importOriginal) => ({
...(await importOriginal<typeof import('common')>()),
useFlag: mockUseFlag,
}))
vi.mock('next/router', () => ({
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('@/hooks/misc/useCheckPermissions', () => ({
useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
}))
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: mockUseIsFeatureEnabled,
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useIsAwsK8sCloudProvider: () => false,
useIsProjectActive: () => true,
useSelectedProjectQuery: mockUseSelectedProjectQuery,
}))
describe('RestartServerButton', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseFlag.mockReturnValue(false)
mockUseAsyncCheckPermissions.mockReturnValue({ can: true })
mockUseIsFeatureEnabled.mockReturnValue({ projectSettingsRestartProject: true })
mockUseSelectedProjectQuery.mockReturnValue({
data: { ref: 'default', region: 'us-east-1', status: 'ACTIVE_HEALTHY' },
})
})
it('uses separate tab stops for the primary action and restart type menu', async () => {
const user = userEvent.setup()
customRender(<RestartServerButton />)
const restartProject = screen.getByRole('button', { name: 'Restart project' })
const chooseRestartType = screen.getByRole('button', { name: 'Choose restart type' })
await user.tab()
expect(restartProject).toHaveFocus()
await user.tab()
expect(chooseRestartType).toHaveFocus()
await user.keyboard('{Enter}')
expect(await screen.findByRole('menuitem', { name: /Fast database reboot/ })).toHaveFocus()
expect(screen.getByText(/Other project services remain running/)).toBeVisible()
})
})
@@ -107,6 +107,7 @@ export const RestartServerButton = () => {
{projectSettingsRestartProject ? (
<div className="flex w-full @lg:w-auto">
<ButtonTooltip
type="button"
variant="default"
className={cn(
'flex-1 px-3 hover:z-10 focus-visible:z-10 @lg:flex-none',
@@ -141,8 +142,9 @@ export const RestartServerButton = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="default"
aria-label={`Restart ${entityLabel}`}
aria-label="Choose restart type"
className="shrink-0 rounded-l-none px-[4px] py-[5px] -ml-px focus-visible:z-10 focus-visible:rounded-l-sm"
icon={<ChevronDown />}
disabled={!canRestartProject}
@@ -158,9 +160,9 @@ export const RestartServerButton = () => {
>
<div className="space-y-0.5">
<p className="block text-foreground">Fast database reboot</p>
<p className="block text-foreground-light">
Restarts only the database. Faster, but may not be able to recover from all
failure modes.
<p className="block text-foreground-lighter">
Restarts only the database service, with less downtime than a full project
restart. Other project services remain running.
</p>
</div>
</DropdownMenuItem>
@@ -72,7 +72,7 @@ export const Project = () => {
<div>
<p className="text-sm">{primaryActionLabel}</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">{primaryActionDescription}</p>
<p className="text-sm text-foreground-lighter">{primaryActionDescription}</p>
</div>
</div>
{isPaused ? (
@@ -98,7 +98,7 @@ export const Project = () => {
<div>
<p className="text-sm">Pause {entityLabel}</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
<p className="text-sm text-foreground-lighter">
Your {entityLabel} will not be accessible while it is paused.
</p>
</div>
@@ -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}` },
@@ -102,5 +102,4 @@ export const DEFAULT_STACK_COLORS: ValidStackColor[] = [
export enum DateTimeFormats {
FULL = 'MMM D, YYYY, hh:mma',
FULL_SECONDS = 'MMM D, hh:mm:ssa',
DATE_ONLY = 'MMM D, YYYY',
}
@@ -6,7 +6,11 @@ import { RefObject, useEffect, useRef, useState } from 'react'
import { cn } from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
import { alignEditor, BASE_MONACO_EDITOR_OPTIONS } from './CodeEditor.utils'
import {
alignEditor,
BASE_MONACO_EDITOR_OPTIONS,
getEditorValueOrSelection,
} from './CodeEditor.utils'
import { Markdown } from '@/components/interfaces/Markdown'
import { useLatest } from '@/hooks/misc/useLatest'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -162,13 +166,8 @@ export const CodeEditor = ({
contextMenuGroupId: 'operation',
contextMenuOrder: 0,
run: () => {
const selection = editorRef?.current?.getSelection()
if (!selection) return
const selectedValue = editorRef?.current?.getModel()?.getValueInRange(selection)
const editorValue = editorRef?.current?.getValue()
runQueryCallbackRef.current(selectedValue || editorValue)
if (!editorRef.current) return
runQueryCallbackRef.current(getEditorValueOrSelection(editorRef.current))
},
})
}
@@ -0,0 +1,51 @@
import type { editor } from 'monaco-editor'
import { describe, expect, it } from 'vitest'
import { getEditorValueOrSelection } from './CodeEditor.utils'
const buildEditor = ({
value,
selectedValue,
hasSelection,
}: {
value: string
selectedValue?: string
hasSelection: boolean
}): editor.IStandaloneCodeEditor => {
return {
getValue: () => value,
getSelection: () => (hasSelection ? {} : null),
getModel: () => ({ getValueInRange: () => selectedValue }),
} as unknown as editor.IStandaloneCodeEditor
}
describe('getEditorValueOrSelection', () => {
it('returns the selected text when there is a non-empty selection', () => {
const editorInstance = buildEditor({
value: 'select * from a;\nselect * from b;',
selectedValue: 'select * from b;',
hasSelection: true,
})
expect(getEditorValueOrSelection(editorInstance)).toBe('select * from b;')
})
it('falls back to the full value when there is no selection', () => {
const editorInstance = buildEditor({
value: 'select * from a;',
hasSelection: false,
})
expect(getEditorValueOrSelection(editorInstance)).toBe('select * from a;')
})
it('falls back to the full value when the selection is collapsed (empty range)', () => {
const editorInstance = buildEditor({
value: 'select * from a;',
selectedValue: '',
hasSelection: true,
})
expect(getEditorValueOrSelection(editorInstance)).toBe('select * from a;')
})
})
@@ -28,3 +28,16 @@ export const alignEditor = (editor: editor.IStandaloneCodeEditor) => {
})
})
}
/**
* The text a "run" gesture should act on: the current selection if there is one
* (a collapsed cursor has an empty range, so `getValueInRange` returns `''` and
* this falls through), otherwise the full editor value.
*/
export const getEditorValueOrSelection = (
editor: editor.IStandaloneCodeEditor
): string | undefined => {
const selection = editor.getSelection()
const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined
return selectedValue || editor.getValue()
}
@@ -1,65 +0,0 @@
import { untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { constructHeaders, fetchHandler } from '@/data/fetchers'
import { BASE_PATH } from '@/lib/constants'
import { ResponseError, UseCustomMutationOptions } from '@/types'
export type ParseClientCodeResponse = {
// Named unchecked_sql to highlight that this SQL must never be run
// automatically without user confirmation — it is AI-generated and may not
// be correct.
unchecked_sql: UntrustedSqlFragment | undefined
valid: boolean
}
export type ParseClientCodeVariables = {
code: string
}
export async function generateSqlTitle({ code }: ParseClientCodeVariables) {
try {
const url = `${BASE_PATH}/api/ai/sql/parse-client-code`
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetchHandler(url, {
headers,
method: 'POST',
body: JSON.stringify({ code }),
}).then((res) => res.json())
if (response.error) throw new Error(response.error)
return {
valid: response.valid as boolean,
unchecked_sql: response.sql != null ? untrustedSql(response.sql as string) : undefined,
} satisfies ParseClientCodeResponse
} catch (error) {
throw error
}
}
type ParseClientCodeData = Awaited<ReturnType<typeof generateSqlTitle>>
export const useParseClientCodeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<ParseClientCodeData, ResponseError, ParseClientCodeVariables>,
'mutationFn'
> = {}) => {
return useMutation<ParseClientCodeData, ResponseError, ParseClientCodeVariables>({
mutationFn: (vars) => generateSqlTitle(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to parse client code: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -1,94 +0,0 @@
import { untrustedSql, type UntrustedSqlFragment } from '@supabase/pg-meta'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { fetchPost } from '@/data/fetchers'
import { BASE_PATH } from '@/lib/constants'
import { ResponseError, UseCustomMutationOptions } from '@/types'
export type SqlPolicyGenerateVariables = {
tableName: string
schema?: string
columns?: string[]
projectRef: string
connectionString: string
orgSlug?: string
message?: string
}
type RawSqlPolicy = {
name: string
table: string
schema: string
action: 'PERMISSIVE' | 'RESTRICTIVE'
roles: string[]
command?: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
definition?: string
check?: string
sql: string
}
/**
* AI-generated policy. `definition` and `check` are branded `UntrustedSqlFragment` at the
* boundary — they originate from third-party AI output and must be promoted via
* `acceptUntrustedSql` at a user gesture before executing.
*/
export type SqlPolicyGenerateResponse = Array<
Omit<RawSqlPolicy, 'definition' | 'check'> & {
definition?: UntrustedSqlFragment
check?: UntrustedSqlFragment
}
>
export async function generateSqlPolicy({
tableName,
schema,
columns,
projectRef,
connectionString,
orgSlug,
message,
}: SqlPolicyGenerateVariables): Promise<SqlPolicyGenerateResponse> {
const result = await fetchPost<Array<RawSqlPolicy>>(`${BASE_PATH}/api/ai/sql/policy`, {
tableName,
schema,
columns,
projectRef,
connectionString,
orgSlug,
message,
})
if ('error' in result) throw new ResponseError((result.error as any).message, 400)
return (result as Array<RawSqlPolicy>).map((policy) => ({
...policy,
definition: policy.definition === undefined ? undefined : untrustedSql(policy.definition),
check: policy.check === undefined ? undefined : untrustedSql(policy.check),
}))
}
type SqlPolicyGenerateData = Awaited<ReturnType<typeof generateSqlPolicy>>
export const useSqlPolicyGenerateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<SqlPolicyGenerateData, ResponseError, SqlPolicyGenerateVariables>,
'mutationFn'
> = {}) => {
return useMutation<SqlPolicyGenerateResponse, ResponseError, SqlPolicyGenerateVariables>({
mutationFn: (vars) => generateSqlPolicy(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to generate policy: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -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,
@@ -1,59 +0,0 @@
import { getTablesRlsEnabledStatusSQL } from '@supabase/pg-meta'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { executeSql } from '../sql/execute-sql-mutation'
import { ResponseError, UseCustomMutationOptions } from '@/types'
type CheckTableRLSStatusVariables = {
projectRef?: string
connectionString?: string | null
tables: { schema: string; table: string }[]
}
export type CheckTableRLSStatusResponse = {
schema: string
table: string
rls_enabled: boolean
}
export async function checkTableRLSStatus({
projectRef,
connectionString,
tables,
}: CheckTableRLSStatusVariables) {
const sql = getTablesRlsEnabledStatusSQL({ tables })
const { result } = await executeSql({
projectRef,
connectionString,
sql,
queryKey: ['table-rls-status'],
})
return result as CheckTableRLSStatusResponse[]
}
type CheckTableRLSStatusData = Awaited<ReturnType<typeof checkTableRLSStatus>>
export const useCheckTableRLSStatusMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<CheckTableRLSStatusData, ResponseError, CheckTableRLSStatusVariables>,
'mutationFn'
> = {}) => {
return useMutation<CheckTableRLSStatusData, ResponseError, CheckTableRLSStatusVariables>({
mutationFn: (vars) => checkTableRLSStatus(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to retrieve table RLS statuses: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -1,59 +0,0 @@
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { constructHeaders, fetchHandler } from '@/data/fetchers'
import { BASE_PATH } from '@/lib/constants'
import { ResponseError, UseCustomMutationOptions } from '@/types'
type ParseSQLQueryVariables = { sql: string }
export type ParseSQLQueryOperations = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | undefined
export type ParseSQLQueryResponse = {
tables: string[]
operation: ParseSQLQueryOperations
whereClause: string | null
statementCount: number
}
export async function parseSQLQuery({ sql }: ParseSQLQueryVariables) {
try {
const url = `${BASE_PATH}/api/parse-query`
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetchHandler(url, {
headers,
method: 'POST',
body: JSON.stringify({ sql }),
}).then((res) => res.json())
if (response.error) throw new Error(response.error)
return response as ParseSQLQueryResponse
} catch (error) {
throw error
}
}
type ParseSQLQueryData = Awaited<ReturnType<typeof parseSQLQuery>>
export const useParseSQLQueryMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<ParseSQLQueryData, ResponseError, ParseSQLQueryVariables>,
'mutationFn'
> = {}) => {
return useMutation<ParseSQLQueryData, ResponseError, ParseSQLQueryVariables>({
mutationFn: (vars) => parseSQLQuery(vars),
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to parse query: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
-2
View File
@@ -1,6 +1,4 @@
export const notificationKeys = {
list: () => ['notifications'] as const,
listV2: (options?: { status?: string; limit: number; filters: any }) =>
['notifications', options] as const,
summary: () => ['notifications', 'summary'] as const,
}
@@ -1,43 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { notificationKeys } from './keys'
import { handleError, patch } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export async function archiveAllNotifications() {
const { data, error } = await patch('/platform/notifications/archive-all', {
headers: { Version: '2' },
})
if (error) handleError(error)
return data
}
type NotificationsArchiveAllData = Awaited<ReturnType<typeof archiveAllNotifications>>
export const useNotificationsArchiveAllMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<NotificationsArchiveAllData, ResponseError>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<NotificationsArchiveAllData, ResponseError>({
mutationFn: () => archiveAllNotifications(),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({ queryKey: notificationKeys.list() })
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to archive all notifications: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -1,28 +0,0 @@
import type { Notification } from '@supabase/shared-types/out/notifications'
import { useQuery } from '@tanstack/react-query'
import { notificationKeys } from './keys'
import { get, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type NotificationsResponse = Notification[]
export async function getNotificationsSummary(signal?: AbortSignal) {
const { data, error } = await get('/platform/notifications/summary', {
signal,
})
if (error) handleError(error)
return data
}
export type NotificationsData = Awaited<ReturnType<typeof getNotificationsSummary>>
export type NotificationsError = ResponseError
export const useNotificationsSummaryQuery = <TData = NotificationsData>(
options: UseCustomQueryOptions<NotificationsData, NotificationsError, TData> = {}
) =>
useQuery<NotificationsData, NotificationsError, TData>({
queryKey: notificationKeys.summary(),
queryFn: ({ signal }) => getNotificationsSummary(signal),
...options,
})
@@ -1,69 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { platformAppKeys } from './keys'
import { del, handleError } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type PlatformAppSigningKeyDeleteVariables = {
slug: string
appId: string
keyId: string
}
export async function deletePlatformAppSigningKey({
slug,
appId,
keyId,
}: PlatformAppSigningKeyDeleteVariables) {
const { data, error } = await del(
'/platform/organizations/{slug}/apps/{app_id}/signing-keys/{key_id}',
{
params: { path: { slug, app_id: appId, key_id: keyId } },
}
)
if (error) handleError(error)
return data
}
export type PlatformAppSigningKeyDeleteData = Awaited<
ReturnType<typeof deletePlatformAppSigningKey>
>
export const usePlatformAppSigningKeyDeleteMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<
PlatformAppSigningKeyDeleteData,
ResponseError,
PlatformAppSigningKeyDeleteVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
PlatformAppSigningKeyDeleteData,
ResponseError,
PlatformAppSigningKeyDeleteVariables
>({
mutationFn: (vars) => deletePlatformAppSigningKey(vars),
async onSuccess(data, variables, context) {
await queryClient.invalidateQueries({
queryKey: platformAppKeys.signingKeys(variables.slug, variables.appId),
})
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to delete signing key: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -1,56 +0,0 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { components } from 'api-types'
import { toast } from 'sonner'
import { platformAppKeys } from './keys'
import { handleError, patch } from '@/data/fetchers'
import type { ResponseError, UseCustomMutationOptions } from '@/types'
export type PlatformAppUpdateVariables = {
slug: string
appId: string
} & components['schemas']['UpdatePlatformAppBody']
export async function updatePlatformApp({ slug, appId, ...body }: PlatformAppUpdateVariables) {
const { data, error } = await patch('/platform/organizations/{slug}/apps/{app_id}', {
params: { path: { slug, app_id: appId } },
body,
})
if (error) handleError(error)
return data
}
export type PlatformAppUpdateData = Awaited<ReturnType<typeof updatePlatformApp>>
export const usePlatformAppUpdateMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseCustomMutationOptions<PlatformAppUpdateData, ResponseError, PlatformAppUpdateVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<PlatformAppUpdateData, ResponseError, PlatformAppUpdateVariables>({
mutationFn: (vars) => updatePlatformApp(vars),
async onSuccess(data, variables, context) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: platformAppKeys.list(variables.slug) }),
queryClient.invalidateQueries({
queryKey: platformAppKeys.detail(variables.slug, variables.appId),
}),
])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to update app: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
@@ -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
View File
@@ -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',
})
@@ -1,18 +0,0 @@
import { hasConsented, posthogClient } from 'common'
import { useEffect } from 'react'
export function useTrackExperimentExposure(
experimentId: string,
variant: string | undefined,
extraProperties?: Record<string, any>
) {
useEffect(() => {
if (!variant) return
posthogClient.captureExperimentExposure(
experimentId,
{ variant, ...extraProperties },
hasConsented()
)
}, [experimentId, variant])
}
+3
View File
@@ -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 () => {
+3
View File
@@ -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,
-45
View File
@@ -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>\`
`
+1 -5
View File
@@ -42,8 +42,6 @@
"@ai-sdk/amazon-bedrock": "5.0.55",
"@ai-sdk/mcp": "2.0.31",
"@ai-sdk/openai": "4.0.41",
"@ai-sdk/provider": "4.0.7",
"@ai-sdk/provider-utils": "5.0.26",
"@ai-sdk/react": "4.0.62",
"@aws-sdk/credential-providers": "^3.1041.0",
"@configcat/sdk": "^1.1.0",
@@ -171,6 +169,7 @@
"@graphql-codegen/cli": "5.0.5",
"@graphql-typed-document-node/core": "^3.2.0",
"@jridgewell/remapping": "^2.3.5",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "4.2.4",
"@tanstack/devtools-vite": "^0.6.0",
"@testing-library/dom": "^10.0.0",
@@ -198,12 +197,9 @@
"@vitest/ui": "catalog:",
"api-types": "workspace:*",
"autoevals": "^0.0.132",
"common": "workspace:*",
"config": "workspace:*",
"date-fns": "^2.30.0",
"eslint-config-supabase": "workspace:*",
"eslint-plugin-barrel-files": "^2.0.7",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hook-form": "^0.3.1",
"eslint-plugin-valtio": "^0.8.0",
"graphql-ws": "5.14.1",
+6 -3
View File
@@ -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,
@@ -1,4 +1,4 @@
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import Reports from '@/components/interfaces/Reports/Reports'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout'
@@ -11,7 +11,7 @@ import {
} from '@/components/interfaces/Reports/renderers/ApiRenderers'
import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import ReportWidget from '@/components/interfaces/Reports/ReportWidget'
@@ -9,7 +9,7 @@ import { Button } from 'ui'
import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants'
import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import { SharedAPIReport } from '@/components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
@@ -10,7 +10,7 @@ import { Alert, AlertDescription, Button } from 'ui'
import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import ReportWidget from '@/components/interfaces/Reports/ReportWidget'
@@ -8,7 +8,7 @@ import { Button } from 'ui'
import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import {
EDGE_FUNCTION_REGIONS,
REPORT_DATERANGE_HELPER_LABELS,
@@ -7,7 +7,7 @@ import { Button } from 'ui'
import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants'
import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import { SharedAPIReport } from '@/components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
@@ -8,7 +8,7 @@ import { Button } from 'ui'
import { OBSERVABILITY_DOCS_HREFS } from '@/components/interfaces/Observability/Observability.constants'
import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import { SharedAPIReport } from '@/components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
@@ -17,7 +17,7 @@ import {
} from '@/components/interfaces/Reports/renderers/StorageRenderers'
import ReportFilterBar from '@/components/interfaces/Reports/ReportFilterBar'
import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
import ReportWidget from '@/components/interfaces/Reports/ReportWidget'
@@ -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
+21
View File
@@ -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'
@@ -794,6 +795,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',
@@ -2277,6 +2283,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
@@ -2580,6 +2587,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
@@ -2900,6 +2908,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
@@ -3219,6 +3228,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/'
@@ -3522,6 +3532,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'
@@ -3841,6 +3852,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/'
@@ -4745,6 +4757,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'
@@ -6950,10 +6969,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)
-3
View File
@@ -7,11 +7,8 @@ import type { UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql'
export namespace Notebooks {
export type Content = NotebookSchema.NotebookContent
export type Cell = NotebookSchema.Cell
export type MarkdownCell = NotebookSchema.MarkdownCell
export type DatabaseCell = NotebookSchema.DatabaseCell
export type LogCell = NotebookSchema.LogCell
export type TimeRange = NotebookSchema.TimeRange
export type ChartConfig = NotebookSchema.ChartConfig
}
export interface UserContent<
@@ -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>
+4
View File
@@ -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` },
]
+4 -1
View File
@@ -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 {
+85 -21
View File
@@ -1,5 +1,7 @@
{
"$schema": "https://unpkg.com/knip@5/schema-jsonc.json",
"$schema": "https://unpkg.com/knip@6/schema-jsonc.json",
// Only files + dependencies are gated in CI for now; the export/type surface
// has too much noise to enforce yet.
"exclude": ["types", "exports"],
"ignore": [
"examples/**",
@@ -7,28 +9,90 @@
// skip supabase functions
"supabase/functions/**",
"**/*.test.ts",
// ignore studio public folder
"apps/studio/public/**",
// ignore studio scripts
"apps/studio/scripts/**",
// ignore studio components that are dynamically imported
"apps/studio/components/interfaces/ConnectSheet/content/**",
"apps/studio/components/interfaces/ConnectSheet/ConnectionParameters.tsx",
"apps/studio/components/interfaces/ConnectSheet/DirectConnectionExamples.tsx",
"apps/studio/components/interfaces/ConnectSheet/useMcpUrl.ts",
// ignore studio data templates
"apps/studio/data/__templates/**",
// ignore studio NextJS to TanStack migration files
"apps/studio/compat/**",
"apps/studio/routes/**",
"apps/studio/router.ts",
"apps/studio/start.ts",
"apps/studio/routeTree.gen.ts",
"apps/studio/styles/fonts.css",
// ignore studio evals used in GH action
"apps/studio/evals/**",
// ignore registry blocks in ui-library
"apps/ui-library/registry/default/**",
"apps/ui-library/contentlayer.config.js",
],
"workspaces": {
"apps/studio": {
// Framework-convention files: nothing in source imports these, the
// framework/host discovers them by path. They are `entry` rather than
// `ignore` so knip still traces what they import — an ignored file's
// imports are invisible, which makes everything only it pulls in look
// dead.
"entry": [
// TanStack Start. knip's tanstack-router plugin only looks under
// `src/`, but vite.config.ts sets `srcDirectory: './'`, so the
// conventional files sit at the workspace root and have to be listed.
// routeTree.gen.ts is deliberately absent: router.tsx imports it, so
// it is traced already.
"router.tsx",
"start.ts",
// Route modules are real entry points. Keeping them as entries (not
// ignores) means components reachable only from the TanStack tree stay
// traced as the Next `pages/**` tree is dismantled.
"routes/**/*.{ts,tsx}",
// Next.js compat shims. Reached only through the `nextShims` aliases in
// vite.config.ts (`next/link` -> compat/next/link.tsx), which knip
// cannot resolve, so they need to be entries in their own right.
"compat/**/*.{ts,tsx}",
// Vercel serverless function. Vercel deploys every file under `api/`
// as a Function by convention; knip's vercel plugin only contributes
// `vercel.ts`, so this one is manual. Listing it also traces its
// runtime `import('../instrument.server.mjs')`, which is why
// instrument.server.mjs needs no entry of its own even though
// scripts/serve.js (its other importer) is ignored below.
"api/server.js",
],
"ignore": [
"public/**",
// one-off build/codegen scripts, run by hand or from package.json
"scripts/**",
// dynamically imported
"components/interfaces/ConnectSheet/content/**",
"components/interfaces/ConnectSheet/DirectConnectionExamples.tsx",
// data layer templates, copied when adding a new query/mutation
"data/__templates/**",
// evals are run from a GitHub action, not from the app
"evals/**",
],
// Narrowly scoped suppressions: one issue type, one path. Preferred over
// `ignore` (which drops the file from the project entirely, hiding the
// imports and dependencies it legitimately uses) and over
// `ignoreMembers` (which matches member names across the whole
// workspace).
"ignoreIssues": {
// graphql-codegen `client` preset output (scripts/codegen.ts,
// regenerated by `pnpm build:graphql-types`). The preset always emits
// the full set, so some of it is unreferenced by design. execute.ts in
// the same directory is hand-written and is NOT listed here.
"data/graphql/{fragment-masking,gql,graphql}.ts": ["files"],
// CONSTRAINT_TYPE mirrors the complete closed set of Postgres
// `pg_constraint.contype` values (c/f/p/u/t/x). The members we don't
// read are documentation of the valid values, not dead code. Scoped to
// this file so `enumMembers` keeps working everywhere else.
"data/database/constraints-query.ts": ["enumMembers"],
},
// `vercel` is a globally installed CLI used by the `deploy:staging` script
"ignoreBinaries": ["vercel"],
// Dependencies that are required implicitly — nothing imports them by a
// specifier knip can follow, but removing them breaks the build or the
// runtime.
"ignoreDependencies": [
// vite.config.ts resolves 'lodash-es/package.json' by string via
// createRequire to build the SSR lodash -> lodash-es alias
"lodash-es",
// named as a webpack/turbopack loader string in next.config.ts
// (`loaders: ['raw-loader']`)
"raw-loader",
// runtime hooks for @sentry/nextjs / OpenTelemetry Node
// instrumentation. Must be direct deps under pnpm's strict isolation
// (#35030).
"import-in-the-middle",
"require-in-the-middle",
// deliberate dependency-resolution pin (#45876), never imported
"@babel/core",
],
},
},
}
+2 -1
View File
@@ -48,7 +48,7 @@
"setup:cli": "supabase start -x studio && supabase status --output json > keys.json && node scripts/generateLocalEnv.js",
"generate:types": "supabase gen types typescript --local > ./supabase/functions/common/database-types.ts",
"api:codegen": "cd packages/api-types && pnpm run codegen",
"knip": "pnpx knip@~5.50.0",
"knip": "knip",
"authorize-vercel-deploys": "tsx scripts/authorizeVercelDeploys.ts"
},
"devDependencies": {
@@ -57,6 +57,7 @@
"@types/node": "catalog:",
"eslint": "^9.0.0",
"jsdom": "^28.1.0",
"knip": "6.32.3",
"prettier": "^3.8.0",
"prettier-plugin-sql-cst": "^0.18.0",
"rimraf": "^6.0.0",
@@ -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) => (
+614 -265
View File
File diff suppressed because it is too large. Load diff