---
.../interfaces/MCP/InterstitialShell.tsx | 19 ++---------
.../MCP/InterstitialTerminalScreen.test.tsx | 32 +++++++++++++++++++
.../MCP/InterstitialTerminalScreen.tsx | 12 +++++++
.../MCP/Secrets/McpSecrets.constants.ts | 2 --
.../MCP/Secrets/McpSecrets.utils.test.ts | 4 +--
.../MCP/Secrets/McpSecrets.utils.ts | 6 ++--
.../interfaces/MCP/Secrets/McpSecretsForm.tsx | 6 ++--
.../MCP/Secrets/McpSecretsWrongAccount.tsx | 2 +-
8 files changed, 56 insertions(+), 27 deletions(-)
create mode 100644 apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.test.tsx
diff --git a/apps/studio/components/interfaces/MCP/InterstitialShell.tsx b/apps/studio/components/interfaces/MCP/InterstitialShell.tsx
index 2eec2a32b54..556bea99dad 100644
--- a/apps/studio/components/interfaces/MCP/InterstitialShell.tsx
+++ b/apps/studio/components/interfaces/MCP/InterstitialShell.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
-import { cn, Skeleton } from 'ui'
+import { Skeleton } from 'ui'
import { InterstitialLayout, SupabaseLogo } from '@/components/layouts/InterstitialLayout'
@@ -36,19 +36,6 @@ export const InterstitialShellSkeleton = ({ children }: { children: ReactNode })
)
-export const InterstitialFooter = ({
- children,
- align = 'center',
-}: {
- children: ReactNode
- align?: 'center' | 'start'
-}) => (
-
- {children}
-
+export const InterstitialFooter = ({ children }: { children: ReactNode }) => (
+ {children}
)
diff --git a/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.test.tsx b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.test.tsx
new file mode 100644
index 00000000000..4494b011528
--- /dev/null
+++ b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.test.tsx
@@ -0,0 +1,32 @@
+import { screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { InterstitialTerminalScreen } from './InterstitialTerminalScreen'
+import { customRender } from '@/tests/lib/custom-render'
+
+const baseCopy = {
+ title: 'Key stored',
+ subtitle: 'RESEND_API_KEY is saved for billing-staging.',
+ calloutTitle: 'Next step',
+ calloutBody: 'Head back to your agent and let it know you finished.',
+ footer: 'You can close this tab.',
+}
+
+describe('InterstitialTerminalScreen', () => {
+ it('links to the project Edge Functions secrets page when a project ref is given', () => {
+ customRender( )
+
+ expect(screen.getByRole('link', { name: 'Go to Edge Functions secrets' })).toHaveAttribute(
+ 'href',
+ '/project/abcdefghijklmnopqrst/functions/secrets'
+ )
+ })
+
+ it('omits the Edge Functions secrets link when there is no project to point at', () => {
+ customRender( )
+
+ expect(
+ screen.queryByRole('link', { name: 'Go to Edge Functions secrets' })
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx
index 93f1d1255a8..801f3cdfbdc 100644
--- a/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx
+++ b/apps/studio/components/interfaces/MCP/InterstitialTerminalScreen.tsx
@@ -1,3 +1,4 @@
+import { Button, Separator } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { InterstitialFooter, InterstitialShell } from './InterstitialShell'
@@ -8,6 +9,7 @@ export type InterstitialTerminalCopy = {
calloutTitle: string
calloutBody: string
footer: string
+ projectRef?: string
}
export const InterstitialTerminalScreen = ({
@@ -16,9 +18,19 @@ export const InterstitialTerminalScreen = ({
calloutTitle,
calloutBody,
footer,
+ projectRef,
}: InterstitialTerminalCopy) => (
+
+ {projectRef && (
+
+ Go to Edge Functions secrets
+
+ )}
+
+
+
{footer}
)
diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts
index bea5ba8befb..7f7ff933493 100644
--- a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts
+++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.constants.ts
@@ -2,8 +2,6 @@ export const MCP_SECRETS_ROUTE = '/mcp/secrets'
export const MCP_SECRETS_FLAG = 'McpElicitURLMode'
-export const UNKNOWN_CLIENT_LABEL = 'your AI client'
-
export const SECRETS_TOOL_NAME = 'create_edge_function_secret'
export const MAX_SECRET_NAME_LENGTH = 256
diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts
index 7ba13370581..4753302dfbb 100644
--- a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts
+++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.test.ts
@@ -35,9 +35,7 @@ describe('getSecretsCopy', () => {
it('names the generic client, because v1 never learns which one sent the user', () => {
const copy = getSecretsCopy({ status: 'stored', request, timedOut: false })
- expect(copy.calloutBody).toBe(
- `Go back to your AI client and choose "I've completed it" to finish the tool call.`
- )
+ expect(copy.calloutBody).toBe(`Head back to your agent and let it know you're finished.`)
})
it('sends the user back to the agent instead of the client on timeout', () => {
diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts
index 796fa0434b2..008c5229472 100644
--- a/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts
+++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecrets.utils.ts
@@ -3,7 +3,6 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import utc from 'dayjs/plugin/utc'
import type { InterstitialTerminalCopy } from '../InterstitialTerminalScreen'
-import { UNKNOWN_CLIENT_LABEL } from './McpSecrets.constants'
import type {
SecretProviderHint,
SecretRequest,
@@ -14,7 +13,7 @@ import type {
dayjs.extend(relativeTime)
dayjs.extend(utc)
-const RETURN_TO_CLIENT_STEP = `Go back to ${UNKNOWN_CLIENT_LABEL} and choose "I've completed it" to finish the tool call.`
+const RETURN_TO_CLIENT_STEP = `Head back to your agent and let it know you're finished.`
const CLOSE_TAB_FOOTER = 'You can close this tab.'
@@ -35,6 +34,7 @@ export function getSecretsCopy(state: SecretsOutcomeState): InterstitialTerminal
calloutBody:
'Ask your agent to store the key again. It will find the saved key and finish without sending you back here.',
footer: UNVERIFIED_KEY_FOOTER,
+ projectRef: state.request.ref,
}
}
@@ -44,6 +44,7 @@ export function getSecretsCopy(state: SecretsOutcomeState): InterstitialTerminal
calloutTitle: 'Next step',
calloutBody: RETURN_TO_CLIENT_STEP,
footer: UNVERIFIED_KEY_FOOTER,
+ projectRef: state.request.ref,
}
}
@@ -54,6 +55,7 @@ export function getSecretsCopy(state: SecretsOutcomeState): InterstitialTerminal
calloutTitle: 'Next step',
calloutBody: RETURN_TO_CLIENT_STEP,
footer: CLOSE_TAB_FOOTER,
+ projectRef: state.request.ref,
}
case 'expired':
diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx
index 834a506c478..18fc2b19471 100644
--- a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx
+++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsForm.tsx
@@ -18,7 +18,7 @@ import { Admonition } from 'ui-patterns/Admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import z from 'zod'
-import { InterstitialShell } from '../InterstitialShell'
+import { InterstitialFooter, InterstitialShell } from '../InterstitialShell'
import type { SecretRequest } from './McpSecrets.types'
import {
getOverwriteWarning,
@@ -163,7 +163,7 @@ export const McpSecretsForm = ({
{providerHint && (
<>
-
+
{`Don't have your key? Create one in the `}
{providerHint.dashboardUrl ? (
+
>
)}
diff --git a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx
index 3fec66f87bd..215251db82a 100644
--- a/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx
+++ b/apps/studio/components/interfaces/MCP/Secrets/McpSecretsWrongAccount.tsx
@@ -27,7 +27,7 @@ export const McpSecretsWrongAccount = ({
-
+
After switching, ask your agent to run the tool again. Nothing has been stored.
From 32f17f994d1ddc39585d8c068fb211acfa27c766 Mon Sep 17 00:00:00 2001
From: Sean Oliver <882952+seanoliver@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:48:47 -0700
Subject: [PATCH 023/107] fix(studio): key query chart series by position, not
column name (#50270)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Bug fix. Hardening ahead of any decision to enable session replay.
### What's inside
- ~47 lines of logic: positional series keys, the X-key collision guard,
and the rewiring of rows, cumulative results and axis width
([QueryResultChart.tsx](https://github.com/supabase/supabase/pull/50270/changes#diff-dae994728613498678bcc06a3ea5176b36708e06b531cfc7eddae3e588fff528))
- ~123 lines of tests, one case per chart type, cumulative setting and
edge case
([QueryResultChart.test.tsx](https://github.com/supabase/supabase/pull/50270/changes#diff-6e1c92ad725bac0324db4d079f3d0cd061b8f6e2aecdc21bcdc5969c90dc3c59))
## What is the current behavior?
Session replay is disabled in every environment, and no recordings
exist. This is about what a recording *would* contain if it were ever
switched on: charting a query result would put the customer's own column
names into it.
`ChartContainer` writes every chart config key into a `\n',
+ },
+ {
+ name: 'Markdown frontmatter, raw HTML, and fenced aliases',
+ lang: 'markdown',
+ code: [
+ '---',
+ 'title: "Greeting"',
+ 'published: true',
+ '---',
+ '# Heading',
+ 'Hi
',
+ '',
+ '```ts',
+ 'const value: number = 42',
+ '```',
+ '',
+ '```sh',
+ 'echo "hello ${USER}"',
+ '```',
+ ].join('\n'),
+ },
+ {
+ name: 'MDX frontmatter, JSX, and fenced SQL',
+ lang: 'mdx',
+ code: '---\ntitle: "Greeting"\n---\nimport Component from "./component"\n\n \n\n```sql\nselect 42;\n```',
+ },
+ {
+ name: 'Vue TypeScript and SCSS',
+ lang: 'vue',
+ code: '{{ greeting }}
\n\n',
+ },
+ {
+ name: 'Astro frontmatter and SCSS',
+ lang: 'astro',
+ code: '---\nconst title: string = "Hello"\n---\n{title} \n',
+ },
+ { name: 'empty code', lang: 'typescript', code: '' },
+ { name: 'plain text', lang: null, code: 'plain & punctuation\n second line' },
+ {
+ name: 'JavaScript tagged template injections',
+ lang: 'javascript',
+ code: 'const result = sql`select * from users where id = 42`\nconst style = css`div { color: red; }`\nconst markup = html`Hi
`',
+ },
+ {
+ name: 'JSX tagged template injections',
+ lang: 'jsx',
+ code: 'const style = css`div { color: red; }`\nconst element = {style}
',
+ },
+ {
+ name: 'Markdown Vue and Angular injections',
+ lang: 'markdown',
+ code: '{{ name }}
\n\n@if (active) { Hello
}\n\n```vue\n{{ name }}
\n```',
+ },
+ {
+ name: 'HTML embedded tagged templates',
+ lang: 'html',
+ code: '',
+ },
+]
+
+describe('selective code block highlighting', () => {
+ let baseline: Awaited>
+ let createActualHighlighter: typeof createHighlighter
+ const create = vi.mocked(createHighlighter)
+
+ beforeAll(async () => {
+ const actual = await vi.importActual('shiki')
+ createActualHighlighter = actual.createHighlighter
+ baseline = await createActualHighlighter({
+ themes: [structuredClone(theme)],
+ langs: Object.keys(bundledLanguages),
+ })
+ })
+
+ beforeEach(() => {
+ vi.resetModules()
+ create.mockReset().mockImplementation(createActualHighlighter)
+ })
+
+ afterEach(async () => {
+ for (const result of create.mock.results) {
+ if (result.type === 'return') {
+ await result.value.then(
+ (highlighter) => highlighter.dispose(),
+ () => {}
+ )
+ }
+ }
+ vi.restoreAllMocks()
+ })
+
+ afterAll(() => baseline.dispose())
+
+ function expected({ code, lang }: (typeof fixtures)[number]) {
+ return baseline.codeToTokens(code, {
+ lang: lang || undefined,
+ theme: 'Supabase Theme',
+ tokenizeTimeLimit: 0,
+ tokenizeMaxLineLength: 100_000,
+ }).tokens
+ }
+
+ it('defers initialization until first use and reuses the theme and highlighter', async () => {
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ expect(create).not.toHaveBeenCalled()
+
+ const first = fixtures[0]
+ expect((await highlightCode(first.code, first.lang)).tokens).toEqual(expected(first))
+ expect(create).toHaveBeenCalledTimes(1)
+ const highlighter = await create.mock.results[0].value
+ expect(highlighter.getLoadedLanguages()).toContain('bash')
+ expect(highlighter.getLoadedLanguages()).not.toContain('sql')
+ expect(highlighter.getLoadedLanguages()).not.toContain('markdown')
+ expect(highlighter.getLoadedLanguages()).not.toContain('typescript')
+
+ const loadLanguage = vi.spyOn(highlighter, 'loadLanguage')
+ const loadTheme = vi.spyOn(highlighter, 'loadTheme')
+ const second = fixtures.find(({ lang }) => lang === 'sql')!
+ await highlightCode(second.code, second.lang)
+ expect((await highlightCode(first.code, first.lang)).tokens).toEqual(expected(first))
+ expect(create).toHaveBeenCalledTimes(1)
+ expect(highlighter.getLoadedLanguages()).toContain('sql')
+ expect(loadLanguage.mock.calls.filter((args) => args.length)).toEqual([['sql']])
+ expect(loadTheme.mock.calls.every((args) => args.length === 0)).toBe(true)
+ expect(highlighter.getLoadedThemes()).toEqual(['Supabase Theme'])
+ })
+
+ it('shares initialization across concurrent languages and aliases', async () => {
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ const selected = [fixtures[0], fixtures[0], fixtures[1], fixtures[6]]
+ const results = await Promise.all(selected.map(({ code, lang }) => highlightCode(code, lang)))
+ expect(results.map(({ tokens }) => tokens)).toEqual(selected.map(expected))
+ expect(create).toHaveBeenCalledTimes(1)
+ })
+
+ it.each(fixtures)(
+ 'matches eager token colors, font flags, and offsets for $name',
+ async (fixture) => {
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ const result = await highlightCode(fixture.code, fixture.lang)
+ expect(result.tokens).toEqual(expected(fixture))
+ }
+ )
+
+ it('keeps embedded tokens and CSS-variable colors stable across rendering order', async () => {
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ const selected = fixtures.filter(({ lang }) =>
+ ['markdown', 'vue', 'html', 'typescript'].includes(lang || '')
+ )
+ const first = await Promise.all(selected.map(({ code, lang }) => highlightCode(code, lang)))
+ const reversed = await Promise.all(
+ [...selected].reverse().map(({ code, lang }) => highlightCode(code, lang))
+ )
+ expect(first.map(({ tokens }) => tokens)).toEqual(selected.map(expected))
+ expect(reversed.reverse().map(({ tokens }) => tokens)).toEqual(
+ first.map(({ tokens }) => tokens)
+ )
+ const colors = first.flatMap(({ tokens }) => tokens.flat().map(({ color }) => color))
+ expect(colors).toContain('var(--code-token-keyword)')
+ expect(colors.every((color) => !color || color.startsWith('var(--'))).toBe(true)
+ })
+
+ it('surfaces supported grammar loading failures', async () => {
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ await highlightCode('echo hello', 'bash')
+ const highlighter = await create.mock.results[0].value
+ const failure = new Error('Grammar could not be loaded')
+ vi.spyOn(highlighter, 'loadLanguage').mockImplementation(async (...languages) => {
+ if (languages.length) throw failure
+ })
+ await expect(highlightCode('select 42', 'sql')).rejects.toBe(failure)
+ expect(create).toHaveBeenCalledTimes(1)
+ })
+
+ it('retains native singleton initialization failure without adding retries', async () => {
+ const failure = new Error('Highlighter could not be initialized')
+ create.mockRejectedValue(failure)
+ const { highlightCode } = await import('./CodeBlock.highlight')
+ await expect(highlightCode('echo hello', 'bash')).rejects.toBe(failure)
+ await expect(highlightCode('select 42', 'sql')).rejects.toBe(failure)
+ expect(create).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.highlight.ts b/apps/docs/features/ui/CodeBlock/CodeBlock.highlight.ts
new file mode 100644
index 00000000000..bf287d629cc
--- /dev/null
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.highlight.ts
@@ -0,0 +1,54 @@
+import {
+ bundledLanguages,
+ createHighlighter,
+ makeSingletonHighlighter,
+ type BundledLanguage,
+} from 'shiki'
+
+import theme from './supabase-2.json' with { type: 'json' }
+
+const getHighlighter = makeSingletonHighlighter(() =>
+ createHighlighter({ themes: [structuredClone(theme)], langs: [] })
+)
+
+// keep the eager highlighter's tagged templates and component syntax intact
+const INJECTED_LANGUAGES: Record> = {
+ 'source.js': ['ts-tags'],
+ 'source.ts': ['ts-tags'],
+ 'text.html.markdown': ['vue'],
+ 'text.html.derivative': ['angular-html', 'vue'],
+ 'text.pug': ['vue'],
+}
+
+export async function highlightCode(code: string, lang: BundledLanguage | null) {
+ const highlighter = await getHighlighter()
+ if (lang && !highlighter.getLoadedLanguages().includes(lang)) {
+ const languages = new Set()
+
+ async function collectLanguages(language: BundledLanguage) {
+ if (languages.has(language)) return
+ languages.add(language)
+ const { default: grammars } = await bundledLanguages[language]()
+ await Promise.all(
+ grammars.flatMap(({ embeddedLangsLazy = [], scopeName }) => {
+ const injected = Object.entries(INJECTED_LANGUAGES).flatMap(([scope, languages]) =>
+ scopeName === scope || scopeName.startsWith(`${scope}.`) ? languages : []
+ )
+ return [...embeddedLangsLazy, ...injected].map((embedded) =>
+ collectLanguages(embedded as BundledLanguage)
+ )
+ })
+ )
+ }
+
+ await collectLanguages(lang)
+ await highlighter.loadLanguage(...languages)
+ }
+
+ return highlighter.codeToTokens(code, {
+ lang: lang || undefined,
+ theme: 'Supabase Theme',
+ tokenizeTimeLimit: 0,
+ tokenizeMaxLineLength: 100_000,
+ })
+}
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.test.tsx b/apps/docs/features/ui/CodeBlock/CodeBlock.test.tsx
new file mode 100644
index 00000000000..715db31e754
--- /dev/null
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.test.tsx
@@ -0,0 +1,187 @@
+import { readFile } from 'node:fs/promises'
+import { load } from 'cheerio'
+import { type ComponentProps, type PropsWithChildren } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import { createHighlighter, type BundledLanguage, type ThemeRegistration } from 'shiki'
+import { createTwoslasher } from 'twoslash'
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { CodeBlock } from './CodeBlock'
+import { type CodeToken } from './CodeBlock.client'
+import { getTokenClassName } from './CodeBlock.utils'
+
+vi.mock('./types/lib.deno.d.ts.include', async () => ({
+ default: await readFile(new URL('./types/lib.deno.d.ts.include', import.meta.url), 'utf8'),
+}))
+
+// Keep the real token renderer while isolating unrelated UI imports and tooltip portals.
+vi.mock('ui', async () => {
+ const { createElement } = await import('react')
+ return {
+ cn: (...classes: Array) => classes.filter(Boolean).join(' '),
+ Tooltip: ({ children }: PropsWithChildren) => children,
+ TooltipTrigger: ({ children }: PropsWithChildren) => children,
+ TooltipContent: () => null,
+ Button: ({ variant, ...props }: ComponentProps<'button'> & { variant?: string }) =>
+ createElement('button', props),
+ copyToClipboard: vi.fn(),
+ }
+})
+
+function getLines(block: Awaited>): Array> {
+ return block.props.children[0].props.children.props.lines
+}
+
+const fixtures: Array<{ name: string; lang?: string; code: string }> = [
+ {
+ name: 'JavaScript',
+ lang: 'javascript',
+ code: '// A greeting\nconst greeting = "hello"\ngreeting',
+ },
+ {
+ name: 'TypeScript',
+ lang: 'typescript',
+ code: 'const count: number = 42\nconst values = [count]',
+ },
+ { name: 'SQL', lang: 'sql', code: "select 'hello' as greeting, 42 as count;\n-- A comment" },
+ { name: 'shell', lang: 'shell', code: 'echo "hello ${USER}"\n# A comment' },
+ { name: 'JSON', lang: 'json', code: '{\n "greeting": "hello",\n "count": 42\n}' },
+ { name: 'empty code', lang: 'typescript', code: '' },
+ { name: 'plain text', code: 'plain & punctuation\n second line' },
+ { name: 'unsupported language', lang: 'not-a-language', code: 'plain & punctuation' },
+]
+
+describe('code block serialization and rendering', () => {
+ let highlighter: Awaited>
+
+ beforeAll(async () => {
+ // Shiki mutates theme.colors, so use a fresh raw theme for this independent tokenization.
+ const theme: ThemeRegistration = JSON.parse(
+ await readFile(new URL('./supabase-2.json', import.meta.url), 'utf8')
+ )
+ highlighter = await createHighlighter({
+ themes: [theme],
+ langs: ['javascript', 'typescript', 'sql', 'shell', 'json'],
+ })
+ })
+
+ afterEach(() => vi.restoreAllMocks())
+ afterAll(() => highlighter.dispose())
+
+ it.each(fixtures)(
+ 'preserves $name token boundaries using compact namespaced classes',
+ async ({ lang, code }) => {
+ const block = await CodeBlock({
+ contents: code,
+ lang,
+ skipTypeGeneration: true,
+ hideControls: true,
+ })
+ const lines = getLines(block)
+ const { tokens } = highlighter.codeToTokens(code, {
+ lang: lang === 'not-a-language' ? undefined : (lang as BundledLanguage | undefined),
+ theme: 'Supabase Theme',
+ tokenizeTimeLimit: 0,
+ tokenizeMaxLineLength: 100_000,
+ })
+
+ expect(lines.map((line) => line.map(([content]) => content))).toEqual(
+ tokens.map((line) => line.map(({ content }) => content))
+ )
+ for (const [lineIndex, line] of tokens.entries()) {
+ for (const [tokenIndex, token] of line.entries()) {
+ expect(lines[lineIndex][tokenIndex]).toEqual([
+ token.content,
+ getTokenClassName(token.color, token.fontStyle),
+ ])
+ }
+ }
+
+ const $ = load(renderToStaticMarkup(block))
+ expect(
+ $('.code-line-number')
+ .toArray()
+ .map((element) => $(element).text())
+ ).toEqual(lines.map((_, index) => String(index + 1)))
+ expect(
+ $('.code-line-content')
+ .toArray()
+ .map((element) => $(element).text())
+ ).toEqual(lines.map((line) => line.map(([content]) => content).join('')))
+ expect($('.code-content [style]')).toHaveLength(0)
+ }
+ )
+
+ it('preserves actual Twoslash annotations and offsets after its source edits', async () => {
+ const source = [
+ "const prefix = 'Hello'",
+ '// ---cut---',
+ '/** The name shown in the greeting. */',
+ "const username = 'reader'",
+ 'const message = `${prefix}, ${username}`',
+ 'message',
+ ].join('\n')
+ const twoslashed = createTwoslasher({ compilerOptions: { ignoreDeprecations: '6.0' } })(source)
+ const hovers = twoslashed.nodes.filter((node) => node.type === 'hover')
+ expect(hovers.length).toBeGreaterThan(0)
+ expect(twoslashed.code).not.toContain('// ---cut---')
+
+ const block = await CodeBlock({ contents: source, lang: 'typescript', hideControls: true })
+ const lines = getLines(block)
+ expect(lines.map((line) => line.map(([content]) => content).join('')).join('\n')).toBe(
+ twoslashed.code
+ )
+
+ for (const [lineIndex, line] of lines.entries()) {
+ let offset = 0
+ for (const token of line) {
+ const annotations = hovers
+ .filter((hover) => hover.line === lineIndex && hover.character === offset)
+ .map(({ text, docs, tags }) => ({ text, docs, tags }))
+ expect(token[2]).toEqual(annotations.length ? annotations : undefined)
+ expect(token).toHaveLength(annotations.length ? 3 : 2)
+ offset += token[0].length
+ }
+ }
+ const annotated = lines.flat().filter((token) => token[2])
+ expect(annotated.length).toBeGreaterThan(0)
+ const $ = load(renderToStaticMarkup(block))
+ expect(
+ $('.code-content button')
+ .toArray()
+ .map((element) => $(element).text())
+ ).toEqual(annotated.map(([content]) => content))
+ expect($('.code-content button[tabindex="0"]')).toHaveLength(annotated.length)
+ })
+
+ it('keeps classes stable across repeated renders in a different order', async () => {
+ const render = async ({ lang, code }: (typeof fixtures)[number]) =>
+ getLines(await CodeBlock({ contents: code, lang, skipTypeGeneration: true }))
+ const first = await Promise.all(fixtures.map(render))
+ const reversed = await Promise.all([...fixtures].reverse().map(render))
+ expect(reversed.reverse()).toEqual(first)
+ })
+
+ it('retains unnumbered layout, source text, hidden controls, and the accessible label', async () => {
+ const code = 'echo "hello"\necho "reader"'
+ const block = await CodeBlock({
+ contents: code,
+ lang: 'shell',
+ lineNumbers: false,
+ hideControls: true,
+ })
+ const $ = load(renderToStaticMarkup(block))
+ expect($('.code-line-number')).toHaveLength(0)
+ expect($('.code-content')).toHaveLength(1)
+ expect(
+ $('.code-content > span')
+ .toArray()
+ .map((element) => $(element).text())
+ .join('\n')
+ ).toBe(code)
+ expect($('button')).toHaveLength(0)
+ expect($('.code-scroll').attr('aria-label')).toBe('Shell, 2 lines')
+ expect($('.code-scroll').attr('tabindex')).toBe('0')
+ expect($('pre > code').hasClass('grid')).toBe(false)
+ })
+})
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.tsx b/apps/docs/features/ui/CodeBlock/CodeBlock.tsx
index 84769bebe25..538a2dea08b 100644
--- a/apps/docs/features/ui/CodeBlock/CodeBlock.tsx
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.tsx
@@ -1,23 +1,23 @@
import { type PropsWithChildren } from 'react'
-import { bundledLanguages, createHighlighter, type BundledLanguage } from 'shiki'
+import { bundledLanguages, type BundledLanguage } from 'shiki'
import { createTwoslasher, type ExtraFiles, type NodeHover } from 'twoslash'
import { cn } from 'ui'
import { CodeBlockControls, CodeBlockTokens, type CodeToken } from './CodeBlock.client'
-import { getCodeBlockLabel } from './CodeBlock.utils'
-import theme from './supabase-2.json' with { type: 'json' }
+import { highlightCode } from './CodeBlock.highlight'
+import { getCodeBlockLabel, getTokenClassName } from './CodeBlock.utils'
import denoTypes from './types/lib.deno.d.ts.include'
const extraFiles: ExtraFiles = { 'deno.d.ts': denoTypes }
-const twoslasher = createTwoslasher({ extraFiles })
+const twoslasher = createTwoslasher({
+ extraFiles,
+ // todo: remove once Twoslash stops using deprecated baseUrl and node10 resolution
+ compilerOptions: { ignoreDeprecations: '6.0' },
+})
const TWOSLASHABLE_LANGS: ReadonlyArray = ['js', 'ts', 'javascript', 'typescript']
const BUNDLED_LANGUAGES = Object.keys(bundledLanguages)
-const highlighter = await createHighlighter({
- themes: [theme],
- langs: BUNDLED_LANGUAGES,
-})
export async function CodeBlock({
className,
@@ -52,10 +52,7 @@ export async function CodeBlock({
}
}
- const { tokens } = highlighter.codeToTokens(code, {
- lang: lang || undefined,
- theme: 'Supabase Theme',
- })
+ const { tokens } = await highlightCode(code, lang)
return (
{
let offset = 0
- return line.map(({ content, color, fontStyle, htmlStyle }): CodeToken => {
+ return line.map(({ content, color, fontStyle }): CodeToken => {
const annotations = twoslashed
?.get(lineIndex)
?.get(offset)
?.map(({ text, docs, tags }) => ({ text, docs, tags }))
offset += content.length
- return annotations
- ? [content, color, fontStyle || 0, { annotations, htmlStyle }]
- : [content, color, fontStyle || 0]
+ const className = getTokenClassName(color, fontStyle)
+
+ return annotations ? [content, className, annotations] : [content, className]
})
})}
/>
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.utils.test.ts b/apps/docs/features/ui/CodeBlock/CodeBlock.utils.test.ts
new file mode 100644
index 00000000000..39d7d4a7361
--- /dev/null
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.utils.test.ts
@@ -0,0 +1,58 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+import { getCodeBlockLabel, getTokenClassName } from './CodeBlock.utils'
+import theme from './supabase-2.json' with { type: 'json' }
+
+const themeColors = (): Array
=> {
+ const colors = new Set()
+
+ const walk = (node: unknown) => {
+ if (Array.isArray(node)) {
+ node.forEach(walk)
+ return
+ }
+ if (node === null || typeof node !== 'object') return
+
+ for (const [key, value] of Object.entries(node)) {
+ if ((key === 'foreground' || key === 'background') && typeof value === 'string') {
+ colors.add(value)
+ }
+ walk(value)
+ }
+ }
+
+ walk(theme)
+ return [...colors]
+}
+
+describe('token class names', () => {
+ it('maps every theme color to a class', () => {
+ const colors = themeColors()
+ expect(colors.length).toBeGreaterThan(0)
+
+ const css = readFileSync(new URL('../../../styles/code-block.css', import.meta.url), 'utf8')
+ for (const color of colors) {
+ expect(getTokenClassName(color, 0), `${color} is missing from the class table`).toMatch(
+ /^s-[a-z]$/
+ )
+ expect(css).toContain(`.shiki .${getTokenClassName(color, 0)} {\n color: ${color};`)
+ }
+ })
+
+ it('appends font style classes', () => {
+ expect(getTokenClassName('var(--code-token-comment)', 1)).toBe('s-c s-i')
+ expect(getTokenClassName(undefined, 2 | 4)).toBe('s-b s-l')
+ expect(getTokenClassName(undefined, 0)).toBeUndefined()
+ expect(getTokenClassName(undefined, -1)).toBeUndefined()
+ })
+})
+
+describe('code block labels', () => {
+ it('names language aliases and singular or plural line counts', () => {
+ expect(getCodeBlockLabel('ts', 1)).toBe('TypeScript, 1 line')
+ expect(getCodeBlockLabel('sh', 2)).toBe('Shell, 2 lines')
+ expect(getCodeBlockLabel('rust', 3)).toBe('rust, 3 lines')
+ expect(getCodeBlockLabel(null, 1)).toBe('1 line')
+ })
+})
diff --git a/apps/docs/features/ui/CodeBlock/CodeBlock.utils.ts b/apps/docs/features/ui/CodeBlock/CodeBlock.utils.ts
index b3046183a38..db04c39b574 100644
--- a/apps/docs/features/ui/CodeBlock/CodeBlock.utils.ts
+++ b/apps/docs/features/ui/CodeBlock/CodeBlock.utils.ts
@@ -1,5 +1,3 @@
-import { type CSSProperties } from 'react'
-
// As defined in @shikijs/core/dist/chunk-tokens.d.mts
enum FontStyle {
NotSet = -1,
@@ -9,24 +7,6 @@ enum FontStyle {
Underline = 4,
}
-export function getFontStyle(styleFlags: number): CSSProperties {
- let style: CSSProperties = {}
-
- if (styleFlags & FontStyle.Italic) {
- ;(style ??= {}).fontStyle = 'italic'
- }
-
- if (styleFlags & FontStyle.Bold) {
- ;(style ??= {}).fontWeight = 'bold'
- }
-
- if (styleFlags & FontStyle.Underline) {
- ;(style ??= {}).textDecoration = 'underline'
- }
-
- return style
-}
-
// Fence aliases a screen reader would otherwise read letter by letter
const LANGUAGE_LABELS: Record = {
c: 'C',
@@ -49,3 +29,38 @@ export function getCodeBlockLabel(lang: string | null, lineCount: number): strin
if (!lang) return lines
return `${LANGUAGE_LABELS[lang] ?? lang}, ${lines}`
}
+
+/*
+ * Shiki gives every token a ~30 char css variable name making page heavier
+ * this sends a one letter code instead for perf optimization
+ *
+ * nb. color missing from this table still renders but full length
+ */
+const COLOR_CLASSES: Record = {
+ 'var(--code-foreground)': 's-f',
+ 'var(--code-token-comment)': 's-c',
+ 'var(--code-token-constant)': 's-n',
+ 'var(--code-token-function)': 's-u',
+ 'var(--code-token-keyword)': 's-k',
+ 'var(--code-token-parameter)': 's-a',
+ 'var(--code-token-property)': 's-r',
+ 'var(--code-token-punctuation)': 's-p',
+ 'var(--code-token-string)': 's-s',
+ 'var(--code-token-string-expression)': 's-e',
+ 'var(--code-token-variable)': 's-v',
+}
+
+export const getTokenClassName = (
+ color: string | undefined,
+ fontStyle: number | undefined
+): string | undefined => {
+ const classes: Array = []
+ const colorClass = color === undefined ? undefined : COLOR_CLASSES[color]
+ if (colorClass) classes.push(colorClass)
+ if (fontStyle && fontStyle > 0) {
+ if (fontStyle & FontStyle.Italic) classes.push('s-i')
+ if (fontStyle & FontStyle.Bold) classes.push('s-b')
+ if (fontStyle & FontStyle.Underline) classes.push('s-l')
+ }
+ return classes.length ? classes.join(' ') : undefined
+}
diff --git a/apps/docs/styles/code-block.css b/apps/docs/styles/code-block.css
new file mode 100644
index 00000000000..b70345df653
--- /dev/null
+++ b/apps/docs/styles/code-block.css
@@ -0,0 +1,113 @@
+@utility code-line-number {
+ @apply select-none text-right text-muted/70 pl-3 pr-2 min-h-5 leading-5;
+ @apply bg-[var(--row-rest)] sticky left-0 z-10;
+ @apply group-hover/row:text-muted group-hover/row:bg-[var(--row-hover)];
+ @apply after:pointer-events-none after:absolute after:inset-y-0 after:left-full after:w-4;
+ @apply [--gutter-fade:var(--row-rest)] group-hover/row:[--gutter-fade:var(--row-hover)];
+ @apply after:bg-[image:linear-gradient(to_right,var(--gutter-fade)_0%,color-mix(in_srgb,var(--gutter-fade)_85%,transparent)_25%,color-mix(in_srgb,var(--gutter-fade)_50%,transparent)_50%,color-mix(in_srgb,var(--gutter-fade)_15%,transparent)_75%,transparent_100%)];
+}
+
+@utility code-line-content {
+ @apply min-h-5 leading-5 pl-3 pr-18 group-hover/row:bg-[var(--row-hover)];
+}
+
+/* Code blocks need margin applied when in content container */
+.prose :where(.shiki:not(.shiki-wrapper *), .shiki-wrapper) {
+ margin-block: 2rem;
+}
+
+/* Code block theme colors for use with Supabase Theme */
+[data-theme='dark'],
+.dark {
+ --code-token-keyword: #bda4ff;
+ --code-foreground: #ffffff;
+ --code-token-constant: #3ecf8e;
+ --code-token-string: #ffcda1;
+ --code-token-comment: #949494;
+ --code-token-parameter: #ffffff;
+ --code-token-function: #3ecf8e;
+ --code-token-string-expression: #ffcda1;
+ --code-token-punctuation: #ffffff;
+ --code-token-link: #ffffff;
+ --code-token-number: #ffffff;
+ --code-token-property: #3ecf8e;
+ --code-token-variable: var(--code-foreground);
+ --code-highlight-color: #232323;
+ --shadow-codeblock:
+ 0 0 0 0 var(--border-default), 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 0 rgba(0, 0, 0, 0.3);
+}
+[data-theme='light'],
+.light {
+ --code-token-keyword: #5f2fc4;
+ --code-foreground: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-constant: #15593b;
+ --code-token-string: #9a5200;
+ --code-token-comment: #6a6a6a;
+ --code-token-parameter: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-function: #15593b;
+ --code-token-string-expression: #9a5200;
+ --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-link: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-number: oklch(from var(--foreground-light) l c h / 1);
+ --code-token-property: #15593b;
+ --code-token-variable: var(--code-foreground);
+ --code-highlight-color: #1c1c1c;
+}
+
+/* Token classes emitted by CodeBlock.utils.ts */
+.shiki .s-f {
+ color: var(--code-foreground);
+}
+.shiki .s-c {
+ color: var(--code-token-comment);
+}
+.shiki .s-n {
+ color: var(--code-token-constant);
+}
+.shiki .s-u {
+ color: var(--code-token-function);
+}
+.shiki .s-k {
+ color: var(--code-token-keyword);
+}
+.shiki .s-a {
+ color: var(--code-token-parameter);
+}
+.shiki .s-r {
+ color: var(--code-token-property);
+}
+.shiki .s-p {
+ color: var(--code-token-punctuation);
+}
+.shiki .s-s {
+ color: var(--code-token-string);
+}
+.shiki .s-e {
+ color: var(--code-token-string-expression);
+}
+.shiki .s-v {
+ color: var(--code-token-variable);
+}
+.shiki .s-i {
+ font-style: italic;
+}
+.shiki .s-b {
+ font-weight: bold;
+}
+.shiki .s-l {
+ text-decoration: underline;
+}
+
+/* Word wrap styles for code blocks */
+.shiki[data-wrapped='true'] .code-scroll {
+ overflow-x: hidden !important;
+}
+
+.shiki[data-wrapped='true'] .code-content {
+ white-space: pre-wrap !important;
+ overflow-wrap: anywhere !important;
+}
+
+.shadow-codeblock {
+ box-shadow: var(--shadow-codeblock, none);
+}
diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css
index 6b15dd3e07a..58ba8ace980 100644
--- a/apps/docs/styles/globals.css
+++ b/apps/docs/styles/globals.css
@@ -1,5 +1,7 @@
@import 'config/tailwind.config.css';
@import './../../../packages/ui/build/css/themes/faux-classic-dark.css';
+@import './code-block.css';
+@import './reference.css';
@source '../app/**/*.{ts,tsx,mdx}';
@source '../components/**/*.tsx';
@@ -34,19 +36,6 @@
--font-weight-normal: 450;
}
-@utility code-line-number {
- @apply select-none text-right text-muted/70 pl-3 pr-2 min-h-5 leading-5;
- @apply bg-[var(--row-rest)] sticky left-0 z-10;
- @apply group-hover/row:text-muted group-hover/row:bg-[var(--row-hover)];
- @apply after:pointer-events-none after:absolute after:inset-y-0 after:left-full after:w-4;
- @apply [--gutter-fade:var(--row-rest)] group-hover/row:[--gutter-fade:var(--row-hover)];
- @apply after:bg-[image:linear-gradient(to_right,var(--gutter-fade)_0%,color-mix(in_srgb,var(--gutter-fade)_85%,transparent)_25%,color-mix(in_srgb,var(--gutter-fade)_50%,transparent)_50%,color-mix(in_srgb,var(--gutter-fade)_15%,transparent)_75%,transparent_100%)];
-}
-
-@utility code-line-content {
- @apply min-h-5 leading-5 pl-3 pr-18 group-hover/row:bg-[var(--row-hover)];
-}
-
@layer utilities {
.prose--remove-p-margin p {
margin: 0;
@@ -418,58 +407,3 @@ th code {
[data-rmiz-modal-img] {
image-rendering: high-quality;
}
-
-/* Code blocks need margin applied when in content container */
-.prose :where(.shiki:not(.shiki-wrapper *), .shiki-wrapper) {
- margin-block: 2rem;
-}
-
-/* Code block theme colors for use with Supabase Theme */
-[data-theme='dark'],
-.dark {
- --code-token-keyword: #bda4ff;
- --code-foreground: #ffffff;
- --code-token-constant: #3ecf8e;
- --code-token-string: #ffcda1;
- --code-token-comment: #949494;
- --code-token-parameter: #ffffff;
- --code-token-function: #3ecf8e;
- --code-token-string-expression: #ffcda1;
- --code-token-punctuation: #ffffff;
- --code-token-link: #ffffff;
- --code-token-number: #ffffff;
- --code-token-property: #3ecf8e;
- --code-highlight-color: #232323;
- --shadow-codeblock:
- 0 0 0 0 var(--border-default), 0 1px 2px 0 rgba(0, 0, 0, 0.3), 0 1px 3px 0 rgba(0, 0, 0, 0.3);
-}
-[data-theme='light'],
-.light {
- --code-token-keyword: #5f2fc4;
- --code-foreground: oklch(from var(--foreground-light) l c h / 1);
- --code-token-constant: #15593b;
- --code-token-string: #9a5200;
- --code-token-comment: #6a6a6a;
- --code-token-parameter: oklch(from var(--foreground-light) l c h / 1);
- --code-token-function: #15593b;
- --code-token-string-expression: #9a5200;
- --code-token-punctuation: oklch(from var(--foreground-light) l c h / 1);
- --code-token-link: oklch(from var(--foreground-light) l c h / 1);
- --code-token-number: oklch(from var(--foreground-light) l c h / 1);
- --code-token-property: #15593b;
- --code-highlight-color: #1c1c1c;
-}
-
-/* Word wrap styles for code blocks */
-.shiki[data-wrapped='true'] .code-scroll {
- overflow-x: hidden !important;
-}
-
-.shiki[data-wrapped='true'] .code-content {
- white-space: pre-wrap !important;
- word-break: break-word !important;
-}
-
-.shadow-codeblock {
- box-shadow: var(--shadow-codeblock, none);
-}
diff --git a/apps/docs/styles/reference.css b/apps/docs/styles/reference.css
new file mode 100644
index 00000000000..9f5a7fb997e
--- /dev/null
+++ b/apps/docs/styles/reference.css
@@ -0,0 +1,19 @@
+@utility reference-details-trigger {
+ @apply w-fit rounded-full px-5 py-1 border border-default;
+ @apply flex items-center gap-2 text-left text-sm text-foreground-light;
+ @apply hover:bg-surface-100;
+ @apply data-open:w-full data-open:rounded-b-none data-open:rounded-tl-lg data-open:rounded-tr-lg;
+ @apply transition [transition-property:width,background-color];
+}
+
+@utility reference-details-trigger-icon {
+ @apply text-foreground-muted group-data-closed:rotate-45 transition-transform;
+}
+
+@utility reference-details-panel {
+ @apply border-b border-x border-default rounded-b-lg;
+}
+
+@utility reference-details-item {
+ @apply px-5 py-3 border-t border-default first:border-t-0 flex flex-col gap-3;
+}
diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts
index ba0c0334250..29796cfbbea 100644
--- a/apps/docs/vitest.config.ts
+++ b/apps/docs/vitest.config.ts
@@ -2,6 +2,8 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: { tsconfigPaths: true },
+ // compile jsx for tests without nextjs
+ oxc: { jsx: { runtime: 'automatic' } },
test: {
// Exclude examples from test discovery (does not affect tsconfig scanning)
exclude: ['examples/**/*', '**/node_modules/**'],
From 38f448b01ccd0ea642c908d6e6c24a323f62fdbd Mon Sep 17 00:00:00 2001
From: Anthony Lio
Date: Wed, 16 Sep 2026 00:00:27 +0300
Subject: [PATCH 069/107] fix(ui): tab component (#50183)
## What kind of change does this PR introduce?
bug fix on tab component + a small refactor
## What is the current behavior?
the active tab in an underline list gets border-b-2 while its siblings
get nothing, so it's 2px taller and its label sits higher than the rest
causing a smol layout shift within docs
## What is the new behavior?
- adds one absolute positioned bar that slides between tabs so nothing
moves
- favors track under an underline as an inset shadow vs a border
| state | preview |
| -------|------|
| before | |
| after | |
## Test
1. visit [api
reference](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/reference/javascript/installing?platform=npm&queryGroups=platform)
2. visit a [guide
](https://docs-git-antlio-ui-components-tabs-supabase.vercel.app/docs/guides/database/prisma)
## Summary by CodeRabbit
- **New Features**
- Added an animated tab indicator that follows the active tab and adapts
to layout changes.
- Added support for customizing tab indicator styling.
- Respects reduced-motion preferences by disabling indicator transitions
when appropriate.
- **Style**
- Streamlined tab borders, spacing, and underlined-tab styling.
- Improved tab panel spacing and standardized tab behavior in
documentation examples.
- Centralized easing behavior for smoother overlays, dropdowns, slides,
and panels.
---------
Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
---
.../components/mdx-components.tsx | 19 +++-
.../registry/default/example/tabs-demo.tsx | 2 +
apps/docs/features/ui/PromptPanel.tsx | 5 +-
apps/docs/features/ui/Tabs.tsx | 62 ++++++++-----
packages/config/css/animations.css | 27 +++---
packages/ui/index.tsx | 1 +
.../ui/src/components/shadcn/ui/tabs.test.tsx | 85 +++++++++++++++++
packages/ui/src/components/shadcn/ui/tabs.tsx | 93 +++++++++++++------
.../components/shadcn/ui/useTabIndicator.ts | 66 +++++++++++++
9 files changed, 292 insertions(+), 68 deletions(-)
create mode 100644 packages/ui/src/components/shadcn/ui/tabs.test.tsx
create mode 100644 packages/ui/src/components/shadcn/ui/useTabIndicator.ts
diff --git a/apps/design-system/components/mdx-components.tsx b/apps/design-system/components/mdx-components.tsx
index 97291acb5f9..28504424149 100644
--- a/apps/design-system/components/mdx-components.tsx
+++ b/apps/design-system/components/mdx-components.tsx
@@ -17,6 +17,7 @@ import {
cn,
Tabs,
TabsContent,
+ TabsIndicator,
TabsList,
TabsTrigger,
} from 'ui'
@@ -226,16 +227,26 @@ const components = {
Tabs: ({ className, ...props }: React.ComponentProps) => (
),
- TabsList: ({ className, ...props }: React.ComponentProps) => (
+ TabsList: ({ className, children, ...props }: React.ComponentProps) => (
+ >
+ {children}
+
+
),
TabsTrigger: ({ className, ...props }: React.ComponentProps) => (
Account
Password
+
diff --git a/apps/docs/features/ui/PromptPanel.tsx b/apps/docs/features/ui/PromptPanel.tsx
index 56424f6eea8..197170cbb62 100644
--- a/apps/docs/features/ui/PromptPanel.tsx
+++ b/apps/docs/features/ui/PromptPanel.tsx
@@ -11,7 +11,7 @@ import {
type ReactNode,
} from 'react'
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
-import { cn, copyToClipboard, Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
+import { cn, copyToClipboard, Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from 'ui'
type PromptTitleProps = {
children: ReactNode
@@ -248,12 +248,13 @@ function PromptPanel({ children, className }: PromptPanelProps) {
const header = (
{hasTabs ? (
-
+
{prompts.map((prompt) => (
{prompt.title}
))}
+
) : (
, string> = {
+ tiny: 'calc(var(--spacing) * 2.5)',
+ small: 'calc(var(--spacing) * 3)',
+ medium: 'calc(var(--spacing) * 4)',
+ large: 'calc(var(--spacing) * 4)',
+ xlarge: 'calc(var(--spacing) * 6)',
+}
+
const isString = (maybeStr: unknown): maybeStr is string => typeof maybeStr === 'string'
export const Tabs = ({
@@ -208,12 +224,13 @@ export const Tabs = ({
]
)
return (
-
- ) => {
if (e.key === 'Enter') {
e.preventDefault()
@@ -242,13 +259,14 @@ export const Tabs = ({
{tab.props.icon}
{tab.props.label}
{tab.props.iconRight}
-
+
)
})}
{addOnAfter}
-
+ {type === 'underlined' ? : null}
+
{childrenArr}
-
+
)
}
@@ -263,12 +281,8 @@ interface TabPanelProps {
export const TabPanel = ({ children, id, className }: TabPanelProps) => {
return (
-
+
{children}
-
+
)
}
diff --git a/packages/config/css/animations.css b/packages/config/css/animations.css
index 77e0d959e16..86ab39a14d9 100644
--- a/packages/config/css/animations.css
+++ b/packages/config/css/animations.css
@@ -10,36 +10,39 @@
*/
@theme {
+ --ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-move: cubic-bezier(0.87, 0, 0.13, 1);
+
--animate-fade-in: fadeIn 300ms both;
--animate-fade-out: fadeOut 300ms both;
/* may be unused — verify before pruning */
- --animate-dropdown-content-show: overlayContentShow 100ms cubic-bezier(0.16, 1, 0.3, 1);
+ --animate-dropdown-content-show: overlayContentShow 100ms var(--ease-enter);
/* may be unused — verify before pruning */
- --animate-dropdown-content-hide: overlayContentHide 100ms cubic-bezier(0.16, 1, 0.3, 1);
+ --animate-dropdown-content-hide: overlayContentHide 100ms var(--ease-enter);
- --animate-overlay-show: overlayContentShow 300ms cubic-bezier(0.16, 1, 0.3, 1);
- --animate-overlay-hide: overlayContentHide 300ms cubic-bezier(0.16, 1, 0.3, 1);
+ --animate-overlay-show: overlayContentShow 300ms var(--ease-enter);
+ --animate-overlay-hide: overlayContentHide 300ms var(--ease-enter);
/* may be unused — verify before pruning */
--animate-fade-in-overlay-bg: fadeInOverlayBg 300ms;
/* may be unused — verify before pruning */
--animate-fade-out-overlay-bg: fadeOutOverlayBg 300ms;
- --animate-slide-down: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1);
- --animate-slide-up: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-slide-down: slideDown 300ms var(--ease-move);
+ --animate-slide-up: slideUp 300ms var(--ease-move);
- --animate-slide-down-normal: slideDownNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
- --animate-slide-up-normal: slideUpNormal 300ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-slide-down-normal: slideDownNormal 300ms var(--ease-move);
+ --animate-slide-up-normal: slideUpNormal 300ms var(--ease-move);
/* may be unused — verify before pruning */
- --animate-panel-slide-left-out: panelSlideLeftOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-panel-slide-left-out: panelSlideLeftOut 200ms var(--ease-move);
/* may be unused — verify before pruning */
- --animate-panel-slide-left-in: panelSlideLeftIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-panel-slide-left-in: panelSlideLeftIn 250ms var(--ease-move);
/* may be unused — verify before pruning */
- --animate-panel-slide-right-out: panelSlideRightOut 200ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-panel-slide-right-out: panelSlideRightOut 200ms var(--ease-move);
/* may be unused — verify before pruning */
- --animate-panel-slide-right-in: panelSlideRightIn 250ms cubic-bezier(0.87, 0, 0.13, 1);
+ --animate-panel-slide-right-in: panelSlideRightIn 250ms var(--ease-move);
--animate-line-loading: lineLoading 1.8s infinite;
--animate-line-loading-slower: lineLoading 2.3s infinite;
diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx
index 8e29e0d4635..0beb615dcdb 100644
--- a/packages/ui/index.tsx
+++ b/packages/ui/index.tsx
@@ -113,6 +113,7 @@ export * from './src/components/ShadowScrollArea'
export * from './src/components/shadcn/ui/collapsible'
export * from './src/components/shadcn/ui/tabs'
+export * from './src/components/shadcn/ui/useTabIndicator'
export * from './src/components/shadcn/ui/tooltip'
diff --git a/packages/ui/src/components/shadcn/ui/tabs.test.tsx b/packages/ui/src/components/shadcn/ui/tabs.test.tsx
new file mode 100644
index 00000000000..40a70bd98ef
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/tabs.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+
+import { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from './tabs'
+
+const renderTabs = ({ indicator = false } = {}) =>
+ render(
+
+
+ One
+ Two
+ {indicator ? : null}
+
+ First
+ Second
+
+ )
+
+describe('Tabs', () => {
+ it('borders each trigger when no indicator is rendered', () => {
+ const { container } = renderTabs()
+
+ expect(screen.getByRole('tab', { name: 'One' })).toHaveClass('border-b-2')
+ expect(container.querySelector('[data-tab-indicator]')).toBeNull()
+ })
+
+ it('measures the active tab only for lists that render an indicator', async () => {
+ const withoutIndicator = renderTabs().container.querySelector
('[role="tablist"]')
+ const withIndicator = renderTabs({ indicator: true }).container.querySelector(
+ '[role="tablist"]'
+ )
+
+ await waitFor(() => expect(withIndicator?.dataset.tabIndicatorReady).toBe(''))
+ expect(withoutIndicator?.dataset.tabIndicatorReady).toBeUndefined()
+ })
+
+ it('hides the underline when no trigger is active', async () => {
+ const { container, rerender } = render(
+
+
+ One
+
+
+ First
+
+ )
+ const list = container.querySelector('[role="tablist"]')
+ await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBe(''))
+
+ rerender(
+
+
+ One
+
+
+ First
+
+ )
+
+ await waitFor(() => expect(list?.dataset.tabIndicatorReady).toBeUndefined())
+ })
+
+ it('renders the first trigger as the first element in the list', () => {
+ const { container } = renderTabs({ indicator: true })
+
+ expect(container.querySelector('[role="tablist"]')?.firstElementChild).toBe(
+ screen.getByRole('tab', { name: 'One' })
+ )
+ })
+
+ it('passes a caller ref through to the list element', () => {
+ const ref = { current: null as HTMLDivElement | null }
+
+ render(
+
+
+ One
+
+ First
+
+ )
+
+ expect(ref.current).toBe(screen.getByRole('tablist'))
+ })
+})
diff --git a/packages/ui/src/components/shadcn/ui/tabs.tsx b/packages/ui/src/components/shadcn/ui/tabs.tsx
index f6b4b0a4b36..514c549ce0b 100644
--- a/packages/ui/src/components/shadcn/ui/tabs.tsx
+++ b/packages/ui/src/components/shadcn/ui/tabs.tsx
@@ -1,46 +1,87 @@
'use client'
import { Tabs as TabsPrimitive } from 'radix-ui'
-import * as React from 'react'
+import { useRef, type ComponentPropsWithRef } from 'react'
import { cn } from '../../../lib/utils/cn'
+import { useTabIndicator } from './useTabIndicator'
const Tabs = TabsPrimitive.Root
-const TabsList = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
- ) => {
+ const listRef = useRef(null)
+ useTabIndicator(listRef)
+
+ return (
+ {
+ listRef.current = node
+ if (typeof ref === 'function') ref(node)
+ else if (ref) ref.current = node
+ }}
+ className={cn('group/list relative flex items-center border-b', trackClasses, className)}
+ {...props}
+ >
+ {children}
+
+ )
+}
+
+const TabsIndicator = ({ className, ...props }: ComponentPropsWithRef<'span'>) => (
+
-))
-TabsList.displayName = TabsPrimitive.List.displayName
+)
-const TabsTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
+const TabsTrigger = ({
+ className,
+ ...props
+}: ComponentPropsWithRef) => (
-))
-TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+)
-const TabsContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-TabsContent.displayName = TabsPrimitive.Content.displayName
+const TabsContent = ({
+ className,
+ ...props
+}: ComponentPropsWithRef) => (
+
+)
-export { Tabs, TabsContent, TabsList, TabsTrigger }
+export { Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger }
diff --git a/packages/ui/src/components/shadcn/ui/useTabIndicator.ts b/packages/ui/src/components/shadcn/ui/useTabIndicator.ts
new file mode 100644
index 00000000000..2dac669a7d6
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/useTabIndicator.ts
@@ -0,0 +1,66 @@
+'use client'
+
+import { useLayoutEffect, type RefObject } from 'react'
+
+export const useTabIndicator = (listRef: RefObject) => {
+ useLayoutEffect(() => {
+ const list = listRef.current
+ if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return
+
+ let cancelled = false
+
+ const measure = () => {
+ const trigger = list.querySelector(':scope > [role="tab"][data-state="active"]')
+ if (cancelled) return
+ if (!trigger) {
+ delete list.dataset.tabIndicatorReady
+ return
+ }
+
+ const styles = getComputedStyle(trigger)
+ const paddingLeft = parseFloat(styles.paddingLeft) || 0
+ const paddingRight = parseFloat(styles.paddingRight) || 0
+
+ list.style.setProperty('--active-tab-left', `${trigger.offsetLeft + paddingLeft}px`)
+ list.style.setProperty(
+ '--active-tab-width',
+ `${trigger.offsetWidth - paddingLeft - paddingRight}px`
+ )
+ if (list.dataset.tabIndicatorReady === undefined) {
+ // makes the bar already sitting on the active tab rather than sliding in
+ requestAnimationFrame(() => {
+ if (!cancelled) list.dataset.tabIndicatorReady = ''
+ })
+ }
+ }
+
+ const resizes = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
+ const sync = () => {
+ resizes?.disconnect()
+ resizes?.observe(list)
+ Array.from(list.children).forEach((child) => resizes?.observe(child))
+ measure()
+ }
+
+ sync()
+
+ const mutations =
+ typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(sync)
+ mutations?.observe(list, {
+ attributes: true,
+ attributeFilter: ['data-state'],
+ subtree: true,
+ childList: true,
+ })
+
+ // label widths shift when webfonts land
+ document.fonts?.ready.then(measure).catch(() => {})
+
+ return () => {
+ cancelled = true
+ mutations?.disconnect()
+ resizes?.disconnect()
+ delete list.dataset.tabIndicatorReady
+ }
+ }, [listRef])
+}
From 229d04d65c8b322c2ad54a89bb1fc0762b09a9a7 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Wed, 16 Sep 2026 11:51:49 +1000
Subject: [PATCH 070/107] feat(studio): give pipeline pages a standard detail
header (#50253)
## What kind of change does this PR introduce?
Studio page-layout polish. This follows the merged destination-brand and
pipelines-list work in #50251 and #50252, and now targets `master`
directly.
## What is the current behaviour?
Pipeline child pages use a bespoke heading, provide limited destination
context, and shift substantially while pipeline and destination data
load.
## What is the new behaviour?
Adds standard breadcrumbs and page-header composition, destination
identity, the primary-database-to-destination path, lifecycle actions,
and child-route integration. Layout-matched loading placeholders keep
the header geometry stable until the resolved pipeline data is
available.
Removes the legacy Overview header and actions now owned by the shared
page shell, and restores standard content gutters around the existing
metrics and tables. Pipeline action errors are handled once by the
layout, avoiding duplicate notifications from the underlying mutations.
Pipeline actions are also temporarily disabled while a table reset is
running to prevent conflicting requests.
| Before | After |
| --- | --- |
| | |
## To test
1. Open a pipeline at
`/project/[/database/replication/]`.
2. Confirm there is one page header with one set of actions, followed by
a consistently padded Overview body.
3. Confirm the breadcrumbs, destination logo and name, status,
source-to-destination path, primary lifecycle action, and overflow
actions.
4. Start, stop, or restart the pipeline and confirm its status and
available actions update appropriately.
5. Reset a replicated table and confirm the pipeline lifecycle, update,
and overflow actions remain disabled until the reset finishes.
6. Throttle the initial pipeline and destination requests and confirm
the header retains its final geometry without showing fallback data.
7. Check the page at desktop and phone widths.
---------
Co-authored-by: Joshen Lim
---
.../Database/Replication/PipelineStatus.tsx | 89 ----
.../ReplicationPipelineLayout.test.tsx | 308 +++++++++++++
.../Replication/ReplicationPipelineLayout.tsx | 424 ++++++++++++++++++
.../ReplicationPipelineStatus.tsx | 187 +-------
.../database/replication/[pipelineId].tsx | 14 +-
.../replication-pipeline-request-status.tsx | 15 +
6 files changed, 763 insertions(+), 274 deletions(-)
delete mode 100644 apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx
create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
deleted file mode 100644
index dcf28a2106b..00000000000
--- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { useParams } from 'common'
-import { Loader2 } from 'lucide-react'
-import { Badge, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
-import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
-
-import { getPipelineDisplayState, getStatusName } from './Pipeline.utils'
-import { PipelineStatusName } from './Replication.constants'
-import { InlineLink } from '@/components/ui/InlineLink'
-import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
-import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
-import type { ResponseError } from '@/types'
-
-interface PipelineStatusProps {
- pipelineStatus: ReplicationPipelineStatusData['status'] | undefined
- error: ResponseError | null
- isLoading: boolean
- isError: boolean
- isSuccess: boolean
- requestStatus?: PipelineStatusRequestStatus
- pipelineId?: number
-}
-
-export const PipelineStatus = ({
- pipelineStatus,
- error,
- isLoading,
- isError,
- isSuccess,
- requestStatus,
- pipelineId,
-}: PipelineStatusProps) => {
- const { ref } = useParams()
-
- const statusName = getStatusName(pipelineStatus)
- const displayState = getPipelineDisplayState(requestStatus, statusName)
- const { type, message, label } = displayState
-
- const pipelineLogsUrl = pipelineId
- ? `/project/${ref}/logs/replication-logs?f=${encodeURIComponent(
- JSON.stringify({ pipeline_id: pipelineId })
- )}`
- : `/project/${ref}/logs/replication-logs`
-
- const showLogsCTA = [PipelineStatusName.UNKNOWN, PipelineStatusName.FAILED].includes(
- statusName as PipelineStatusName
- )
-
- return (
- <>
- {isLoading && }
-
- {isError && (
-
-
- Unknown
-
-
- Unable to retrieve status: {error?.message}
-
-
- )}
-
- {isSuccess && (
-
-
-
-
- {label}
-
- {type === 'loading' && }
-
-
-
- {message}{' '}
- {showLogsCTA && (
- <>
- Check the logs for more information.
- >
- )}
-
-
- )}
- >
- )
-}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx
new file mode 100644
index 00000000000..5b5722d2f40
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.test.tsx
@@ -0,0 +1,308 @@
+import { screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import type { components } from 'api-types'
+import { mockAnimationsApi } from 'jsdom-testing-mocks'
+import { HttpResponse } from 'msw'
+import { ReactNode, type AnchorHTMLAttributes } from 'react'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { ReplicationPipelineLayout } from './ReplicationPipelineLayout'
+import { ReplicationPipelineStatus } from './ReplicationPipelineStatus/ReplicationPipelineStatus'
+import {
+ PipelineRequestStatusProvider,
+ usePipelineRequestStatus,
+} from '@/state/replication-pipeline-request-status'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+mockAnimationsApi()
+
+// The global setup pins useParams to { ref: 'default' }; this layout also needs a pipeline id
+vi.mock('common', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ ref: 'default', pipelineId: '42' }),
+}))
+
+vi.mock('next/router', () => ({
+ useRouter: () => ({ push: vi.fn() }),
+}))
+
+vi.mock('next/link', () => ({
+ default: ({
+ href,
+ children,
+ ...props
+ }: AnchorHTMLAttributes & { href: string }) => (
+
+ {children}
+
+ ),
+}))
+
+const renderLayout = (children?: ReactNode) =>
+ customRender(
+
+ {children}
+
+ )
+
+const TableResetFixture = () => {
+ const { setTableResetting } = usePipelineRequestStatus()
+
+ return (
+ <>
+ setTableResetting(42, true)}>
+ Begin table reset
+
+ setTableResetting(42, false)}>
+ Finish table reset
+
+ >
+ )
+}
+
+type PipelineResponse = components['schemas']['PipelineResponse_Output']
+type PipelineStatusResponse = components['schemas']['PipelineStatusResponse_Output']
+type PipelineReplicationStatusResponse =
+ components['schemas']['PipelineReplicationStatusResponse_Output']
+type PipelineVersionResponse = components['schemas']['PipelineVersionResponse_Output']
+type DestinationResponse = components['schemas']['DestinationResponse_Output']
+type DestinationsResponse = components['schemas']['DestinationsResponse_Output']
+type PipelinesResponse = components['schemas']['PipelinesResponse_Output']
+type SourcesResponse = components['schemas']['SourcesResponse_Output']
+
+const PIPELINE: PipelineResponse = {
+ id: 42,
+ config: { publication_name: 'analytics_publication' },
+ destination_id: 7,
+ destination_name: 'Analytics warehouse',
+ replicator_id: 1,
+ source_id: 2,
+ source_name: 'main-db',
+ tenant_id: 'default',
+}
+
+const DESTINATION: DestinationResponse = {
+ id: 7,
+ name: 'Analytics warehouse',
+ tenant_id: 'default',
+ config: {
+ big_query: {
+ connection_pool_size: 8,
+ dataset_id: 'analytics',
+ project_id: 'acme-analytics',
+ },
+ },
+}
+
+const mockPipeline = () => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id',
+ response: () => HttpResponse.json(PIPELINE),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/destinations/:destination_id',
+ response: () => HttpResponse.json(DESTINATION),
+ })
+
+ // The header renders DestinationPanel so Edit destination can open in place
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/destinations',
+ response: () => HttpResponse.json({ destinations: [DESTINATION] }),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines',
+ response: () => HttpResponse.json({ pipelines: [PIPELINE] }),
+ })
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/sources',
+ response: () =>
+ HttpResponse.json({
+ sources: [
+ {
+ tenant_id: 'default',
+ id: 2,
+ name: 'default',
+ config: { host: 'db.internal', port: 5432, name: 'main-db', username: 'etl_user' },
+ },
+ ],
+ }),
+ })
+}
+
+const mockStatus = (name: PipelineStatusResponse['status']['name']) =>
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id/status',
+ response: () =>
+ HttpResponse.json({ pipeline_id: 42, status: { name } }),
+ })
+
+const mockVersion = (hasUpdate: boolean) =>
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id/version',
+ response: () =>
+ HttpResponse.json({
+ pipeline_id: 42,
+ version: { id: 1, name: 'v0.2.0' },
+ ...(hasUpdate ? { new_version: { id: 2, name: 'v0.3.0' } } : {}),
+ }),
+ })
+
+const mockReplicationStatus = () =>
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id/replication-status',
+ response: () =>
+ HttpResponse.json({
+ pipeline_id: 42,
+ table_statuses: [],
+ }),
+ })
+
+describe('ReplicationPipelineLayout', () => {
+ beforeEach(() => {
+ mockPipeline()
+ mockVersion(false)
+ })
+
+ test('renders pipeline identity, chrome actions and the lifecycle action', async () => {
+ mockStatus('started')
+
+ renderLayout(Overview content
)
+
+ expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
+ expect(screen.getByRole('link', { name: 'Replication' })).toHaveAttribute(
+ 'href',
+ '/project/default/database/replication'
+ )
+ expect(screen.getByRole('link', { name: 'View logs' }).getAttribute('href')).toContain(
+ 'pipeline_id'
+ )
+ expect(screen.getByText('Running')).toBeVisible()
+ expect(screen.getByText('Primary database')).toBeVisible()
+ expect(await screen.findByText('BigQuery')).toBeVisible()
+ expect(await screen.findByRole('button', { name: 'Stop' })).toBeVisible()
+ expect(screen.getByText('Overview content')).toBeVisible()
+ })
+
+ test('keeps the pipeline identity structure in place while it loads', async () => {
+ let resolvePipeline: (pipeline: PipelineResponse) => void = () => {}
+ const pipelineResponse = new Promise((resolve) => {
+ resolvePipeline = resolve
+ })
+
+ addAPIMock({
+ method: 'get',
+ path: '/platform/replication/:ref/pipelines/:pipeline_id',
+ response: async () => HttpResponse.json(await pipelineResponse),
+ })
+ mockStatus('started')
+
+ renderLayout()
+
+ expect(screen.getByText('Primary database')).toBeVisible()
+ expect(screen.getByText('Loading destination')).toBeInTheDocument()
+ expect(screen.getAllByText('Loading pipeline')).toHaveLength(2)
+ expect(screen.queryByText('Running')).not.toBeInTheDocument()
+
+ resolvePipeline(PIPELINE)
+
+ expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
+ expect(await screen.findByText('BigQuery')).toBeVisible()
+ expect(await screen.findByText('Running')).toBeVisible()
+ })
+
+ test('composes the legacy overview without duplicating the detail header', async () => {
+ mockStatus('stopped')
+ mockReplicationStatus()
+
+ renderLayout( )
+
+ expect(await screen.findByRole('heading', { name: 'Analytics warehouse' })).toBeVisible()
+ expect(screen.getAllByRole('link', { name: 'View logs' })).toHaveLength(1)
+ expect(screen.getAllByRole('button', { name: 'Start' })).toHaveLength(1)
+ expect(await screen.findByRole('heading', { name: 'Pipeline stopped' })).toBeVisible()
+ })
+
+ test('explains the state in a tooltip when the dot is hovered', async () => {
+ // The dotted underline promises a tooltip; this guards the asChild ref binding that makes it work
+ mockStatus('failed')
+ renderLayout()
+
+ await userEvent.hover(await screen.findByText('Failed'))
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
+ 'Replication has encountered an error. Check the logs for more information.'
+ )
+ expect(screen.queryByRole('link', { name: 'logs' })).not.toBeInTheDocument()
+ })
+
+ test('offers the pipeline actions the primary button does not', async () => {
+ mockStatus('started')
+ renderLayout()
+
+ await screen.findByRole('heading', { name: 'Analytics warehouse' })
+ await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
+
+ // Primary button is Stop while running, so the menu carries Restart instead
+ expect(await screen.findByRole('menuitem', { name: 'Restart pipeline' })).toBeVisible()
+ expect(screen.queryByRole('menuitem', { name: 'Stop pipeline' })).not.toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: 'Edit pipeline' })).toBeVisible()
+ expect(screen.getByRole('menuitem', { name: 'Delete pipeline' })).toBeVisible()
+ })
+
+ test('blocks pipeline actions while a table reset is running', async () => {
+ mockStatus('started')
+ mockVersion(true)
+ renderLayout( )
+
+ const lifecycleAction = await screen.findByRole('button', { name: 'Stop' })
+ const options = screen.getByRole('button', { name: 'Pipeline options' })
+ const update = await screen.findByRole('button', { name: 'Update available' })
+ expect(lifecycleAction).toBeEnabled()
+ expect(options).toBeEnabled()
+ expect(update).toBeEnabled()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Begin table reset' }))
+ expect(lifecycleAction).toBeDisabled()
+ expect(options).toBeDisabled()
+ expect(update).toBeDisabled()
+
+ await userEvent.click(screen.getByRole('button', { name: 'Finish table reset' }))
+ expect(lifecycleAction).toBeEnabled()
+ expect(options).toBeEnabled()
+ expect(update).toBeEnabled()
+ })
+
+ test('offers Stop from the menu when the primary button is Restart', async () => {
+ mockStatus('failed')
+ renderLayout()
+
+ await screen.findByRole('heading', { name: 'Analytics warehouse' })
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Restart' })).toBeEnabled())
+ await userEvent.click(screen.getByRole('button', { name: 'Pipeline options' }))
+
+ expect(await screen.findByRole('menuitem', { name: 'Stop pipeline' })).toBeVisible()
+ expect(screen.queryByRole('menuitem', { name: 'Restart pipeline' })).not.toBeInTheDocument()
+ })
+
+ test('keeps an available update visible and opens its modal', async () => {
+ mockStatus('started')
+ mockVersion(true)
+
+ renderLayout()
+
+ const updateButton = await screen.findByRole('button', { name: 'Update available' })
+ expect(updateButton).toHaveClass('bg-brand-400')
+ await userEvent.click(updateButton)
+ // The trigger button shares this name, so match the dialog's heading specifically
+ expect(await screen.findByRole('heading', { name: 'Update available' })).toBeVisible()
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
new file mode 100644
index 00000000000..77c453d2996
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineLayout.tsx
@@ -0,0 +1,424 @@
+import { useParams } from 'common'
+import {
+ ArrowRight,
+ ArrowUpCircle,
+ CircleStop,
+ Edit,
+ MoreVertical,
+ Play,
+ RotateCcw,
+ Trash,
+} from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { parseAsInteger, useQueryState } from 'nuqs'
+import { PropsWithChildren, useEffect, useState, type ReactNode } from 'react'
+import { toast } from 'sonner'
+import {
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from 'ui'
+import { PageBreadcrumbs, PageBreadcrumbsActions } from 'ui-patterns/PageBreadcrumbs'
+import {
+ PageHeader,
+ PageHeaderAside,
+ PageHeaderDescription,
+ PageHeaderIcon,
+ PageHeaderMeta,
+ PageHeaderSummary,
+ PageHeaderTitle,
+} from 'ui-patterns/PageHeader'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import { DeleteDestination } from './DeleteDestination'
+import { DestinationLogo } from './DestinationLogo'
+import { DestinationPanel } from './DestinationPanel/DestinationPanel'
+import {
+ getPipelineDisplayState,
+ getStatusName,
+ PIPELINE_ACTIONABLE_STATES,
+} from './Pipeline.utils'
+import { PipelineStatePill } from './PipelineStatePill'
+import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants'
+import { getReplicationDestinationType } from './ReplicationDiagram/Nodes.utils'
+import { UpdateVersionModal } from './UpdateVersionModal'
+import { DocsButton } from '@/components/ui/DocsButton'
+import { useDeleteDestinationPipelineMutation } from '@/data/replication/delete-destination-pipeline-mutation'
+import { useReplicationDestinationByIdQuery } from '@/data/replication/destination-by-id-query'
+import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query'
+import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
+import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query'
+import { useRestartPipelineMutation } from '@/data/replication/restart-pipeline-mutation'
+import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
+import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
+import { DOCS_URL } from '@/lib/constants'
+import {
+ PipelineStatusRequestStatus,
+ usePipelineRequestStatus,
+} from '@/state/replication-pipeline-request-status'
+import { type ResponseError } from '@/types'
+
+type LifecycleAction = 'start' | 'stop' | 'restart'
+
+const LIFECYCLE_BY_STATUS: Partial<
+ Record
+> = {
+ [PipelineStatusName.STOPPED]: { label: 'Start', action: 'start', icon: },
+ [PipelineStatusName.STARTED]: { label: 'Stop', action: 'stop', icon: },
+ [PipelineStatusName.FAILED]: { label: 'Restart', action: 'restart', icon: },
+}
+
+export const ReplicationPipelineLayout = ({ children }: PropsWithChildren) => {
+ const { ref: projectRef, pipelineId: pipelineIdParam } = useParams()
+ const pipelineId = Number(pipelineIdParam)
+ const router = useRouter()
+ const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false)
+ const [showDeleteDestination, setShowDeleteDestination] = useState(false)
+ const [isDeleting, setIsDeleting] = useState(false)
+ const [, setEdit] = useQueryState(
+ 'edit',
+ parseAsInteger.withOptions({ history: 'push', clearOnDefault: true })
+ )
+ const { getRequestStatus, getIsTableResetting, setRequestStatus, updatePipelineStatus } =
+ usePipelineRequestStatus()
+ const requestStatus = getRequestStatus(pipelineId)
+ const isTableResetting = getIsTableResetting(pipelineId)
+
+ const {
+ data: pipeline,
+ error: pipelineError,
+ isPending: isPipelineLoading,
+ } = useReplicationPipelineByIdQuery(
+ { projectRef, pipelineId },
+ { enabled: Number.isSafeInteger(pipelineId) }
+ )
+ const {
+ data: pipelineStatusData,
+ error: pipelineStatusError,
+ isLoading: isPipelineStatusLoading,
+ isError: isPipelineStatusError,
+ isSuccess: isPipelineStatusSuccess,
+ } = useReplicationPipelineStatusQuery(
+ { projectRef, pipelineId },
+ { enabled: !!pipelineId, refetchInterval: STATUS_REFRESH_FREQUENCY_MS }
+ )
+ const { data: versionData } = useReplicationPipelineVersionQuery({
+ projectRef,
+ pipelineId: pipeline?.id,
+ })
+ const { data: destination, isPending: isDestinationLoading } = useReplicationDestinationByIdQuery(
+ {
+ projectRef,
+ destinationId: pipeline?.destination_id,
+ }
+ )
+
+ const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation({
+ onError: () => {},
+ })
+ const { mutateAsync: stopPipeline, isPending: isStoppingPipeline } = useStopPipelineMutation({
+ onError: () => {},
+ })
+ const { mutateAsync: restartPipeline, isPending: isRestartingPipeline } =
+ useRestartPipelineMutation()
+ const { mutateAsync: deleteDestinationPipeline } = useDeleteDestinationPipelineMutation({
+ onError: () => {},
+ })
+
+ const statusName = getStatusName(pipelineStatusData?.status)
+ const displayState = getPipelineDisplayState(requestStatus, statusName)
+ const destinationType = getReplicationDestinationType(
+ destination?.config as Record | undefined
+ )
+ const isPipelineIdentityLoading =
+ isPipelineLoading || (pipeline !== undefined && isDestinationLoading)
+ const hasUpdate = Boolean(versionData?.new_version)
+ const isTransitioning = requestStatus !== PipelineStatusRequestStatus.None
+ const isActionable = PIPELINE_ACTIONABLE_STATES.includes(statusName as PipelineStatusName)
+
+ // What the primary button offers for each state it can act on. Anything not listed here (a
+ // pipeline mid-transition, or one in an unknown state) has no action, so the button falls back
+ // to the display state's own label and renders no icon.
+ const lifecycle = LIFECYCLE_BY_STATUS[statusName as PipelineStatusName]
+ const primaryAction: LifecycleAction | undefined = lifecycle?.action
+ const lifecycleLabel = isTransitioning
+ ? displayState.label
+ : (lifecycle?.label ?? displayState.label)
+ // No icon while Starting/Stopping/Restarting: those states have no entry above, and the
+ // button's loading state carries the transition anyway.
+ const lifecycleIcon = lifecycle?.icon
+
+ // The overflow menu carries the lifecycle actions the primary button isn't already offering,
+ // so the detail page has the same reach as the row menu on the list without repeating itself.
+ const isRunningOrFailed =
+ statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
+ const canUseMenuActions =
+ isRunningOrFailed && !isTransitioning && !isPipelineStatusError && !!pipeline
+ const canRestart = canUseMenuActions && !isTableResetting && primaryAction !== 'restart'
+ const canStop = canUseMenuActions && !isTableResetting && primaryAction !== 'stop'
+
+ const onLifecycleAction = async (action?: LifecycleAction) => {
+ const resolvedAction = action ?? primaryAction
+ if (!projectRef || !pipeline || resolvedAction === undefined) return
+
+ try {
+ if (resolvedAction === 'start') {
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
+ await startPipeline({ projectRef, pipelineId: pipeline.id })
+ } else if (resolvedAction === 'stop') {
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
+ await stopPipeline({ projectRef, pipelineId: pipeline.id })
+ } else {
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
+ await restartPipeline({ projectRef, pipelineId: pipeline.id })
+ }
+ } catch (error) {
+ setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
+ toast.error(`Failed to ${resolvedAction} pipeline: ${(error as ResponseError).message}`)
+ }
+ }
+
+ const onDeleteDestination = async () => {
+ if (!projectRef || !pipeline) return
+
+ try {
+ setIsDeleting(true)
+ if (statusName !== PipelineStatusName.STOPPED) {
+ await stopPipeline({ projectRef, pipelineId: pipeline.id })
+ }
+ await deleteDestinationPipeline({
+ projectRef,
+ destinationId: pipeline.destination_id,
+ pipelineId: pipeline.id,
+ })
+ setShowDeleteDestination(false)
+ toast.success(`Deleted pipeline "${pipeline.destination_name}"`)
+ router.push(`/project/${projectRef}/database/replication`)
+ } catch (error) {
+ toast.error(`Failed to delete pipeline: ${(error as ResponseError).message}`)
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ useEffect(() => {
+ updatePipelineStatus(pipelineId, statusName)
+ }, [pipelineId, statusName, updatePipelineStatus])
+
+ const logsUrl = `/project/${projectRef}/logs/replication-logs?f=${encodeURIComponent(
+ JSON.stringify({ pipeline_id: pipelineId })
+ )}`
+
+ return (
+
+
+
+
+
+ View logs
+
+
+ }
+ >
+
+
+
+ Replication
+
+
+
+
+
+ {isPipelineLoading ? (
+
+ Loading pipeline
+
+
+ ) : (
+ (pipeline?.destination_name ?? 'Pipeline')
+ )}
+
+
+
+
+
+
+
+
+ {isPipelineIdentityLoading ? (
+
+ ) : destinationType !== undefined ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isPipelineLoading ? (
+
+ Loading pipeline
+
+
+ ) : (
+ (pipeline?.destination_name ?? 'Pipeline')
+ )}
+
+
+
+
+ ·
+
+
+ Primary database
+
+ {isPipelineIdentityLoading ? (
+
+ Loading destination
+
+
+ ) : (
+ {destinationType ?? pipeline?.destination_name ?? 'Unknown'}
+ )}
+
+
+
+
+
+
+ {hasUpdate && (
+ }
+ onClick={() => setShowUpdateVersionModal(true)}
+ disabled={isTableResetting}
+ >
+ Update available
+
+ )}
+ onLifecycleAction()}
+ loading={
+ displayState.type === 'loading' ||
+ isTransitioning ||
+ isStartingPipeline ||
+ isStoppingPipeline ||
+ isRestartingPipeline
+ }
+ disabled={
+ Boolean(pipelineError) ||
+ isPipelineStatusError ||
+ !pipeline ||
+ isTransitioning ||
+ isTableResetting ||
+ !isActionable
+ }
+ >
+ {lifecycleLabel}
+
+
+
+ }
+ disabled={isTableResetting}
+ />
+
+
+ {canRestart && (
+ onLifecycleAction('restart')}
+ >
+
+ Restart pipeline
+
+ )}
+ {canStop && (
+ onLifecycleAction('stop')}
+ >
+
+ Stop pipeline
+
+ )}
+ {(canRestart || canStop) && }
+ {
+ if (pipeline !== undefined) setEdit(pipeline.destination_id)
+ }}
+ >
+
+ Edit pipeline
+
+ setShowDeleteDestination(true)}
+ >
+
+ Delete pipeline
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
setShowUpdateVersionModal(false)}
+ confirmLabel={
+ statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
+ ? 'Update and restart'
+ : 'Update version'
+ }
+ />
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx
index 623e4abb98f..6efa5ed4cb1 100644
--- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx
+++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx
@@ -1,22 +1,7 @@
import { useParams } from 'common'
-import {
- Activity,
- ArrowUpCircle,
- Ban,
- ChevronDown,
- ChevronLeft,
- Info,
- Pause,
- Play,
- RotateCcw,
- Search,
- WifiOff,
- X,
-} from 'lucide-react'
-import Link from 'next/link'
+import { Activity, ChevronDown, Info, RotateCcw, Search, WifiOff, X } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
-import { useEffect, useMemo, useState } from 'react'
-import { toast } from 'sonner'
+import { useMemo, useState } from 'react'
import {
Button,
Card,
@@ -31,19 +16,14 @@ import {
TableRow,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
+import { PageContainer } from 'ui-patterns/PageContainer'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { BatchRestartDialog } from '../BatchRestartDialog'
import { ErrorDetailsDialog } from '../ErrorDetailsDialog'
-import {
- getPipelineDisplayState,
- getStatusName,
- PIPELINE_ACTIONABLE_STATES,
-} from '../Pipeline.utils'
-import { PipelineStatus } from '../PipelineStatus'
+import { getStatusName } from '../Pipeline.utils'
import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import { RestartTableDialog } from '../RestartTableDialog'
-import { UpdateVersionModal } from '../UpdateVersionModal'
import { SlotLagMetrics } from './ReplicationPipelineStatus.types'
import { getDisabledStateConfig } from './ReplicationPipelineStatus.utils'
import { SlotLagMetricsInline, SlotLagMetricsList } from './SlotLagMetrics'
@@ -54,15 +34,10 @@ import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip
import { useReplicationPipelineByIdQuery } from '@/data/replication/pipeline-by-id-query'
import { useReplicationPipelineReplicationStatusQuery } from '@/data/replication/pipeline-replication-status-query'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
-import { useReplicationPipelineVersionQuery } from '@/data/replication/pipeline-version-query'
-import { useRestartPipelineMutation } from '@/data/replication/restart-pipeline-mutation'
-import { useStartPipelineMutation } from '@/data/replication/start-pipeline-mutation'
-import { useStopPipelineMutation } from '@/data/replication/stop-pipeline-mutation'
import {
PipelineStatusRequestStatus,
usePipelineRequestStatus,
} from '@/state/replication-pipeline-request-status'
-import { type ResponseError } from '@/types'
/**
* Component for displaying replication pipeline status and table replication details.
@@ -72,7 +47,6 @@ export const ReplicationPipelineStatus = () => {
const { ref: projectRef, pipelineId: _pipelineId } = useParams()
const [searchString, setSearchString] = useQueryState('search', parseAsString.withDefault(''))
- const [showUpdateVersionModal, setShowUpdateVersionModal] = useState(false)
const [showErrorDialog, setShowErrorDialog] = useState(false)
const [selectedTableError, setSelectedTableError] = useState<{
tableName: string
@@ -90,7 +64,7 @@ export const ReplicationPipelineStatus = () => {
const [restartingTableIds, setRestartingTableIds] = useState>(new Set())
const pipelineId = Number(_pipelineId)
- const { getRequestStatus, updatePipelineStatus, setRequestStatus } = usePipelineRequestStatus()
+ const { getRequestStatus, setTableResetting } = usePipelineRequestStatus()
const requestStatus = getRequestStatus(pipelineId)
const {
@@ -103,13 +77,7 @@ export const ReplicationPipelineStatus = () => {
pipelineId,
})
- const {
- data: pipelineStatusData,
- error: pipelineStatusError,
- isLoading: isPipelineStatusLoading,
- isError: isPipelineStatusError,
- isSuccess: isPipelineStatusSuccess,
- } = useReplicationPipelineStatusQuery(
+ const { data: pipelineStatusData } = useReplicationPipelineStatusQuery(
{ projectRef, pipelineId },
{
enabled: !!pipelineId,
@@ -129,19 +97,7 @@ export const ReplicationPipelineStatus = () => {
}
)
- const { data: versionData } = useReplicationPipelineVersionQuery({
- projectRef,
- pipelineId: pipeline?.id,
- })
- const hasUpdate = Boolean(versionData?.new_version)
-
- const { mutateAsync: startPipeline, isPending: isStartingPipeline } = useStartPipelineMutation()
- const { mutateAsync: stopPipeline, isPending: isStoppingPipeline } = useStopPipelineMutation()
- const { mutateAsync: restartPipeline } = useRestartPipelineMutation()
-
- const destinationName = pipeline?.destination_name
const statusName = getStatusName(pipelineStatusData?.status)
- const displayState = getPipelineDisplayState(requestStatus, statusName)
const config = getDisabledStateConfig({ requestStatus, statusName })
// Sort tables by schema and name for consistent ordering (memoized)
@@ -201,121 +157,9 @@ export const ReplicationPipelineStatus = () => {
? `${Math.round(STATUS_REFRESH_FREQUENCY_MS / 1000)}s`
: `${STATUS_REFRESH_FREQUENCY_MS}ms`
- const logsUrl = `/project/${projectRef}/logs/replication-logs${
- pipelineId ? `?f=${encodeURIComponent(JSON.stringify({ pipeline_id: pipelineId }))}` : ''
- }`
-
- const label = isEnablingDisabling
- ? displayState.label
- : statusName === PipelineStatusName.STOPPED
- ? 'Start'
- : statusName === PipelineStatusName.STARTED
- ? 'Stop'
- : statusName === PipelineStatusName.FAILED
- ? 'Restart'
- : displayState.label
-
- const icon =
- statusName === PipelineStatusName.STOPPED ? (
-
- ) : statusName === PipelineStatusName.STARTED ? (
-
- ) : statusName === PipelineStatusName.FAILED ? (
-
- ) : (
-
- )
-
- const onPrimaryAction = async () => {
- if (!projectRef) return console.error('Project ref is required')
- if (!pipeline) return toast.error('No pipeline found')
-
- const action =
- statusName === PipelineStatusName.STOPPED
- ? 'start'
- : statusName === PipelineStatusName.STARTED
- ? 'stop'
- : 'restart'
- try {
- if (statusName === PipelineStatusName.STOPPED) {
- setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName)
- await startPipeline({ projectRef, pipelineId: pipeline.id })
- } else if (statusName === PipelineStatusName.STARTED) {
- setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName)
- await stopPipeline({ projectRef, pipelineId: pipeline.id })
- } else if (statusName === PipelineStatusName.FAILED) {
- setRequestStatus(pipeline.id, PipelineStatusRequestStatus.RestartRequested, statusName)
- await restartPipeline({ projectRef, pipelineId: pipeline.id })
- }
- } catch (error) {
- setRequestStatus(pipeline.id, PipelineStatusRequestStatus.None)
- toast.error(`Failed to ${action} pipeline: ${(error as ResponseError).message}`)
- }
- }
-
- useEffect(() => {
- updatePipelineStatus(pipelineId, statusName)
- }, [pipelineId, statusName, updatePipelineStatus])
-
return (
<>
-
-
-
-
} style={{ padding: '5px' }}>
-
-
-
-
{destinationName || 'Pipeline'}
-
-
-
-
-
- {hasUpdate && (
- }
- onClick={() => setShowUpdateVersionModal(true)}
- >
- Update available
-
- )}
-
-
- View logs
-
-
-
- {label}
-
-
-
+
{isPipelineError && (
)}
@@ -560,18 +404,7 @@ export const ReplicationPipelineStatus = () => {
)}
-
-
- setShowUpdateVersionModal(false)}
- confirmLabel={
- statusName === PipelineStatusName.STARTED || statusName === PipelineStatusName.FAILED
- ? 'Update and restart'
- : 'Update version'
- }
- />
+
{/* Restart Table Confirmation Dialog */}
{selectedTableForRestart && (
@@ -584,9 +417,11 @@ export const ReplicationPipelineStatus = () => {
publicationName={pipeline?.config.publication_name}
pipelineStatusName={statusName}
onRestartStart={() => {
+ setTableResetting(pipelineId, true)
setRestartingTableIds((prev) => new Set(prev).add(selectedTableForRestart.id))
}}
onRestartComplete={() => {
+ setTableResetting(pipelineId, false)
setRestartingTableIds((prev) => {
const next = new Set(prev)
next.delete(selectedTableForRestart.id)
@@ -619,9 +454,11 @@ export const ReplicationPipelineStatus = () => {
tableSyncCopy={pipeline?.config.table_sync_copy}
pipelineStatusName={statusName}
onRestartStart={(tableIds) => {
+ setTableResetting(pipelineId, true)
setRestartingTableIds((prev) => new Set([...prev, ...tableIds]))
}}
onRestartComplete={(tableIds) => {
+ setTableResetting(pipelineId, false)
setRestartingTableIds((prev) => {
const next = new Set(prev)
tableIds.forEach((id) => next.delete(id))
diff --git a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx
index e1caff23d17..6da8e106e10 100644
--- a/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx
+++ b/apps/studio/pages/project/[ref]/database/replication/[pipelineId].tsx
@@ -2,12 +2,11 @@ import { FeatureFlagContext, useParams } from 'common'
import { useRouter } from 'next/router'
import { useContext, useEffect } from 'react'
+import { ReplicationPipelineLayout } from '@/components/interfaces/Database/Replication/ReplicationPipelineLayout'
import { ReplicationPipelineStatus } from '@/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus'
import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
-import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold'
-import { FormHeader } from '@/components/ui/Forms/FormHeader'
import { PipelineRequestStatusProvider } from '@/state/replication-pipeline-request-status'
import type { NextPageWithLayout } from '@/types'
@@ -27,14 +26,9 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
<>
{enablePgReplicate && (
-
-
-
-
-
-
-
-
+
+
+
)}
>
diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx
index b14c7864ebd..2284fece4d8 100644
--- a/apps/studio/state/replication-pipeline-request-status.tsx
+++ b/apps/studio/state/replication-pipeline-request-status.tsx
@@ -24,6 +24,8 @@ interface PipelineRequestStatusContextType {
snapshotStatus?: string
) => void
getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus
+ setTableResetting: (pipelineId: number, isResetting: boolean) => void
+ getIsTableResetting: (pipelineId: number) => boolean
updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void
}
@@ -45,6 +47,7 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu
const [pipelineStatusSnapshot, setPipelineStatusSnapshot] = useState<
Record
>({})
+ const [tableResetStatus, setTableResetStatus] = useState>({})
const timeoutsRef = useRef>({})
const REQUEST_TIMEOUT_MS = 10_000
@@ -100,6 +103,16 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu
return requestStatus[pipelineId] || PipelineStatusRequestStatus.None
}
+ const setTableResetting = (pipelineId: number, isResetting: boolean) => {
+ setTableResetStatus((prev) => {
+ if (isResetting) return { ...prev, [pipelineId]: true }
+ const { [pipelineId]: _omit, ...rest } = prev
+ return rest
+ })
+ }
+
+ const getIsTableResetting = (pipelineId: number) => tableResetStatus[pipelineId] === true
+
const updatePipelineStatus = useCallback(
(pipelineId: number, newStatus: string | undefined) => {
const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None
@@ -129,6 +142,8 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu
pipelineStatusSnapshot,
setRequestStatus,
getRequestStatus,
+ setTableResetting,
+ getIsTableResetting,
updatePipelineStatus,
}}
>
From 7e9c483625a8a3bcb5a20488c1f27096b3fd7c00 Mon Sep 17 00:00:00 2001
From: Danny White <3104761+dnywh@users.noreply.github.com>
Date: Wed, 16 Sep 2026 12:27:16 +1000
Subject: [PATCH 071/107] feat(ui): support bounded MultiSelector wrapping
(#50439)
## What kind of change does this PR introduce?
UI component enhancement and Design System documentation update.
## What is the current behavior?
`MultiSelectorTrigger` can either limit the number of visible badges or
wrap every selected badge. Consumers cannot combine a numeric limit with
wrapping.
## What is the new behavior?
Adds `wrapBadges` so consumers can combine it with a numeric
`badgeLimit`. For example, `badgeLimit={3} wrapBadges` renders up to
three wrapped badges followed by the remaining `+n` count.
Existing `badgeLimit=\"wrap\"` behaviour remains supported.
## To test
1. Open the Design System Multi Select page.
2. Find the **Wrapped badge limit** example.
3. Confirm three selected fruit badges wrap within the trigger and the
remaining selections appear as `+2`.
4. Remove or add selections and confirm the visible badges and remaining
count update together.
## Summary by CodeRabbit
- **New Features**
- Added a `wrapBadges` option to multi-select triggers, allowing
selected badges to wrap across multiple lines while retaining a numeric
badge limit.
- Numeric limits now show the specified badges and an overflow count for
additional selections.
- Updated the example with controls for adjusting the badge limit.
- **Documentation**
- Clarified multi-select badge limit and wrapping behavior.
- **Tests**
- Added coverage for badge limits, wrapping, visible selections, and
overflow counts.
---
.../content/docs/fragments/multi-select.mdx | 6 +-
.../example/multi-select-badge-limit-wrap.tsx | 60 ++++++++++++-------
.../src/multi-select/multi-select.test.tsx | 18 ++++++
.../src/multi-select/multi-select.tsx | 18 +++---
4 files changed, 68 insertions(+), 34 deletions(-)
diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx
index 73e09df3899..e20164f4593 100644
--- a/apps/design-system/content/docs/fragments/multi-select.mdx
+++ b/apps/design-system/content/docs/fragments/multi-select.mdx
@@ -60,16 +60,16 @@ creatable: `boolean`
-### Badge Limit
+### Badge limit
badgeLimit: `number` | `"wrap"`.
`badgeLimit` prop on the `MultiSelectorTrigger` component can be used to limit the number of badges displayed.
-### Badge Limit="wrap"
+### Wrapped badge limit
-`badgeLimit` prop can also be "wrap" to wrap the badges to the next line.
+Combine `badgeLimit` with `wrapBadges` to limit the number of badges and allow them to wrap onto additional lines. Use `badgeLimit="wrap"` to show and wrap every selected badge.
diff --git a/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx
index 7b1b8b283ad..fae3733b2ec 100644
--- a/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx
+++ b/apps/design-system/registry/default/example/multi-select-badge-limit-wrap.tsx
@@ -1,4 +1,6 @@
+import { Minus, Plus } from 'lucide-react'
import { useState } from 'react'
+import { Button } from 'ui'
import {
MultiSelector,
MultiSelectorContent,
@@ -15,30 +17,42 @@ export default function MultiSelectDemo() {
'Date',
'Elderberrie',
])
+ const [limit, setLimit] = useState(3)
return (
-
-
-
-
- Apple
- Banana
- Cherry
- Date
- Elderberrie
- Fig
- Grape
- Kiwi
- Mango
- Strawberry
-
-
-
+
+
+
setLimit((value) => value - 1)} disabled={limit < 1}>
+
+
+
Limit: {limit}
+
setLimit((value) => value + 1)}>
+
+
+
+
+
+
+
+ Apple
+ Banana
+ Cherry
+ Date
+ Elderberrie
+ Fig
+ Grape
+ Kiwi
+ Mango
+ Strawberry
+
+
+
+
)
}
diff --git a/packages/ui-patterns/src/multi-select/multi-select.test.tsx b/packages/ui-patterns/src/multi-select/multi-select.test.tsx
index a3278b7d6f6..9c50c5284b4 100644
--- a/packages/ui-patterns/src/multi-select/multi-select.test.tsx
+++ b/packages/ui-patterns/src/multi-select/multi-select.test.tsx
@@ -97,6 +97,24 @@ describe('multi-select', () => {
expect(badge).toHaveClass('normal-case', 'tracking-normal')
})
+ it('supports wrapping badges with a numeric badge limit', () => {
+ render(
+ undefined}
+ >
+
+
+ )
+
+ const trigger = screen.getByRole('combobox')
+ expect(trigger.firstElementChild).toHaveClass('flex-wrap')
+ expect(trigger).toHaveTextContent('Apple')
+ expect(trigger).toHaveTextContent('Banana')
+ expect(trigger).toHaveTextContent('+3')
+ expect(trigger).not.toHaveTextContent('Cherry')
+ })
+
it('opens the dropdown when the MultiSelectorTrigger is clicked', () => {
render( )
diff --git a/packages/ui-patterns/src/multi-select/multi-select.tsx b/packages/ui-patterns/src/multi-select/multi-select.tsx
index 6771530488d..df0bd27b5e7 100644
--- a/packages/ui-patterns/src/multi-select/multi-select.tsx
+++ b/packages/ui-patterns/src/multi-select/multi-select.tsx
@@ -223,6 +223,7 @@ export interface MultiSelectorTriggerProps extends React.HTMLAttributes {
if (!inputRef?.current || !badgesRef.current) return
- if (IS_BADGE_LIMIT_WRAP) {
- setVisibleBadges(values)
- setExtraBadgesCount(0)
- } else {
+ if (IS_NUMERIC_LIMIT) {
setVisibleBadges(values.slice(0, badgeLimit))
setExtraBadgesCount(Math.max(0, values.length - badgeLimit))
+ } else {
+ setVisibleBadges(values)
+ setExtraBadgesCount(0)
}
}, [values, badgeLimit])
@@ -427,8 +429,8 @@ const MultiSelectorTrigger = React.forwardRef
@@ -478,7 +480,7 @@ const MultiSelectorTrigger = React.forwardRef
Date: Wed, 16 Sep 2026 16:21:26 +1000
Subject: [PATCH 072/107] fix(www): add local KB rewrite URL (#50452)
## What kind of change does this PR introduce?
Bug fix for the local www development environment.
## What is the current behaviour?
Running `pnpm dev:www` fails Next.js rewrite validation because
`NEXT_PUBLIC_KB_URL` is missing from the tracked public environment
defaults, producing an `undefined` destination for `/kb`.
## What is the new behaviour?
The tracked public environment defines the local KB URL, matching
`.env.local.example`, so the www rewrite configuration is valid in a
fresh checkout.
## To test
1. Run `pnpm dev:www`.
2. Open http://localhost:3000.
3. Confirm www starts without the `Invalid rewrites found` error for
`/kb`.
## Summary by CodeRabbit
* **Configuration**
* Added a public URL setting for accessing the knowledge base service in
local development.
---
apps/www/.env | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/www/.env b/apps/www/.env
index cec21042802..5a4a6aaf747 100644
--- a/apps/www/.env
+++ b/apps/www/.env
@@ -10,3 +10,4 @@ NEXT_PUBLIC_STUDIO_URL="http://localhost:8082"
NEXT_PUBLIC_REFERENCE_DOCS_URL="http://localhost:3010"
NEXT_PUBLIC_LIBRARY_URL="http://localhost:3004"
NEXT_PUBLIC_DESIGN_SYSTEM_URL="http://localhost:3003"
+NEXT_PUBLIC_KB_URL="http://localhost:3008/kb"
From b1a9e072ec9410b9b4a265cc7edac3bd02fab275 Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Wed, 16 Sep 2026 14:32:19 +0800
Subject: [PATCH 073/107] Update table editor ilike related comparators to
implicitly wrap filter string with % if not provided (#50394)
### Context
For table editor - the `ilike` related comparators expect users to input
a `%` in the filter string, which for non-developers might not be
intuitive.
Hence opting to implicitly wrap the filter string with `%` in the query
when filtering if non provided
## Summary by CodeRabbit
* **Bug Fixes**
* Improved pattern-match filters so bare text values perform contains
matching by automatically wrapping them with wildcards.
* Preserved explicit wildcard patterns using `%` or `_` without adding
additional wildcards.
* Improved handling of empty values for non-text filters while retaining
existing numeric filter validation.
---
apps/studio/data/table-rows/utils.test.ts | 20 +++++++++-
apps/studio/data/table-rows/utils.ts | 4 +-
packages/pg-meta/src/query/table-row-query.ts | 29 +++++++++++---
.../test/query/table-row-query.test.ts | 40 +++++++++++++++++++
4 files changed, 85 insertions(+), 8 deletions(-)
diff --git a/apps/studio/data/table-rows/utils.test.ts b/apps/studio/data/table-rows/utils.test.ts
index d6e449618d8..220fdfcbc48 100644
--- a/apps/studio/data/table-rows/utils.test.ts
+++ b/apps/studio/data/table-rows/utils.test.ts
@@ -12,9 +12,9 @@ const table = {
],
}
-const makeFilter = (column: string, value: any): Filter => ({
+const makeFilter = (column: string, value: any, operator: Filter['operator'] = '='): Filter => ({
column,
- operator: '=',
+ operator,
value,
})
@@ -56,4 +56,20 @@ describe('formatFilterValue', () => {
Number.MIN_SAFE_INTEGER
)
})
+
+ it.each(['~~', '~~*', '!~~', '!~~*'] as const)(
+ 'wraps the value in %%...%% for the %s operator when no wildcard is present',
+ (operator) => {
+ expect(formatFilterValue(table, makeFilter('name', 'al', operator))).toBe('%al%')
+ }
+ )
+
+ it.each(['~~', '~~*', '!~~', '!~~*'] as const)(
+ 'leaves the value untouched for the %s operator when it already contains a % or _ wildcard',
+ (operator) => {
+ expect(formatFilterValue(table, makeFilter('name', 'al%', operator))).toBe('al%')
+ expect(formatFilterValue(table, makeFilter('name', '%al', operator))).toBe('%al')
+ expect(formatFilterValue(table, makeFilter('name', 'j_hn', operator))).toBe('j_hn')
+ }
+ )
})
diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts
index 80aa54d9ded..996ef1f2c6e 100644
--- a/apps/studio/data/table-rows/utils.ts
+++ b/apps/studio/data/table-rows/utils.ts
@@ -1,3 +1,5 @@
+import { formatPatternMatchFilterValue } from '@supabase/pg-meta/src/query/table-row-query'
+
import type { Filter, ServiceError } from '@/components/grid/types'
import { isNumericalColumn } from '@/components/grid/utils/types'
import { Entity, isTableLike } from '@/data/table-editor/table-editor-types'
@@ -22,7 +24,7 @@ export function formatFilterValue(
return filter.value
else return numberValue
}
- return filter.value
+ return formatPatternMatchFilterValue(filter.value, filter.operator)
}
export function getPrimaryKeys({ table }: { table: Entity }): {
diff --git a/packages/pg-meta/src/query/table-row-query.ts b/packages/pg-meta/src/query/table-row-query.ts
index a305acf60cd..f9e101a2e2d 100644
--- a/packages/pg-meta/src/query/table-row-query.ts
+++ b/packages/pg-meta/src/query/table-row-query.ts
@@ -87,6 +87,25 @@ const LARGE_COLUMNS_TYPES_SET = new Set(LARGE_COLUMNS_TYPES)
// Threshold count for applying default sort
export const THRESHOLD_COUNT = 100000
+// Operators that perform SQL pattern matching, where a bare value with no `%`/`_`
+// wildcard should be treated as a "contains" search rather than an exact match
+const PATTERN_MATCH_OPERATORS: Filter['operator'][] = ['~~', '~~*', '!~~', '!~~*']
+
+export function formatPatternMatchFilterValue(
+ value: Filter['value'],
+ operator: Filter['operator']
+) {
+ if (
+ PATTERN_MATCH_OPERATORS.includes(operator) &&
+ typeof value === 'string' &&
+ !value.includes('%') &&
+ !value.includes('_')
+ ) {
+ return `%${value}%`
+ }
+ return value
+}
+
// Return the primary key columns if exists, otherwise return the first column to use as a default sort
export const getDefaultOrderByColumns = (
table: Pick,
@@ -149,11 +168,11 @@ export const getTableRowsSql = ({
filters.forEach((x) => {
const col = table.columns?.find((y) => y.name === x.column)
const isStringTypeColumn = !!col ? (TEXT_TYPES as string[]).includes(col.format) : true
- queryChains = queryChains.filter(
- x.column,
- x.operator,
- !isStringTypeColumn && x.value === '' ? null : x.value
- )
+ const value =
+ !isStringTypeColumn && x.value === ''
+ ? null
+ : formatPatternMatchFilterValue(x.value, x.operator)
+ queryChains = queryChains.filter(x.column, x.operator, value)
})
// If sorts is empty and table row count is within threshold, use the primary key as the default sort.
diff --git a/packages/pg-meta/test/query/table-row-query.test.ts b/packages/pg-meta/test/query/table-row-query.test.ts
index caab0e86933..27d62a906f4 100644
--- a/packages/pg-meta/test/query/table-row-query.test.ts
+++ b/packages/pg-meta/test/query/table-row-query.test.ts
@@ -1142,6 +1142,46 @@ describe('Table Row Query', () => {
expect(queryResult.every((row: any) => row.category === 'A')).toBe(true)
})
+ withTestDatabase(
+ 'should wrap a bare ilike filter value in wildcards for a contains match',
+ async (db) => {
+ // Create test table and insert data
+ await db.executeQuery(`
+ CREATE TABLE test_sql_filter_ilike (
+ id SERIAL PRIMARY KEY,
+ name TEXT
+ );
+
+ INSERT INTO test_sql_filter_ilike (name) VALUES
+ ('Alizarin'),
+ ('Amber'),
+ ('Coral');
+ `)
+
+ // Get table metadata
+ const { sql: tablesSql, zod: tablesZod } = pgMeta.tables.list()
+ const tables = tablesZod.parse(await db.executeQuery(tablesSql))
+ const testTable = tables.find((table) => table.name === 'test_sql_filter_ilike')
+
+ expect(testTable).toBeDefined()
+
+ // A bare value with no `%`/`_` should be treated as a "contains" search
+ const filters: Filter[] = [{ column: 'name', operator: '~~*', value: 'al' }]
+
+ const sql = getTableRowsSql({
+ table: testTable!,
+ filters,
+ page: 1,
+ limit: 10,
+ })
+
+ expect(sql).toContain(`name::text ~~* '%al%'`)
+
+ const queryResult = await db.executeQuery(sql)
+ expect(queryResult.map((row: any) => row.name).sort()).toEqual(['Alizarin', 'Coral'])
+ }
+ )
+
withTestDatabase('should generate SQL with sorting', async (db) => {
// Create test table and insert data
await db.executeQuery(`
From e296d0ae4f84f24a33c6403deb6b38cc4cc9088b Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Wed, 16 Sep 2026 14:34:41 +0800
Subject: [PATCH 074/107] Joshenlim/fe 4373 add ilike comparator for pathname
in unified logs (#50386)
## Context
In unified logs, filtering on pathname can benefit using the `ilike`
comparator so this PR adds support for that
FilterBar is also updated to omit facet values if the selected
comparator is `ilike`, otherwise it doesn't really make sense to show a
dropdown of values for users to select as the value for `ilike`
filtering.
The facet values should still show if the selected comparator is an
equals comparator
Related to https://github.com/supabase/supabase/pull/50394 - am also
updating ilike comparator logic for unified logs to implicitly wrap the
provided string with `%`, but only if the string doesn't already contain
a `%` or `_` for UX convenience. (Unified logs already had this
behaviour, except the latter part RE omitting default `%` if string
already has) - this affects pathname and event_message searching
## Summary by CodeRabbit
## Summary by CodeRabbit
* **New Features**
* Added case-insensitive pathname filtering for logs with ILIKE and NOT
ILIKE.
* Added pathname support for comparison and pattern-matching operators.
* Preserved user-provided `%` and `_` wildcard patterns in searches.
* **Bug Fixes**
* Corrected BigQuery pathname filtering for consistent case-insensitive
matching.
* Prevented misleading exact-value suggestions for pattern-based
searches.
---
.../UnifiedLogs/UnifiedLogs.queries.bq.ts | 9 ++-
.../UnifiedLogs/UnifiedLogs.queries.test.ts | 80 +++++++++++++++++++
.../UnifiedLogs/UnifiedLogs.queries.ts | 15 +++-
.../UnifiedLogs/UnifiedLogs.utils.test.ts | 16 ++++
.../UnifiedLogs/UnifiedLogs.utils.ts | 9 +++
.../components/LogsFilterBar.utils.test.ts | 15 +++-
.../components/LogsFilterBar.utils.ts | 37 +++++----
.../src/FilterBar/menuItems.test.ts | 61 +++++++++++++-
.../ui-patterns/src/FilterBar/menuItems.ts | 12 +++
9 files changed, 232 insertions(+), 22 deletions(-)
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts
index 3aff9b46fd1..82fb7753a96 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq.ts
@@ -9,6 +9,7 @@ import dayjs from 'dayjs'
import { DEFAULT_LOG_TYPES } from './UnifiedLogs.constants'
import { groupLogsFiltersByColumn, parseLogsFilterUrlParams } from './UnifiedLogs.filters'
import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types'
+import { wrapIlikePattern } from './UnifiedLogs.utils'
import {
joinSqlFragments,
analyticsLiteral as lit,
@@ -73,16 +74,18 @@ const buildConditions = (
const likeOp = isNeq ? NOT_LIKE_OP : LIKE_OP
const joinAndOr = isNeq ? ' AND ' : ' OR '
- if (key === 'event_message' && (operator === '~~*' || operator === '!~~*')) {
+ if (
+ (key === 'event_message' || key === 'pathname') &&
+ (operator === '~~*' || operator === '!~~*')
+ ) {
// BigQuery has no ILIKE; emulate via LOWER(col) (NOT) LIKE LOWER('%v%').
// Auto-wrap with `%…%` unless the user already included one. Multiple
// ILIKE values join with OR; NOT ILIKE joins with AND (row must contain
// none of the given substrings).
- const pattern = (v: string) => (v.includes('%') ? v : '%' + v + '%')
const likeKeyword = operator === '!~~*' ? safeSql`NOT LIKE` : safeSql`LIKE`
const join = operator === '!~~*' ? ' AND ' : ' OR '
const branches = values.map(
- (v) => safeSql`LOWER(${col}) ${likeKeyword} LOWER(${lit(pattern(v))})`
+ (v) => safeSql`LOWER(${col}) ${likeKeyword} LOWER(${lit(wrapIlikePattern(v))})`
)
conditions.push(safeSql`(${joinSqlFragments(branches, join)})`)
} else if (key === 'host' || key === 'pathname') {
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
index ee37c99163d..3eee4bca3bf 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts
@@ -126,6 +126,37 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(sql).toContain(`log_attributes['request.url'] NOT LIKE '%cdn.foo%'`)
})
+ it('emits ILIKE for pathname `~~*`, case-insensitive unlike the `=` LIKE behavior', () => {
+ const sql = getUnifiedLogsQuery(withFilters('pathname:ilike:Customers'))
+ expect(sql).toContain(`log_attributes['request.path'] ILIKE '%Customers%'`)
+ })
+
+ it('emits NOT ILIKE for pathname `!~~*` so rows matching the term are excluded', () => {
+ const sql = getUnifiedLogsQuery(withFilters('pathname:notilike:health'))
+ expect(sql).toContain(`log_attributes['request.path'] NOT ILIKE '%health%'`)
+ })
+
+ it('joins multiple pathname NOT ILIKE values with AND (row must match none)', () => {
+ const sql = getUnifiedLogsQuery(
+ withFilters('pathname:notilike:health', 'pathname:notilike:metrics')
+ )
+ expect(sql).toMatch(
+ /log_attributes\['request\.path'\] NOT ILIKE '%health%' AND log_attributes\['request\.path'\] NOT ILIKE '%metrics%'/
+ )
+ })
+
+ it('passes through user-supplied `%` wildcards on pathname ILIKE without double-wrapping', () => {
+ const sql = getUnifiedLogsQuery(withFilters('pathname:ilike:foo%'))
+ expect(sql).toContain(`log_attributes['request.path'] ILIKE 'foo%'`)
+ expect(sql).not.toContain(`'%foo%%'`)
+ })
+
+ it('passes through user-supplied `_` wildcards on pathname ILIKE without wrapping', () => {
+ const sql = getUnifiedLogsQuery(withFilters('pathname:ilike:fo_bar'))
+ expect(sql).toContain(`log_attributes['request.path'] ILIKE 'fo_bar'`)
+ expect(sql).not.toContain(`'%fo_bar%'`)
+ })
+
it('emits ILIKE with auto-wrapped `%…%` for event_message `~~*`', () => {
const sql = getUnifiedLogsQuery(withFilters('event_message:ilike:Permission Denied'))
expect(sql).toContain(`event_message ILIKE '%Permission Denied%'`)
@@ -151,6 +182,12 @@ describe('UnifiedLogs.queries (OTEL flat)', () => {
expect(sql).not.toContain(`'%error%%'`)
})
+ it('passes through user-supplied `_` wildcards on event_message ILIKE without wrapping', () => {
+ const sql = getUnifiedLogsQuery(withFilters('event_message:ilike:foo_bar'))
+ expect(sql).toContain(`event_message ILIKE 'foo_bar'`)
+ expect(sql).not.toContain(`'%foo_bar%'`)
+ })
+
it('excludes connection log messages when show_connection_logs=false', () => {
const sql = getUnifiedLogsQuery({ ...baseSearch, show_connection_logs: false } as any)
expect(sql).toContain("source != 'postgres_logs'")
@@ -457,4 +494,47 @@ describe('UnifiedLogs.queries.bq', () => {
expect(sql).toContain("LOWER(`event_message`) NOT LIKE LOWER('%cron%')")
expect(sql).not.toMatch(/\bILIKE\b/)
})
+
+ it('emulates ILIKE with LOWER()/LOWER() for pathname `~~*`', () => {
+ const sql = getUnifiedLogsQueryBQ(withFilters('pathname:ilike:Customers'))
+ expect(sql).toContain("LOWER(`pathname`) LIKE LOWER('%Customers%')")
+ expect(sql).not.toMatch(/\bILIKE\b/)
+ })
+
+ it('emulates NOT ILIKE with LOWER()/NOT LIKE/LOWER() for pathname `!~~*`', () => {
+ const sql = getUnifiedLogsQueryBQ(withFilters('pathname:notilike:health'))
+ expect(sql).toContain("LOWER(`pathname`) NOT LIKE LOWER('%health%')")
+ expect(sql).not.toMatch(/\bILIKE\b/)
+ })
+
+ it('passes through user-supplied `%` wildcards on pathname ILIKE without double-wrapping', () => {
+ const sql = getUnifiedLogsQueryBQ(withFilters('pathname:ilike:foo%'))
+ expect(sql).toContain("LOWER(`pathname`) LIKE LOWER('foo%')")
+ expect(sql).not.toContain("LOWER('%foo%%')")
+ })
+
+ it('passes through user-supplied `_` wildcards on pathname ILIKE without wrapping', () => {
+ const sql = getUnifiedLogsQueryBQ(withFilters('pathname:ilike:fo_bar'))
+ expect(sql).toContain("LOWER(`pathname`) LIKE LOWER('fo_bar')")
+ expect(sql).not.toContain("LOWER('%fo_bar%')")
+ })
+})
+
+describe('pathname ILIKE prefix matching (cross-builder)', () => {
+ // Both query builders must treat an explicit `%` the same way — a value
+ // like `foo%` is a prefix match, not a "contains" search with a stray
+ // trailing wildcard — otherwise switching the `otelUnifiedLogs` flag would
+ // silently change what a saved/shared filter matches.
+ it('produces equivalent prefix-matching patterns for `pathname:ilike:foo%` on both backends', () => {
+ const clickhouseSql = getUnifiedLogsQuery(withFilters('pathname:ilike:foo%'))
+ const bqSql = getUnifiedLogsQueryBQ(withFilters('pathname:ilike:foo%'))
+
+ expect(clickhouseSql).toContain(`log_attributes['request.path'] ILIKE 'foo%'`)
+ expect(bqSql).toContain("LOWER(`pathname`) LIKE LOWER('foo%')")
+
+ // Neither backend should have double-wrapped the wildcard into a
+ // "contains" pattern.
+ expect(clickhouseSql).not.toContain(`'%foo%%'`)
+ expect(bqSql).not.toContain("LOWER('%foo%%')")
+ })
})
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
index 8da74e7b222..973e24e75b3 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
@@ -7,6 +7,7 @@ import {
type LogsFilterOperator,
} from './UnifiedLogs.filters'
import { QuerySearchParamsType, SearchParamsType } from './UnifiedLogs.types'
+import { wrapIlikePattern } from './UnifiedLogs.utils'
import {
joinSqlFragments,
analyticsLiteral as lit,
@@ -179,11 +180,20 @@ const translateFilter = (
// Postgres SQLSTATE for postgres rows. Inline STATUS_EXPR so e.g.
// filtering on '00000' picks up postgres success rows.
return safeSql`(${STATUS_EXPR}) ${inOp} ${inList(values)}`
- case 'pathname':
+ case 'pathname': {
+ if (operator === '~~*' || operator === '!~~*') {
+ const op = operator === '~~*' ? ILIKE_OP : NOT_ILIKE_OP
+ const join = operator === '!~~*' ? ' AND ' : ' OR '
+ return safeSql`(${joinSqlFragments(
+ values.map((v) => safeSql`${ATTR.path} ${op} ${lit(wrapIlikePattern(v))}`),
+ join
+ )})`
+ }
return safeSql`(${joinSqlFragments(
values.map((v) => safeSql`${ATTR.path} ${likeOp} ${lit('%' + v + '%')}`),
joinAndOr
)})`
+ }
case 'host':
// Best-effort: use full request URL since `host` isn't a top-level field.
return safeSql`(${joinSqlFragments(
@@ -199,9 +209,8 @@ const translateFilter = (
if (operator === '~~*' || operator === '!~~*') {
const op = operator === '~~*' ? ILIKE_OP : NOT_ILIKE_OP
const join = operator === '!~~*' ? ' AND ' : ' OR '
- const pattern = (v: string) => (v.includes('%') ? v : '%' + v + '%')
return safeSql`(${joinSqlFragments(
- values.map((v) => safeSql`event_message ${op} ${lit(pattern(v))}`),
+ values.map((v) => safeSql`event_message ${op} ${lit(wrapIlikePattern(v))}`),
join
)})`
}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
index d57936b28b6..96e55493dac 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.test.ts
@@ -8,6 +8,7 @@ import {
getEventMessageDisplay,
getRawLogData,
parseMultigresEventMessage,
+ wrapIlikePattern,
} from './UnifiedLogs.utils'
describe('buildUnifiedLogsUrl', () => {
@@ -260,3 +261,18 @@ describe('getComputeLogsAvailability', () => {
})
})
})
+
+describe('wrapIlikePattern', () => {
+ it('wraps a bare value in %...% for a contains match', () => {
+ expect(wrapIlikePattern('foo')).toBe('%foo%')
+ })
+
+ it('leaves the value untouched when it already contains a % wildcard', () => {
+ expect(wrapIlikePattern('foo%')).toBe('foo%')
+ expect(wrapIlikePattern('%foo')).toBe('%foo')
+ })
+
+ it('leaves the value untouched when it already contains a _ wildcard', () => {
+ expect(wrapIlikePattern('fo_bar')).toBe('fo_bar')
+ })
+})
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
index bda880a754a..3027e48b6c7 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts
@@ -261,6 +261,15 @@ export function gateLogTypeOptions>
diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.test.ts b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.test.ts
index 5a9828e42f6..177bb2953e3 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.test.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.test.ts
@@ -20,6 +20,7 @@ describe('buildFilterProperties', () => {
options: [{ label: 'Postgres', value: 'postgres' }],
},
{ label: 'Event message', value: 'event_message', type: 'input' },
+ { label: 'Pathname', value: 'pathname', type: 'checkbox' },
]
it('produces properties that all satisfy the FilterProperty schema', () => {
@@ -30,7 +31,7 @@ describe('buildFilterProperties', () => {
it('drops timerange fields and appends the synthetic user property last', () => {
const names = buildFilterProperties({ fields }).map((property) => property.name)
expect(names).not.toContain('date')
- expect(names).toEqual(['log_type', 'event_message', USER_PROPERTY])
+ expect(names).toEqual(['log_type', 'event_message', 'pathname', USER_PROPERTY])
})
it('gives the event_message column pattern (ILIKE) operators', () => {
@@ -53,6 +54,18 @@ describe('buildFilterProperties', () => {
])
})
+ it('gives pathname both comparison and pattern (ILIKE) operators', () => {
+ const pathname = buildFilterProperties({ fields }).find(
+ (property) => property.name === 'pathname'
+ )
+ expect(pathname?.operators).toEqual([
+ { label: 'Equals', value: '=', group: 'comparison' },
+ { label: 'Not equal', value: '<>', group: 'comparison' },
+ { label: 'iLike', value: '~~*', group: 'pattern' },
+ { label: 'Not iLike', value: '!~~*', group: 'pattern' },
+ ])
+ })
+
it('defaults a column without options to an empty array', () => {
const eventMessage = buildFilterProperties({ fields }).find(
(property) => property.name === 'event_message'
diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.ts
index 302d1e424f2..4616e4a8643 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogsFilterBar.utils.ts
@@ -31,6 +31,16 @@ export const filterPropertySchema = z.object({
operators: z.array(filterOperatorSchema).optional(),
})
+const COMPARISON_OPERATORS: FilterProperty['operators'] = [
+ { label: 'Equals', value: '=', group: 'comparison' },
+ { label: 'Not equal', value: '<>', group: 'comparison' },
+]
+
+const PATTERN_OPERATORS: FilterProperty['operators'] = [
+ { label: 'iLike', value: '~~*', group: 'pattern' },
+ { label: 'Not iLike', value: '!~~*', group: 'pattern' },
+]
+
export const buildFilterProperties = ({
fields,
userOptions,
@@ -41,24 +51,23 @@ export const buildFilterProperties = ({
return [
...fields
.filter((field) => field.type !== 'timerange')
- .map(
- (field): FilterProperty => ({
+ .map((field): FilterProperty => {
+ // event_message only supports substring matching (it's free text); pathname
+ // supports both exact segment matching and substring matching.
+ const operators =
+ field.value === 'event_message'
+ ? PATTERN_OPERATORS
+ : field.value === 'pathname'
+ ? [...COMPARISON_OPERATORS, ...PATTERN_OPERATORS]
+ : COMPARISON_OPERATORS
+ return {
label: field.label,
name: field.value,
type: 'string',
options: field.options ?? [],
- operators:
- field.value === 'event_message'
- ? [
- { label: 'iLike', value: '~~*', group: 'pattern' },
- { label: 'Not iLike', value: '!~~*', group: 'pattern' },
- ]
- : [
- { label: 'Equals', value: '=', group: 'comparison' },
- { label: 'Not equal', value: '<>', group: 'comparison' },
- ],
- })
- ),
+ operators,
+ }
+ }),
{
label: 'User',
name: USER_PROPERTY,
diff --git a/packages/ui-patterns/src/FilterBar/menuItems.test.ts b/packages/ui-patterns/src/FilterBar/menuItems.test.ts
index 60be56a664c..52c1a95b984 100644
--- a/packages/ui-patterns/src/FilterBar/menuItems.test.ts
+++ b/packages/ui-patterns/src/FilterBar/menuItems.test.ts
@@ -31,7 +31,22 @@ const booleanProperty: FilterProperty = {
],
}
-const filterProperties: FilterProperty[] = [stringProperty, booleanProperty]
+const pathProperty: FilterProperty = {
+ label: 'Pathname',
+ name: 'pathname',
+ type: 'string',
+ options: [
+ { label: '/auth/v1/health', value: '/auth/v1/health' },
+ { label: '/rest-admin/v1/ready', value: '/rest-admin/v1/ready' },
+ ],
+ operators: [
+ { value: '=', label: 'Equals', group: 'comparison' as const },
+ { value: '~~*', label: 'iLike', group: 'pattern' as const },
+ { value: '!~~*', label: 'Not iLike', group: 'pattern' as const },
+ ],
+}
+
+const filterProperties: FilterProperty[] = [stringProperty, booleanProperty, pathProperty]
describe('buildOperatorItems', () => {
it('returns matching operators for operator draft text', () => {
@@ -145,6 +160,50 @@ describe('buildValueItems', () => {
{ value: 'bob', label: 'Bob' },
])
})
+
+ it.each(['~~*', '!~~*'])(
+ 'suppresses value suggestions for pattern-group operator `%s`',
+ (operator) => {
+ const filters: FilterGroup = {
+ logicalOperator: 'AND',
+ conditions: [{ propertyName: 'pathname', operator, value: '' }],
+ }
+
+ const items = buildValueItems(
+ { type: 'value', path: [0] },
+ filters,
+ filterProperties,
+ {},
+ {},
+ '',
+ false
+ )
+
+ expect(items).toEqual([])
+ }
+ )
+
+ it('still returns options for pathname with a comparison-group operator', () => {
+ const filters: FilterGroup = {
+ logicalOperator: 'AND',
+ conditions: [{ propertyName: 'pathname', operator: '=', value: '' }],
+ }
+
+ const items = buildValueItems(
+ { type: 'value', path: [0] },
+ filters,
+ filterProperties,
+ {},
+ {},
+ '',
+ false
+ )
+
+ expect(items).toEqual([
+ { value: '/auth/v1/health', label: '/auth/v1/health' },
+ { value: '/rest-admin/v1/ready', label: '/rest-admin/v1/ready' },
+ ])
+ })
})
describe('buildPropertyItems', () => {
diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts
index 34e8687190e..8ed83c386d2 100644
--- a/packages/ui-patterns/src/FilterBar/menuItems.ts
+++ b/packages/ui-patterns/src/FilterBar/menuItems.ts
@@ -146,6 +146,18 @@ export function buildValueItems(
return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus)
}
+ // Pattern-matching operators (e.g. iLike) search for a substring, not an exact
+ // value, so a dropdown of exact-value suggestions would be misleading — let the
+ // user type freely instead.
+ const activeOperator = property.operators?.find((op) =>
+ isFilterOperatorObject(op)
+ ? op.value === activeCondition?.operator
+ : op === activeCondition?.operator
+ )
+ if (isFilterOperatorObject(activeOperator) && activeOperator.group === 'pattern') {
+ return items
+ }
+
if (!Array.isArray(property.options) && isCustomOptionObject(property.options)) {
items.push({
value: 'custom',
From 232ce7e68cf50969da75254b220c1f439420cea6 Mon Sep 17 00:00:00 2001
From: Saxon Fletcher
Date: Wed, 16 Sep 2026 16:52:03 +1000
Subject: [PATCH 075/107] docs: focus Logs on the unified view and export
queryable fields (#50073)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
Yes.
## What kind of change does this PR introduce?
Documentation update and a small Studio copy correction.
## What is the current behavior?
The Logs guide mixes unified filtering with retired SQL Explorer
instructions, and the Markdown field reference loses query semantics.
## What is the new behavior?
The Logs guide mixed filtering and event inspection with the retired SQL
Logs Explorer workflow. It now documents the unified Logs view: default
sources, filter semantics, event details, Live, sharing, bounded
exports, and missing results.
The log field reference uses one mapping for HTML and Markdown,
preserving source IDs, query expressions, and source/query types. The
Studio User-filter empty state and comments now match its Auth and API
Gateway scope.
## Additional context
Validation: shared-field mapping tests, guides Markdown generation, docs
typecheck, targeted docs/Studio ESLint, formatting, and browser
inspection of the field table. Exported Markdown includes the source IDs
and usable ClickHouse expressions. Repository-wide MDX lint has existing
failures; changed pages have no reported violations.
Self-review: hosted Studio filtering and log ingestion were checked
against the implementation, not exercised against a hosted project. The
documentation assumes the unified Logs experience is the default.
Stage 1 of 3. Review and merge from the bottom of the stack.
Stack: #50073 → #50074 → #50075.
Production docs build also passes at the stack tip after standard
reference generation.
Initial CI note: the spelling action failed while building its container
because Debian package downloads returned 404, before checking content.
The stack has no merge conflicts.
## Summary by CodeRabbit
* **Documentation**
* Expanded the log field reference with ClickHouse fields, nested-field
query examples, types, schema references, and capture limits.
* Reworked the Logs guide with clearer instructions for filtering, event
inspection, live mode, sharing, exports, retention, and missing results.
* Clarified service and Postgres log behavior and updated navigation and
metadata.
* **Bug Fixes**
* Corrected user-filtering guidance and empty-state messaging to
identify Auth and API Gateway logs as supported sources.
---------
Co-authored-by: Steven Eubank
Co-authored-by: Steven Eubank <47563310+smeubank@users.noreply.github.com>
---
apps/docs/components/SharedData.tsx | 4 +-
apps/docs/components/SharedData.utils.test.ts | 39 ++++++++
apps/docs/components/SharedData.utils.ts | 40 ++++++++
.../observability/log-field-reference.mdx | 80 +++++++++------
.../content/guides/observability/logs.mdx | 97 ++++++++-----------
.../internals/markdown-schema/SharedData.ts | 30 +++---
.../UnifiedLogs/UnifiedLogs.queries.ts | 14 +--
.../interfaces/UnifiedLogs/UnifiedLogs.tsx | 2 +-
8 files changed, 197 insertions(+), 109 deletions(-)
create mode 100644 apps/docs/components/SharedData.utils.test.ts
diff --git a/apps/docs/components/SharedData.tsx b/apps/docs/components/SharedData.tsx
index 744be85b829..9e91e59cedb 100644
--- a/apps/docs/components/SharedData.tsx
+++ b/apps/docs/components/SharedData.tsx
@@ -1,11 +1,11 @@
import { ReactNode } from 'react'
import { config, logConstants } from 'shared-data'
-import { resolveSharedDataPath } from './SharedData.utils'
+import { getLogFieldReference, resolveSharedDataPath } from './SharedData.utils'
const sharedData = {
config,
- logConstants,
+ logConstants: { schemas: getLogFieldReference(logConstants.schemas) },
}
/**
diff --git a/apps/docs/components/SharedData.utils.test.ts b/apps/docs/components/SharedData.utils.test.ts
new file mode 100644
index 00000000000..5bc8bcc86bc
--- /dev/null
+++ b/apps/docs/components/SharedData.utils.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+
+import { getLogFieldReference } from './SharedData.utils'
+
+describe('getLogFieldReference', () => {
+ it('distinguishes ClickHouse columns from both prefixed and unprefixed attributes', () => {
+ const source = {
+ name: 'API Gateway',
+ reference: 'edge_logs',
+ fields: [
+ { path: 'id', type: 'string' },
+ { path: 'identifier', type: 'string' },
+ { path: 'metadata.response.status_code', type: 'number' },
+ ],
+ }
+ const [result] = getLogFieldReference([source])
+ expect(result.reference).toBe('edge_logs')
+ expect(result.fields).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: 'id', queryField: 'id', queryType: 'String' }),
+ expect.objectContaining({ path: 'identifier', queryField: "log_attributes['identifier']" }),
+ expect.objectContaining({
+ path: 'metadata.response.status_code',
+ type: 'number',
+ queryField: "log_attributes['response.status_code']",
+ queryType: 'String',
+ }),
+ expect.objectContaining({
+ path: 'timestamp',
+ queryField: 'timestamp',
+ queryType: 'DateTime64',
+ }),
+ expect.objectContaining({ path: 'source', queryField: 'source' }),
+ ])
+ )
+ expect(result.fields.filter((field) => field.path === 'id')).toHaveLength(1)
+ expect(source.fields).toHaveLength(3)
+ })
+})
diff --git a/apps/docs/components/SharedData.utils.ts b/apps/docs/components/SharedData.utils.ts
index 512eab59cfe..c0fbbff1fc1 100644
--- a/apps/docs/components/SharedData.utils.ts
+++ b/apps/docs/components/SharedData.utils.ts
@@ -17,3 +17,43 @@ export function resolveSharedDataPath(dataset: unknown, path: string): string |
}
return selected
}
+
+type LogSourceSchema = {
+ name: string
+ reference: string
+ fields: { path: string; type: string }[]
+}
+
+const LOG_COLUMNS = new Map([
+ ['id', 'String'],
+ ['timestamp', 'DateTime64'],
+ ['event_message', 'String'],
+ ['severity_text', 'String'],
+ ['source', 'String'],
+])
+
+/** One field mapping for the HTML reference and its Markdown export. */
+export function getLogFieldReference(schemas: LogSourceSchema[]) {
+ return schemas.map((schema) => {
+ const fields = [...schema.fields]
+ for (const [path, type] of LOG_COLUMNS) {
+ if (!fields.some((field) => field.path === path)) fields.push({ path, type })
+ }
+ return {
+ ...schema,
+ fields: fields
+ .sort((a, b) => a.path.localeCompare(b.path))
+ .map((field) => {
+ const key = field.path
+ .replace(/^metadata\./, '')
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "''")
+ return {
+ ...field,
+ queryField: LOG_COLUMNS.has(field.path) ? field.path : `log_attributes['${key}']`,
+ queryType: LOG_COLUMNS.get(field.path) ?? 'String',
+ }
+ }),
+ }
+ })
+}
diff --git a/apps/docs/content/guides/observability/log-field-reference.mdx b/apps/docs/content/guides/observability/log-field-reference.mdx
index 23bec25e0eb..99e444585ef 100644
--- a/apps/docs/content/guides/observability/log-field-reference.mdx
+++ b/apps/docs/content/guides/observability/log-field-reference.mdx
@@ -1,42 +1,68 @@
---
id: 'logs-field-reference'
title: 'Logs field reference'
-description: 'Supabase Logs field reference'
+description: 'Log sources, ClickHouse fields, and event capture limits'
---
-Use this reference to find the fields available for each log source. Query `id`, `timestamp`, `event_message`, and `source` as top-level columns. Other structured fields are keys in the `log_attributes` map: drop the `metadata.` prefix shown in the source schema and keep the rest of the dotted path.
+Each event is a row in the ClickHouse `logs` table. Filter the `source` column to select a service. The tables below list its fields; a field is not necessarily populated on every event.
-For example, the schema path `metadata.request.cf.country` is queried as `log_attributes['request.cf.country']`. See [Query and filter logs](/docs/guides/observability/advanced-log-filtering) for complete ClickHouse examples.
+`id`, `timestamp`, `event_message`, `severity_text`, and `source` are top-level columns. Service fields are string values in `log_attributes`, even when the original event contains a number or boolean. Use the **ClickHouse query field** column directly. See [Query logs with SQL](/docs/guides/observability/advanced-log-filtering) for casting and field discovery.
+
+## Capture limits
+
+- Hosted Postgres events longer than 100,000 characters and Edge Function log messages longer than 10,000 characters are truncated.
+- Internal Supabase service connection events are not recorded in hosted Postgres logs.
+- An Edge Function invocation uses `function_edge_logs`; its console output uses `function_logs`.
+- For [API Load Balancer](/docs/guides/platform/read-replicas#api-load-balancer) traffic, `log_attributes['load_balancer_redirect_identifier']` identifies the upstream database.
+
+## Fields by source
{(logConstants) => (
{logConstants.schemas.map((schema) => (
-
-
-
- Schema path
- ClickHouse query field
- Type
-
-
-
- {schema.fields
- .sort((a, b) => a.path.localeCompare(b.path))
- .map((field) => (
-
- {field.path}
-
- {field.path.startsWith('metadata.')
- ? `log_attributes['${field.path.slice('metadata.'.length)}']`
- : field.path}
-
- {field.type}
-
- ))}
-
-
+
+ Source: {schema.reference}
+
+
+ {schema.fields
+ .sort((a, b) => a.path.localeCompare(b.path))
+ .map((field) => {
+ const shortName = field.path.replace(/^metadata\./, '')
+ const isTopLevel = field.queryField === field.path
+ return (
+
+
+ {shortName}
+
+ {field.type}
+
+
+ {!isTopLevel && (
+
+
+
+ schema
+
+
+ {field.path}
+
+
+
+
+ clickhouse
+
+
+ {field.queryField}
+
+
+
+ )}
+
+ )
+ })}
+
))}
diff --git a/apps/docs/content/guides/observability/logs.mdx b/apps/docs/content/guides/observability/logs.mdx
index 6d8b9f62278..28e03353b82 100644
--- a/apps/docs/content/guides/observability/logs.mdx
+++ b/apps/docs/content/guides/observability/logs.mdx
@@ -1,81 +1,68 @@
---
id: 'logs'
-title: 'Logs'
-description: 'Inspect project log events in the unified Logs view in Studio'
+title: 'Logs in Studio'
+description: 'Filter, inspect, and export project events in the Logs view'
---
-This guide explains how to inspect project logs in Studio. Log retention is based on your [project's pricing plan](/pricing). For details on how Logs usage is billed, see [Manage Logs usage](/docs/guides/platform/manage-your-usage/logs).
+Use [Logs](/dashboard/project/_/logs) to inspect events across your hosted project's services. For SQL queries through [Explorer](/dashboard/project/_/explorer), MCP, or the API, see [Query logs with SQL](/docs/guides/observability/advanced-log-filtering).
-Use this page to filter and inspect events in [Logs](#product-logs). To query the same data with SQL from Studio, MCP, the API, or a script, or to record extra Postgres, API, and Realtime events, see [Query and filter logs](/docs/guides/observability/advanced-log-filtering).
-
-
-
-If you already have a specific error, start at [Diagnosing](/docs/guides/troubleshooting). To pick up a signal from these events, see [Detecting](/docs/guides/observability/detecting).
-
-
-
-## Filter and inspect events [#product-logs]
-
-Open [Logs](/dashboard/project/_/logs). The page shows a timeline of success, warning, and error events, a filterable table, and a detail panel when you select a row.
-
-If you don't select a log type, Logs queries **Postgres** and **API Gateway** events. Selecting log types replaces that default set.
-
-
-
-For regular expression filtering, structured-field queries, and field discovery, see [Query and filter logs](/docs/guides/observability/advanced-log-filtering).
-
-
-
-### Filter logs
+## Find events [#product-logs]
1. Open [Logs](/dashboard/project/_/logs).
-2. Set the **Time Range** in the sidebar.
-3. Select one or more **Log Type** values. Nested toggles under API Gateway include or exclude Auth, Storage, and PostgREST request paths. The nested toggle under Postgres shows or hides connection logs.
-4. Optionally filter by **Level**, **Status**, **Method**, **Pathname**, or **Event message**. Type in the filter bar to search event messages.
-5. Optionally filter by **User**. This filter only matches Auth and Postgres events.
+2. Set the **Time Range** in the sidebar, or select a range on the timeline.
+3. Select one or more **Log Type** values.
+4. Add filters in the filter bar, or type text to search event messages.
+5. Select a row to inspect the event.
-Refresh the table, hide columns, download matching rows as CSV or JSON, or turn on live mode to stream new events.
+Without a log type selection, Logs queries **Postgres** and **API Gateway**. Selecting types replaces this default set. The timeline groups events by success, warning, and error.
-### Log types
+## Filter events
-Selecting a log type in Studio queries the matching ClickHouse `source`. For the `source` names to use in SQL, see [Sources](/docs/guides/observability/advanced-log-filtering#logs-explorer).
+{/* supa-mdx-lint-disable Rule003Spelling */}
+| Filter | Behavior |
+| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Log Type | Select API Gateway, Postgres, Auth, Storage, PostgREST, Edge Function, Realtime, or pooler events. |
+| Level | Match success, warning, or error. |
+| Status | Match an HTTP status or Postgres SQLSTATE. |
+| Method | Match an HTTP method. |
+| Pathname | Match a request path. |
+| Event message | Use **iLike** or **Not iLike** for case-insensitive text matching or exclusion. Plain text matches anywhere in the message; `%` specifies a wildcard pattern. |
+| User | Match the user's ID in Auth actor IDs or API Gateway JWT subjects. Other log types cannot match this filter. |
-| Log type | Events |
-| ------------- | ----------------------------------------------------------------- |
-| API Gateway | HTTP requests through the API gateway, including REST and GraphQL |
-| Postgres | Database queries and activity |
-| PostgREST | PostgREST server logs |
-| Auth | Auth server logs |
-| Storage | Storage API server logs |
-| Edge Function | Edge Function HTTP invocations and `console` output |
-| Realtime | Realtime server logs |
-| Supavisor | Connection pooler logs |
-| PgBouncer | PgBouncer logs |
+{/* supa-mdx-lint-enable Rule003Spelling */}
-Selecting **API Gateway** is not the same as selecting **Auth**, **Storage**, or **PostgREST**. The nested API Gateway toggles filter HTTP paths on the gateway. The Auth, Storage, and PostgREST log types query those services' own logs.
+Filters other than **Event message** and **User** support **Equals** and **Not equal**. **User** supports **Equals**. Included values within a field match any selected value; exclusions remove every selected value. Filters on different fields must all match.
+
+### Gateway and service logs
+
+The nested service toggles under **API Gateway** include or exclude gateway request paths. Selecting the separate **Auth**, **Storage**, or **PostgREST** log type retrieves that service's own logs. These are different events.
+
+For SQL source names, see the [Log field reference](/docs/guides/observability/log-field-reference).
### Postgres [#postgres]
-Postgres logs show queries and activity for your database. Connection lifecycle events appear here when [connection logging](/docs/guides/observability/advanced-log-filtering#logging-postgres-connections) is enabled. They are included by default; clear **Connection logs** under the Postgres log type to hide them.
+Postgres logs contain database activity and errors. Connection events appear when [connection logging](/docs/guides/platform/postgres-connection-logging) is enabled. Clear **Connection logs** under **Postgres** to hide them.
To record additional statement classes, see [Logging Postgres queries](/docs/guides/observability/advanced-log-filtering#logging-postgres-queries).
-### Inspect a log
+## Inspect an event [#expanding-results]
-1. Select a row in the table.
-2. Open **Overview** to follow the request through the services that handled it. Open **Raw JSON** for the full event.
-3. Dock the panel at the bottom or on the right.
+Select a row to open its detail panel. **Overview**, when available for the log type, shows service details. **Raw JSON** shows the event data. Dock the panel at the bottom or on the right.
-Edge Function rows include console output from that invocation. In SQL, the HTTP request is `function_edge_logs` and console output is `function_logs`. Function log messages longer than 10,000 characters are truncated.
+Edge Function invocations can include associated console output. In SQL, invocation events use `function_edge_logs` and console events use `function_logs`.
-### Expanding results [#expanding-results]
+## Watch, share, and export
-In the [Logs Explorer](/dashboard/project/_/logs/explorer), query results can be hard to read in the table. Double-click a row to expand it as JSON:
+- Select **Live** to fetch new events automatically. Select it again to pause. Starting live mode clears the fixed time range and sort; selecting a time range or sort stops live mode.
+- Copy the page URL to share the current filters. Recipients need access to the project.
+- Open **Download logs**, choose CSV or JSON, and select a result limit of 100, 500, or 1,000 rows. The export applies the current filters. Without a fixed time range, choose the duration to retrieve.
-
+For continuous export, use [Log drains](/docs/guides/observability/log-drains).
-### Single-service collections [#single-service-collections]
+## Missing results [#single-service-collections]
-The Logs sidebar still lists collections for one service at a time, such as [API Gateway](/dashboard/project/_/logs/edge-logs) or [Postgres](/dashboard/project/_/logs/postgres-logs). Use a collection when you want a dedicated view.
+Check the time range, selected log types, and exclusions first. **User** combined with only Postgres or another unsupported type returns no matches. An empty result does not establish that the user had no activity.
-If [Read Replicas](/docs/guides/platform/read-replicas) are enabled, collections can filter by database with the **Source** control. For API logs from the [API Load Balancer](/docs/guides/platform/read-replicas#api-load-balancer), the upstream database is the Redirect Identifier field (`log_attributes['load_balancer_redirect_identifier']` in SQL).
+Events must be recorded before they can appear in Logs. See [Logging configuration](/docs/guides/observability/advanced-log-filtering#logging-postgres-connections) and the [source limitations](/docs/guides/observability/log-field-reference#capture-limits).
+
+Retention depends on your [pricing plan](/pricing). See [Manage Logs usage](/docs/guides/platform/manage-your-usage/logs) for billing details.
diff --git a/apps/docs/internals/markdown-schema/SharedData.ts b/apps/docs/internals/markdown-schema/SharedData.ts
index c915f95d7c2..430b6740246 100644
--- a/apps/docs/internals/markdown-schema/SharedData.ts
+++ b/apps/docs/internals/markdown-schema/SharedData.ts
@@ -1,25 +1,29 @@
import { createRequire } from 'node:module'
-import { resolveSharedDataPath } from '../../components/SharedData.utils'
+import { getLogFieldReference, resolveSharedDataPath } from '../../components/SharedData.utils'
// tsx's ESM loader can't pick up named exports from the `shared-data` package
// (CJS, no `"type": "module"`). Load via `createRequire` to use CJS interop —
// this file only runs in the build script, never in the Next.js bundle.
const { config, logConstants } = createRequire(import.meta.url)('shared-data')
-type Field = { path: string; type: string }
-type Schema = { name: string; fields: Field[] }
-
const sharedData: Record = { config, logConstants }
-const renderLogConstants = (data: { schemas: Schema[] }): string =>
- data.schemas
- .map(
- (s) =>
- `#### ${s.name}\n${[...s.fields]
- .sort((a, b) => a.path.localeCompare(b.path))
- .map((f) => ` - \`${f.path}\`, \`${f.type}\``)
- .join('\n')}`
+const renderLogConstants = (data: typeof logConstants): string =>
+ getLogFieldReference(data.schemas)
+ .map((schema) =>
+ [
+ `### ${schema.name}`,
+ '',
+ `Source: \`${schema.reference}\``,
+ '',
+ '| Schema path | ClickHouse query field | Source type | Query value type |',
+ '| --- | --- | --- | --- |',
+ ...schema.fields.map(
+ (field) =>
+ `| \`${field.path}\` | \`${field.queryField}\` | \`${field.type}\` | \`${field.queryType}\` |`
+ ),
+ ].join('\n')
)
.join('\n\n')
@@ -41,6 +45,6 @@ export const SharedData = ({
// The schema walker strips the MDX expression children before this handler
// runs, and we can't evaluate the function statically anyway — hardcode the
// markdown for the only dataset that uses this form today.
- if (props.data === 'logConstants') return renderLogConstants(dataset as { schemas: Schema[] })
+ if (props.data === 'logConstants') return renderLogConstants(dataset as typeof logConstants)
return ''
}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
index 973e24e75b3..777a6e49ba3 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
@@ -357,14 +357,8 @@ const userFilterValue = (search: QuerySearchParamsType): string =>
typeof search.user === 'string' ? search.user.trim() : ''
/**
- * Cross-cutting "attributable to one user" condition. Only the two sources that can
- * be positively tied to a user are eligible, each with its own match:
- * - auth_logs: structured identity (`auth_event.actor_id`)
- * - postgres_logs: the identifier appears verbatim in the error text (e.g. a 23502
- * failing row echoing the id column).
- * edge_logs / storage_logs / realtime_logs carry no per-user field and can't satisfy
- * either branch, so they're auto-excluded while the filter is active — never guessed
- * at via IP or timestamp proximity.
+ * Matches structured user identifiers in Auth events and API Gateway JWT subjects.
+ * Sources without these fields cannot match the user filter.
*/
const userAttributionCondition = (search: QuerySearchParamsType): SafeLogSqlFragment | null => {
const value = userFilterValue(search)
@@ -381,12 +375,10 @@ const USER_ATTRIBUTABLE_SOURCES = new Set([LOG_TYPE_TO_SOURCE.auth, LOG_TYPE_TO_
/**
* True when the user filter is active but an explicit log_type filter restricts the
- * view to source(s) that can never satisfy `userAttributionCondition` (e.g. `edge`) —
+ * view to source(s) that can never satisfy `userAttributionCondition` (e.g. `postgres`) —
* the combination is guaranteed to match zero rows. Consumed by the UI to show a
* specific empty state instead of the generic "No results found".
*
- * [Joshen] Basically filtering by user only works on Auth and Postgres logs atm
- * Refer to userAttributeCondition above
*/
export const isUserFilterUnreachable = (search: QuerySearchParamsType): boolean => {
if (!userFilterValue(search)) return false
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
index 06724b70d65..f67b3f1040a 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
@@ -521,7 +521,7 @@ export const UnifiedLogs = () => {
No results found
- Filtering by user is only supported for Auth and Postgres log types
+ Filtering by user is only supported for Auth and API Gateway log types
) : undefined
From 32341830b3bf9f38f25ae42e0e48bf7fd5fb23d9 Mon Sep 17 00:00:00 2001
From: Saxon Fletcher
Date: Wed, 16 Sep 2026 17:03:43 +1000
Subject: [PATCH 076/107] docs: organize observability by task and move SQL
logs to Explorer (#50074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
Yes.
## What kind of change does this PR introduce?
Documentation update.
## What is the current behavior?
The observability overview and access page overlap; configuration
interrupts querying; related guides send log queries to the old editor.
## What is the new behavior?
The observability overview and navigation follow the same four sections:
Read project data, Detect and diagnose, Hire an agent, and Configure and
export. The overview absorbs the redundant access page, with permanent
redirects for both HTML and Markdown URLs.
“Query logs with SQL” owns ClickHouse querying through MCP, the
Management API, and Explorer with query source Logs. Logging
configuration moves to its own guide; sources, captured headers, and
limits live in the field reference. Inspection links to canonical
diagnostic SQL. Related Storage and database guides use the replacement
Explorer workflow and retain existing anchors where headings move.
## Additional context
Validation: Markdown generation, docs typecheck, targeted ESLint,
formatting, and content-listing tests. Browser overview/navigation
checked; old HTML and Markdown URLs return 308, and the new
configuration page returns 200 in both formats. Three ClickHouse
examples and the Postgres configuration query ran in a disposable
container sandbox. Changed pages have no MDX lint violations;
repository-wide existing failures remain.
Self-review: the Management API request was verified against its
published schema but not sent to a hosted project. Realtime ingestion
and hosted logging configuration still need a hosted smoke check. No
compatibility path for the deprecated logs engine is documented.
Stage 2 of 3; depends on stage 1.
Stack: #50073 → #50074 → #50075.
Production docs build also passes at the stack tip after standard
reference generation.
## Summary by CodeRabbit
- **Documentation**
- Reorganized observability guidance around reading data, detecting
issues, diagnosing problems, agent setup, and exporting data.
- Added a guide for configuring Postgres and Realtime logging.
- Updated log investigation instructions to use Explorer, SQL queries,
and clearer filters.
- Added log source, field, and captured-header references.
- Improved advisor guidance and database performance troubleshooting.
- Added redirects for moved observability content.
- **Accessibility**
- Improved screen-reader labels for copy and feature-selection controls.
---------
Co-authored-by: Claude Opus 5
---
.../NavigationMenu.constants.ts | 139 +----
apps/docs/content/guides/ai-tools/mcp.mdx | 2 +-
.../guides/api/rest/postgrest-error-codes.mdx | 2 +-
.../guides/database/extensions/pgaudit.mdx | 2 +-
.../guides/database/postgres/timeouts.mdx | 4 +-
apps/docs/content/guides/database/prisma.mdx | 2 +-
apps/docs/content/guides/observability.mdx | 30 +-
.../guides/observability/access-data.mdx | 31 -
.../observability/advanced-log-filtering.mdx | 549 +++---------------
.../content/guides/observability/advisors.mdx | 6 +-
.../observability/configure-logging.mdx | 47 ++
.../guides/observability/detecting.mdx | 6 +-
.../content/guides/observability/inspect.mdx | 152 +----
.../observability/log-field-reference.mdx | 56 +-
.../content/guides/observability/logs.mdx | 4 +-
.../platform/manage-your-usage/egress.mdx | 2 +-
.../content/guides/storage/cdn/metrics.mdx | 6 +-
.../content/guides/storage/debugging/logs.mdx | 6 +-
.../guides/storage/serving/bandwidth.mdx | 4 +-
.../data/content-listings/telemetry.data.ts | 28 +-
apps/docs/next.config.mjs | 10 +
apps/www/lib/redirects.js | 10 +
.../ui-patterns/src/CodeBlock/CodeBlock.tsx | 1 +
.../components/McpConfigurationOptions.tsx | 1 +
24 files changed, 280 insertions(+), 820 deletions(-)
delete mode 100644 apps/docs/content/guides/observability/access-data.mdx
create mode 100644 apps/docs/content/guides/observability/configure-logging.mdx
diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
index d1617528709..a578dd53fa5 100644
--- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
+++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts
@@ -3071,143 +3071,58 @@ export const telemetry: NavMenuConstant = {
items: [
{ name: 'Overview', url: '/guides/observability' },
{
- name: 'Observe the data',
- url: '/guides/observability/access-data' as `/${string}`,
+ name: 'Read project data',
items: [
- {
- name: 'Logs',
- url: '/guides/observability/advanced-log-filtering' as `/${string}`,
- items: [
- {
- name: 'Query and filter logs',
- url: '/guides/observability/advanced-log-filtering' as `/${string}`,
- },
- {
- name: 'Sources',
- url: '/guides/observability/advanced-log-filtering#logs-explorer' as `/${string}`,
- },
- {
- name: 'Logs field reference',
- url: '/guides/observability/log-field-reference' as `/${string}`,
- },
- {
- name: 'Logs in Studio',
- url: '/guides/observability/logs' as `/${string}`,
- },
- ],
- },
+ { name: 'Query logs with SQL', url: '/guides/observability/advanced-log-filtering' },
+ { name: 'Logs in Studio', url: '/guides/observability/logs' },
+ { name: 'Log sources and fields', url: '/guides/observability/log-field-reference' },
+ { name: 'Inspect the database', url: '/guides/observability/inspect' },
+ { name: 'Advisors', url: '/guides/observability/advisors' },
+ { name: 'Reports', url: '/guides/observability/reports' },
{
name: 'Metrics API',
- url: '/guides/observability/metrics' as `/${string}`,
+ url: '/guides/observability/metrics',
items: [
- {
- name: 'Grafana Cloud',
- url: '/guides/observability/metrics/grafana-cloud' as `/${string}`,
- },
+ { name: 'Grafana Cloud', url: '/guides/observability/metrics/grafana-cloud' },
{
name: 'Grafana self-hosted',
- url: '/guides/observability/metrics/grafana-self-hosted' as `/${string}`,
- },
- {
- name: 'Datadog',
- url: 'https://docs.datadoghq.com/integrations/supabase/',
- },
- {
- name: 'Elastic',
- url: 'https://www.elastic.co/docs/reference/integrations/supabase',
- },
- {
- name: 'Vendor-agnostic setup',
- url: '/guides/observability/metrics/vendor-agnostic' as `/${string}`,
+ url: '/guides/observability/metrics/grafana-self-hosted',
},
+ { name: 'Datadog', url: 'https://docs.datadoghq.com/integrations/supabase/' },
+ { name: 'Elastic', url: 'https://www.elastic.co/docs/reference/integrations/supabase' },
+ { name: 'Vendor-agnostic setup', url: '/guides/observability/metrics/vendor-agnostic' },
],
},
- {
- name: 'Database',
- url: '/guides/observability/inspect' as `/${string}`,
- items: [
- {
- name: 'CLI commands',
- url: '/guides/observability/inspect#using-the-cli' as `/${string}`,
- },
- {
- name: 'SQL',
- url: '/guides/observability/inspect#using-sql' as `/${string}`,
- },
- ],
- },
- {
- name: 'Advisors',
- url: '/guides/observability/advisors' as `/${string}`,
- },
- {
- name: 'Reports',
- url: '/guides/observability/reports' as `/${string}`,
- },
],
},
{
- name: 'Detect issues',
- url: '/guides/observability/detecting' as `/${string}`,
+ name: 'Detect and diagnose',
items: [
- {
- name: 'Detection checks',
- url: '/guides/observability/detecting' as `/${string}`,
- },
- ],
- },
- {
- name: 'Diagnose and resolve',
- url: '/guides/troubleshooting' as `/${string}`,
- items: [
- {
- name: 'Troubleshooting',
- url: '/guides/troubleshooting' as `/${string}`,
- },
+ { name: 'Detection checks', url: '/guides/observability/detecting' },
+ { name: 'Troubleshooting', url: '/guides/troubleshooting' },
],
},
{
name: 'Hire an agent',
- url: '/guides/observability/automate-with-agents' as `/${string}`,
items: [
- {
- name: 'Generalist',
- url: '/guides/observability/automate-with-agents/all' as `/${string}`,
- },
- {
- name: 'Health monitor',
- url: '/guides/observability/automate-with-agents/health' as `/${string}`,
- },
- {
- name: 'Security monitor',
- url: '/guides/observability/automate-with-agents/security' as `/${string}`,
- },
+ { name: 'Set up an agent', url: '/guides/observability/automate-with-agents' },
+ { name: 'Generalist', url: '/guides/observability/automate-with-agents/all' },
+ { name: 'Health monitor', url: '/guides/observability/automate-with-agents/health' },
+ { name: 'Security monitor', url: '/guides/observability/automate-with-agents/security' },
{
name: 'Performance monitor',
- url: '/guides/observability/automate-with-agents/performance' as `/${string}`,
- },
- {
- name: 'Capacity monitor',
- url: '/guides/observability/automate-with-agents/usage' as `/${string}`,
+ url: '/guides/observability/automate-with-agents/performance',
},
+ { name: 'Capacity monitor', url: '/guides/observability/automate-with-agents/usage' },
],
},
{
- name: 'Export',
- url: undefined,
+ name: 'Configure and export',
items: [
- {
- name: 'Log drains',
- url: '/guides/observability/log-drains' as `/${string}`,
- },
- {
- name: 'Client-side tracing',
- url: '/guides/observability/client-side-tracing' as `/${string}`,
- },
- {
- name: 'Sentry integration',
- url: '/guides/observability/sentry-monitoring' as `/${string}`,
- },
+ { name: 'Configure logging', url: '/guides/observability/configure-logging' },
+ { name: 'Log drains', url: '/guides/observability/log-drains' },
+ { name: 'Client-side tracing', url: '/guides/observability/client-side-tracing' },
+ { name: 'Sentry integration', url: '/guides/observability/sentry-monitoring' },
],
},
],
diff --git a/apps/docs/content/guides/ai-tools/mcp.mdx b/apps/docs/content/guides/ai-tools/mcp.mdx
index d0701342afc..abfc36d5f60 100644
--- a/apps/docs/content/guides/ai-tools/mcp.mdx
+++ b/apps/docs/content/guides/ai-tools/mcp.mdx
@@ -56,7 +56,7 @@ The Supabase MCP server provides tools organized into feature groups. All groups
### Debugging
-- `query_logs` - Run a read-only SQL query against project logs to filter, aggregate, or join across log fields. See [Query and filter logs](/docs/guides/observability/advanced-log-filtering).
+- `query_logs` - Run a read-only SQL query against project logs to filter, aggregate, or join across log fields. See [Query logs with SQL](/docs/guides/observability/advanced-log-filtering).
- `get_advisors` - Get security and performance advisors
### Development
diff --git a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx
index 36decb42cb9..19288d73d08 100644
--- a/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx
+++ b/apps/docs/content/guides/api/rest/postgrest-error-codes.mdx
@@ -145,7 +145,7 @@ Data API error unspecified
## Viewing errors in the logs
-One can filter for API errors in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. Below are useful queries for filtering and analyzing API errors:
+One can filter for API errors in the [Explorer](/dashboard/project/_/explorer) after selecting **Run SQL**, query source **Logs**, and a time range. Below are useful queries for filtering and analyzing API errors:
### Find all API errors that occurred at the database level
diff --git a/apps/docs/content/guides/database/extensions/pgaudit.mdx b/apps/docs/content/guides/database/extensions/pgaudit.mdx
index 78b20cff9fd..7514e339dea 100644
--- a/apps/docs/content/guides/database/extensions/pgaudit.mdx
+++ b/apps/docs/content/guides/database/extensions/pgaudit.mdx
@@ -254,7 +254,7 @@ Generates the following log in the [Dashboard's Postgres Logs](/dashboard/projec
## Finding and filtering audit logs
-Logs generated by PGAudit can be found in [Postgres Logs](/dashboard/project/_/logs/postgres-logs?s=AUDIT). To find a specific log, you can use the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**. Below is a basic example to extract logs referencing `CREATE TABLE` events
+Find pgAudit events in [Logs](/dashboard/project/_/logs): select **Postgres** as the log type and filter **Event message** for `AUDIT`. To find a specific log, you can use the [Explorer](/dashboard/project/_/explorer) after selecting **Run SQL**, query source **Logs**, and a time range. Below is a basic example to extract logs referencing `CREATE TABLE` events
```sql
select
diff --git a/apps/docs/content/guides/database/postgres/timeouts.mdx b/apps/docs/content/guides/database/postgres/timeouts.mdx
index c831515f0fe..13e803959e2 100644
--- a/apps/docs/content/guides/database/postgres/timeouts.mdx
+++ b/apps/docs/content/guides/database/postgres/timeouts.mdx
@@ -126,9 +126,9 @@ language sql;
The Supabase Dashboard contains tools to help you identify timed-out and long-running queries.
-### Using the SQL Editor
+### Query timeout logs [#using-the-sql-editor]
-Go to the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs), set the query source to **Logs**, and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`).
+Go to the [Explorer](/dashboard/project/_/explorer), select **Run SQL**, choose query source **Logs**, set a time range, and run the following query to identify timed-out events (`statement timeout`) and queries that successfully run for longer than 10 seconds (`duration`).
```sql
select
diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx
index 17f83390eb5..f7b9b40d783 100644
--- a/apps/docs/content/guides/database/prisma.mdx
+++ b/apps/docs/content/guides/database/prisma.mdx
@@ -18,7 +18,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t
- In the [SQL Editor](/dashboard/project/_/sql/new), create a Prisma DB user with full privileges on the public schema.
- - This gives you better control over Prisma's access and makes it easier to monitor using Supabase tools like the [Query Performance Dashboard](/dashboard/project/_/advisors/query-performance) and [Log Explorer](/dashboard/project/_/logs/explorer).
+ - This gives you better control over Prisma's access and makes it easier to monitor using Supabase tools like the [Query Performance Dashboard](/dashboard/project/_/advisors/query-performance) and [Logs](/dashboard/project/_/logs).
For security, consider using a [password generator](https://bitwarden.com/password-generator/) for the Prisma role.
diff --git a/apps/docs/content/guides/observability.mdx b/apps/docs/content/guides/observability.mdx
index 7b3f6b054a9..31c7817a010 100644
--- a/apps/docs/content/guides/observability.mdx
+++ b/apps/docs/content/guides/observability.mdx
@@ -1,38 +1,28 @@
---
title: Observability
-description: 'Access project data, detect issues, diagnose findings, and automate repeatable checks with an agent.'
+description: 'Read project data, diagnose issues, and hire an agent to monitor your project'
---
-
+Use project data to understand what is happening, investigate issues, and give an agent repeatable checks to run.
-Monitor your Supabase project with the tools you already use, as a person or an agent.
+## Read project data [#metrics-api]
-## 1. Observe the data
-
-The sources you can query, and where to read them.
+Query logs for events, inspect database statistics, or review advisor findings. Use Reports to visualize signals and the Metrics API to export them.
-## 2. Detect issues
+## Detect and diagnose
-Use queries and checks against those sources to pick up health, security, performance, and usage signals.
+Run [detection checks](/docs/guides/observability/detecting) to identify health, security, performance, or capacity issues. Take the resulting error code, time window, or affected object to the [troubleshooting guides](/docs/guides/troubleshooting), then rerun the check after a fix.
-
+## Hire an agent
-## 3. Diagnose and resolve
-
-Use a concrete finding, symptom, or error code to identify the cause and apply a known solution.
-
-
-
-## 4. Hire an agent
-
-Turn the checks you trust into a read-only routine in your agent harness and run it on a schedule.
+Give an agent recurring checks to run and findings to report. [Set up an agent](/docs/guides/observability/automate-with-agents) with read-only access to your project.
-## Export your data
+## Configure and export
-Send logs and traces to the tools you already run.
+Record additional events or send telemetry to your monitoring tools.
diff --git a/apps/docs/content/guides/observability/access-data.mdx b/apps/docs/content/guides/observability/access-data.mdx
deleted file mode 100644
index 4e5cdbe7ab8..00000000000
--- a/apps/docs/content/guides/observability/access-data.mdx
+++ /dev/null
@@ -1,31 +0,0 @@
----
-id: 'access-data'
-title: 'Observe the data'
-description: 'Query logs, metrics, database diagnostics, and advisors. Each source page lists Studio, MCP, the API, and the CLI.'
----
-
-This guide lists the project data you can query. Each source page lists where to read that source. To pick up a signal from this data, see [Detecting](/docs/guides/observability/detecting).
-
-## Logs
-
-Request, database, Auth, Storage, Realtime, and function events in ClickHouse.
-
-Query them with SQL in [Query and filter logs](/docs/guides/observability/advanced-log-filtering) from the [Logs Explorer](/dashboard/project/_/logs/explorer), MCP `query_logs`, or the [Management API](/docs/reference/api/v1-get-project-logs). Filter events without SQL in [Logs](/docs/guides/observability/logs). See the [Logs field reference](/docs/guides/observability/log-field-reference) for sources and fields.
-
-The CLI does not query ClickHouse logs. Call the Management API from a script, or [inspect the database](/docs/guides/observability/inspect) for Postgres diagnostics.
-
-## Metrics [#metrics-api]
-
-Prometheus-compatible CPU, IO, WAL, connections, and query stats. Scrape the [Metrics API](/docs/guides/observability/metrics) for custom dashboards, alerting, or retention beyond Studio. Chart a subset of the same window in [Reports](/docs/guides/observability/reports).
-
-## Database
-
-Live Postgres statistics such as bloat, cache hit rate, blocking sessions, index usage, and slow queries. Run the same checks from the [SQL Editor](/dashboard/project/_/sql), MCP `execute_sql`, or `supabase inspect db`. See [Inspect the database](/docs/guides/observability/inspect).
-
-## Advisors
-
-Deterministic security and performance findings. Pull them from Studio, MCP `get_advisors`, [`supabase db advisors`](/docs/reference/cli/usage#supabase-db-advisors), or the Management API. See [Advisors](/docs/guides/observability/advisors).
-
-## Reports
-
-Studio dashboards for API, Auth, Storage, Realtime, and database signals. Use them to pick a time window or resource, then follow [Detecting](/docs/guides/observability/detecting). See [Reports](/docs/guides/observability/reports).
diff --git a/apps/docs/content/guides/observability/advanced-log-filtering.mdx b/apps/docs/content/guides/observability/advanced-log-filtering.mdx
index dc4e02d7f75..fc46a5c8b22 100644
--- a/apps/docs/content/guides/observability/advanced-log-filtering.mdx
+++ b/apps/docs/content/guides/observability/advanced-log-filtering.mdx
@@ -1,539 +1,120 @@
---
-title: 'Query and filter logs'
-description: 'Query project logs from Studio, MCP, the API, or a script. Record extra Postgres, API, and Realtime events.'
+title: 'Query logs with SQL'
+description: 'Query ClickHouse logs through MCP, the Management API, or Explorer'
---
-This guide explains how to query project logs and how to record extra events. The same ClickHouse SQL runs in the [Logs Explorer](/dashboard/project/_/logs/explorer), the MCP [`query_logs`](/docs/guides/ai-tools/mcp) tool, and the [Management API](/docs/reference/api/v1-get-project-logs). Filter events without SQL in [Logs](/docs/guides/observability/logs) in Studio. From a terminal, call the Management API; the CLI inspects the database rather than ClickHouse logs.
+This guide explains how to query project logs with ClickHouse SQL. Use [MCP](#mcp) or the [Management API](#api) for programmatic access, or [Explorer](#studio) in Studio. To filter events without SQL, use [Logs in Studio](/docs/guides/observability/logs).
-Use this page to:
+## Query events [#querying-with-the-logs-explorer]
-- Query logs from [Studio](#studio), [MCP](#mcp), the [API](#api), or a [script](#cli)
-- Pick a [`source`](#logs-explorer) for the layer that reported the error
-- Record extra [API](#working-with-api-logs), [Postgres](#logging-postgres-queries), and [Realtime](#logging-realtime-connections) events
-- Write [ClickHouse SQL](#querying-with-the-logs-explorer)
+Every event is a row in `logs`. Select a service with `source`, use a bounded time range, and limit the returned rows. For example, this query returns the latest API server errors within the supplied time range:
-Every log line is one row in a single `logs` table, tagged by a `source` column. Structured fields live in a `log_attributes` map, and the raw line is in `event_message`. Filter by `source` to scope a query to one service.
+```sql
+-- recent API server errors
+select timestamp, id,
+ toInt32OrZero(log_attributes['response.status_code']) as status,
+ log_attributes['request.path'] as path
+from logs
+where source = 'edge_logs'
+ and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599
+order by timestamp desc
+limit 100;
+```
-
-
-ClickHouse has been the default engine since June 2026. Projects created before this date use BigQuery, whose `cross join unnest(metadata)` syntax is deprecated. We recommend rewriting those queries in the ClickHouse syntax shown in this guide.
-
-
-
-On hosted projects, prefer `query_logs` over `get_logs`. `get_logs` returns a service's recent logs without SQL; it remains the option for local and self-hosted projects.
-
-## Query from Studio, MCP, the API, or the CLI
-
-### Studio [#studio]
-
-Open [Logs](/dashboard/project/_/logs) to filter and inspect events. Open the [Logs Explorer](/dashboard/project/_/logs/explorer) to run ClickHouse SQL. See [Logs](/docs/guides/observability/logs) for the unified Logs interface.
+Use the returned timestamp, ID, status, and path to investigate an event. No rows means no matching recorded events in that window; check the source, filters, and retention before concluding that there were no errors.
### MCP [#mcp]
-On hosted projects, call [`query_logs`](/docs/guides/ai-tools/mcp) with the same SQL as this guide. Keep the connection project-scoped and read-only.
+Connect [Supabase MCP](/docs/guides/ai-tools/mcp) with `project_ref` and `read_only=true`. Call `query_logs` with the SQL and an explicit time range, using the tool's input schema. Use `execute_sql` for Postgres database diagnostics, not ClickHouse logs.
-### API [#api]
+### Management API [#api]
-Pass ClickHouse SQL in the `sql` parameter of the [Management API logs endpoint](/docs/reference/api/v1-get-project-logs). Unless you pass `sql`, that endpoint queries `edge_logs` only. Supply `iso_timestamp_start` and `iso_timestamp_end`; the range must be 24 hours or less.
+Set `SUPABASE_ACCESS_TOKEN` to a Management API access token authorized to read project logs, and `PROJECT_REF` to the project reference. Set `START` and `END` to UTC timestamps such as `2026-09-07T09:00:00Z`, with a range of 24 hours or less. Save the query above as `logs.sql`, then run:
-### CLI [#cli]
-
-The Supabase CLI does not query ClickHouse logs. Call the [Management API](/docs/reference/api/v1-get-project-logs) from a script, or use [`supabase inspect db`](/docs/guides/observability/inspect) for database diagnostics.
-
-## Sources [#logs-explorer]
-
-Filter by `source` to query one service. The Logs Explorer **Sources** drop-down lists these values.
-
-Pick the source for the layer that reported the error. A request hits the API gateway first, then one service, then the pooler and Postgres. The layer that _reports_ an error is often not the layer that _caused_ it. When two sources could fit, start closer to the database.
-
-```mermaid
-flowchart TD
- Client --> Gateway["API gateway — edge_logs"]
- Gateway --> PostgREST
- Gateway --> Auth
- Gateway --> Storage
- Gateway --> Realtime
- PostgREST --> Pooler["Pooler — supavisor_logs, pgbouncer_logs"]
- Auth --> Pooler
- Storage --> Pooler
- Pooler --> Postgres["Postgres — postgres_logs"]
+```bash
+curl --get "https://api.supabase.com/v1/projects/$PROJECT_REF/analytics/endpoints/logs" \
+ --header "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
+ --data-urlencode "sql@logs.sql" \
+ --data-urlencode "iso_timestamp_start=$START" \
+ --data-urlencode "iso_timestamp_end=$END"
```
-Edge Functions sit outside that path: `function_edge_logs` is the HTTP request to the function, and `function_logs` is `console` output from inside it.
+Inspect both the HTTP status and the response for query errors before interpreting the results. Without `sql`, this endpoint queries API Gateway events only. See the [logs endpoint reference](/docs/reference/api/v1-get-project-logs) for request and response fields.
-A permission error or an empty result at the API is often row-level security in `postgres_logs`.
+### Explorer [#studio]
-| `source` | Events |
-| -------------------- | ------------------------------------------------------------------------------------------------------ |
-| `edge_logs` | HTTP requests through the API gateway, including REST and GraphQL |
-| `postgres_logs` | Database queries, SQLSTATE, RLS, and functions |
-| `postgrest_logs` | PostgREST process logs. Low-signal; `PGRST*` evidence usually lives in `edge_logs` and `postgres_logs` |
-| `auth_logs` | Auth server: login, JWT, OAuth, email |
-| `auth_audit_logs` | Auth audit events |
-| `storage_logs` | Storage API: uploads and object access |
-| `realtime_logs` | Realtime server: channels, presence, broadcast |
-| `function_edge_logs` | HTTP request and response for an Edge Function invocation |
-| `function_logs` | `console` output from inside an Edge Function |
-| `supavisor_logs` | Shared pooler: pooling and timeouts |
-| `pgbouncer_logs` | Dedicated pooler |
-| `pg_upgrade_logs` | Database version upgrade |
+1. Open [Explorer](/dashboard/project/_/explorer) and select **Run SQL**.
+2. Open the query source menu and select **Logs**.
+3. Choose the time range in that menu.
+4. Enter the query and select **Run**.
-For `postgres_logs`, statement text and error detail live in `event_message`. `parsed.query` and `parsed.detail` are usually empty.
+The selected range is applied to the query. The **Logs** query source chooses ClickHouse; `source = 'edge_logs'` chooses API Gateway events within it. Select **Database** instead when running Postgres SQL.
-For API Load Balancer traffic, the upstream database is `log_attributes['load_balancer_redirect_identifier']`.
+### Terminal access [#cli]
-See the [Logs field reference](/docs/guides/observability/log-field-reference) for the ClickHouse field names on each source.
+The Supabase CLI does not query ClickHouse logs. Use the Management API command above. For live database statistics, use [`supabase inspect db`](/docs/guides/observability/inspect).
-## Working with API logs [#working-with-api-logs]
+## Sources and fields [#logs-explorer]
-API Gateway logs run through Cloudflare and include Cloudflare metadata on the request.
+Use the [Log sources and fields reference](/docs/guides/observability/log-field-reference) to choose the service and query expressions. API Gateway events and a service's own logs describe different layers of a request.
-### Allowed headers
+### Read structured fields [#understanding-field-references]
-A strict list of request and response headers are permitted in the API logs. Request and response headers will still be received by the server(s) and client(s), but will not be attached to the API logs generated.
+Read a map key with bracket access, retaining its full dotted path. Values in `log_attributes` are strings. Cast numeric values before comparing them. `toInt32OrZero` treats missing or non-numeric values as zero; do not interpret that zero as a measured status or duration.
-Request headers:
-
-- `accept`
-- `cf-connecting-ip`
-- `cf-ipcountry`
-- `host`
-- `user-agent`
-- `x-forwarded-proto`
-- `referer`
-- `content-length`
-- `x-real-ip`
-- `x-client-info`
-- `x-forwarded-user-agent`
-- `range`
-- `prefer`
-
-Response headers:
-
-- `cf-cache-status`
-- `cf-ray`
-- `content-location`
-- `content-range`
-- `content-type`
-- `content-length`
-- `date`
-- `transfer-encoding`
-- `x-kong-proxy-latency`
-- `x-kong-upstream-latency`
-- `sb-gateway-mode`
-- `sb-gateway-version`
-
-### Additional request metadata
-
-To attach additional metadata to a request, it is recommended to use the `User-Agent` header for purposes such as device or version identification.
-
-For example:
-
-```
-node MyApp/1.2.3 (device-id:abc123)
-Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 MyApp/1.2.3 (Foo v1.3.2; Bar v2.2.2)
-```
-
-
-
-Do not log Personal Identifiable Information (PII) within the `User-Agent` header, to avoid infringing data protection privacy laws. Overly fine-grained and detailed user agents may allow fingerprinting and identification of the end user through PII.
-
-
-
-## Logging Postgres connections [#logging-postgres-connections]
-
-Postgres can log connection lifecycle events to your project's Postgres logs, for example when a client connects or authenticates. By default, Supabase sets `log_connections` to off for new projects and you must enable it first.
-
-To enable connection logging for audit or compliance, see [Postgres connection logging](/docs/guides/platform/postgres-connection-logging).
-
-In Logs, connection lifecycle messages are included when the Postgres log type is selected. Clear **Connection logs** under Postgres to hide them.
-
-## Logging Postgres queries [#logging-postgres-queries]
-
-To enable query logs for other categories of statements:
-
-1. [Enable the pgAudit extension](/dashboard/project/_/database/extensions).
-2. Configure `pgaudit.log` (see below). Perform a fast reboot if needed.
-3. View your query logs in [Logs](/dashboard/project/_/logs). Filter **Log Type** to Postgres.
-
-### Configuring `pgaudit.log` [#configuring-pgauditlog]
-
-The stored value under `pgaudit.log` determines the classes of statements that are logged by [pgAudit extension](https://www.pgaudit.org/). Refer to the pgAudit documentation for the [full list of values](https://github.com/pgaudit/pgaudit/blob/master/README.md#pgauditlog).
-
-To enable logging for function calls/do blocks, writes, and DDL statements for a single session, execute the following within the session:
+When a field is missing or unfamiliar, discover the keys present on recorded events:
```sql
--- temporary single-session config update
-set pgaudit.log = 'function, write, ddl';
-```
-
-To _permanently_ set a logging configuration (beyond a single session), execute the following, then perform a fast reboot:
-
-```sql
--- equivalent permanent config update.
-alter role postgres set pgaudit.log to 'function, write, ddl';
-```
-
-To help with debugging, we recommend adjusting the log scope to only relevant statements as having too wide of a scope would result in a lot of noise in your Postgres logs.
-
-Note that in the above example, the role is set to `postgres`. To log user traffic flowing through the [HTTP APIs](/docs/guides/api#rest-api-overview), which use PostgREST, set your configuration values for the `authenticator`.
-
-```sql
--- for API-related logs
-alter role authenticator set pgaudit.log to 'write';
-```
-
-By default, the log level will be set to `log`. To view other levels, run the following:
-
-```sql
--- adjust log level
-alter role postgres set pgaudit.log_level to 'info';
-alter role postgres set pgaudit.log_level to 'debug5';
-```
-
-Note that as per the pgAudit [log_level documentation](https://github.com/pgaudit/pgaudit/blob/master/README.md#pgauditlog_level), `error`, `fatal`, and `panic` are not allowed.
-
-To reset system-wide settings, execute the following, then perform a fast reboot:
-
-```sql
--- resets stored config.
-alter role postgres reset pgaudit.log
-```
-
-
-
-If any permission errors are encountered when executing `alter role postgres ...`, it is likely that your project has yet to receive the patch to the latest version of [supautils](https://github.com/supabase/supautils), which is currently being rolled out.
-
-
-
-### `RAISE`d log messages in Postgres
-
-Messages that are manually logged via `RAISE INFO`, `RAISE NOTICE`, `RAISE WARNING`, and `RAISE LOG` are shown in Postgres Logs. Note that only messages at or above your logging level are shown. Syncing of messages to Postgres Logs may take a few minutes.
-
-If your logs aren't showing, check your logging level by running:
-
-```sql
-show log_min_messages;
-```
-
-Note that `LOG` is a higher level than `WARNING` and `ERROR`, so if your level is set to `LOG`, you will not see `WARNING` and `ERROR` messages.
-
-### Limits and caveats
-
-- Postgres log events on the Supabase Platform are limited to 100,000 characters. If a log event exceeds this limit, it will be truncated. This does not apply to self-hosting.
-- Internal connection logs to Postgres within the Supabase Platform by internal services are not logged. This does not apply to self-hosting.
-
-## Logging realtime connections [#logging-realtime-connections]
-
-Realtime doesn't log new WebSocket connections or Channel joins by default. Enable connection logging per client by including an `info` `log_level` parameter when instantiating the Supabase client.
-
-```javascript
-import { createClient } from '@supabase/supabase-js'
-
-const options = {
- realtime: {
- params: {
- log_level: 'info',
- },
- },
-}
-const supabase = createClient('https://xyzcompany.supabase.co', 'sb_publishable_...', options)
-```
-
-## Querying logs [#querying-with-the-logs-explorer]
-
-Read fields with bracket access, keeping the full dotted key, for example `log_attributes['request.path']` rather than `path`. Wrap numeric values in `toInt32OrZero(...)`, which returns `0` for a missing or non-numeric value. Use `count()` rather than `count(*)`.
-
-For example, to find failing API requests:
-
-```sql
-select timestamp,
- toInt32OrZero(log_attributes['response.status_code']) as status,
- log_attributes['request.path'] as path
-from logs
-where source = 'edge_logs'
- and toInt32OrZero(log_attributes['response.status_code']) >= 400
-order by timestamp desc
-limit 100;
-```
-
-For example, to find a specific Postgres SQLSTATE (`42501` permission denied, `42P01` relation missing, `23505` duplicate key):
-
-```sql
-select timestamp, log_attributes['parsed.user_name'] as role, event_message
-from logs
-where source = 'postgres_logs'
- and log_attributes['parsed.sql_state_code'] = '42501'
-order by timestamp desc
-limit 100;
-```
-
-The Management API accepts this SQL in the `sql` parameter. Unless you pass `sql`, that endpoint queries `edge_logs` only. Supply `iso_timestamp_start` and `iso_timestamp_end`; the range must be 24 hours or less.
-
-## Timestamp display and behavior
-
-The `timestamp` column is a `DateTime64` value in UTC, formatted as an ISO-8601 string like `2026-06-22T09:34:06.215000`. You can order and compare it directly, so no conversion function is needed. In the Logs Explorer the selected time range is applied for you, so you rarely need to filter on `timestamp` by hand. MCP and the Management API require an explicit time range.
-
-```sql
-select timestamp, event_message
-from logs
-where source = 'edge_logs'
-order by timestamp desc
-limit 100;
-```
-
-## Reading fields from log_attributes
-
-Structured fields live in the `log_attributes` map. Read a field with bracket access, keeping the full dotted key. There are no unnesting joins.
-
-```sql
-select
- log_attributes['request.method'] as method,
- log_attributes['request.path'] as path,
- log_attributes['response.status_code'] as status
-from logs
-where source = 'edge_logs'
-limit 100;
-```
-
-The key keeps the full dotted path, with the `metadata` root dropped. What BigQuery expressed as `metadata.request.cf.country` is `log_attributes['request.cf.country']`. Keep the full prefix rather than shortening it.
-
-Map values are always strings. To compare or aggregate a numeric field, wrap it in `toInt32OrZero`, which returns `0` for a missing or non-numeric value:
-
-```sql
-select count() as server_errors
-from logs
-where source = 'edge_logs'
- and toInt32OrZero(log_attributes['response.status_code']) between 500 and 599;
-```
-
-Do not guess keys. Discover the keys a source sets from recent rows:
-
-```sql
-select arrayJoin(mapKeys(log_attributes)) as key, count() as n
+-- discover Postgres attributes
+select arrayJoin(mapKeys(log_attributes)) as key, count() as events
from logs
where source = 'postgres_logs'
group by key
-order by n desc
+order by events desc
limit 100;
```
-## LIMIT and result row limitations
+### Time ranges [#timestamp-display-and-behavior]
-The Logs Explorer has a maximum of 1000 rows per run. Use `LIMIT` to reduce the number of rows returned further.
+`timestamp` is a UTC `DateTime64` value. Compare and order it directly. Explorer supplies the chosen time range; MCP and API callers must supply their own bounded range. To compare more than 24 hours through the API, fetch separate windows within retention and combine their aggregates.
-## Best practices
+## Search messages [#filtering-with-regular-expressions]
-1. **Use a narrow time range.**
-
-The Logs Explorer applies the time range you select, so keep it tight. Querying a very large range risks timeouts, especially for Enterprise customers with long retention, because of the extra data scanned.
-
-2. **Select only the fields you need.**
-
-Selecting the whole `log_attributes` map, or every column, reads far more data than you need and slows the query down. Select the specific keys instead.
+Use `ilike` for a case-insensitive substring, or ClickHouse's [`match`](https://clickhouse.com/docs/sql-reference/functions/string-search-functions#match) for a regular expression:
```sql
--- ❌ Avoid this: selecting the whole attributes map
-select timestamp, log_attributes
+-- find connection failures
+select timestamp, id, event_message
from logs
-where source = 'edge_logs';
-
--- ✅ Do this: select only the keys you need
-select timestamp, log_attributes['request.method'] as method
-from logs
-where source = 'edge_logs';
-```
-
-3. **Query one source at a time.**
-
-Identify which service owns the problem from the error or status code first, then query only that source. Scanning every source at once buries the signal you need and scans far more data than the investigation requires.
-
-4. **Follow a request across sources with an anchor.** Once a query gives you an anchor such as a timestamp, request id, or SQL state, filter the adjacent source by that anchor to correlate the request across layers (for example `edge_logs` to `postgres_logs`), instead of re-scanning each source from scratch.
-
-5. **Reference only fields you have confirmed.**
-
-A misspelled or non-existent field name either errors or silently returns nothing, which leaves a working query look empty. Confirm field names in the [Logs field reference](/docs/guides/observability/log-field-reference), or select `event_message` and inspect a sample row first.
-
-## Examples and templates
-
-The Logs Explorer includes **Templates** (available in the Templates tab or the dropdown in the Query tab) to help you get started.
-
-For example, you can enter the following query in the SQL Editor to retrieve each user's IP address:
-
-```sql
-select timestamp, log_attributes['request.headers.x_real_ip'] as x_real_ip
-from logs
-where source = 'edge_logs'
- and log_attributes['request.headers.x_real_ip'] != ''
- and log_attributes['request.method'] = 'GET'
+where source = 'postgres_logs'
+ and event_message ilike '%connection%'
+ and match(event_message, '(?i)failed|refused|timeout')
order by timestamp desc
limit 100;
```
-## Understanding field references
+Combine predicates with `and`, `or`, and `not`. Select only the fields needed for the investigation. To correlate sources, use an identifier present in both; a shared timestamp alone does not establish that events belong to the same request.
-Every log source shares the same `logs` table. Each row has these columns:
+## Query limits [#limit-and-result-row-limitations]
-| column | description |
-| ---------------- | -------------------------------------------------- |
-| `id` | unique log identifier |
-| `timestamp` | time the event was recorded |
-| `event_message` | the log's message |
-| `severity_text` | log level, when the source sets one |
-| `source` | the service the log came from |
-| `log_attributes` | structured per-source fields, keyed by dotted path |
+Use an explicit `limit` and narrow time range. The logs query surface rejects `select *` and `count(*)`; list columns and use `count()`. A result limit bounds returned rows, not the time range scanned.
-Service-specific details live in `log_attributes`. For example, in `postgres_logs` the `log_attributes['parsed.error_severity']` field holds the error level of an event. Read those fields with bracket access:
+## Record additional events [#working-with-api-logs]
-```sql
-select
- event_message,
- log_attributes['parsed.error_severity'] as error_severity,
- log_attributes['parsed.user_name'] as user_name
-from logs
-where source = 'postgres_logs'
-limit 100;
-```
+For HTTP header capture, see [Captured HTTP headers](/docs/guides/observability/log-field-reference#captured-http-headers). Configure event recording separately from querying:
-## Filtering with [regular expressions](https://en.wikipedia.org/wiki/Regular_expression)
+### Postgres connections [#logging-postgres-connections]
-Use the ClickHouse [`match` function](https://clickhouse.com/docs/sql-reference/functions/string-search-functions#match) for regular expressions. In its most basic form, it checks whether a pattern is present in a column.
+See [Configure connection logging](/docs/guides/observability/configure-logging#postgres-connections).
-```sql
-select timestamp, event_message
-from logs
-where source = 'postgres_logs'
- and match(event_message, 'is present')
-limit 100;
-```
+### Postgres statements [#logging-postgres-queries]
-There are multiple operators to consider using.
+See [Configure statement logging](/docs/guides/observability/configure-logging#postgres-statements).
-### Find messages that start with a phrase
+### Statement classes [#configuring-pgauditlog]
-`^` only looks for values at the start of a string
+See [pgAudit configuration](/docs/guides/database/extensions/pgaudit#configure-the-extension) for session and role scope.
-```sql
--- find only messages that start with connection
-match(event_message, '^connection')
-```
+### Realtime connections [#logging-realtime-connections]
-### Find messages that end with a phrase
-
-`$` only looks for values at the end of the string
-
-```sql
--- find only messages that end with port=12345
-match(event_message, 'port=12345$')
-```
-
-### Ignore case sensitivity
-
-`(?i)` ignores capitalization for all proceeding characters
-
-```sql
--- find all event_messages with the word "connection"
-match(event_message, '(?i)COnnecTion')
-```
-
-For a plain case-insensitive substring match, `ilike` is simpler:
-
-```sql
--- find all event_messages containing "connection", in any case
-event_message ilike '%connection%'
-```
-
-### Wildcards
-
-`.` matches any single character, and `.*` matches any sequence of characters
-
-```sql
--- find event_messages like "helloworld"
-match(event_message, 'hello.*world')
-```
-
-### Alphanumeric ranges
-
-`[0-9a-zA-Z]` matches a single alphanumeric character. Anchor it with `^[0-9a-zA-Z]+$` to match a value that is entirely alphanumeric.
-
-```sql
--- find event_messages that contain a digit between 1 and 5 (inclusive)
-match(event_message, '[1-5]')
-```
-
-### Repeated values
-
-`x*` zero or more x
-`x+` one or more x
-`x?` zero or one x
-`x{4,}` four or more x
-`x{3}` exactly 3 x
-
-```sql
--- find event_messages that contain any sequence of 3 digits
-match(event_message, '[0-9]{3}')
-```
-
-### Escaping reserved characters
-
-`\.` is interpreted as a period `.` instead of as a wildcard
-
-```sql
--- escapes .
-match(event_message, 'hello world\.')
-```
-
-### `or` statements
-
-`x|y` any string with `x` or `y` present
-
-```sql
--- find event_messages that have the word 'started' followed by either "host" or "authenticated"
-match(event_message, 'started (host|authenticated)')
-```
-
-### `and`/`or`/`not` statements in SQL
-
-`and`, `or`, and `not` are native terms in SQL and can be used with regular expressions to filter results
-
-```sql
-select timestamp, event_message
-from logs
-where source = 'postgres_logs'
- and (
- (match(event_message, 'connection') and match(event_message, 'host'))
- or not match(event_message, 'received')
- )
-limit 100;
-```
-
-### Filtering example
-
-Filter for Postgres errors:
-
-```sql
-select
- timestamp,
- log_attributes['parsed.error_severity'] as error_severity,
- log_attributes['parsed.user_name'] as user_name,
- event_message
-from logs
-where source = 'postgres_logs'
- and match(log_attributes['parsed.error_severity'], 'ERROR|FATAL|PANIC')
-order by timestamp desc
-limit 100;
-```
-
-## Limitations
-
-### The wildcard operator `*` is not supported
-
-The logs query surface rejects `select *` and `count(*)`. List the columns you need, and use `count()` for row counts:
-
-```sql
-select timestamp, event_message, log_attributes['parsed.error_severity'] as error_severity
-from logs
-where source = 'postgres_logs'
-order by timestamp desc
-limit 100;
-```
+See [Configure Realtime logging](/docs/guides/observability/configure-logging#realtime-connections).
diff --git a/apps/docs/content/guides/observability/advisors.mdx b/apps/docs/content/guides/observability/advisors.mdx
index 7f64deff229..16098634142 100644
--- a/apps/docs/content/guides/observability/advisors.mdx
+++ b/apps/docs/content/guides/observability/advisors.mdx
@@ -6,16 +6,16 @@ description: 'Deterministic security and performance findings you or an agent ca
Advisors are programmatic checks that ship with the platform. They inspect the live schema and return deterministic findings, such as missing indexes or incorrectly configured RLS policies.
-Use them as part of ongoing observability, together with [logs](/docs/guides/observability/advanced-log-filtering). A finding is not a fix. Confirm it against recent log evidence, then search [Diagnosing](/docs/guides/troubleshooting) for the check name or the object it names.
+Confirm each finding against the intended schema and access model. Search [Troubleshooting](/docs/guides/troubleshooting) for its check name or affected object; logs can provide additional context but are not required to establish a schema finding.
You or an agent can pull the same checks from:
- Studio: [Security Advisor](/dashboard/project/_/advisors/security) and [Performance Advisor](/dashboard/project/_/advisors/performance)
-- MCP: `get_advisors`
+- MCP: `get_advisors` with `type` set to `security` or `performance`
- CLI: [`supabase db advisors`](/docs/reference/cli/supabase-db-advisors)
- Management API: [security advisors](/docs/reference/api/v1-get-security-advisors) and [performance advisors](/docs/reference/api/v1-get-performance-advisors)
-The advisors run automatically in Studio. Rerun them after you resolve an issue.
+Prioritize warning and error findings. Each finding names a check, severity, affected object, and remediation guidance. Informational findings provide context and do not always require a change. The advisors run automatically in Studio. After an authorized fix, rerun the relevant advisor and confirm that the finding no longer appears.
## Available checks
diff --git a/apps/docs/content/guides/observability/configure-logging.mdx b/apps/docs/content/guides/observability/configure-logging.mdx
new file mode 100644
index 00000000000..1b268fe1ce0
--- /dev/null
+++ b/apps/docs/content/guides/observability/configure-logging.mdx
@@ -0,0 +1,47 @@
+---
+title: 'Configure logging'
+description: 'Record additional Postgres and Realtime events for an investigation'
+---
+
+This guide explains how to record events that are not logged by default. Logging changes affect future events; they cannot recover past activity. Keep the scope limited to the investigation, because recorded statements and messages can contain sensitive values.
+
+## Postgres connections
+
+To record connection and authentication events, follow [Postgres connection logging](/docs/guides/platform/postgres-connection-logging). Note the current setting before changing it.
+
+After enabling logging, open a new database connection and find its event in [Logs](/dashboard/project/_/logs) with **Log Type** set to **Postgres** and **Connection logs** enabled. Restore the previous setting when the investigation is complete, unless continued logging is required.
+
+## Postgres statements
+
+1. Enable [pgAudit](/docs/guides/database/extensions/pgaudit#enable-the-extension).
+2. Select the statement classes and session or role scope in [pgAudit configuration](/docs/guides/database/extensions/pgaudit#configure-the-extension). Record the previous setting first. API traffic through PostgREST uses the `authenticator` role.
+3. Run an authorized operation in the configured scope, then find its audit event in [Logs](/dashboard/project/_/logs) with **Log Type** set to **Postgres**.
+4. Restore the previous logging configuration when finished.
+
+Session settings apply only to that database connection. Studio queries do not maintain a persistent session. For persistent logging, follow the role-scoped instructions in the pgAudit guide.
+
+### Messages from database functions
+
+Whether a `RAISE` message reaches Postgres logs depends on `log_min_messages`. Read the current value from a database connection:
+
+```sql
+show log_min_messages;
+```
+
+Allow a few minutes for messages to appear. See [Postgres message levels](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-MESSAGES) before changing the threshold; their ordering differs from client message levels.
+
+## Realtime connections
+
+Realtime does not log new WebSocket connections or channel joins by default. Enable connection logging for the client under investigation:
+
+```javascript
+import { createClient } from '@supabase/supabase-js'
+
+const supabase = createClient('https://your-project.supabase.co', 'sb_publishable_...', {
+ realtime: { params: { log_level: 'info' } },
+})
+```
+
+Reconnect that client and join a channel, then inspect **Realtime** events in [Logs](/dashboard/project/_/logs). Remove `log_level: 'info'` and recreate the client to restore the default behavior.
+
+For truncation and capture constraints, see [Log sources and fields](/docs/guides/observability/log-field-reference#capture-limits).
diff --git a/apps/docs/content/guides/observability/detecting.mdx b/apps/docs/content/guides/observability/detecting.mdx
index 7196a9779cd..c8ec348a221 100644
--- a/apps/docs/content/guides/observability/detecting.mdx
+++ b/apps/docs/content/guides/observability/detecting.mdx
@@ -4,9 +4,9 @@ title: 'Detecting issues'
description: 'Run Health, Security, Performance, and Usage checks against logs and database statistics to pick up actionable signals.'
---
-Detection is the step between accessing project data and troubleshooting a specific problem. Use the sources in [Observe the data](/docs/guides/observability/access-data) to produce a count, rate, trend, or named finding. Do not try to prove the root cause yet.
+Detection is the step between accessing project data and troubleshooting a specific problem. Use the sources in [Observability](/docs/guides/observability) to produce a count, rate, trend, or named finding. Do not try to prove the root cause yet.
-This guide provides starting checks for [Health](#health), [Security](#security), [Performance](#performance), and [Usage](#usage). The log examples use ClickHouse SQL in the [Logs Explorer](/dashboard/project/_/logs/explorer) or MCP `query_logs`. The database examples use Postgres SQL in the [SQL Editor](/dashboard/project/_/sql) or MCP `execute_sql`.
+This guide provides starting checks for [Health](#health), [Security](#security), [Performance](#performance), and [Usage](#usage). The log examples use ClickHouse SQL in the [Explorer](/dashboard/project/_/explorer) with query source **Logs** or MCP `query_logs`. The database examples use Postgres SQL in the [Explorer](/dashboard/project/_/explorer) with query source **Database** or MCP `execute_sql`.
Use a time range that represents normal traffic, then compare it with the same period after a deployment or configuration change. When a check returns a spike, error code, SQLSTATE, object name, or advisor finding, take that evidence to [Diagnosing](/docs/guides/troubleshooting).
@@ -280,4 +280,4 @@ order by connections desc;
A detection result should name an affected time window and at least one concrete anchor: a path, status, SQLSTATE, request ID, query, relation, PID, policy, or advisor lint. Take that evidence to [Diagnosing](/docs/guides/troubleshooting), identify the cause, apply the smallest relevant solution, and rerun the same detection check to verify the result.
-After a check is useful and repeatable, [hire an agent](/docs/guides/observability/automate-with-agents) to run it on a schedule.
+After a check is useful and repeatable, [automate monitoring](/docs/guides/observability/automate-with-agents) to run it on a schedule.
diff --git a/apps/docs/content/guides/observability/inspect.mdx b/apps/docs/content/guides/observability/inspect.mdx
index 41dde492d6a..07f3116a2ce 100644
--- a/apps/docs/content/guides/observability/inspect.mdx
+++ b/apps/docs/content/guides/observability/inspect.mdx
@@ -1,23 +1,23 @@
---
id: 'inspect'
title: 'Inspect the database'
-description: 'Read live Postgres statistics such as bloat, cache hit rate, locks, and slow queries from the CLI, SQL Editor, or MCP.'
+description: 'Read live Postgres statistics such as bloat, cache hit rate, locks, and slow queries from the CLI, Explorer, or MCP.'
---
-Database performance is a large topic and many factors can contribute. Common causes of poor performance include inefficient schemas or queries, missing or unused indexes, insufficient memory, lock contention, and table bloat.
+This guide explains how to read live database statistics using the CLI, MCP, or Explorer.
-Use the live Postgres statistics in this guide to check for those conditions. You or an agent can run the same checks from:
+Read database statistics from:
-- Studio: [SQL Editor](/dashboard/project/_/sql)
+- Studio: [Explorer](/dashboard/project/_/explorer) with query source **Database**
- MCP: `execute_sql`
- CLI: [`supabase inspect db`](/docs/reference/cli/supabase-inspect-db)
Use this page to:
- Run [CLI inspection commands](#using-the-cli)
-- Copy the matching [SQL](#using-sql)
+- Run [SQL checks](#using-sql)
-To pick up a signal from these checks, see [Detecting](/docs/guides/observability/detecting). For the other sources, see [Observe the data](/docs/guides/observability/access-data).
+To pick up a signal from these checks, see [Detecting](/docs/guides/observability/detecting). For the other sources, see [Observability](/docs/guides/observability).
## Using the CLI
@@ -104,144 +104,12 @@ The commands below are useful if your Postgres database consumes a lot of resour
- [role-connections](/docs/reference/cli/supabase-inspect-db-role-connections) - shows number of active connections for all database roles (Supabase-specific command)
- [replication-slots](/docs/reference/cli/supabase-inspect-db-replication-slots) - shows information about replication slots on the database
-### Notes on `pg_stat_statements`
-
-Following commands require `pg_stat_statements` to be enabled: calls, locks, cache-hit, blocking, unused-indexes, index-usage, bloat, outliers, table-record-counts, replication-slots, seq-scans, vacuum-stats, long-running-queries.
-
-When using `pg_stat_statements` also take note that it only stores the latest 5,000 statements. Moreover, consider resetting the analysis after optimizing any queries by running `select pg_stat_statements_reset();`
-
-Learn more about [`pg_stat_statements`](/docs/guides/database/extensions/pg_stat_statements).
-
## Using SQL
-
+Open [Explorer](/dashboard/project/_/explorer), select **Run SQL**, and choose **Database** as the query source. You can also run read-only diagnostics through MCP `execute_sql`.
-If you're seeing an `insufficient privilege` error when viewing the Query Performance page from the dashboard, run this command:
+Use [Performance checks](/docs/guides/observability/detecting#performance) for active sessions, blockers, expensive statements, and cache hit rates. Use [Capacity checks](/docs/guides/observability/detecting#usage) for relation sizes and connection counts.
-```shell
-$ grant pg_read_all_stats to postgres;
-```
+`pg_stat_activity` is a live snapshot. `pg_stat_statements` and cache counters are cumulative since their last reset; they do not describe an arbitrary historical window. Compare saved snapshots with the same reset interval when measuring changes. Check the [pg_stat_statements guide](/docs/guides/database/extensions/pg_stat_statements) for extension requirements.
-
-
-### Postgres cumulative statistics system
-
-Postgres collects data about its own operations using the [cumulative statistics system](https://www.postgresql.org/docs/current/monitoring-stats.html). In addition to this, every Supabase project has the [pg_stat_statements extension](/docs/guides/database/extensions/pg_stat_statements) enabled by default. This extension records query execution performance details.
-
-Here are some example queries to get you started.
-
-### Most frequently called queries
-
-```sql
-select
- auth.rolname,
- statements.query,
- statements.calls,
- -- -- Postgres 13, 14, 15
- statements.total_exec_time + statements.total_plan_time as total_time,
- statements.min_exec_time + statements.min_plan_time as min_time,
- statements.max_exec_time + statements.max_plan_time as max_time,
- statements.mean_exec_time + statements.mean_plan_time as mean_time,
- -- -- Postgres <= 12
- -- total_time,
- -- min_time,
- -- max_time,
- -- mean_time,
- statements.rows / statements.calls as avg_rows
-from
- pg_stat_statements as statements
- inner join pg_authid as auth on statements.userid = auth.oid
-order by statements.calls desc
-limit 100;
-```
-
-This query shows:
-
-- query statistics, ordered by the number of times each query has been executed
-- the role that ran the query
-- the number of times it has been called
-- the average number of rows returned
-- the cumulative total time the query has spent running
-- the min, max and mean query times.
-
-This provides useful information about the queries you run most frequently. Queries that have high `max_time` or `mean_time` times and are being called often can be good candidates for optimization.
-
-### Slowest queries by execution time
-
-```sql
-select
- auth.rolname,
- statements.query,
- statements.calls,
- -- -- Postgres 13, 14, 15
- statements.total_exec_time + statements.total_plan_time as total_time,
- statements.min_exec_time + statements.min_plan_time as min_time,
- statements.max_exec_time + statements.max_plan_time as max_time,
- statements.mean_exec_time + statements.mean_plan_time as mean_time,
- -- -- Postgres <= 12
- -- total_time,
- -- min_time,
- -- max_time,
- -- mean_time,
- statements.rows / statements.calls as avg_rows
-from
- pg_stat_statements as statements
- inner join pg_authid as auth on statements.userid = auth.oid
-order by max_time desc
-limit 100;
-```
-
-This query will show you statistics about queries ordered by the maximum execution time. It is similar to the query above ordered by calls, but this one highlights outliers that may have high executions times. Queries which have high or mean execution times are good candidates for optimization.
-
-### Most time consuming queries
-
-```sql
-select
- auth.rolname,
- statements.query,
- statements.calls,
- statements.total_exec_time + statements.total_plan_time as total_time,
- to_char(
- (
- (statements.total_exec_time + statements.total_plan_time) / sum(
- statements.total_exec_time + statements.total_plan_time
- ) over ()
- ) * 100,
- 'FM90D0'
- ) || '%' as prop_total_time
-from
- pg_stat_statements as statements
- inner join pg_authid as auth on statements.userid = auth.oid
-order by total_time desc
-limit 100;
-```
-
-This query will show you statistics about queries ordered by the cumulative total execution time. It shows the total time the query has spent running as well as the proportion of total execution time the query has taken up.
-
-Queries which are the most time consuming are not necessarily bad, you may have a very efficient and frequently ran queries that end up taking a large total % time, but it can be useful to help spot queries that are taking up more time than they should.
-
-### Hit rate
-
-Generally for most applications a small percentage of data is accessed more regularly than the rest. To make sure that your regularly accessed data is available, Postgres tracks your data access patterns and keeps this in its [shared_buffers](https://www.postgresql.org/docs/15/runtime-config-resource.html#RUNTIME-CONFIG-RESOURCE-MEMORY) cache.
-
-Applications with lower cache hit rates generally perform more poorly since they have to hit the disk to get results rather than serving them from memory. Very poor hit rates can also cause you to burst past your [Disk IO limits](/docs/guides/platform/compute-and-disk#disk) causing significant performance issues.
-
-You can view your cache and index hit rate by executing the following query:
-
-```sql
-select
- 'index hit rate' as name,
- (sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) * 100 as ratio
-from pg_statio_user_indexes
-union all
-select
- 'table hit rate' as name,
- sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100 as ratio
-from pg_statio_user_tables;
-```
-
-This shows the ratio of data blocks fetched from the Postgres [shared_buffers](https://www.postgresql.org/docs/15/runtime-config-resource.html#RUNTIME-CONFIG-RESOURCE-MEMORY) cache against the data blocks that were read from disk or the OS cache.
-
-A ratio below 99% means more than 1% of observed block accesses missed `shared_buffers`. Postgres cannot distinguish whether those reads were served by the operating system cache or physical disk. Treat that as a [Performance](/docs/guides/observability/detecting#performance) signal, then search [Diagnosing](/docs/guides/troubleshooting).
-
-When a check names a slow statement, get a query plan with [`explain`](/docs/guides/database/query-optimization#analyze-the-query-plan) in SQL, or [`explain()`](/docs/guides/database/debugging-performance) on the Data API. Pair `pg_stat_statements` with the [Metrics API](/docs/guides/observability/metrics) to read the same window from Postgres stats and host metrics.
+When a check identifies a statement, inspect its [query plan](/docs/guides/database/query-optimization#analyze-the-query-plan). A long-running session or high cumulative query time is evidence to investigate, not a reason by itself to cancel a query or reset statistics.
diff --git a/apps/docs/content/guides/observability/log-field-reference.mdx b/apps/docs/content/guides/observability/log-field-reference.mdx
index 99e444585ef..d3e7ef6db3c 100644
--- a/apps/docs/content/guides/observability/log-field-reference.mdx
+++ b/apps/docs/content/guides/observability/log-field-reference.mdx
@@ -1,6 +1,6 @@
---
id: 'logs-field-reference'
-title: 'Logs field reference'
+title: 'Log sources and fields'
description: 'Log sources, ClickHouse fields, and event capture limits'
---
@@ -8,6 +8,60 @@ Each event is a row in the ClickHouse `logs` table. Filter the `source` column t
`id`, `timestamp`, `event_message`, `severity_text`, and `source` are top-level columns. Service fields are string values in `log_attributes`, even when the original event contains a number or boolean. Use the **ClickHouse query field** column directly. See [Query logs with SQL](/docs/guides/observability/advanced-log-filtering) for casting and field discovery.
+## Sources
+
+| `source` | Events |
+| -------------------- | ----------------------------------------------------------------- |
+| `edge_logs` | HTTP requests through the API gateway, including REST and GraphQL |
+| `postgres_logs` | Database activity, statements, and errors |
+| `postgrest_logs` | PostgREST server logs |
+| `auth_logs` | Auth server: login, JWT, OAuth, email |
+| `auth_audit_logs` | Auth audit events |
+| `storage_logs` | Storage API: uploads and object access |
+| `realtime_logs` | Realtime server: channels, presence, broadcast |
+| `function_edge_logs` | HTTP request and response for an Edge Function invocation |
+| `function_logs` | `console` output from inside an Edge Function |
+| `supavisor_logs` | Shared pooler: pooling and timeouts |
+| `pgbouncer_logs` | Dedicated pooler |
+| `pg_upgrade_logs` | Database version upgrade |
+
+For `postgres_logs`, statement text and error details can appear in `event_message`. A missing structured field does not mean the event has no detail.
+
+## Captured HTTP headers
+
+API Gateway logs capture only the headers below. Other headers still reach the application and client but are omitted from these logs.
+
+Request headers:
+
+- `accept`
+- `cf-connecting-ip`
+- `cf-ipcountry`
+- `host`
+- `user-agent`
+- `x-forwarded-proto`
+- `referer`
+- `content-length`
+- `x-real-ip`
+- `x-client-info`
+- `x-forwarded-user-agent`
+- `range`
+- `prefer`
+
+Response headers:
+
+- `cf-cache-status`
+- `cf-ray`
+- `content-location`
+- `content-range`
+- `content-type`
+- `content-length`
+- `date`
+- `transfer-encoding`
+- `x-kong-proxy-latency`
+- `x-kong-upstream-latency`
+- `sb-gateway-mode`
+- `sb-gateway-version`
+
## Capture limits
- Hosted Postgres events longer than 100,000 characters and Edge Function log messages longer than 10,000 characters are truncated.
diff --git a/apps/docs/content/guides/observability/logs.mdx b/apps/docs/content/guides/observability/logs.mdx
index 28e03353b82..2d82edf23a2 100644
--- a/apps/docs/content/guides/observability/logs.mdx
+++ b/apps/docs/content/guides/observability/logs.mdx
@@ -43,7 +43,7 @@ For SQL source names, see the [Log field reference](/docs/guides/observability/l
Postgres logs contain database activity and errors. Connection events appear when [connection logging](/docs/guides/platform/postgres-connection-logging) is enabled. Clear **Connection logs** under **Postgres** to hide them.
-To record additional statement classes, see [Logging Postgres queries](/docs/guides/observability/advanced-log-filtering#logging-postgres-queries).
+To record additional statement classes, see [Configure statement logging](/docs/guides/observability/configure-logging#postgres-statements).
## Inspect an event [#expanding-results]
@@ -63,6 +63,6 @@ For continuous export, use [Log drains](/docs/guides/observability/log-drains).
Check the time range, selected log types, and exclusions first. **User** combined with only Postgres or another unsupported type returns no matches. An empty result does not establish that the user had no activity.
-Events must be recorded before they can appear in Logs. See [Logging configuration](/docs/guides/observability/advanced-log-filtering#logging-postgres-connections) and the [source limitations](/docs/guides/observability/log-field-reference#capture-limits).
+Events must be recorded before they can appear in Logs. See [Configure logging](/docs/guides/observability/configure-logging) and the [source limitations](/docs/guides/observability/log-field-reference#capture-limits).
Retention depends on your [pricing plan](/pricing). See [Manage Logs usage](/docs/guides/platform/manage-your-usage/logs) for billing details.
diff --git a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
index 09e49705282..6b11627f331 100644
--- a/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
+++ b/apps/docs/content/guides/platform/manage-your-usage/egress.mdx
@@ -195,7 +195,7 @@ height={510}
### Most requested API endpoints
-In the [Logs Explorer](/dashboard/project/_/logs/explorer) you can access Edge Logs, and review the top paths to identify heavily queried endpoints. These logs currently do not include response byte data. That data will be available in the future too.
+In [Explorer](/dashboard/project/_/explorer), select query source **Logs** and [query API Gateway events](/docs/guides/observability/advanced-log-filtering) to identify heavily queried paths. These logs currently do not include response byte data. That data will be available in the future too.
-For more details on filtering the log tables, see [Query and filter logs](/docs/guides/observability/advanced-log-filtering)
+For more details on filtering the log tables, see [Query logs with SQL](/docs/guides/observability/advanced-log-filtering)
diff --git a/apps/docs/content/guides/storage/serving/bandwidth.mdx b/apps/docs/content/guides/storage/serving/bandwidth.mdx
index 815133e8103..9334f352774 100644
--- a/apps/docs/content/guides/storage/serving/bandwidth.mdx
+++ b/apps/docs/content/guides/storage/serving/bandwidth.mdx
@@ -10,9 +10,9 @@ sidebar_label: 'Bandwidth & Storage Egress'
Free Plan Organizations in Supabase have a limit of 10 GB of bandwidth (5 GB cached + 5 GB uncached). This limit is calculated by the sum of all the data transferred from the Supabase servers to the client. This includes all the data transferred from the database, storage, and functions.
-### Checking Storage egress requests in the SQL Editor
+### Query storage egress requests [#checking-storage-egress-requests-in-the-sql-editor]
-You can use the following query to get the number of requests for each object. Run it in the [SQL Editor](/dashboard/project/_/sql/new?skip=true&source=logs) with the query source set to **Logs**.
+You can use the following query to get the number of requests for each object. Run it in the [Explorer](/dashboard/project/_/explorer) after selecting **Run SQL**, query source **Logs**, and a time range.
```sql
select
diff --git a/apps/docs/data/content-listings/telemetry.data.ts b/apps/docs/data/content-listings/telemetry.data.ts
index b901c3ba481..65d8062d898 100644
--- a/apps/docs/data/content-listings/telemetry.data.ts
+++ b/apps/docs/data/content-listings/telemetry.data.ts
@@ -8,10 +8,19 @@ export const telemetryAccessWhat: ContentListingGroup = {
columns: 2,
items: [
{
- title: 'Logs',
+ title: 'Query logs with SQL',
href: '/guides/observability/advanced-log-filtering',
- description:
- 'Query ClickHouse logs from Studio, MCP, or the API. Filter events in the Logs UI.',
+ description: 'Query ClickHouse events through MCP, the API, or Explorer.',
+ },
+ {
+ title: 'Logs in Studio',
+ href: '/guides/observability/logs',
+ description: 'Filter, inspect, and export events in the unified Logs view.',
+ },
+ {
+ title: 'Log sources and fields',
+ href: '/guides/observability/log-field-reference',
+ description: 'Look up sources, ClickHouse query fields, and capture limits.',
},
{
title: 'Metrics API',
@@ -19,9 +28,9 @@ export const telemetryAccessWhat: ContentListingGroup = {
description: 'Scrape Prometheus-compatible database metrics, or chart a subset in Reports.',
},
{
- title: 'Database',
+ title: 'Inspect the database',
href: '/guides/observability/inspect',
- description: 'Inspect live Postgres stats from the CLI, the SQL Editor, or MCP.',
+ description: 'Inspect live Postgres stats from the CLI, Explorer, or MCP.',
},
{
title: 'Advisors',
@@ -44,7 +53,7 @@ export const telemetryDetect: ContentListingGroup = {
title: 'Detect issues',
href: '/guides/observability/detecting',
description:
- 'Run health, security, performance, and usage checks against logs and database statistics to pick up a signal.',
+ 'Run health, security, performance, and capacity checks against logs and database statistics to pick up a signal.',
},
],
}
@@ -72,7 +81,7 @@ export const telemetryHireAgent: ContentListingGroup = {
href: '/guides/observability/automate-with-agents/all',
subtitle: getScheduleLabel(monitoringAgents.all),
description:
- 'Run all four checks — health, security, performance, and usage — in one daily pass.',
+ 'Run all four checks — health, security, performance, and capacity — in one daily pass.',
},
{
title: monitoringAgents.health.name,
@@ -106,6 +115,11 @@ export const telemetryExport: ContentListingGroup = {
type: 'grid',
columns: 3,
items: [
+ {
+ title: 'Configure logging',
+ href: '/guides/observability/configure-logging',
+ description: 'Record additional Postgres and Realtime events.',
+ },
{
title: 'Log drains',
href: '/guides/observability/log-drains',
diff --git a/apps/docs/next.config.mjs b/apps/docs/next.config.mjs
index 1f522ef0604..d416bea5b85 100644
--- a/apps/docs/next.config.mjs
+++ b/apps/docs/next.config.mjs
@@ -132,6 +132,16 @@ const nextConfig = {
*/
async redirects() {
return [
+ {
+ source: '/guides/observability/access-data',
+ destination: '/guides/observability',
+ permanent: true,
+ },
+ {
+ source: '/guides/observability/access-data.md',
+ destination: '/guides/observability.md',
+ permanent: true,
+ },
// Redirect root to docs base path in dev/preview envs
{
source: '/',
diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js
index ce4e019f7d1..7cea0b4e1a9 100644
--- a/apps/www/lib/redirects.js
+++ b/apps/www/lib/redirects.js
@@ -185,6 +185,16 @@ module.exports = [
source: '/storage/Storage',
destination: '/storage',
},
+ {
+ permanent: true,
+ source: '/docs/guides/observability/access-data',
+ destination: '/docs/guides/observability',
+ },
+ {
+ permanent: true,
+ source: '/docs/guides/observability/access-data.md',
+ destination: '/docs/guides/observability.md',
+ },
{
permanent: true,
source: '/docs/guides/reports/:match*',
diff --git a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx
index 0d40dd320b7..ed2b57644e5 100644
--- a/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx
+++ b/packages/ui-patterns/src/CodeBlock/CodeBlock.tsx
@@ -277,6 +277,7 @@ export const CodeBlock = ({
className="px-1.5 dark:bg-200! dark:hover:bg-button! hover:bg-alternative!"
icon={copied ? : }
onClick={() => onSelectCopy(value || children)}
+ aria-label={copied ? 'Copied' : 'Copy'}
>
{copied ? 'Copied' : ''}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx
index 3729a0c7dc0..4cfb476ebca 100644
--- a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx
+++ b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx
@@ -58,6 +58,7 @@ export function McpConfigurationOptions({
From 92fdb1d3c5df0f42090e33f35f33b27003212d9a Mon Sep 17 00:00:00 2001
From: Francesco Sansalvadore
Date: Wed, 16 Sep 2026 09:38:15 +0200
Subject: [PATCH 077/107] feat: update storage move UI (#50346)
Update path selection as destination where to move files in the Storage
File Explorer.
## What is the current behavior?
Currently you need to write out the entire path by hand, which is error
prone and quirky.
## What is the new behavior?
This PR adds a ui that lets the user select any folder as the
destination of the file move.
https://github.com/user-attachments/assets/261ac24e-ec24-4bcf-ad47-72bc48e27bab
To test:
- go to Storage File explorer and pick a file to "move" (action in the
dropdown menu)
- mov file to any other folder in the same bucket selecting destination
folder from the ui in the dialog
- both empty folder or also a folder with sub-folders can be
destinations, as any selected folder becomes the active destination
(notice the cta changing when selecting a folder)
- batch move multiple items via multi-select (already supported, but
using the updated ui now)
- only folders should appear in this ui
- destination folders can be searched using the search input
## Summary by CodeRabbit
- **New Features**
- Replaced the move-items path field with an interactive folder browser.
- Browse, select, and search folders by name or path, with pagination
and loading or empty states.
- Navigate using breadcrumbs, including collapsed-path menus for deeply
nested folders.
- Receive warnings when folder search results are incomplete for very
large buckets.
- See clearer destination labels and protection against moving items to
their current location.
---------
Co-authored-by: Claude
---
.../StorageExplorer/MoveItemsFolderPicker.tsx | 266 ++++++++++++++++++
.../MoveItemsFolderPickerBreadcrumb.tsx | 98 +++++++
.../MoveItemsFolderPickerRow.tsx | 72 +++++
.../StorageExplorer/MoveItemsModal.test.tsx | 257 +++++++++++++++++
.../StorageExplorer/MoveItemsModal.tsx | 142 +++++-----
.../MoveItemsModal.utils.test.ts | 248 ++++++++++++++++
.../StorageExplorer/MoveItemsModal.utils.ts | 117 ++++++++
.../StorageExplorer/StorageExplorer.tsx | 5 +-
.../data/storage/bucket-folders-query.ts | 121 ++++++++
apps/studio/data/storage/keys.ts | 2 +
10 files changed, 1258 insertions(+), 70 deletions(-)
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPicker.tsx
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerBreadcrumb.tsx
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerRow.tsx
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.test.tsx
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.test.ts
create mode 100644 apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.ts
create mode 100644 apps/studio/data/storage/bucket-folders-query.ts
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPicker.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPicker.tsx
new file mode 100644
index 00000000000..2520ee8638d
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPicker.tsx
@@ -0,0 +1,266 @@
+import { useQuery } from '@tanstack/react-query'
+import { useDebounce } from '@uidotdev/usehooks'
+import { ArrowLeft, Search, X } from 'lucide-react'
+import { useMemo, useState } from 'react'
+import { Button } from 'ui'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+
+import { STORAGE_SORT_BY, STORAGE_SORT_BY_ORDER } from '../Storage.constants'
+import { MoveItemsFolderPickerBreadcrumb } from './MoveItemsFolderPickerBreadcrumb'
+import { FolderPickerRow } from './MoveItemsFolderPickerRow'
+import { filterFoldersBySearch, getDestinationLabel, toFolders } from './MoveItemsModal.utils'
+import { AlertError } from '@/components/ui/AlertError'
+import { InfiniteListDefault, LoaderForIconMenuItems } from '@/components/ui/InfiniteList'
+import { bucketFoldersQueryOptions } from '@/data/storage/bucket-folders-query'
+import { useBucketObjectsInfiniteQuery } from '@/data/storage/bucket-objects-infinite-query'
+import { onSearchInputEscape } from '@/lib/keyboard'
+
+const ROW_HEIGHT = 37
+
+interface MoveItemsFolderPickerProps {
+ projectRef: string
+ bucketId: string
+ bucketName: string
+ pathSegments: string[]
+ onChangePath: (pathSegments: string[]) => void
+}
+
+/**
+ * A file explorer scoped down to picking a destination folder
+ */
+export const MoveItemsFolderPicker = ({
+ projectRef,
+ bucketId,
+ bucketName,
+ pathSegments,
+ onChangePath,
+}: MoveItemsFolderPickerProps) => {
+ const [searchString, setSearchString] = useState('')
+ const debouncedSearchString = useDebounce(searchString, 300)
+ const isSearching = debouncedSearchString.trim().length > 0
+
+ const path = pathSegments.join('/')
+
+ const {
+ data: objectsData,
+ isPending: isPendingObjects,
+ isFetching: isFetchingObjects,
+ isError: isErrorObjects,
+ error: objectsError,
+ hasNextPage,
+ fetchNextPage,
+ refetch: refetchObjects,
+ } = useBucketObjectsInfiniteQuery({
+ projectRef,
+ bucketId,
+ path,
+ options: {
+ sortBy: { column: STORAGE_SORT_BY.NAME, order: STORAGE_SORT_BY_ORDER.ASC },
+ },
+ })
+
+ const {
+ data: foldersData,
+ isPending: isPendingFolders,
+ isError: isErrorFolders,
+ error: foldersError,
+ refetch: refetchFolders,
+ } = useQuery({
+ ...bucketFoldersQueryOptions({ projectRef, bucketId }),
+ enabled: isSearching,
+ })
+
+ const folders = useMemo(
+ () => toFolders(objectsData?.pages.flat() ?? [], path),
+ [objectsData, path]
+ )
+
+ const searchResults = useMemo(
+ () => filterFoldersBySearch(foldersData?.folders ?? [], debouncedSearchString),
+ [foldersData, debouncedSearchString]
+ )
+
+ const isRoot = pathSegments.length === 0
+ const currentFolderName = isRoot ? bucketName : pathSegments[pathSegments.length - 1]
+ // A page of pure files leaves nothing to render yet
+ const isDrainingPages = folders.length === 0 && hasNextPage
+
+ // Navigating always leaves search mode
+ const handleNavigate = (segments: string[]) => {
+ setSearchString('')
+ onChangePath(segments)
+ }
+
+ const handleSelectFolder = (folderPath: string) => handleNavigate(folderPath.split('/'))
+
+ return (
+
+
+ }
+ disabled={isRoot}
+ onClick={() => handleNavigate(pathSegments.slice(0, -1))}
+ />
+ }
+ placeholder={`Search folders in ${bucketName}...`}
+ type="text"
+ value={searchString}
+ onChange={(event) => setSearchString(event.target.value)}
+ onKeyDown={onSearchInputEscape(searchString, setSearchString)}
+ actions={
+ searchString.length > 0
+ ? [
+ }
+ className="h-5 w-5 p-0"
+ onClick={() => setSearchString('')}
+ />,
+ ]
+ : undefined
+ }
+ />
+
+
+
+
+
+ Moving to{' '}
+
+ {getDestinationLabel(bucketName, pathSegments)}
+
+
+
+
+
+ {isSearching && isPendingFolders && (
+
+
+
+
+
+ )}
+
+ {isSearching && isErrorFolders && (
+
+
refetchFolders()}>
+ Try again
+
+ }
+ />
+
+ )}
+
+ {isSearching && !isPendingFolders && !isErrorFolders && searchResults.length === 0 && (
+
+
No folders match "{debouncedSearchString}"
+
+ Clear the search to browse {bucketName} instead.
+
+
+ )}
+
+ {isSearching && !isErrorFolders && searchResults.length > 0 && (
+
searchResults[index]?.path ?? `folder-${index}`}
+ getItemSize={() => ROW_HEIGHT}
+ ItemComponent={FolderPickerRow}
+ LoaderComponent={LoaderForIconMenuItems}
+ />
+ )}
+
+ {!isSearching && isPendingObjects && (
+
+
+
+
+
+ )}
+
+ {!isSearching && isErrorObjects && (
+
+
refetchObjects()}>
+ Try again
+
+ }
+ />
+
+ )}
+
+ {!isSearching &&
+ !isPendingObjects &&
+ !isErrorObjects &&
+ folders.length === 0 &&
+ !isDrainingPages && (
+
+
No folders in {currentFolderName}
+
+ Move the files here, or go back to choose another folder.
+
+
+ )}
+
+ {!isSearching &&
+ !isPendingObjects &&
+ !isErrorObjects &&
+ (folders.length > 0 || isDrainingPages) && (
+ folders[index]?.path ?? `folder-${index}`}
+ getItemSize={() => ROW_HEIGHT}
+ ItemComponent={FolderPickerRow}
+ LoaderComponent={LoaderForIconMenuItems}
+ hasNextPage={hasNextPage}
+ isLoadingNextPage={isFetchingObjects}
+ onLoadNextPage={fetchNextPage}
+ />
+ )}
+
+
+ {isSearching && !!foldersData?.isTruncated && (
+
+ {bucketName} has too many folders to search through all of them. Browse to the folder if
+ it isn't listed.
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerBreadcrumb.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerBreadcrumb.tsx
new file mode 100644
index 00000000000..47cceaa9b46
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerBreadcrumb.tsx
@@ -0,0 +1,98 @@
+import { Fragment } from 'react'
+import {
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from 'ui'
+
+import { getMoveBreadcrumbs, type MoveBreadcrumb } from './MoveItemsModal.utils'
+
+const CRUMB_CLASS = 'max-w-20 truncate text-xs md:max-w-none'
+
+interface MoveItemsFolderPickerBreadcrumbProps {
+ bucketName: string
+ pathSegments: string[]
+ onNavigate: (pathSegments: string[]) => void
+}
+
+const Crumb = ({
+ crumb,
+ onNavigate,
+}: {
+ crumb: MoveBreadcrumb
+ onNavigate: (pathSegments: string[]) => void
+}) => (
+
+ {crumb.isCurrent ? (
+ {crumb.label}
+ ) : (
+
+ onNavigate(crumb.pathSegments)}>
+ {crumb.label}
+
+
+ )}
+
+)
+
+/**
+ * Path to the folder being browsed. Follows the design system's responsive breadcrumb: the
+ * bucket and the deepest folders stay visible, and the folders between them collapse into a
+ * dropdown so a deeply nested path stays readable.
+ */
+export const MoveItemsFolderPickerBreadcrumb = ({
+ bucketName,
+ pathSegments,
+ onNavigate,
+}: MoveItemsFolderPickerBreadcrumbProps) => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs(bucketName, pathSegments)
+
+ return (
+
+
+
+
+ {collapsed.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ {collapsed.map((crumb) => (
+ onNavigate(crumb.pathSegments)}
+ >
+ {crumb.label}
+
+ ))}
+
+
+
+ >
+ )}
+
+ {tail.map((crumb) => (
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerRow.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerRow.tsx
new file mode 100644
index 00000000000..b4e9c3ad3d1
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsFolderPickerRow.tsx
@@ -0,0 +1,72 @@
+import { ChevronRight } from 'lucide-react'
+import type { CSSProperties } from 'react'
+import { cn } from 'ui'
+
+import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
+import { StorageRowIcon } from '../StorageRowIcon'
+import { getParentPathLabel } from './MoveItemsModal.utils'
+import type { StorageFolder } from '@/data/storage/bucket-folders-query'
+
+interface FolderPickerRowProps {
+ item: StorageFolder
+ /** Path of the folder currently set as the move destination */
+ selectedPath: string
+ /** Shows where each folder lives — only useful for search, which spans the whole bucket */
+ showLocation: boolean
+ /** Stands in for a folder's location when it sits at the root of the bucket */
+ bucketName: string
+ onSelectFolder: (path: string) => void
+ style?: CSSProperties
+}
+
+export const FolderPickerRow = ({
+ item,
+ selectedPath,
+ showLocation,
+ bucketName,
+ onSelectFolder,
+ style,
+}: FolderPickerRowProps) => {
+ const isSelected = item.path === selectedPath
+ const location = getParentPathLabel(item.path, bucketName)
+
+ return (
+
+ onSelectFolder(item.path)}
+ aria-label={showLocation ? `${item.name} in ${location}` : item.name}
+ aria-current={isSelected ? 'true' : undefined}
+ className={cn(
+ 'flex h-full w-full cursor-pointer items-center gap-x-2 px-2.5 text-left',
+ 'focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--ring)]',
+ isSelected
+ ? 'bg-selection'
+ : 'hover:bg-panel-footer-light in-data-[theme*=dark]:hover:bg-panel-footer-dark'
+ )}
+ >
+
+
+
+
+ {item.name}
+
+ {showLocation && (
+
+ {location}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.test.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.test.tsx
new file mode 100644
index 00000000000..551719be029
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.test.tsx
@@ -0,0 +1,257 @@
+import { screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { HttpResponse } from 'msw'
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
+import type { StorageItemWithColumn } from '../Storage.types'
+import { MoveItemsModal } from './MoveItemsModal'
+import type { components } from '@/data/api'
+import { clickDropdown } from '@/tests/helpers'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+type StorageObject = components['schemas']['StorageObject_Output']
+
+const createFolder = (name: string): StorageObject => ({
+ id: null,
+ name,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ metadata: null,
+})
+
+const createFile = (name: string): StorageObject => ({
+ id: `id-${name}`,
+ name,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ last_accessed_at: '2024-01-01T00:00:00Z',
+ metadata: { size: 1024, mimetype: 'image/png' },
+})
+
+/** Contents of the fake bucket the picker browses, keyed by folder path */
+const BUCKET_CONTENTS: Record = {
+ '': [
+ createFolder('photos'),
+ createFolder('photos-old'),
+ createFolder('invoices'),
+ createFile('avatar.png'),
+ ],
+ photos: [createFolder('2024'), createFile('beach.png')],
+ 'photos/2024': [createFolder('q1')],
+ 'photos/2024/q1': [],
+ invoices: [],
+}
+
+const mockObjectsList = () =>
+ addAPIMock({
+ method: 'post',
+ path: '/platform/storage/:ref/buckets/:id/objects/list',
+ response: async ({ request }) => {
+ const body = (await request.json()) as { path: string }
+ return HttpResponse.json(BUCKET_CONTENTS[body.path] ?? [])
+ },
+ })
+
+const selectedFile: StorageItemWithColumn = {
+ id: 'id-avatar.png',
+ name: 'avatar.png',
+ type: STORAGE_ROW_TYPES.FILE,
+ status: STORAGE_ROW_STATUS.READY,
+ metadata: null,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ isCorrupted: false,
+ columnIndex: 0,
+}
+
+const renderModal = (props: Partial[0]> = {}) => {
+ const onSelectMove = vi.fn()
+ const onSelectCancel = vi.fn()
+
+ customRender(
+
+ )
+
+ return { onSelectMove, onSelectCancel }
+}
+
+describe('MoveItemsModal', () => {
+ beforeAll(() => {
+ // The picker virtualizes its rows, and jsdom reports every element as zero-sized, which
+ // would leave the list empty. Give the scroll container a viewport to render into.
+ Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 400 })
+ Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, value: 640 })
+ })
+
+ afterAll(() => {
+ // @ts-expect-error -- restoring jsdom's own zero-size getters
+ delete HTMLElement.prototype.offsetHeight
+ // @ts-expect-error -- restoring jsdom's own zero-size getters
+ delete HTMLElement.prototype.offsetWidth
+ })
+
+ beforeEach(() => {
+ mockObjectsList()
+ })
+
+ it('names the item being moved and defaults the destination to the bucket root', async () => {
+ renderModal()
+
+ expect(await screen.findByText('Move avatar.png')).toBeInTheDocument()
+ expect(await screen.findByRole('button', { name: 'photos' })).toBeInTheDocument()
+ expect(screen.getByText('avatars', { selector: 'span.font-mono' })).toBeInTheDocument()
+ })
+
+ it('lists folders only, leaving files out entirely', async () => {
+ renderModal()
+
+ await screen.findByRole('button', { name: 'photos' })
+ expect(screen.getByRole('button', { name: 'invoices' })).toBeInTheDocument()
+ expect(screen.queryByText('avatar.png')).not.toBeInTheDocument()
+ })
+
+ it('marks the destination folder in the search results', async () => {
+ const user = userEvent.setup()
+ renderModal()
+
+ await user.click(await screen.findByRole('button', { name: 'photos' }))
+ await screen.findByRole('button', { name: 'Move to photos' })
+
+ await user.type(screen.getByPlaceholderText('Search folders in avatars...'), 'photos')
+
+ expect(await screen.findByRole('button', { name: 'photos in avatars' })).toHaveAttribute(
+ 'aria-current',
+ 'true'
+ )
+ expect(screen.getByRole('button', { name: 'photos-old in avatars' })).not.toHaveAttribute(
+ 'aria-current'
+ )
+ })
+
+ it('keeps paging until it finds folders hidden behind a page of files', async () => {
+ const files = Array.from({ length: 200 }, (_, index) => createFile(`file-${index}.png`))
+ addAPIMock({
+ method: 'post',
+ path: '/platform/storage/:ref/buckets/:id/objects/list',
+ response: async ({ request }) => {
+ const body = (await request.json()) as { options?: { offset?: number } }
+ const isFirstPage = (body.options?.offset ?? 0) === 0
+ return HttpResponse.json(isFirstPage ? files : [createFolder('buried')])
+ },
+ })
+
+ renderModal()
+
+ expect(await screen.findByRole('button', { name: 'buried' })).toBeInTheDocument()
+ })
+
+ it('collapses the middle of a deep path into a dropdown', async () => {
+ const user = userEvent.setup()
+ renderModal()
+
+ await user.click(await screen.findByRole('button', { name: 'photos' }))
+ await user.click(await screen.findByRole('button', { name: '2024' }))
+ await user.click(await screen.findByRole('button', { name: 'q1' }))
+ await screen.findByRole('button', { name: 'Move to q1' })
+
+ // The bucket and the last two folders stay visible, so only "photos" collapses
+ const breadcrumb = screen.getByRole('navigation', { name: 'breadcrumb' })
+ expect(within(breadcrumb).getByText('avatars')).toBeInTheDocument()
+ expect(within(breadcrumb).getByText('2024')).toBeInTheDocument()
+ expect(within(breadcrumb).getByText('q1')).toBeInTheDocument()
+ expect(within(breadcrumb).queryByText('photos')).not.toBeInTheDocument()
+
+ clickDropdown(within(breadcrumb).getByLabelText('Show the folders in between'))
+
+ const collapsedItem = await screen.findByRole('menuitem', { name: 'photos' })
+ await user.click(collapsedItem)
+
+ expect(await screen.findByRole('button', { name: 'Move to photos' })).toBeInTheDocument()
+ })
+
+ it('keeps a shallow path fully visible', async () => {
+ const user = userEvent.setup()
+ renderModal()
+
+ await user.click(await screen.findByRole('button', { name: 'photos' }))
+ await screen.findByRole('button', { name: 'Move to photos' })
+
+ const breadcrumb = screen.getByRole('navigation', { name: 'breadcrumb' })
+ expect(within(breadcrumb).getByText('photos')).toBeInTheDocument()
+ expect(
+ within(breadcrumb).queryByLabelText('Show the folders in between')
+ ).not.toBeInTheDocument()
+ })
+
+ it('blocks moving items into the folder they are already in', async () => {
+ renderModal()
+
+ const moveButton = await screen.findByRole('button', { name: 'Move to avatars' })
+ expect(moveButton).toBeAriaDisabled()
+ })
+
+ it('opens a folder on click and moves items into it', async () => {
+ const user = userEvent.setup()
+ const { onSelectMove } = renderModal()
+
+ await user.click(await screen.findByRole('button', { name: 'photos' }))
+
+ expect(await screen.findByRole('button', { name: '2024' })).toBeInTheDocument()
+ const moveButton = await screen.findByRole('button', { name: 'Move to photos' })
+ expect(moveButton).toBeEnabled()
+
+ await user.click(moveButton)
+ expect(onSelectMove).toHaveBeenCalledWith('photos')
+ })
+
+ it('navigates back up through the breadcrumb', async () => {
+ const user = userEvent.setup()
+ renderModal()
+
+ await user.click(await screen.findByRole('button', { name: 'photos' }))
+ await screen.findByRole('button', { name: 'Move to photos' })
+
+ const breadcrumb = screen.getByRole('navigation', { name: 'breadcrumb' })
+ await user.click(within(breadcrumb).getByRole('button', { name: 'avatars' }))
+
+ expect(await screen.findByRole('button', { name: 'Move to avatars' })).toBeInTheDocument()
+ })
+
+ it('swaps the listing for matching folders while searching', async () => {
+ const user = userEvent.setup()
+ renderModal()
+
+ await screen.findByRole('button', { name: 'photos' })
+ await user.type(screen.getByPlaceholderText('Search folders in avatars...'), '2024')
+
+ expect(await screen.findByRole('button', { name: '2024 in photos' })).toBeInTheDocument()
+ await waitFor(() => expect(screen.queryByText('invoices')).not.toBeInTheDocument())
+ })
+
+ it('selects a searched folder as the destination', async () => {
+ const user = userEvent.setup()
+ const { onSelectMove } = renderModal()
+
+ await screen.findByRole('button', { name: 'photos' })
+ await user.type(screen.getByPlaceholderText('Search folders in avatars...'), '2024')
+
+ await user.click(await screen.findByRole('button', { name: '2024 in photos' }))
+ await user.click(await screen.findByRole('button', { name: 'Move to 2024' }))
+
+ expect(onSelectMove).toHaveBeenCalledWith('photos/2024')
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx
index b634cb18f91..5998b1583e4 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.tsx
@@ -1,5 +1,4 @@
-import { noop } from 'lodash'
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import {
Button,
Dialog,
@@ -10,90 +9,95 @@ import {
DialogSection,
DialogSectionSeparator,
DialogTitle,
- Input,
} from 'ui'
-import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
-import { StorageItemWithColumn } from '../Storage.types'
+import { StorageItem, StorageItemWithColumn } from '../Storage.types'
+import { MoveItemsFolderPicker } from './MoveItemsFolderPicker'
+import {
+ getDestinationName,
+ getMoveItemsTitle,
+ getSourcePaths,
+ isSameAsSourcePath,
+} from './MoveItemsModal.utils'
+import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface MoveItemsModalProps {
+ projectRef: string
+ bucketId: string
bucketName: string
visible: boolean
selectedItemsToMove: StorageItemWithColumn[]
+ openedFolders: readonly StorageItem[]
onSelectCancel: () => void
onSelectMove: (path: string) => void
}
-export const MoveItemsModal = ({
- bucketName = '',
- visible = false,
- selectedItemsToMove = [],
- onSelectCancel = noop,
- onSelectMove = noop,
-}: MoveItemsModalProps) => {
- const [moving, setMoving] = useState(false)
- const [newPath, setNewPath] = useState('')
+const MoveItemsDialogBody = ({
+ projectRef,
+ bucketId,
+ bucketName,
+ selectedItemsToMove,
+ openedFolders,
+ onSelectCancel,
+ onSelectMove,
+}: Omit) => {
+ const [isMoving, setIsMoving] = useState(false)
+ const [pathSegments, setPathSegments] = useState([])
- useEffect(() => {
- setMoving(false)
- setNewPath('')
- }, [visible])
+ const destinationPath = pathSegments.join('/')
+ const destinationName = getDestinationName(bucketName, pathSegments)
+ const sourcePaths = getSourcePaths(selectedItemsToMove, openedFolders)
+ const isAlreadyInDestination = isSameAsSourcePath(sourcePaths, destinationPath)
- const multipleFiles = selectedItemsToMove.length > 1
-
- const title = multipleFiles
- ? `Moving ${selectedItemsToMove.length} items within ${bucketName}`
- : selectedItemsToMove.length === 1
- ? `Moving ${selectedItemsToMove[0]?.name} within ${bucketName}`
- : ``
-
- const description = `Enter the path to where you'd like to move the file${
- multipleFiles ? 's' : ''
- } to.`
-
- const onConfirmMove = (event: any) => {
- if (event) {
- event.preventDefault()
- }
- setMoving(true)
- const formattedPath = newPath[0] === '/' ? newPath.slice(1) : newPath
- onSelectMove(formattedPath)
+ const handleMove = () => {
+ setIsMoving(true)
+ onSelectMove(destinationPath)
}
return (
-
-
-
- {title}
- {description}
-
-
-
-
-
-
- Cancel
-
- {moving ? 'Moving files' : 'Move files'}
-
-
+export const MoveItemsModal = ({ visible, ...props }: MoveItemsModalProps) => {
+ return (
+
+
+
)
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.test.ts
new file mode 100644
index 00000000000..f0eeb0ab1b6
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.test.ts
@@ -0,0 +1,248 @@
+import { describe, expect, it } from 'vitest'
+
+import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants'
+import type { StorageItem, StorageItemWithColumn } from '../Storage.types'
+import {
+ BREADCRUMB_ITEMS_TO_DISPLAY,
+ filterFoldersBySearch,
+ getDestinationLabel,
+ getDestinationName,
+ getMoveBreadcrumbs,
+ getMoveItemsTitle,
+ getParentPathLabel,
+ getSourcePaths,
+ isSameAsSourcePath,
+ MAX_FOLDER_SEARCH_RESULTS,
+ toFolders,
+} from './MoveItemsModal.utils'
+
+const createItem = (name: string, type = STORAGE_ROW_TYPES.FILE): StorageItem => ({
+ id: name,
+ name,
+ type,
+ status: STORAGE_ROW_STATUS.READY,
+ metadata: null,
+ created_at: null,
+ updated_at: null,
+ last_accessed_at: null,
+ isCorrupted: false,
+})
+
+const createItemWithColumn = (name: string, columnIndex: number): StorageItemWithColumn => ({
+ ...createItem(name),
+ columnIndex,
+})
+
+const createFolder = (path: string) => ({ name: path.split('/').pop()!, path })
+
+describe('getSourcePaths', () => {
+ const openedFolders = [
+ createItem('photos', STORAGE_ROW_TYPES.FOLDER),
+ createItem('2024', STORAGE_ROW_TYPES.FOLDER),
+ ]
+
+ it('returns the root for items in the first column', () => {
+ expect(getSourcePaths([createItemWithColumn('a.png', 0)], openedFolders)).toEqual([''])
+ })
+
+ it('joins the folders opened to reach the item', () => {
+ expect(getSourcePaths([createItemWithColumn('a.png', 2)], openedFolders)).toEqual([
+ 'photos/2024',
+ ])
+ })
+
+ it('deduplicates items that share a source folder', () => {
+ const items = [createItemWithColumn('a.png', 1), createItemWithColumn('b.png', 1)]
+ expect(getSourcePaths(items, openedFolders)).toEqual(['photos'])
+ })
+
+ it('returns every distinct source when items span columns', () => {
+ const items = [createItemWithColumn('a.png', 1), createItemWithColumn('b.png', 2)]
+ expect(getSourcePaths(items, openedFolders)).toEqual(['photos', 'photos/2024'])
+ })
+})
+
+describe('isSameAsSourcePath', () => {
+ it('is true when the only source folder is the destination', () => {
+ expect(isSameAsSourcePath(['photos'], 'photos')).toBe(true)
+ expect(isSameAsSourcePath([''], '')).toBe(true)
+ })
+
+ it('is false when the destination is a different folder', () => {
+ expect(isSameAsSourcePath(['photos'], 'photos/2024')).toBe(false)
+ expect(isSameAsSourcePath([''], 'photos')).toBe(false)
+ })
+
+ it('is false when items come from more than one folder', () => {
+ expect(isSameAsSourcePath(['photos', 'photos/2024'], 'photos')).toBe(false)
+ })
+})
+
+describe('getDestinationName', () => {
+ it('falls back to the bucket name at the root', () => {
+ expect(getDestinationName('avatars', [])).toBe('avatars')
+ })
+
+ it('uses the deepest folder', () => {
+ expect(getDestinationName('avatars', ['photos', '2024'])).toBe('2024')
+ })
+})
+
+describe('getDestinationLabel', () => {
+ it('prefixes the path with the bucket', () => {
+ expect(getDestinationLabel('avatars', ['photos', '2024'])).toBe('avatars/photos/2024')
+ expect(getDestinationLabel('avatars', [])).toBe('avatars')
+ })
+})
+
+describe('filterFoldersBySearch', () => {
+ const folders = [
+ createFolder('archive/photos'),
+ createFolder('photos'),
+ createFolder('photos-2024'),
+ createFolder('screenshots'),
+ createFolder('videos'),
+ ]
+
+ it('returns nothing for an empty search', () => {
+ expect(filterFoldersBySearch(folders, ' ')).toEqual([])
+ })
+
+ it('matches names case insensitively', () => {
+ expect(filterFoldersBySearch(folders, 'PHOTOS').map((folder) => folder.path)).toEqual([
+ 'archive/photos',
+ 'photos',
+ 'photos-2024',
+ ])
+ })
+
+ it('ranks exact names first, then prefixes, then substrings', () => {
+ expect(filterFoldersBySearch(folders, 'photos').map((folder) => folder.path)).toEqual([
+ 'archive/photos',
+ 'photos',
+ 'photos-2024',
+ ])
+ })
+
+ it('matches on the folder path when the name does not match', () => {
+ expect(filterFoldersBySearch(folders, 'archive').map((folder) => folder.path)).toEqual([
+ 'archive/photos',
+ ])
+ })
+
+ it('returns nothing when no folder matches', () => {
+ expect(filterFoldersBySearch(folders, 'invoices')).toEqual([])
+ })
+
+ it('caps the number of results', () => {
+ const many = Array.from({ length: MAX_FOLDER_SEARCH_RESULTS + 10 }, (_, index) =>
+ createFolder(`photos-${index}`)
+ )
+ expect(filterFoldersBySearch(many, 'photos')).toHaveLength(MAX_FOLDER_SEARCH_RESULTS)
+ })
+})
+
+describe('toFolders', () => {
+ // Objects without an id are prefixes (folders); the rest are files
+ const objects = [
+ { id: null, name: 'photos' },
+ { id: 'id-a', name: 'a.png' },
+ { id: null, name: 'videos' },
+ ] as Parameters[0]
+
+ it('drops files and keeps folders in order', () => {
+ expect(toFolders(objects, '').map((folder) => folder.name)).toEqual(['photos', 'videos'])
+ })
+
+ it('builds paths relative to the bucket root', () => {
+ expect(toFolders(objects, '').map((folder) => folder.path)).toEqual(['photos', 'videos'])
+ })
+
+ it('prefixes paths with the parent folder', () => {
+ expect(toFolders(objects, 'archive/2024').map((folder) => folder.path)).toEqual([
+ 'archive/2024/photos',
+ 'archive/2024/videos',
+ ])
+ })
+
+ it('returns nothing for a folder of only files', () => {
+ expect(toFolders([{ id: 'id-a', name: 'a.png' }] as typeof objects, '')).toEqual([])
+ })
+})
+
+describe('getParentPathLabel', () => {
+ it('falls back to the bucket name for a top level folder', () => {
+ expect(getParentPathLabel('photos', 'avatars')).toBe('avatars')
+ })
+
+ it('joins the parent segments for a nested folder', () => {
+ expect(getParentPathLabel('archive/2024/photos', 'avatars')).toBe('archive/2024')
+ })
+})
+
+describe('getMoveBreadcrumbs', () => {
+ const labels = (crumbs: { label: string }[]) => crumbs.map((crumb) => crumb.label)
+
+ it('shows only the bucket at the root, marked as current', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', [])
+ expect(first).toEqual({ label: 'avatars', pathSegments: [], isCurrent: true })
+ expect(collapsed).toEqual([])
+ expect(tail).toEqual([])
+ })
+
+ it('keeps every crumb visible up to the display limit', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', ['a', 'b'])
+ expect(first.label).toBe('avatars')
+ expect(collapsed).toEqual([])
+ expect(labels(tail)).toEqual(['a', 'b'])
+ })
+
+ it('collapses the middle once the path exceeds the display limit', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', ['a', 'b', 'c'])
+ expect(first.label).toBe('avatars')
+ expect(labels(collapsed)).toEqual(['a'])
+ expect(labels(tail)).toEqual(['b', 'c'])
+ })
+
+ it('collapses everything between the bucket and the last two folders', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', ['a', 'b', 'c', 'd', 'e'])
+ expect(first.label).toBe('avatars')
+ expect(labels(collapsed)).toEqual(['a', 'b', 'c'])
+ expect(labels(tail)).toEqual(['d', 'e'])
+ })
+
+ it('never renders more than the display limit of visible crumbs', () => {
+ const deep = Array.from({ length: 20 }, (_, index) => `folder-${index}`)
+ const { collapsed, tail } = getMoveBreadcrumbs('avatars', deep)
+ expect(1 + tail.length).toBe(BREADCRUMB_ITEMS_TO_DISPLAY)
+ expect(collapsed).toHaveLength(deep.length - tail.length)
+ })
+
+ it('points each crumb at the path it should navigate to', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', ['a', 'b', 'c', 'd'])
+ expect(first.pathSegments).toEqual([])
+ expect(collapsed.map((crumb) => crumb.pathSegments)).toEqual([['a'], ['a', 'b']])
+ expect(tail.map((crumb) => crumb.pathSegments)).toEqual([
+ ['a', 'b', 'c'],
+ ['a', 'b', 'c', 'd'],
+ ])
+ })
+
+ it('marks only the deepest folder as current', () => {
+ const { first, collapsed, tail } = getMoveBreadcrumbs('avatars', ['a', 'b', 'c', 'd'])
+ expect(first.isCurrent).toBe(false)
+ expect(collapsed.some((crumb) => crumb.isCurrent)).toBe(false)
+ expect(tail.map((crumb) => crumb.isCurrent)).toEqual([false, true])
+ })
+})
+
+describe('getMoveItemsTitle', () => {
+ it('names the file when moving one item', () => {
+ expect(getMoveItemsTitle([createItemWithColumn('avatar.png', 0)])).toBe('Move avatar.png')
+ })
+
+ it('counts the items when moving several', () => {
+ const items = [createItemWithColumn('a.png', 0), createItemWithColumn('b.png', 0)]
+ expect(getMoveItemsTitle(items)).toBe('Move 2 items')
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.ts
new file mode 100644
index 00000000000..dfab83ac4d8
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/MoveItemsModal.utils.ts
@@ -0,0 +1,117 @@
+import type { StorageItem, StorageItemWithColumn } from '../Storage.types'
+import type { StorageFolder } from '@/data/storage/bucket-folders-query'
+import type { StorageObject } from '@/data/storage/bucket-objects-list-mutation'
+
+/** Maximum number of folders rendered in the search results list */
+export const MAX_FOLDER_SEARCH_RESULTS = 100
+
+/**
+ * Returns the folder each item currently lives in
+ */
+export function getSourcePaths(
+ items: StorageItemWithColumn[],
+ openedFolders: readonly StorageItem[]
+): string[] {
+ const paths = items.map((item) =>
+ openedFolders
+ .slice(0, item.columnIndex)
+ .map((folder) => folder.name)
+ .join('/')
+ )
+ return Array.from(new Set(paths))
+}
+
+/**
+ * True when every item is already in `destinationPath`, which would make the move a no-op.
+ */
+export function isSameAsSourcePath(sourcePaths: string[], destinationPath: string): boolean {
+ return sourcePaths.length === 1 && sourcePaths[0] === destinationPath
+}
+
+export function getDestinationName(bucketName: string, pathSegments: string[]): string {
+ return pathSegments.length > 0 ? pathSegments[pathSegments.length - 1] : bucketName
+}
+
+export function getDestinationLabel(bucketName: string, pathSegments: string[]): string {
+ return [bucketName, ...pathSegments].join('/')
+}
+
+/**
+ * Folders whose name matches `searchString`, ordered by how closely they match: exact name
+ * first, then names starting with the search string, then the rest. Capped at
+ * `MAX_FOLDER_SEARCH_RESULTS` so a broad search can't render thousands of rows.
+ */
+export function filterFoldersBySearch(
+ folders: StorageFolder[],
+ searchString: string
+): StorageFolder[] {
+ const query = searchString.trim().toLowerCase()
+ if (query.length === 0) return []
+
+ const scored = folders
+ .map((folder) => {
+ const name = folder.name.toLowerCase()
+ if (name === query) return { folder, score: 0 }
+ if (name.startsWith(query)) return { folder, score: 1 }
+ if (name.includes(query)) return { folder, score: 2 }
+ if (folder.path.toLowerCase().includes(query)) return { folder, score: 3 }
+ return undefined
+ })
+ .filter((match) => match !== undefined)
+
+ return scored
+ .sort((a, b) => a.score - b.score || a.folder.path.localeCompare(b.folder.path))
+ .slice(0, MAX_FOLDER_SEARCH_RESULTS)
+ .map((match) => match.folder)
+}
+
+/**
+ * Narrows a listing to the folders in it. The picker only offers folders, so files are dropped.
+ */
+export function toFolders(objects: StorageObject[], parentPath: string): StorageFolder[] {
+ return objects
+ .filter((object) => !object.id)
+ .map((object) => ({
+ name: object.name,
+ path: parentPath.length > 0 ? `${parentPath}/${object.name}` : object.name,
+ }))
+}
+
+export function getParentPathLabel(folderPath: string, bucketName: string): string {
+ const parentSegments = folderPath.split('/').slice(0, -1)
+ return parentSegments.length > 0 ? parentSegments.join('/') : bucketName
+}
+
+export const BREADCRUMB_ITEMS_TO_DISPLAY = 3
+
+export type MoveBreadcrumb = {
+ label: string
+ pathSegments: string[]
+ isCurrent: boolean
+}
+
+export function getMoveBreadcrumbs(
+ bucketName: string,
+ pathSegments: string[]
+): { first: MoveBreadcrumb; collapsed: MoveBreadcrumb[]; tail: MoveBreadcrumb[] } {
+ const crumbs: MoveBreadcrumb[] = [bucketName, ...pathSegments].map((label, index) => ({
+ label,
+ pathSegments: pathSegments.slice(0, index),
+ isCurrent: index === pathSegments.length,
+ }))
+
+ const [first, ...rest] = crumbs
+ const tailLength = BREADCRUMB_ITEMS_TO_DISPLAY - 1
+ const shouldCollapse = crumbs.length > BREADCRUMB_ITEMS_TO_DISPLAY
+
+ return {
+ first,
+ collapsed: shouldCollapse ? rest.slice(0, -tailLength) : [],
+ tail: shouldCollapse ? rest.slice(-tailLength) : rest,
+ }
+}
+
+export function getMoveItemsTitle(items: StorageItemWithColumn[]): string {
+ if (items.length === 1) return `Move ${items[0].name}`
+ return `Move ${items.length} items`
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
index e372d772d45..6bc07b56742 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
@@ -182,9 +182,12 @@ export const StorageExplorer = () => {
0}
+ projectRef={projectRef}
+ bucketId={selectedBucket.id}
+ bucketName={selectedBucket.name}
selectedItemsToMove={selectedItemsToMove}
+ openedFolders={openedFolders}
onSelectCancel={() => setSelectedItemsToMove([])}
onSelectMove={onMoveSelectedFiles}
/>
diff --git a/apps/studio/data/storage/bucket-folders-query.ts b/apps/studio/data/storage/bucket-folders-query.ts
new file mode 100644
index 00000000000..d17a8395f09
--- /dev/null
+++ b/apps/studio/data/storage/bucket-folders-query.ts
@@ -0,0 +1,121 @@
+import { queryOptions } from '@tanstack/react-query'
+
+import { listBucketObjects } from './bucket-objects-list-mutation'
+import { storageKeys } from './keys'
+import type { ResponseError } from '@/types'
+
+/** Number of objects requested per folder listing */
+const PAGE_LIMIT = 1000
+/** Cap on how many listings the crawl issues */
+const MAX_LISTINGS = 200
+/** Cap on how many folders are collected */
+const MAX_FOLDERS = 1000
+/** How many listings are issued at a time while crawling */
+const CONCURRENCY = 5
+
+export type StorageFolder = {
+ name: string
+ path: string
+}
+
+export type BucketFoldersVariables = {
+ projectRef?: string
+ bucketId?: string
+}
+
+export type BucketFoldersData = {
+ folders: StorageFolder[]
+ /** True when the crawl hit its limits, so `folders` is only part of the bucket */
+ isTruncated: boolean
+}
+
+export type BucketFoldersError = ResponseError
+
+/** Pages through a single folder, counting each page fetched against the shared `listingsState` */
+const listFolderNames = async (
+ { projectRef, bucketId, path }: { projectRef: string; bucketId: string; path: string },
+ listingsState: { count: number },
+ signal?: AbortSignal
+): Promise<{ names: string[]; isTruncated: boolean }> => {
+ const names: string[] = []
+ let offset = 0
+
+ while (true) {
+ if (listingsState.count >= MAX_LISTINGS) {
+ return { names, isTruncated: true }
+ }
+
+ listingsState.count++
+ const objects = await listBucketObjects(
+ {
+ projectRef,
+ bucketId,
+ path,
+ options: { limit: PAGE_LIMIT, offset, sortBy: { column: 'name', order: 'asc' } },
+ },
+ signal
+ )
+
+ const page = objects ?? []
+ // Objects without an id are prefixes (folders) rather than files
+ names.push(...page.filter((object) => !object.id).map((object) => object.name))
+
+ if (page.length < PAGE_LIMIT) return { names, isTruncated: false }
+ offset += PAGE_LIMIT
+ }
+}
+
+/** Crawls a bucket breadth first for every folder, stopping early once `MAX_LISTINGS`/`MAX_FOLDERS` is hit */
+async function getBucketFolders(
+ { projectRef, bucketId }: BucketFoldersVariables,
+ signal?: AbortSignal
+): Promise {
+ if (!projectRef) throw new Error('projectRef is required')
+ if (!bucketId) throw new Error('bucketId is required')
+
+ const folders: StorageFolder[] = []
+ let queue = ['']
+ const listingsState = { count: 0 }
+ let isTruncated = false
+
+ while (queue.length > 0) {
+ if (listingsState.count >= MAX_LISTINGS || folders.length >= MAX_FOLDERS) {
+ isTruncated = true
+ break
+ }
+
+ const batch = queue.slice(0, CONCURRENCY)
+ queue = queue.slice(CONCURRENCY)
+
+ const results = await Promise.all(
+ batch.map(async (path) => {
+ const { names, isTruncated: isPathTruncated } = await listFolderNames(
+ { projectRef, bucketId, path },
+ listingsState,
+ signal
+ )
+ return { path, names, isTruncated: isPathTruncated }
+ })
+ )
+
+ for (const { path, names, isTruncated: isPathTruncated } of results) {
+ if (isPathTruncated) isTruncated = true
+ for (const name of names) {
+ const folderPath = path.length > 0 ? `${path}/${name}` : name
+ folders.push({ name, path: folderPath })
+ queue.push(folderPath)
+ }
+ }
+ }
+
+ return { folders: folders.slice(0, MAX_FOLDERS), isTruncated: isTruncated || queue.length > 0 }
+}
+
+export const bucketFoldersQueryOptions = ({ projectRef, bucketId }: BucketFoldersVariables) =>
+ queryOptions({
+ queryKey: storageKeys.folders(projectRef, bucketId),
+ queryFn: ({ signal }) => getBucketFolders({ projectRef, bucketId }, signal),
+ enabled: typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined',
+ // The crawl is expensive, so hold onto the result for the lifetime of a picker session
+ staleTime: 60 * 1000,
+ })
diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts
index 70d12059147..f6614f28357 100644
--- a/apps/studio/data/storage/keys.ts
+++ b/apps/studio/data/storage/keys.ts
@@ -54,6 +54,8 @@ export const storageKeys = {
...(path ? [path] : []),
...(params ? [params] : []),
] as const,
+ folders: (projectRef: string | undefined, bucketId: string | undefined) =>
+ ['projects', projectRef, 'buckets', bucketId, 'folders'] as const,
icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) =>
[projectRef, 'warehouse', warehouse, 'namespaces'] as const,
icebergNamespace: ({
From 7fce0a12d9435816cbba184edeb11de93cf1f47b Mon Sep 17 00:00:00 2001
From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com>
Date: Wed, 16 Sep 2026 09:18:40 +0100
Subject: [PATCH 078/107] feat(design-system): first pass at db report chart
colours (#46787)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
This is a first draft at introducing semantic colours to our
Observability charts. This moves away from just random colours being
assigned to prop after prop. They're only scoped to the Database reports
right now, but if it flows nice, we can open it up to the other reports
too.
This also aims to tone down some of the harsher colours in our charts,
such as the orange which sometimes can look like a warning metric/prop.
| Before | After |
|--------|--------|
| | |
cc @supabase/design for additional thoughts.
## Summary by CodeRabbit
* **New Features**
* Added semantic chart color roles with light and dark theme variants
for consistent visualizations.
* Standardized colors and fills across database, networking, storage,
and connection charts.
* Maximum-value lines now use configured chart colors when available.
* Added chart palette reference and stress-test examples.
* Added stacked bar charts, customizable margins, and gradient-filled
line charts.
* Improved multi-series bar chart focus and date-range footer alignment.
* **Documentation**
* Documented the chart palette, theme variants, accessibility guidance,
and usage recommendations.
---------
Co-authored-by: Claude Opus 4.8 (1M context)
Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
---
.../content/docs/components/chart.mdx | 74 +++-------
.../content/docs/ui-patterns/charts.mdx | 23 +++
apps/design-system/registry/charts.ts | 16 +++
.../default/block/chart-bar-interactive.tsx | 4 +-
.../default/block/chart-composed-basic.tsx | 4 +-
.../default/block/chart-palette-stress.tsx | 72 ++++++++++
.../registry/default/block/chart-palette.tsx | 135 ++++++++++++++++++
.../default/example/chart-tooltip-demo.tsx | 12 +-
apps/design-system/styles/globals.css | 16 ---
apps/learn/styles/globals.css | 15 --
.../Explorer/QueryEditor/QueryResultChart.tsx | 6 +-
.../EdgeFunctionOverview.utils.ts | 2 +-
.../QueryInsightsChart.constants.ts | 4 +-
.../Reports/ReportBlock/ChartBlock.tsx | 8 +-
.../interfaces/UnifiedLogs/UnifiedLogs.tsx | 4 +-
.../components/ui/Charts/ComposedChart.tsx | 25 ++--
.../ui/Charts/ComposedChart.utils.tsx | 23 +--
.../ui/DataTable/DataTable.utils.ts | 4 +-
apps/studio/data/reports/database-charts.ts | 91 ++++++++++--
apps/studio/styles/globals.css | 20 ---
apps/ui-library/styles/globals.css | 15 --
packages/config/css/charts.css | 48 +++++++
packages/config/tailwind.config.css | 1 +
.../src/Chart/charts/chart-bar.tsx | 49 ++++++-
.../src/Chart/charts/chart-line.tsx | 84 ++++++-----
.../ui-patterns/src/LogsBarChart/index.tsx | 4 +-
26 files changed, 540 insertions(+), 219 deletions(-)
create mode 100644 apps/design-system/registry/default/block/chart-palette-stress.tsx
create mode 100644 apps/design-system/registry/default/block/chart-palette.tsx
create mode 100644 packages/config/css/charts.css
diff --git a/apps/design-system/content/docs/components/chart.mdx b/apps/design-system/content/docs/components/chart.mdx
index 671491da8e8..68e336bdcf2 100644
--- a/apps/design-system/content/docs/components/chart.mdx
+++ b/apps/design-system/content/docs/components/chart.mdx
@@ -49,27 +49,7 @@ We do not wrap Recharts. This means you're not locked into an abstraction. When
-Add the following colors to your CSS file in your app.
-
-```css
-@layer base {
- :root {
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- }
-
- .dark {
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
- }
-}
-```
+Chart colors are already defined for every app in `packages/config/css/charts.css`, which ships through the shared Tailwind config. It provides eight categorical slots, `--chart-1` through `--chart-8`, each with a matching `-fill` token, resolved per theme. See the [Charts](/docs/ui-patterns/charts) pattern page for the palette and the rules for assigning slots.
## Your First Chart
@@ -327,25 +307,19 @@ Charts has built-in support for theming. You can use css variables (recommended)
-Define your colors in your css file
+Pick a slot from the shared palette
-```css {6-7,14-15} title="globals.css"
-@layer base {
- :root {
- --background: 0 0% 100%;
- --foreground: 240 10% 3.9%;
- // ...
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- }
+```css title="packages/config/css/charts.css"
+:root {
+ --chart-1: var(--color-brand-800);
+ --chart-2: var(--color-blue-900);
+ /* ... */
+}
- .dark: {
- --background: 240 10% 3.9%;
- --foreground: 0 0% 100%;
- // ...
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- }
+[data-theme*='dark'] {
+ --chart-1: var(--color-brand-900);
+ --chart-2: var(--color-blue-1100);
+ /* ... */
}
```
@@ -355,28 +329,18 @@ Charts has built-in support for theming. You can use css variables (recommended)
const chartConfig = {
desktop: {
label: 'Desktop',
- color: 'hsl(var(--chart-1))',
+ color: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
- color: 'hsl(var(--chart-2))',
+ color: 'var(--chart-2)',
},
} satisfies ChartConfig
```
-We're wrapping the value in `hsl()` here because we define the colors without color space function.
-
-This is not required. You can use full color values, such as hex, hsl or oklch.
-
-```css
---chart-1: oklch(70% 0.227 154.59);
-```
-
-```tsx
-color: "var(--chart-1)",
-```
+The slots are full color values, so pass them as `var(--chart-1)`. Do not wrap them in `hsl()`; that form is for bare HSL triplets and produces an invalid color here.
@@ -472,11 +436,11 @@ const chartConfig = {
},
chrome: {
label: 'Chrome',
- color: 'hsl(var(--chart-1))',
+ color: 'var(--chart-1)',
},
safari: {
label: 'Safari',
- color: 'hsl(var(--chart-2))',
+ color: 'var(--chart-2)',
},
} satisfies ChartConfig
```
@@ -516,11 +480,11 @@ const chartData = [
const chartConfig = {
chrome: {
label: 'Chrome',
- color: 'hsl(var(--chart-1))',
+ color: 'var(--chart-1)',
},
safari: {
label: 'Safari',
- color: 'hsl(var(--chart-2))',
+ color: 'var(--chart-2)',
},
} satisfies ChartConfig
```
diff --git a/apps/design-system/content/docs/ui-patterns/charts.mdx b/apps/design-system/content/docs/ui-patterns/charts.mdx
index ef2b6cf8ad9..db889d46815 100644
--- a/apps/design-system/content/docs/ui-patterns/charts.mdx
+++ b/apps/design-system/content/docs/ui-patterns/charts.mdx
@@ -23,6 +23,29 @@ Our charts use a combination of our own presentational components and [Recharts]
3. **Keep it simple**: Try to avoid abstracting the chart content too much. These components should cover most of your presentational needs.
+## Color
+
+Series colors come from eight categorical slots, `--chart-1` through `--chart-8`, defined in
+`packages/config/css/charts.css`. Assign them in order and never cycle: a ninth series folds
+into "Other" or becomes small multiples. Each slot has a matching `-fill` token. Slots resolve
+per theme, so pass `var(--chart-n)` and never branch on light/dark in code. Adjacent slots
+alternate hue families and clear colorblind separation in both themes.
+
+Reference lines use `--chart-reference`. Headroom, idle and unused capacity use `--chart-muted`.
+Directional pairs use `--chart-in` / `--chart-out` so read and write keep the same hue across
+charts.
+
+Status colors (`--chart-status-success`, `-warning`, `-destructive`, each with a `-muted` tier)
+are reserved for state and always ship with an icon or label. Never use one as a series color:
+amber on a neutral metric reads as a problem. Warm hues are otherwise limited to tomato, slot 5,
+because no amber or yellow step is legible on the dark surface.
+
+
+
+Every slot stacked together, to check adjacent segments stay separable in both themes.
+
+
+
## Examples
### Basic Chart Types
diff --git a/apps/design-system/registry/charts.ts b/apps/design-system/registry/charts.ts
index 9a42e5095a0..a272b16eede 100644
--- a/apps/design-system/registry/charts.ts
+++ b/apps/design-system/registry/charts.ts
@@ -57,4 +57,20 @@ export const charts: Registry = [
category: 'Charts',
subcategory: 'Composed',
},
+ {
+ name: 'chart-palette',
+ type: 'components:block',
+ registryDependencies: ['chart'],
+ files: ['block/chart-palette.tsx'],
+ category: 'Charts',
+ subcategory: 'Palette',
+ },
+ {
+ name: 'chart-palette-stress',
+ type: 'components:block',
+ registryDependencies: ['chart'],
+ files: ['block/chart-palette-stress.tsx'],
+ category: 'Charts',
+ subcategory: 'Palette',
+ },
]
diff --git a/apps/design-system/registry/default/block/chart-bar-interactive.tsx b/apps/design-system/registry/default/block/chart-bar-interactive.tsx
index 128664bb431..c235862f1a0 100644
--- a/apps/design-system/registry/default/block/chart-bar-interactive.tsx
+++ b/apps/design-system/registry/default/block/chart-bar-interactive.tsx
@@ -116,11 +116,11 @@ const chartConfig = {
},
desktop: {
label: 'Desktop',
- color: 'hsl(var(--chart-1))',
+ color: 'var(--chart-1)',
},
mobile: {
label: 'Mobile',
- color: 'hsl(var(--chart-2))',
+ color: 'var(--chart-2)',
},
} satisfies ChartConfig
diff --git a/apps/design-system/registry/default/block/chart-composed-basic.tsx b/apps/design-system/registry/default/block/chart-composed-basic.tsx
index 99be1076b08..d185c5e79bb 100644
--- a/apps/design-system/registry/default/block/chart-composed-basic.tsx
+++ b/apps/design-system/registry/default/block/chart-composed-basic.tsx
@@ -52,11 +52,11 @@ export default function ComposedChartBasic() {
},
performance: {
label: 'Performance',
- color: 'hsl(var(--chart-2))',
+ color: 'var(--chart-2)',
},
efficiency: {
label: 'Efficiency',
- color: 'hsl(var(--chart-5))',
+ color: 'var(--chart-5)',
},
}
diff --git a/apps/design-system/registry/default/block/chart-palette-stress.tsx b/apps/design-system/registry/default/block/chart-palette-stress.tsx
new file mode 100644
index 00000000000..740e5c882e6
--- /dev/null
+++ b/apps/design-system/registry/default/block/chart-palette-stress.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import {
+ Chart,
+ ChartBar,
+ ChartCard,
+ ChartContent,
+ ChartHeader,
+ ChartTitle,
+ type ChartBarTick,
+ type ChartConfig,
+} from 'ui-patterns/Chart'
+
+const SERIES = [
+ { key: 'postgres', label: 'Postgres' },
+ { key: 'postgrest', label: 'PostgREST' },
+ { key: 'reserved', label: 'Reserved' },
+ { key: 'auth', label: 'Auth' },
+ { key: 'storage', label: 'Storage' },
+ { key: 'realtime', label: 'Realtime' },
+ { key: 'cron', label: 'Cron' },
+ { key: 'other', label: 'Other roles' },
+]
+
+const config: ChartConfig = Object.fromEntries(
+ SERIES.map((s, i) => [s.key, { label: s.label, color: `var(--chart-${i + 1})` }])
+)
+
+export default function ChartPaletteStress() {
+ const data: ChartBarTick[] = Array.from({ length: 40 }, (_, i) => {
+ const date = new Date()
+ date.setMinutes(date.getMinutes() - (40 - i) * 3)
+ const row: ChartBarTick = { timestamp: date.toISOString() }
+
+ const trend = Math.sin((i / 40) * Math.PI * 2)
+ SERIES.forEach((s, idx) => {
+ const phase = Math.sin(i / 3.5 + idx * 1.7)
+ const jitter = Math.sin(i * 2.3 + idx * 0.9) * 1.5
+ row[s.key] = Math.max(1, Math.round(5 + idx * 1.8 + phase * 3 + trend * 2 + jitter))
+ })
+ return row
+ })
+
+ return (
+
+
+
+
+
+ Client connections by role
+
+
+
+
+ s.key)}
+ config={config}
+ isStacked
+ isFullHeight
+ showGrid
+ showYAxis
+ YAxisProps={{ width: 36 }}
+ />
+
+
+
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/block/chart-palette.tsx b/apps/design-system/registry/default/block/chart-palette.tsx
new file mode 100644
index 00000000000..8d44644e59d
--- /dev/null
+++ b/apps/design-system/registry/default/block/chart-palette.tsx
@@ -0,0 +1,135 @@
+import { ReactNode } from 'react'
+
+const SLOTS = [1, 2, 3, 4, 5, 6, 7, 8]
+
+const STATUS = [
+ { name: '--chart-status-success', muted: '--chart-status-success-muted', note: 'Healthy, ok' },
+ {
+ name: '--chart-status-warning',
+ muted: '--chart-status-warning-muted',
+ note: 'Threshold breach',
+ },
+ {
+ name: '--chart-status-destructive',
+ muted: '--chart-status-destructive-muted',
+ note: 'Error, failure',
+ },
+]
+
+const DEFAULTS = [
+ { name: '--chart-in', note: 'Pinned: network in, disk read' },
+ { name: '--chart-out', note: 'Pinned: network out, disk write' },
+ { name: '--chart-reference', note: 'Reference lines, max values' },
+ { name: '--chart-muted', note: 'Headroom, idle, unused capacity' },
+]
+
+function Swatch({ token, label }: { token: string; label: string }) {
+ return (
+
+ )
+}
+
+function TokenCard({
+ title,
+ token,
+ note,
+ children,
+}: {
+ title: ReactNode
+ token: string
+ note?: string
+ children: ReactNode
+}) {
+ return (
+
+
+
{children}
+ {note &&
{note}
}
+
+ )
+}
+
+function Section({
+ title,
+ description,
+ children,
+ className,
+}: {
+ title: string
+ description: string
+ children: ReactNode
+ className: string
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+ {children}
+
+ )
+}
+
+export default function ChartPalette() {
+ return (
+
+
+ {SLOTS.map((n) => (
+
+
+
+
+ ))}
+
+
+
+ {STATUS.map((d) => (
+
+
+
+
+ ))}
+
+
+
+ {DEFAULTS.map((d) => (
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/chart-tooltip-demo.tsx b/apps/design-system/registry/default/example/chart-tooltip-demo.tsx
index 5fffac2424a..af4c7735696 100644
--- a/apps/design-system/registry/default/example/chart-tooltip-demo.tsx
+++ b/apps/design-system/registry/default/example/chart-tooltip-demo.tsx
@@ -32,8 +32,8 @@ export default function Component() {
@@ -64,8 +64,8 @@ export default function Component() {
label="Browser"
hideLabel
payload={[
- { name: 'Chrome', value: 1286, fill: 'hsl(var(--chart-3))' },
- { name: 'Firefox', value: 1000, fill: 'hsl(var(--chart-4))' },
+ { name: 'Chrome', value: 1286, fill: 'var(--chart-3)' },
+ { name: 'Firefox', value: 1000, fill: 'var(--chart-4)' },
]}
indicator="dashed"
className="w-32"
@@ -74,7 +74,7 @@ export default function Component() {
@@ -84,7 +84,7 @@ export default function Component() {
diff --git a/apps/design-system/styles/globals.css b/apps/design-system/styles/globals.css
index 32c9d5d396b..0a55d08da3e 100644
--- a/apps/design-system/styles/globals.css
+++ b/apps/design-system/styles/globals.css
@@ -36,22 +36,6 @@
}
@layer base {
- :root {
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- }
-
- .dark {
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
- }
-
* {
@apply border-border;
}
diff --git a/apps/learn/styles/globals.css b/apps/learn/styles/globals.css
index 1478d871f62..a0fc87d4f3f 100644
--- a/apps/learn/styles/globals.css
+++ b/apps/learn/styles/globals.css
@@ -30,11 +30,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: hsl(var(--chart-1));
- --color-chart-2: hsl(var(--chart-2));
- --color-chart-3: hsl(var(--chart-3));
- --color-chart-4: hsl(var(--chart-4));
- --color-chart-5: hsl(var(--chart-5));
}
@layer base {
@@ -58,11 +53,6 @@
--border: hsl(220 13% 91%);
--input: hsl(220 13% 91%);
--ring: hsl(220 13% 70.8%);
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
--radius: 0.625rem;
--sidebar: hsl(210 20% 98.5%);
--sidebar-foreground: hsl(224 71.4% 4.1%);
@@ -94,11 +84,6 @@
--border: hsl(215 27.9% 16.9%);
--input: hsl(215 27.9% 16.9%);
--ring: hsl(217.9 10.6% 55.6%);
- --chart-1: 220 70% 50%;
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
--sidebar: hsl(220.9 39.3% 11%);
--sidebar-foreground: hsl(210 20% 98%);
--sidebar-primary: hsl(220 70% 50%);
diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx
index 32e018133a6..25412b9ea91 100644
--- a/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx
+++ b/apps/studio/components/interfaces/Explorer/QueryEditor/QueryResultChart.tsx
@@ -17,11 +17,7 @@ interface QueryResultChartProps {
result?: QueryResult
}
-const Y_SERIES_COLORS = [
- 'hsl(var(--brand-default))',
- 'hsl(var(--chart-blue))',
- 'hsl(var(--chart-3))',
-]
+const Y_SERIES_COLORS = ['hsl(var(--brand-default))', 'var(--chart-2)', 'var(--chart-3)']
const toChartValue = (value: unknown): string | number => {
if (typeof value === 'number' || typeof value === 'string') return value
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts
index dbb91f1aed2..a0d90f31fd9 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts
@@ -88,7 +88,7 @@ export const INVOCATION_CHART_CONFIG = {
},
warning_count: {
label: 'Warnings',
- color: 'var(--chart-warning)',
+ color: 'var(--chart-status-warning)',
},
error_count: {
label: 'Errors',
diff --git a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts
index 35ec04d9a08..ebcad57322f 100644
--- a/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts
+++ b/apps/studio/components/interfaces/QueryInsights/QueryInsightsChart/QueryInsightsChart.constants.ts
@@ -9,7 +9,7 @@ export const CHART_TABS = [
export const LEGEND_ITEMS: Record
= {
query_latency: [
- { label: 'P50', color: 'hsl(var(--chart-4))', dataKey: 'p50' },
+ { label: 'P50', color: 'var(--chart-4)', dataKey: 'p50' },
{ label: 'P95', color: CHART_COLORS.GREEN_1, dataKey: 'p95' },
],
rows_read: [{ label: 'Rows Read', color: CHART_COLORS.GREEN_1, dataKey: 'rows_read' }],
@@ -19,4 +19,4 @@ export const LEGEND_ITEMS: Record= threshold.danger
- ? 'var(--chart-destructive)'
+ ? 'var(--chart-status-destructive)'
: value >= threshold.warning
- ? 'var(--chart-warning)'
+ ? 'var(--chart-status-warning)'
: 'var(--chart-1)'
} else {
return value <= threshold.danger
- ? 'var(--chart-destructive)'
+ ? 'var(--chart-status-destructive)'
: value <= threshold.warning
- ? 'var(--chart-warning)'
+ ? 'var(--chart-status-warning)'
: 'var(--chart-1)'
}
}
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
index f67b3f1040a..b5099542a44 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
@@ -77,11 +77,11 @@ import { useShortcut } from '@/state/shortcuts/useShortcut'
export const CHART_CONFIG = {
success: {
label: ,
- color: 'var(--chart-success)',
+ color: 'var(--chart-muted)',
},
warning: {
label: ,
- color: 'var(--chart-warning)',
+ color: 'var(--chart-status-warning)',
},
error: {
label: ,
diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx
index 90b1babe16e..757556976d4 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.tsx
@@ -40,6 +40,7 @@ import {
CustomTooltip,
getStackId,
MultiAttribute,
+ resolveChartColor,
} from './ComposedChart.utils'
import NoDataPlaceholder from './NoDataPlaceholder'
import { ChartHighlight } from './useChartHighlight'
@@ -307,7 +308,7 @@ export function ComposedChart({
const maxAttribute = attributes.find((a) => a.isMaxValue)
const maxAttributeData = {
name: maxAttribute?.attribute,
- color: CHART_COLORS.REFERENCE_LINE,
+ color: resolveChartColor(maxAttribute?.color, isDarkMode) ?? CHART_COLORS.REFERENCE_LINE,
}
const referenceLines = attributes.filter((attribute) => {
@@ -342,16 +343,12 @@ export function ComposedChart({
const attribute = attributes.find((attr) => attr.attribute === att.name)
return {
...att,
- color: attribute?.color
- ? isDarkMode
- ? attribute.color.dark
- : attribute.color.light
- : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length],
- fill: attribute?.fill
- ? isDarkMode
- ? attribute.fill.dark
- : attribute.fill.light
- : STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length],
+ color:
+ resolveChartColor(attribute?.color, isDarkMode) ??
+ STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length],
+ fill:
+ resolveChartColor(attribute?.fill, isDarkMode) ??
+ STACKED_CHART_FILLS[index % STACKED_CHART_FILLS.length],
}
})
: []
@@ -600,7 +597,9 @@ export function ComposedChart({
key={maxAttribute.attribute}
type="linear"
dataKey={maxAttribute.attribute}
- stroke={CHART_COLORS.REFERENCE_LINE}
+ stroke={
+ resolveChartColor(maxAttribute.color, isDarkMode) ?? CHART_COLORS.REFERENCE_LINE
+ }
strokeWidth={2}
strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'}
dot={false}
@@ -616,7 +615,7 @@ export function ComposedChart({
key={line.attribute}
y={line.value}
strokeWidth={1}
- stroke={isDarkMode ? line.color?.dark : line.color?.light}
+ stroke={resolveChartColor(line.color, isDarkMode)}
strokeDasharray={line.strokeDasharray ?? '3 3'}
label={undefined}
>
diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
index 3aa6b215d15..60690c6bdb2 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
@@ -40,18 +40,25 @@ export interface ReportAttributes {
export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs'
+export type ThemedColor = {
+ light?: string
+ dark?: string
+}
+
+export const resolveChartColor = (
+ color: string | ThemedColor | undefined,
+ isDarkMode: boolean | undefined
+) => {
+ if (typeof color === 'string') return color
+ return isDarkMode ? color?.dark : color?.light
+}
+
export type MultiAttribute = {
attribute: string
provider?: Provider
label?: string
- color?: {
- light: string
- dark: string
- }
- fill?: {
- light?: string
- dark?: string
- }
+ color?: string | { light: string; dark: string }
+ fill?: string | ThemedColor
statusCode?: string
grantType?: string
providerType?: string
diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts
index 54366ba78c7..b26e17c4d38 100644
--- a/apps/studio/components/ui/DataTable/DataTable.utils.ts
+++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts
@@ -63,9 +63,9 @@ export function getLevelColor(
case 'success':
return {
text: 'text-muted',
- bg: 'bg-[var(--chart-success)] group-data-[state=selected]/row:bg-foreground-lighter',
+ bg: 'bg-[var(--chart-muted)] group-data-[state=selected]/row:bg-foreground-lighter',
border:
- 'border-[var(--chart-success)] group-data-[state=selected]/row:border-foreground-lighter',
+ 'border-[var(--chart-muted)] group-data-[state=selected]/row:border-foreground-lighter',
}
case 'warning':
return {
diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts
index 65e300266e0..45682dbdc16 100644
--- a/apps/studio/data/reports/database-charts.ts
+++ b/apps/studio/data/reports/database-charts.ts
@@ -75,6 +75,8 @@ export const getReportAttributesV2: (
attribute: 'ram_usage_used',
provider: 'infra-monitoring',
label: 'Used',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip:
'RAM in use by Postgres and the operating system. Sustained high usage may indicate memory pressure',
},
@@ -82,6 +84,8 @@ export const getReportAttributesV2: (
attribute: 'ram_usage_cache_and_buffers',
provider: 'infra-monitoring',
label: 'Cache + Buffers',
+ color: 'var(--chart-2)',
+ fill: 'var(--chart-2-fill)',
tooltip:
'RAM used by the operating system page cache and PostgreSQL buffers to accelerate disk reads/writes',
},
@@ -89,6 +93,8 @@ export const getReportAttributesV2: (
attribute: 'ram_usage_free',
provider: 'infra-monitoring',
label: 'Free',
+ color: 'var(--chart-muted)',
+ fill: 'var(--chart-muted-fill)',
tooltip:
'Unallocated memory available for use. A small portion is always reserved by the operating system',
},
@@ -105,6 +111,7 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'Swap',
omitFromTotal: true,
+ color: 'var(--chart-3)',
tooltip:
'Swap space in use by the operating system. Sustained swap usage indicates memory pressure and may degrade database performance',
},
@@ -132,6 +139,8 @@ export const getReportAttributesV2: (
attribute: 'ram_commit_used',
provider: 'infra-monitoring',
label: 'Committed',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip:
'Total memory the kernel has promised to processes (RAM plus swap). Sustained values near or above the commit limit indicate overcommitment and a high risk of out-of-memory failures',
},
@@ -139,6 +148,7 @@ export const getReportAttributesV2: (
attribute: 'ram_commit_limit',
provider: 'infra-monitoring',
label: 'Commit limit',
+ color: 'var(--chart-reference)',
isMaxValue: true,
omitFromTotal: true,
tooltip:
@@ -201,8 +211,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'System',
format: '%',
- color: { light: '#EDC35E', dark: '#EDD35E' },
- fill: { light: '#F6D99F', dark: '#5C5230' },
+ color: 'var(--chart-2)',
+ fill: 'var(--chart-2-fill)',
tooltip:
'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead',
},
@@ -211,8 +221,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'User',
format: '%',
- color: { light: '#0063E8', dark: '#65BCD9' },
- fill: { light: '#80B1F4', dark: '#2A3D45' },
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip:
'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries',
},
@@ -221,8 +231,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'IOwait',
format: '%',
- color: { light: '#DB3A34', dark: '#FF6B6B' },
- fill: { light: '#F2A7A3', dark: '#5C2A2A' },
+ color: 'var(--chart-3)',
+ fill: 'var(--chart-3-fill)',
tooltip:
'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks',
},
@@ -231,8 +241,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'IRQs',
format: '%',
- color: { light: '#DA760B', dark: '#DA760B' },
- fill: { light: '#FFB885', dark: '#5C3D0A' },
+ color: 'var(--chart-4)',
+ fill: 'var(--chart-4-fill)',
tooltip: 'CPU time handling hardware interrupt requests (IRQ)',
},
{
@@ -240,11 +250,30 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
label: 'Other',
format: '%',
- color: { light: '#B616A6', dark: '#DB8DF9' },
- fill: { light: '#DB8BD3', dark: '#4A3D5C' },
+ color: 'var(--chart-5)',
+ fill: 'var(--chart-5-fill)',
tooltip:
'CPU time spent on other tasks (e.g., background processes, software interrupts)',
},
+ {
+ attribute: 'cpu_usage_busy_idle',
+ provider: 'infra-monitoring',
+ label: 'Idle',
+ format: '%',
+ omitFromTotal: true,
+ color: 'var(--chart-muted)',
+ fill: 'var(--chart-muted-fill)',
+ tooltip: 'CPU time spent idle and available for new work',
+ },
+ {
+ attribute: 'cpu_usage_max',
+ provider: 'reference-line',
+ label: 'Max',
+ value: 100,
+ color: 'var(--chart-reference)',
+ tooltip: 'Max CPU usage',
+ isMaxValue: true,
+ },
],
},
{
@@ -269,12 +298,16 @@ export const getReportAttributesV2: (
attribute: 'network_receive_bytes',
provider: 'infra-monitoring',
label: 'Network in',
+ color: 'var(--chart-in)',
+ fill: 'var(--chart-in-fill)',
tooltip: 'Inbound network throughput (bytes per second)',
},
{
attribute: 'network_transmit_bytes',
provider: 'infra-monitoring',
label: 'Network out',
+ color: 'var(--chart-out)',
+ fill: 'var(--chart-out-fill)',
tooltip: 'Outbound network throughput (bytes per second)',
},
],
@@ -301,6 +334,8 @@ export const getReportAttributesV2: (
attribute: 'disk_iops_write',
provider: 'infra-monitoring',
label: 'Write IOPS',
+ color: 'var(--chart-out)',
+ fill: 'var(--chart-out-fill)',
tooltip:
'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity',
},
@@ -308,6 +343,8 @@ export const getReportAttributesV2: (
attribute: 'disk_iops_read',
provider: 'infra-monitoring',
label: 'Read IOPS',
+ color: 'var(--chart-in)',
+ fill: 'var(--chart-in-fill)',
tooltip:
'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching',
},
@@ -315,6 +352,7 @@ export const getReportAttributesV2: (
attribute: 'disk_iops_max',
provider: 'reference-line',
label: 'Max IOPS',
+ color: 'var(--chart-reference)',
value: effectiveMaxIops,
tooltip:
'Effective maximum IOPS for your current compute and disk configuration. Equal to the lower of the compute IOPS limit and the provisioned disk IOPS',
@@ -345,18 +383,23 @@ export const getReportAttributesV2: (
attribute: 'disk_bytes_read',
provider: 'infra-monitoring',
label: 'Read throughput',
+ color: 'var(--chart-in)',
+ fill: 'var(--chart-in-fill)',
tooltip: 'Disk read throughput (bytes per second)',
},
{
attribute: 'disk_bytes_written',
provider: 'infra-monitoring',
label: 'Write throughput',
+ color: 'var(--chart-out)',
+ fill: 'var(--chart-out-fill)',
tooltip: 'Disk write throughput (bytes per second)',
},
{
attribute: 'disk_throughput_max',
provider: 'reference-line',
label: 'Max throughput',
+ color: 'var(--chart-reference)',
value:
diskConfig?.attributes?.type === 'gp3' &&
typeof diskConfig.attributes.throughput_mbps === 'number'
@@ -418,12 +461,15 @@ export const getReportAttributesV2: (
attribute: 'pg_stat_database_num_backends',
provider: 'infra-monitoring',
label: 'Total connections',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip: 'Total number of active database connections',
},
{
attribute: 'max_db_connections',
provider: 'reference-line',
label: 'Max connections',
+ color: 'var(--chart-reference)',
value: maxConnections?.maxConnections,
tooltip: 'Max available connections for your current compute size',
isMaxValue: true,
@@ -452,6 +498,8 @@ export const getReportAttributesV2: (
attribute: 'client_connections_postgres',
provider: 'infra-monitoring',
label: 'Postgres',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip:
'Direct connections to the Postgres database from your application and external clients',
},
@@ -459,12 +507,16 @@ export const getReportAttributesV2: (
attribute: 'client_connections_authenticator',
provider: 'infra-monitoring',
label: 'PostgREST',
+ color: 'var(--chart-2)',
+ fill: 'var(--chart-2-fill)',
tooltip: 'Connection pool managed by PostgREST',
},
{
attribute: 'client_connections_supabase_admin',
provider: 'infra-monitoring',
label: 'Reserved',
+ color: 'var(--chart-3)',
+ fill: 'var(--chart-3-fill)',
tooltip:
'Administrative connections used by various Supabase services for internal operations and maintenance tasks',
},
@@ -472,24 +524,31 @@ export const getReportAttributesV2: (
attribute: 'client_connections_supabase_auth_admin',
provider: 'infra-monitoring',
label: 'Auth',
+ color: 'var(--chart-4)',
+ fill: 'var(--chart-4-fill)',
tooltip: 'Connection pool managed by Supabase Auth',
},
{
attribute: 'client_connections_supabase_storage_admin',
provider: 'infra-monitoring',
label: 'Storage',
+ color: 'var(--chart-5)',
+ fill: 'var(--chart-5-fill)',
tooltip: 'Connection pool managed by Supabase Storage',
},
{
attribute: 'client_connections_other',
provider: 'infra-monitoring',
label: 'Other roles',
+ color: 'var(--chart-6)',
+ fill: 'var(--chart-6-fill)',
tooltip: "Miscellaneous database connections that don't fall into other categories.",
},
{
attribute: 'max_db_connections',
provider: 'reference-line',
label: 'Max connections',
+ color: 'var(--chart-reference)',
value: maxConnections?.maxConnections,
tooltip: 'Max available connections for your current compute size',
isMaxValue: true,
@@ -522,12 +581,15 @@ export const getReportAttributesV2: (
attribute: 'client_connections_pgbouncer',
provider: 'infra-monitoring',
label: isHighAvailability ? 'multipooler' : 'pgbouncer',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip: isHighAvailability ? 'Multipooler connections' : 'PgBouncer connections',
},
{
attribute: 'pg_pooler_max_connections',
provider: 'reference-line',
label: 'Max pooler connections',
+ color: 'var(--chart-reference)',
value: pgBouncerMaxConnections,
tooltip: 'Maximum allowed pooler connections for your current compute size',
isMaxValue: true,
@@ -555,6 +617,8 @@ export const getReportAttributesV2: (
attribute: 'supavisor_connections_active',
provider: 'infra-monitoring',
label: 'supavisor',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip: 'Supavisor connections',
},
],
@@ -582,6 +646,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
format: 'bytes',
label: 'System',
+ color: 'var(--chart-3)',
+ fill: 'var(--chart-3-fill)',
tooltip: 'Reserved space for the system to ensure your database runs smoothly',
},
{
@@ -589,6 +655,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
format: 'bytes',
label: 'WAL',
+ color: 'var(--chart-2)',
+ fill: 'var(--chart-2-fill)',
tooltip:
'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database',
},
@@ -597,6 +665,8 @@ export const getReportAttributesV2: (
provider: 'infra-monitoring',
format: 'bytes',
label: 'Database',
+ color: 'var(--chart-1)',
+ fill: 'var(--chart-1-fill)',
tooltip: 'Disk usage by your database (tables, indexes, data, ...)',
},
{
@@ -605,6 +675,7 @@ export const getReportAttributesV2: (
isMaxValue: true,
format: 'bytes',
label: 'Disk Size',
+ color: 'var(--chart-reference)',
tooltip: 'Disk Size refers to the total space your project occupies on disk',
},
entitledFeatures.includes('database') &&
diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css
index 44e8f55b3c1..d75d9ca72f4 100644
--- a/apps/studio/styles/globals.css
+++ b/apps/studio/styles/globals.css
@@ -138,16 +138,6 @@
}
:root {
- --chart-1: hsl(var(--brand-default));
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- --chart-blue: 217 91% 60%;
- --chart-warning: hsl(var(--warning-500));
- --chart-warning-muted: hsl(var(--warning-400));
- --chart-destructive: hsl(var(--destructive-default));
- --chart-success: color-mix(in oklch, var(--foreground-muted) 50%, white);
--sidebar-background: var(--background-dash-sidebar);
--sidebar-foreground: var(--foreground-default);
--sidebar-primary: var(--foreground-default);
@@ -161,16 +151,6 @@
[data-theme='dark'],
.dark {
- --chart-1: hsl(var(--brand-default));
- --chart-2: 160 60% 45%;
- --chart-3: 30 80% 55%;
- --chart-4: 280 65% 60%;
- --chart-5: 340 75% 55%;
- --chart-blue: 217 91% 65%;
- --chart-warning: hsl(var(--warning-default));
- --chart-warning-muted: hsl(var(--warning-500));
- --chart-destructive: hsl(var(--destructive-default));
- --chart-success: color-mix(in oklch, var(--foreground-muted) 55%, var(--background));
--sidebar-background: var(--background-dash-sidebar);
--sidebar-foreground: var(--foreground-default);
--sidebar-primary: var(--foreground-default);
diff --git a/apps/ui-library/styles/globals.css b/apps/ui-library/styles/globals.css
index 6d89a7d3595..f1cb2bfd4d2 100644
--- a/apps/ui-library/styles/globals.css
+++ b/apps/ui-library/styles/globals.css
@@ -37,11 +37,6 @@
/* --color-border: var(--border); */
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: hsl(var(--chart-1));
- --color-chart-2: hsl(var(--chart-2));
- --color-chart-3: hsl(var(--chart-3));
- --color-chart-4: hsl(var(--chart-4));
- --color-chart-5: hsl(var(--chart-5));
}
@layer base {
@@ -65,11 +60,6 @@
--border: hsl(0 0% 92.2%);
--input: hsl(0 0% 92.2%);
--ring: hsl(0 0% 70.8%);
- --chart-1: 41.1 22.2% 64.6%;
- --chart-2: 184.7 11.8% 60%;
- --chart-3: 227.4 7% 39.8%;
- --chart-4: 84.4 18.9% 82.8%;
- --chart-5: 70.1 18.8% 76.9%;
--radius: 0.625rem;
--sidebar: hsl(0 0% 98.5%);
--sidebar-foreground: hsl(0 0% 14.5%);
@@ -102,11 +92,6 @@
--border: hsl(0 0% 26.9%);
--input: hsl(0 0% 26.9%);
--ring: hsl(0 0% 55.6%);
- --chart-1: 264.4 24.3% 48.8%;
- --chart-2: 162.5 17% 69.6%;
- --chart-3: 70.1 18.8% 76.9%;
- --chart-4: 303.9 26.5% 62.7%;
- --chart-5: 16.4 24.6% 64.5%;
--sidebar: hsl(0 0% 20.5%);
--sidebar-foreground: hsl(0 0% 98.5%);
--sidebar-primary: hsl(264.4 24.3% 48.8%);
diff --git a/packages/config/css/charts.css b/packages/config/css/charts.css
new file mode 100644
index 00000000000..806419ac650
--- /dev/null
+++ b/packages/config/css/charts.css
@@ -0,0 +1,48 @@
+:root {
+ --chart-1: var(--color-brand-800);
+ --chart-2: var(--color-blue-900);
+ --chart-3: var(--color-pink-1000);
+ --chart-4: var(--color-violet-900);
+ --chart-5: var(--color-tomato-1000);
+ --chart-6: var(--color-indigo-1000);
+ --chart-7: var(--color-green-1100);
+ --chart-8: var(--color-purple-1000);
+
+ --chart-1-fill: var(--color-brand-500);
+ --chart-2-fill: var(--color-blue-500);
+ --chart-3-fill: var(--color-pink-500);
+ --chart-4-fill: var(--color-violet-500);
+ --chart-5-fill: var(--color-tomato-500);
+ --chart-6-fill: var(--color-indigo-500);
+ --chart-7-fill: var(--color-green-500);
+ --chart-8-fill: var(--color-purple-500);
+
+ --chart-in: var(--chart-2);
+ --chart-out: var(--chart-5);
+ --chart-in-fill: var(--chart-2-fill);
+ --chart-out-fill: var(--chart-5-fill);
+
+ --chart-reference: var(--color-scale-1000);
+ --chart-muted: var(--color-scale-700);
+ --chart-muted-fill: var(--color-scale-400);
+
+ --chart-status-success: hsl(var(--brand-default));
+ --chart-status-success-muted: hsl(var(--brand-500));
+ --chart-status-warning: var(--warning);
+ --chart-status-warning-muted: hsl(var(--warning-500));
+ --chart-status-destructive: var(--destructive);
+ --chart-status-destructive-muted: hsl(var(--destructive-500));
+}
+
+[data-theme*='dark'] {
+ --chart-1: var(--color-brand-900);
+ --chart-2: var(--color-blue-1100);
+ --chart-3: var(--color-pink-1100);
+ --chart-4: var(--color-violet-1100);
+ --chart-5: var(--color-tomato-1100);
+ --chart-6: var(--color-indigo-1100);
+ --chart-7: var(--color-green-1100);
+ --chart-8: var(--color-purple-1100);
+
+ --chart-muted: var(--color-scale-800);
+}
diff --git a/packages/config/tailwind.config.css b/packages/config/tailwind.config.css
index 329638a906a..28f167840a1 100644
--- a/packages/config/tailwind.config.css
+++ b/packages/config/tailwind.config.css
@@ -26,6 +26,7 @@
* consumes them from @theme inline so Tailwind generates the utilities. */
@import './css/colors.css';
@import './css/theme.css';
+@import './css/charts.css';
@import './css/animations.css';
@import './css/utilities.css';
@import './tailwind-plugins/hit-area.css';
diff --git a/packages/ui-patterns/src/Chart/charts/chart-bar.tsx b/packages/ui-patterns/src/Chart/charts/chart-bar.tsx
index 49825a36265..1418ac7ab9a 100644
--- a/packages/ui-patterns/src/Chart/charts/chart-bar.tsx
+++ b/packages/ui-patterns/src/Chart/charts/chart-bar.tsx
@@ -15,6 +15,8 @@ import {
import type { CategoricalChartState } from 'recharts/types/chart/types'
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui'
+const DIMMED_FILL_OPACITY = 0.35
+
const CHART_COLORS = {
TICK: 'var(--background-overlay-hover)',
AXIS: 'var(--background-overlay-hover)',
@@ -66,6 +68,8 @@ export interface ChartBarProps {
showGrid?: boolean
showYAxis?: boolean
showXAxis?: boolean
+ isStacked?: boolean
+ margin?: { top?: number; right?: number; bottom?: number; left?: number }
XAxisProps?: {
tick?: boolean
tickFormatter?: (value: any) => string
@@ -102,6 +106,8 @@ export const ChartBar = ({
showGrid = false,
showYAxis = false,
showXAxis = false,
+ isStacked = false,
+ margin: marginProp,
XAxisProps,
YAxisProps,
}: ChartBarProps) => {
@@ -146,23 +152,28 @@ export const ChartBar = ({
...XAxisProps,
}
+ const yAxisWidth = showYAxis ? (YAxisProps?.width ?? 60) : 0
+
const yAxisConfig = {
tick: showYAxis
? { fill: 'var(--color-foreground-lighter)', fontSize: 10, fontFamily: 'var(--font-mono)' }
: false,
hide: !showYAxis,
tickMargin: showYAxis ? (YAxisProps?.tickMargin ?? 4) : 0,
- width: showYAxis ? (YAxisProps?.width ?? 60) : 0,
+ width: yAxisWidth,
axisLine: { stroke: CHART_COLORS.AXIS },
tickLine: { stroke: CHART_COLORS.AXIS },
...YAxisProps,
}
+ const hasDateRangeFooter = xKey === 'timestamp' && data.length > 0
+
const margin = {
top: 0,
right: 0,
left: 0,
bottom: 0,
+ ...marginProp,
}
return (
@@ -170,7 +181,10 @@ export const ChartBar = ({
data-testid="chart-bar"
className={cn('flex flex-col gap-y-3 w-full', isFullHeight ? 'h-full' : 'h-24', className)}
>
-
+
}
/>
- {/* Selection highlight area */}
{showHighlightActions && (
+ return (
+
+ {data.map((_entry: ChartBarTick, dataIndex: number) => (
+ |
+ ))}
+
+ )
})
) : (
@@ -267,8 +301,11 @@ export const ChartBar = ({
- {xKey === 'timestamp' && data && data.length > 0 && (
-
+ {hasDateRangeFooter && (
+
{dayjs(data[0][xKey]).format(DateTimeFormat)}
{dayjs(data[data.length - 1]?.[xKey]).format(DateTimeFormat)}
diff --git a/packages/ui-patterns/src/Chart/charts/chart-line.tsx b/packages/ui-patterns/src/Chart/charts/chart-line.tsx
index c100f8100fd..9ef6069064e 100644
--- a/packages/ui-patterns/src/Chart/charts/chart-line.tsx
+++ b/packages/ui-patterns/src/Chart/charts/chart-line.tsx
@@ -2,7 +2,7 @@
import dayjs from 'dayjs'
import { useTheme } from 'next-themes'
-import { ReactNode, useState } from 'react'
+import { ReactNode, useId, useState } from 'react'
import {
Area,
CartesianGrid,
@@ -118,6 +118,7 @@ export const ChartLine = ({
referenceLines,
}: ChartLineProps) => {
const [focusDataIndex, setFocusDataIndex] = useState
(null)
+ const gradientPrefix = `chart-line-fill-${useId().replace(/:/g, '')}`
const { resolvedTheme } = useTheme()
const isDarkMode = resolvedTheme?.includes('dark')
@@ -157,18 +158,30 @@ export const ChartLine = ({
...XAxisProps,
}
+ const yAxisWidth = showYAxis ? (YAxisProps?.width ?? 60) : 0
+
const yAxisConfig = {
tick: showYAxis
? { fill: 'var(--color-foreground-lighter)', fontSize: 10, fontFamily: 'var(--font-mono)' }
: false,
hide: !showYAxis,
tickMargin: showYAxis ? (YAxisProps?.tickMargin ?? 4) : 0,
- width: showYAxis ? (YAxisProps?.width ?? 60) : 0,
+ width: yAxisWidth,
axisLine: { stroke: CHART_COLORS.AXIS },
tickLine: { stroke: CHART_COLORS.AXIS },
...YAxisProps,
}
+ const hasDateRangeFooter = xKey === 'timestamp' && data.length > 0
+
+ const resolveSeriesColor = (key: string) => {
+ const keyConfig = chartConfig[key]
+ return (
+ keyConfig?.color ||
+ (keyConfig?.theme ? (isDarkMode ? keyConfig.theme.dark : keyConfig.theme.light) : color)
+ )
+ }
+
const margin = {
top: 0,
right: 0,
@@ -189,7 +202,10 @@ export const ChartLine = ({
data-testid="chart-line"
className={cn('flex flex-col gap-y-3 w-full', isFullHeight ? 'h-full' : 'h-24', className)}
>
-
+
+
+ {keysToRender.map((key, index) => (
+
+
+
+
+ ))}
+
{showGrid && }
@@ -275,7 +306,6 @@ export const ChartLine = ({
/>
}
/>
- {/* Selection highlight area */}
{showHighlightActions && (
))}
- {keysToRender.map((key, index) => {
- const keyConfig = chartConfig[key]
- const lineColor =
- keyConfig?.color ||
- (keyConfig?.theme
- ? isDarkMode
- ? keyConfig.theme.dark
- : keyConfig.theme.light
- : color)
- const baseOpacity = 0.2
- const opacityIncrement = 0.1
- const maxOpacity = 0.6
- const fillOpacity = Math.min(baseOpacity + index * opacityIncrement, maxOpacity)
-
- return (
- 1 ? `stack-${key}` : undefined}
- />
- )
- })}
+ {keysToRender.map((key, index) => (
+ 1 ? `stack-${key}` : undefined}
+ />
+ ))}
- {xKey === 'timestamp' && data && data.length > 0 && (
-
+ {hasDateRangeFooter && (
+
{dayjs(data[0][xKey]).format(DateTimeFormat)}
{dayjs(data[data.length - 1]?.[xKey]).format(DateTimeFormat)}
diff --git a/packages/ui-patterns/src/LogsBarChart/index.tsx b/packages/ui-patterns/src/LogsBarChart/index.tsx
index c22c003eda2..3d05eaed38e 100644
--- a/packages/ui-patterns/src/LogsBarChart/index.tsx
+++ b/packages/ui-patterns/src/LogsBarChart/index.tsx
@@ -13,8 +13,8 @@ const CHART_COLORS = {
GREEN_2: 'hsl(var(--brand-500))',
RED_1: 'hsl(var(--destructive-default))',
RED_2: 'hsl(var(--destructive-500))',
- YELLOW_1: 'var(--chart-warning, hsl(var(--warning-default)))',
- YELLOW_2: 'var(--chart-warning-muted, hsl(var(--warning-500)))',
+ YELLOW_1: 'var(--chart-status-warning)',
+ YELLOW_2: 'var(--chart-status-warning-muted)',
}
type LogsBarChartDatum = {
From 0d970b837062e6820844aa4637c04ea9db933f1a Mon Sep 17 00:00:00 2001
From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 10:24:04 +0200
Subject: [PATCH 079/107] fix(deps): add cooldown to npm Dependabot updates to
satisfy pnpm minimumReleaseAge (#50417)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_Requested by **Jonny Summers-Muir** · [Slack
thread](https://supabase.slack.com/archives/C0429V78ACX/p1789496187445649?thread_ts=1789496187.445649&cid=C0429V78ACX)_
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.
YES
## What kind of change does this PR introduce?
Chore / CI config fix (Dependabot supply-chain policy).
## What is the current behavior?
Dependabot's `npm`-ecosystem pull requests fail our Vercel preview
builds. `pnpm-workspace.yaml` sets `minimumReleaseAge: 4320` (3 days),
which makes `pnpm install` reject any dependency version published more
recently than 3 days ago as a supply-chain safeguard. Dependabot
proposes the newest available version the moment it's released, so a
Dependabot PR's pinned versions can be — and repeatedly have been —
younger than pnpm's 3-day cutoff at the moment CI first runs.
Concrete example: branch
`dependabot/npm_and_yarn/npm_and_yarn-a6265761c1` (commit `2e988c6`),
the `design-system` Vercel preview build fails because `pnpm install`
rejects `@antfu/install-pkg@2.1.0` and `@types/d3-selection@3.0.12`
under the minimum-release-age policy. This is not a one-off — it recurs
across many Dependabot npm PRs (e.g. #50399, #49060, #49013).
Note: while investigating, I could not find a pre-existing
`package-ecosystem: npm` entry in `.github/dependabot.yml` despite the
repo's long history of grouped `npm_and_yarn` Dependabot PRs — meaning
the previous npm update cadence (grouping across directories such as
`/`, `/apps/studio`, `/e2e/studio`) was apparently running under a
GitHub-managed default rather than an explicit, in-repo config. This PR
makes that configuration explicit so it's actually possible to attach a
`cooldown` to it (see caveats below).
## What is the new behavior?
Added an explicit `package-ecosystem: npm` entry to
`.github/dependabot.yml`, covering the monorepo's workspace directories
(`/`, `/apps/*`, `/packages/*`, `/blocks/*`, `/e2e/*`, matching
`pnpm-workspace.yaml`'s `packages:` globs), with:
```yaml
cooldown:
default-days: 4
```
A `cooldown` tells Dependabot not to propose a version until it has been
out for at least that many days — 4 days here, one day above pnpm's
3-day `minimumReleaseAge` gate to leave margin for scheduling/CI
latency. This means Dependabot's proposals are now aligned with pnpm's
acceptance window: by the time a PR is opened and CI runs, the version
has already cleared the age check, so `pnpm install` no longer rejects
it.
This fixes the root scheduling mismatch (Dependabot proposes instantly,
pnpm requires 3 days of age) rather than weakening the supply-chain
check itself — `minimumReleaseAge` and `minimumReleaseAgeExclude` in
`pnpm-workspace.yaml` are unchanged.
This is a pure CI/dependency-tooling config change with no dependency
version bumps, so `pnpm-lock.yaml` did not need to be regenerated.
## Validation
- YAML syntax: parsed `.github/dependabot.yml` with `yaml.safe_load` —
valid.
- pnpm accepts the config: ran `pnpm install --lockfile-only --filter
design-system...` (the project named in the failing example) from a
clean checkout; it printed `Verifying lockfile against supply-chain
policies (3424 entries)... Lockfile passes supply-chain policies`,
confirming `pnpm-workspace.yaml`'s
`minimumReleaseAge`/`minimumReleaseAgeExclude` config (untouched by this
PR) still parses and behaves correctly.
- Did not attempt a full monorepo `pnpm install`/lockfile regen: not
needed since no dependency versions changed, and a full workspace
install is separately blocked in this environment by an unrelated
`npm.jsr.io` 403 on `apps/studio`'s `@jsr/std__path` dependency.
- Could not validate the new `directories` (plural/glob)
`dependabot.yml` field against GitHub's live Dependabot config validator
from this sandbox (no network path to it); it is a documented, GA
`dependabot.yml` option, but worth a maintainer double-checking the
"Insights > Dependency graph > Dependabot" config validation tab once
this PR is open.
## Additional context
Caveat for a maintainer with org-admin visibility: since no npm entry
existed in version control before this PR, it's worth confirming there
isn't a separate, org/enterprise-level Dependabot configuration also
managing npm updates for this repo (which could now run alongside this
new repo-level entry). If one exists, this repo-level entry should take
precedence per GitHub's documented behavior, but it's worth a quick
check in org Settings, Code security, to rule out duplicate/conflicting
scheduling.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BGhzwT3p6k35oEBJa8ivgH
Co-authored-by: Claude
---
.github/dependabot.yml | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index edf2b0976ec..ec6ffff5911 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -6,3 +6,22 @@ updates:
interval: 'weekly'
cooldown:
default-days: 7
+ # `pnpm-workspace.yaml`'s `minimumReleaseAge: 4320` (3 days) rejects any
+ # dependency version younger than 3 days old during `pnpm install`. Without
+ # a cooldown, Dependabot proposes the newest release the moment it's
+ # published, so its PRs are structurally guaranteed to fail CI/Vercel until
+ # the proposed version happens to age past the pnpm gate on its own. This
+ # cooldown holds Dependabot's proposals back until they've already cleared
+ # (with a one-day margin for scheduling/CI latency) pnpm's minimum release
+ # age, so the version pnpm sees is always old enough to be accepted.
+ - package-ecosystem: 'npm'
+ directories:
+ - '/'
+ - '/apps/*'
+ - '/packages/*'
+ - '/blocks/*'
+ - '/e2e/*'
+ schedule:
+ interval: 'weekly'
+ cooldown:
+ default-days: 4
From 7ab2a0d84f7a0f326b479d3707c6750f7b022dee Mon Sep 17 00:00:00 2001
From: Gildas Garcia <1122076+djhi@users.noreply.github.com>
Date: Wed, 16 Sep 2026 10:24:17 +0200
Subject: [PATCH 080/107] Fix `TextConfirmModal` does not reset its state
(#50406)
## Problem
`TextConfirmModal` does not reset its state after closing, whether users
confirmed or not. If they would restart the action, the confirmation
text they may have entered is kept, preventing the secure confirmation.
Also fixed an accessibility issue as we didn't enable the submit button
until the form was valid
## Solution
Reset the form state whenever the dialog opens.
## How to test
- On
https://studio-staging-git-gildasgarcia-design-505-rese-196b28-supabase.vercel.app/dashboard/account/security
- Either:
- Add an MFA if you haven't already
- Generate recovery codes if you already have an MFA
- Click the _Regenerate recovery codes_
## Summary by CodeRabbit
- **Improvements**
- Text confirmation dialogs now reset their input whenever opened or
closed, ensuring a fresh form for each use.
- Confirmation actions remain available unless the dialog is processing
a submission.
- Copy-to-clipboard actions now provide an accessible announcement when
text has been copied.
- **Tests**
- Added coverage for successful confirmation, cancellation, invalid
submissions, input reset behavior, and recovery-code regeneration
retries.
---
.../RegenerateRecoveryCodesModal.test.tsx | 8 +-
.../src/Dialogs/TextConfirmModal.test.tsx | 108 ++++++++++++++++++
.../src/Dialogs/TextConfirmModal.tsx | 42 ++++---
3 files changed, 142 insertions(+), 16 deletions(-)
create mode 100644 packages/ui-patterns/src/Dialogs/TextConfirmModal.test.tsx
diff --git a/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx
index aab69bb6209..bc12a9f8181 100644
--- a/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx
+++ b/apps/studio/components/interfaces/Account/TOTPFactors/RegenerateRecoveryCodesModal.test.tsx
@@ -103,7 +103,13 @@ describe('RegenerateRecoveryCodesModal', () => {
// Retry
fireEvent.click(await screen.findByRole('button', { name: 'Regenerate my recovery codes' }))
-
+ // Confirm regeneration
+ await userEvent.type(
+ await within(await screen.findByRole('dialog')).findByRole('textbox', {
+ name: /Type REGENERATE to confirm./,
+ }),
+ 'REGENERATE'
+ )
fireEvent.click(
await within(await screen.findByRole('dialog')).findByRole('button', { name: 'Regenerate' })
)
diff --git a/packages/ui-patterns/src/Dialogs/TextConfirmModal.test.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.test.tsx
new file mode 100644
index 00000000000..54ab669893d
--- /dev/null
+++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.test.tsx
@@ -0,0 +1,108 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+
+import TextConfirmModal from './TextConfirmModal'
+
+describe('TextConfirmModal', () => {
+ it('requires users to enter the confirmation text', async () => {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Submit' }))
+ await screen.findByText('Value entered does not match')
+ expect(onConfirm).not.toHaveBeenCalled()
+ })
+ it('calls the confirm callback when users submit the form successfully', async () => {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+ render(
+
+ )
+
+ await userEvent.type(await screen.findByRole('textbox'), 'CONFIRM')
+ fireEvent.click(await screen.findByRole('button', { name: 'Submit' }))
+ await waitFor(() => expect(onConfirm).toHaveBeenCalled())
+ })
+ it('calls the confirm callback when users cancel', async () => {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+ render(
+
+ )
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Close' }))
+ await waitFor(() => expect(onCancel).toHaveBeenCalled())
+ })
+ it('resets its state when closed', async () => {
+ const onConfirm = vi.fn()
+ const onCancel = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ await userEvent.type(await screen.findByRole('textbox'), 'CONFIRM')
+
+ // Close the dialog
+ rerender(
+
+ )
+
+ // Reopen the dialog
+ rerender(
+
+ )
+ expect(await screen.findByRole('textbox')).toHaveValue('')
+ })
+})
diff --git a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
index 250021f5538..2243999342e 100644
--- a/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
+++ b/packages/ui-patterns/src/Dialogs/TextConfirmModal.tsx
@@ -109,8 +109,6 @@ export const TextConfirmModal = forwardRef<
},
})
- const isFormValid = form.formState.isValid
-
// 2. Define a submit handler.
function onSubmit(_values: z.infer) {
// Do something with the form values.
@@ -122,6 +120,10 @@ export const TextConfirmModal = forwardRef<
if (confirmString) form.reset()
}, [confirmString])
+ useEffect(() => {
+ if (visible) form.reset()
+ }, [visible])
+
useEffect(() => {
if (!showCopied) return
const timer = setTimeout(() => setShowCopied(false), 2000)
@@ -137,6 +139,7 @@ export const TextConfirmModal = forwardRef<
{...props}
onOpenChange={() => {
if (visible) {
+ form.reset()
onCancel()
}
}}
@@ -183,18 +186,27 @@ export const TextConfirmModal = forwardRef<
Type{' '}
{enableCopy ? (
- :
- }
- onClick={() => {
- setShowCopied(true)
- copyToClipboard(confirmString)
- }}
- >
- {confirmString}
-
+ <>
+
+ ) : (
+
+ )
+ }
+ onClick={() => {
+ setShowCopied(true)
+ copyToClipboard(confirmString)
+ }}
+ >
+ {confirmString}
+
+
+ {showCopied ? `${confirmString} copied to clipboard` : ''}
+
+ >
) : (
{confirmString}
@@ -233,7 +245,7 @@ export const TextConfirmModal = forwardRef<
}
type="submit"
loading={loading}
- disabled={!isFormValid || loading}
+ disabled={loading}
className="truncate"
>
{confirmLabel}
From 881a7d3151f749510618e52f76c1f5707fe49a21 Mon Sep 17 00:00:00 2001
From: Ali Waseem
Date: Wed, 16 Sep 2026 02:33:20 -0600
Subject: [PATCH 081/107] fix(studio): show only the disabled reason on the
invite members button (#50426)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The invite members button sits inside two Radix tooltip roots — the
keyboard-shortcut tooltip and the disabled-reason tooltip — which both
anchor to the same element and stack on top of each other when the user
lacks invite permission.
Widened the existing `tooltipOpen` condition so the shortcut tooltip
stays closed whenever a disabled reason is showing, and hoisted that
reason into one variable so the tooltip text and the suppression
condition can't drift.
Fixes FE-4393
## Summary by CodeRabbit
* **Bug Fixes**
* Disabled member invitations now prevent the invite button and keyboard
shortcut from opening the invite dialog.
* Invite controls display the appropriate disabled-feature or permission
warning.
* Shortcut tooltips are hidden when invitations are unavailable or the
user lacks permission.
* **Tests**
* Added coverage for disabled invitations, permission warnings, dialog
prevention, and shortcut tooltip visibility.
Fixes: https://github.com/supabase/supabase/issues/49859
---
.../TeamSettings/InviteMemberButton.tsx | 22 ++++----
.../TeamSettings/InviteMemberButton.test.tsx | 53 +++++++++++++++++--
2 files changed, 60 insertions(+), 15 deletions(-)
diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
index a53bd1fb260..516b9ed8a26 100644
--- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
+++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx
@@ -125,6 +125,13 @@ export const InviteMemberButton = () => {
)
)
+ const inviteDisabledReason = !organizationMembersCreationEnabled
+ ? 'Inviting members is currently disabled'
+ : !canInviteMembers
+ ? 'You need additional permissions to invite members to this organization'
+ : undefined
+ const isInviteDisabled = inviteDisabledReason !== undefined
+
const { mutateAsync: inviteMemberAsync, isPending: isInviting } =
useOrganizationCreateInvitationMutation()
@@ -273,26 +280,21 @@ export const InviteMemberButton = () => {
{
- if (canInviteMembers) setIsOpen(true)
- }}
+ onTrigger={() => setIsOpen(true)}
+ options={{ enabled: !isInviteDisabled }}
side="bottom"
- tooltipOpen={isOpen ? false : undefined}
+ tooltipOpen={isOpen || isInviteDisabled ? false : undefined}
>
}
className="pointer-events-auto grow md:grow-0"
onClick={() => setIsOpen(true)}
tooltip={{
content: {
side: 'bottom',
- text: !organizationMembersCreationEnabled
- ? 'Inviting members is currently disabled'
- : !canInviteMembers
- ? 'You need additional permissions to invite members to this organization'
- : undefined,
+ text: inviteDisabledReason,
},
}}
>
diff --git a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx
index 8aafc5b1259..0efc0e0a1d3 100644
--- a/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx
+++ b/apps/studio/tests/components/Organization/TeamSettings/InviteMemberButton.test.tsx
@@ -32,7 +32,7 @@ vi.mock('@/hooks/misc/useCheckPermissions', () => ({
}))
vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({
- useIsFeatureEnabled: () => ({ organizationMembersCreate: true }),
+ useIsFeatureEnabled: mockIsFeatureEnabled,
}))
vi.mock('@/data/organizations/organization-members-query', () => ({
@@ -76,11 +76,13 @@ vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
useCheckEntitlements: () => ({ hasAccess: false }),
}))
+const { mockRolesManagementPermissions, mockIsFeatureEnabled } = vi.hoisted(() => ({
+ mockRolesManagementPermissions: vi.fn(),
+ mockIsFeatureEnabled: vi.fn(),
+}))
+
vi.mock('@/components/interfaces/Organization/TeamSettings/TeamSettings.utils', () => ({
- useGetRolesManagementPermissions: () => ({
- rolesAddable: [1, 2, 3, 4],
- rolesRemovable: [1, 2, 3, 4],
- }),
+ useGetRolesManagementPermissions: mockRolesManagementPermissions,
}))
const mockInvite = vi.fn().mockResolvedValue({ succeeded: [], failed: [] })
@@ -122,6 +124,47 @@ async function submitForm(emailValue: string) {
describe('InviteMemberButton', () => {
beforeEach(() => {
mockInvite.mockResolvedValue({ succeeded: [], failed: [] })
+ mockRolesManagementPermissions.mockReturnValue({
+ rolesAddable: [1, 2, 3, 4],
+ rolesRemovable: [1, 2, 3, 4],
+ })
+ mockIsFeatureEnabled.mockReturnValue({ organizationMembersCreate: true })
+ })
+
+ it('disables the button when member creation is turned off despite sufficient permissions', async () => {
+ mockIsFeatureEnabled.mockReturnValue({ organizationMembersCreate: false })
+ customRender( )
+
+ const button = screen.getByRole('button', { name: /invite members/i })
+ expect(button).toHaveAttribute('aria-disabled', 'true')
+
+ await userEvent.click(button)
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+
+ await userEvent.hover(button)
+ expect(await screen.findAllByText('Inviting members is currently disabled')).not.toHaveLength(0)
+ expect(screen.queryAllByText(/shift|⇧/i)).toHaveLength(0)
+ })
+
+ describe('when the user cannot invite members', () => {
+ beforeEach(() => {
+ mockRolesManagementPermissions.mockReturnValue({ rolesAddable: [], rolesRemovable: [] })
+ })
+
+ it('shows only the permission warning, not the shortcut tooltip', async () => {
+ customRender( )
+ const button = screen.getByRole('button', { name: /invite members/i })
+ expect(button).toHaveAttribute('aria-disabled', 'true')
+
+ await userEvent.hover(button)
+
+ expect(
+ await screen.findAllByText(
+ 'You need additional permissions to invite members to this organization'
+ )
+ ).not.toHaveLength(0)
+ expect(screen.queryAllByText(/shift|⇧/i)).toHaveLength(0)
+ })
})
it('renders an enabled Invite members button', () => {
From 32b1a0f4920ec3472a5bb991dcf130dc950e331d Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Wed, 16 Sep 2026 16:33:36 +0800
Subject: [PATCH 082/107] FilterBar to disable filter options if already
selected (#50331)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Context
Updates FilterBar UI component to prevent selecting a filter option
that's already selected + adds a check:
## Summary by CodeRabbit
* **New Features**
* Filter menus now identify values already used in another condition for
the same property.
* Duplicate values are shown as disabled and cannot be selected.
* Optional selection indicators can be displayed in filter menus.
* Disabled options include clear visual styling and accessibility
information.
* **Bug Fixes**
* Prevented disabled filter values from being selected through mouse
interactions or keyboard navigation.
* The active condition’s own value remains available for selection.
---
.../src/FilterBar/CommandListItem.tsx | 22 +++++++--
.../src/FilterBar/DefaultCommandList.tsx | 3 ++
.../src/FilterBar/FilterCondition.tsx | 8 ++--
.../src/FilterBar/menuItems.test.ts | 25 ++++++++++
.../ui-patterns/src/FilterBar/menuItems.ts | 48 ++++++++++++++-----
packages/ui-patterns/src/FilterBar/types.ts | 1 +
packages/ui-patterns/src/FilterBar/utils.ts | 12 +++++
7 files changed, 100 insertions(+), 19 deletions(-)
diff --git a/packages/ui-patterns/src/FilterBar/CommandListItem.tsx b/packages/ui-patterns/src/FilterBar/CommandListItem.tsx
index 774d92e5ae4..e0fd1199a5b 100644
--- a/packages/ui-patterns/src/FilterBar/CommandListItem.tsx
+++ b/packages/ui-patterns/src/FilterBar/CommandListItem.tsx
@@ -1,3 +1,4 @@
+import { Check } from 'lucide-react'
import { cn } from 'ui'
import { OperatorSymbolBadge } from './OperatorSymbolBadge'
@@ -8,6 +9,7 @@ export type CommandListItemProps = {
item: MenuItem
isHighlighted: boolean
includeIcon: boolean
+ showSelection?: boolean
onSelect: (item: MenuItem) => void
setRef: (el: HTMLDivElement | null) => void
}
@@ -16,6 +18,7 @@ export function CommandListItem({
item,
isHighlighted,
includeIcon,
+ showSelection = false,
onSelect,
setRef,
}: CommandListItemProps) {
@@ -23,15 +26,28 @@ export function CommandListItem({
onSelect(item)}
+ aria-disabled={item.disabled}
+ onClick={() => {
+ if (!item.disabled) onSelect(item)
+ }}
className={cn(
- 'relative flex items-center justify-between gap-2 px-2 h-[28px] text-xs cursor-pointer select-none outline-hidden text-foreground',
+ 'relative flex items-center justify-between gap-2 px-2 h-[28px] text-xs select-none outline-hidden',
+ item.disabled ? 'text-muted' : 'text-foreground cursor-pointer',
isHighlighted && 'bg-overlay-hover',
- !isHighlighted && 'hover:bg-surface-200'
+ !isHighlighted && !item.disabled && 'hover:bg-surface-200'
)}
data-testid={`filter-menu-item-${item.value}`}
>
+ {showSelection && (
+ <>
+ {item.disabled ? (
+
+ ) : (
+
+ )}
+ >
+ )}
{includeIcon && item.icon}
{getActionItemLabel(item)}
diff --git a/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx b/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx
index 761019dfc25..a84e996a17a 100644
--- a/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx
+++ b/packages/ui-patterns/src/FilterBar/DefaultCommandList.tsx
@@ -12,6 +12,7 @@ export type DefaultCommandListProps = {
highlightedIndex: number
onSelect: (item: MenuItem) => void
includeIcon?: boolean
+ showSelection?: boolean
grouped?: boolean
}
@@ -20,6 +21,7 @@ export function DefaultCommandList({
highlightedIndex,
onSelect,
includeIcon = true,
+ showSelection = false,
grouped = false,
}: DefaultCommandListProps) {
const listRef = useRef
(null)
@@ -73,6 +75,7 @@ export function DefaultCommandList({
)}
{groupData.items.map(({ item, index }) => (
{
const item = valueItems[index]
- if (!item) return
+ if (!item || item.disabled) return
if (item.isCustom) {
setShowValueCustom(true)
} else {
@@ -421,11 +421,13 @@ export function FilterCondition({
})
) : (
+ onSelect={(item) => {
+ if (item.disabled) return
item.isCustom ? setShowValueCustom(true) : handleSelectMenuItem(item)
- }
+ }}
includeIcon
/>
)}
diff --git a/packages/ui-patterns/src/FilterBar/menuItems.test.ts b/packages/ui-patterns/src/FilterBar/menuItems.test.ts
index 52c1a95b984..ba5358c7052 100644
--- a/packages/ui-patterns/src/FilterBar/menuItems.test.ts
+++ b/packages/ui-patterns/src/FilterBar/menuItems.test.ts
@@ -161,6 +161,31 @@ describe('buildValueItems', () => {
])
})
+ it('disables options already used by another condition on the same property', () => {
+ const filters: FilterGroup = {
+ logicalOperator: 'AND',
+ conditions: [
+ { propertyName: 'name', operator: '=', value: 'alice' },
+ { propertyName: 'name', operator: '=', value: '' },
+ ],
+ }
+
+ const items = buildValueItems(
+ { type: 'value', path: [1] },
+ filters,
+ filterProperties,
+ {},
+ {},
+ '',
+ false
+ )
+
+ expect(items).toEqual([
+ { value: 'alice', label: 'Alice', disabled: true },
+ { value: 'bob', label: 'Bob' },
+ ])
+ })
+
it.each(['~~*', '!~~*'])(
'suppresses value suggestions for pattern-group operator `%s`',
(operator) => {
diff --git a/packages/ui-patterns/src/FilterBar/menuItems.ts b/packages/ui-patterns/src/FilterBar/menuItems.ts
index 8ed83c386d2..3031b95e00a 100644
--- a/packages/ui-patterns/src/FilterBar/menuItems.ts
+++ b/packages/ui-patterns/src/FilterBar/menuItems.ts
@@ -1,9 +1,11 @@
import { ActiveInputState, FilterBarAction, FilterGroup, FilterProperty, MenuItem } from './types'
import {
+ collectConditions,
findConditionByPath,
isCustomOptionObject,
isFilterOperatorObject,
isFilterOptionObject,
+ pathsEqual,
} from './utils'
export function buildOperatorItems(
@@ -142,8 +144,16 @@ export function buildValueItems(
if (!property) return items
+ // Values already used by other conditions on this same property shouldn't be re-selectable.
+ const usedValues = new Set(
+ collectConditions(activeFilters)
+ .filter(({ path }) => !pathsEqual(path, activeInput.path))
+ .filter(({ condition }) => condition.propertyName === activeCondition?.propertyName)
+ .map(({ condition }) => String(condition.value))
+ )
+
if (activeCondition?.operator === 'is') {
- return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus)
+ return getIsOperatorValueItems(property, inputValue, hasTypedSinceFocus, usedValues)
}
// Pattern-matching operators (e.g. iLike) search for a substring, not an exact
@@ -174,10 +184,11 @@ export function buildValueItems(
inputValue,
hasTypedSinceFocus,
showCount: activeCondition?.operator === '=',
+ usedValues,
})
)
} else if (propertyOptionsCache[property.name]) {
- items.push(...getCachedOptionItems(propertyOptionsCache[property.name].options))
+ items.push(...getCachedOptionItems(propertyOptionsCache[property.name].options, usedValues))
}
return items
@@ -188,11 +199,13 @@ function getArrayOptionItems({
inputValue,
hasTypedSinceFocus,
showCount,
+ usedValues,
}: {
options: any[]
inputValue: string
hasTypedSinceFocus: boolean
showCount?: boolean
+ usedValues?: Set
}): MenuItem[] {
const items: MenuItem[] = []
const normalizedInput = inputValue.toLowerCase()
@@ -203,7 +216,7 @@ function getArrayOptionItems({
for (const option of options) {
if (typeof option === 'string') {
if (!shouldFilter || option.toLowerCase().includes(normalizedInput)) {
- items.push({ value: option, label: option })
+ items.push({ value: option, label: option, disabled: usedValues?.has(option) || undefined })
}
} else if (isFilterOptionObject(option)) {
if (!shouldFilter || option.label.toLowerCase().includes(normalizedInput)) {
@@ -211,6 +224,7 @@ function getArrayOptionItems({
value: option.value,
label: option.label,
count: showCount ? option.count : undefined,
+ disabled: usedValues?.has(option.value) || undefined,
})
}
} else if (isCustomOptionObject(option)) {
@@ -227,19 +241,24 @@ function getArrayOptionItems({
return items
}
-function getCachedOptionItems(options: any[]): MenuItem[] {
+function getCachedOptionItems(options: any[], usedValues?: Set): MenuItem[] {
return options.map((option) => {
if (typeof option === 'string') {
- return { value: option, label: option }
+ return { value: option, label: option, disabled: usedValues?.has(option) || undefined }
+ }
+ return {
+ value: option.value,
+ label: option.label,
+ disabled: usedValues?.has(option.value) || undefined,
}
- return { value: option.value, label: option.label }
})
}
function getIsOperatorValueItems(
property: FilterProperty,
inputValue: string,
- hasTypedSinceFocus: boolean
+ hasTypedSinceFocus: boolean,
+ usedValues?: Set
): MenuItem[] {
const options: { value: string; label: string }[] = [
{ value: 'null', label: 'NULL' },
@@ -251,11 +270,14 @@ function getIsOperatorValueItems(
}
const shouldFilter = hasTypedSinceFocus && inputValue.length > 0
- if (!shouldFilter) return options
+ const filtered = shouldFilter
+ ? options.filter((opt) => {
+ const normalizedInput = inputValue.toLowerCase()
+ return (
+ opt.label.toLowerCase().includes(normalizedInput) || opt.value.includes(normalizedInput)
+ )
+ })
+ : options
- const normalizedInput = inputValue.toLowerCase()
- return options.filter(
- (opt) =>
- opt.label.toLowerCase().includes(normalizedInput) || opt.value.includes(normalizedInput)
- )
+ return filtered.map((opt) => ({ ...opt, disabled: usedValues?.has(opt.value) || undefined }))
}
diff --git a/packages/ui-patterns/src/FilterBar/types.ts b/packages/ui-patterns/src/FilterBar/types.ts
index 5a1a29b3216..af18893e0eb 100644
--- a/packages/ui-patterns/src/FilterBar/types.ts
+++ b/packages/ui-patterns/src/FilterBar/types.ts
@@ -104,6 +104,7 @@ export type MenuItem = {
isFreeformSearch?: boolean
freeformPropertyName?: string
freeformValue?: string
+ disabled?: boolean
}
export type GroupedMenuItem = {
diff --git a/packages/ui-patterns/src/FilterBar/utils.ts b/packages/ui-patterns/src/FilterBar/utils.ts
index c89a39e846b..be7e9769396 100644
--- a/packages/ui-patterns/src/FilterBar/utils.ts
+++ b/packages/ui-patterns/src/FilterBar/utils.ts
@@ -56,6 +56,18 @@ export function findConditionByPath(group: FilterGroup, path: number[]): FilterC
return null
}
+export function collectConditions(
+ group: FilterGroup,
+ path: number[] = []
+): { condition: FilterCondition; path: number[] }[] {
+ return group.conditions.flatMap((condition, index) => {
+ const currentPath = [...path, index]
+ return isGroup(condition)
+ ? collectConditions(condition, currentPath)
+ : [{ condition, path: currentPath }]
+ })
+}
+
export function isCustomOptionObject(option: any): option is CustomOptionObject {
return typeof option === 'object' && option !== null && 'component' in option
}
From ee3fbc4e61e26d2ce590d0e23102ef72cc4b8c11 Mon Sep 17 00:00:00 2001
From: Joshen Lim
Date: Wed, 16 Sep 2026 16:35:26 +0800
Subject: [PATCH 083/107] Joshenlim/fe 4383 consolidate tablerow no search
result state (#50389)
### Context
Just some housekeeping/consolidate refactors. There's a number of places
where we render the same "no result" empty state for tables. So this PR
just consolidates that into a reusable component `TableRowNoResults` to
reduce duplication.
Opting to save this under `components/ui` instead of the `ui` package as
this is more of a derivation of `TableRow` than a primitive
## Summary by CodeRabbit
- **UI Improvements**
- Standardized empty search-result messages across database, functions,
storage, and vector bucket tables.
- Search terms now appear consistently when no matching records are
found.
- Added an accessible label to the vector bucket row actions menu.
- Improved the storage explorer loading layout so content expands to use
available vertical space.
---
.../EnumeratedTypes/EnumeratedTypes.tsx | 10 ++----
.../Functions/FunctionsList/FunctionList.tsx | 12 ++-----
.../interfaces/Database/Indexes/Indexes.tsx | 10 ++----
.../Database/Migrations/Migrations.tsx | 10 ++----
.../Database/Replication/Destinations.tsx | 13 ++-----
.../interfaces/Database/Tables/TableList.tsx | 10 ++----
.../EventTriggersList/EventTriggerList.tsx | 27 ++------------
.../Triggers/TriggersList/TriggerList.tsx | 12 ++-----
.../EdgeFunctionSecrets.tsx | 14 ++++----
.../Storage/AnalyticsBuckets/index.tsx | 14 ++++----
.../BucketsPickerDialog/BucketTableRow.tsx | 21 -----------
.../BucketsPickerDialog/BucketsTable.tsx | 15 ++++++--
.../Storage/FilesBuckets/BucketTable.tsx | 30 +++++++++-------
.../Storage/FilesBuckets/BucketsTable.tsx | 23 ++++++++++--
.../StorageExplorer/FileExplorerColumn.tsx | 2 +-
.../VectorBucketDetails/index.tsx | 35 +++++++++----------
.../Storage/VectorBuckets/index.tsx | 14 ++++----
.../components/ui/TableRowNoResults.tsx | 30 ++++++++++++++++
.../pages/project/[ref]/functions/index.tsx | 16 ++++-----
19 files changed, 137 insertions(+), 181 deletions(-)
create mode 100644 apps/studio/components/ui/TableRowNoResults.tsx
diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx
index b0054b670d9..07abc181f0f 100644
--- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx
+++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx
@@ -30,6 +30,7 @@ import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { SchemaSelector } from '@/components/ui/SchemaSelector'
import { Shortcut } from '@/components/ui/Shortcut'
+import { TableRowNoResults } from '@/components/ui/TableRowNoResults'
import { useEnumeratedTypeDeleteMutation } from '@/data/enumerated-types/enumerated-type-delete-mutation'
import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
@@ -217,14 +218,7 @@ export const EnumeratedTypes = () => {
)}
{filteredEnumeratedTypes.length === 0 && search.length > 0 && (
-
-
- No results found
-
- Your search for "{search}" did not return any results
-
-
-
+
)}
{filteredEnumeratedTypes.length > 0 &&
filteredEnumeratedTypes.map((type) => (
diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
index bbecee321e8..37b98390db8 100644
--- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
+++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx
@@ -24,6 +24,7 @@ import { stripInArgModePrefixes } from '../Functions.utils'
import { getDatabaseTriggersHref, getFilteredFunctions } from './FunctionList.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
+import { TableRowNoResults } from '@/components/ui/TableRowNoResults'
import {
useDatabaseFunctionsQuery,
type SavedDatabaseFunction,
@@ -91,16 +92,7 @@ export const FunctionList = ({
}
if (_functions.length === 0 && filterString.length > 0) {
- return (
-
-
- No results found
-
- Your search for "{filterString}" did not return any results
-
-
-
- )
+ return
}
return (
diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx
index ca4e967ed42..75a971a5110 100644
--- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx
+++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx
@@ -28,6 +28,7 @@ import { AlertError } from '@/components/ui/AlertError'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { SchemaSelector } from '@/components/ui/SchemaSelector'
import { Shortcut } from '@/components/ui/Shortcut'
+import { TableRowNoResults } from '@/components/ui/TableRowNoResults'
import { useDatabaseIndexDeleteMutation } from '@/data/database-indexes/index-delete-mutation'
import { useIndexesQuery, type DatabaseIndex } from '@/data/database-indexes/indexes-query'
import { useSchemasQuery } from '@/data/database/schemas-query'
@@ -240,14 +241,7 @@ export const Indexes = () => {
)}
{indexes.length === 0 && search.length > 0 && (
-
-
- No results found
-
- Your search for "{search}" did not return any results
-
-
-
+
)}
{indexes.length > 0 &&
indexes.map((index) => (
diff --git a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx
index 528ea2b7484..f5b627e7449 100644
--- a/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx
+++ b/apps/studio/components/interfaces/Database/Migrations/Migrations.tsx
@@ -25,6 +25,7 @@ import { MigrationsEmptyState } from './MigrationsEmptyState'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
import { InlineLink } from '@/components/ui/InlineLink'
+import { TableRowNoResults } from '@/components/ui/TableRowNoResults'
import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
@@ -184,14 +185,7 @@ export const Migrations = () => {
)
})
) : (
-
-
- No results found
-
- Your search for "{search}" did not return any results
-
-
-
+
)}