diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index 13644b1c7eacd..e41b2b124dde4 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -232,6 +232,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp - [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx` - [x] A `routes/project/$ref/workers/$name.tsx` ← `pages/project/[ref]/workers/[name].tsx` +- [x] A `routes/project/$ref/workers/secrets.tsx` ← `pages/project/[ref]/workers/secrets.tsx` ### Project shell — `/functions/*` diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index 44280555658ad..cd5666b0596f7 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -1,16 +1,15 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react' +import { BookOpen, Loader2, Send } from 'lucide-react' import { useState } from 'react' -import { useFieldArray, useForm, useWatch } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { Badge, Button, Form, FormControl, FormField, - Input, Label, ResizableHandle, ResizablePanel, @@ -33,27 +32,21 @@ import { } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { KeyValueFieldArray } from 'ui-patterns/form/KeyValueFieldArray/KeyValueFieldArray' import * as z from 'zod' import { HTTP_METHODS } from './EdgeFunctionDetails.constants' import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types' import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils' -import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover' +import { buildEdgeFunctionTestHeaders } from './EdgeFunctionTesterSheet.utils' +import { buildEdgeFunctionHeaderAddActions } from '@/components/interfaces/Functions/httpHeaderAddActions' import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' import { useAPIKeys } from '@/data/api-keys/api-keys-query' -import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' -import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' import { useEdgeFunctionTestMutation } from '@/data/edge-functions/edge-function-test-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { IS_PLATFORM } from '@/lib/constants' import { prettifyJSON } from '@/lib/helpers' -import { getRoleImpersonationJWT } from '@/lib/role-impersonation' import { useTrack } from '@/lib/telemetry/track' -import { - RoleImpersonationStateContextProvider, - useGetImpersonatedRoleState, -} from '@/state/role-impersonation-state' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' @@ -84,31 +77,33 @@ const FormSchema = z.object({ type FormValues = z.infer -export const EdgeFunctionTesterSheet = (props: EdgeFunctionTesterSheetProps) => { - const { ref: projectRef } = useParams() - - // [Alaister]: We're using a fresh context here as edge functions don't allow impersonating users. - return ( - - - - ) -} - -const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { +export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => { const { ref: projectRef, functionSlug } = useParams() - const getImpersonatedRoleState = useGetImpersonatedRoleState() const [response, setResponse] = useState(null) const [error, setError] = useState(null) const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*') - const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys }) - const { serviceKey } = apiKeysData ?? {} - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: apiKeysData } = useAPIKeys( + { projectRef, reveal: true }, + { enabled: canReadAPIKeys } + ) + const { anonKey, publishableKey, secretKey, serviceKey } = apiKeysData ?? {} const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + + // Sent on the `apikey` header. Defaults to the least privileged key available, matching what the + // function details page shows in its example snippets. + const clientApiKey = publishableKey?.api_key ?? anonKey?.api_key + const secretApiKey = secretKey?.api_key ?? serviceKey?.api_key + + // Both keys are offered so the user can swap the request's credential without looking one up. + // The webhook specific action the helper also builds is not relevant here. + const headerAddActions = buildEdgeFunctionHeaderAddActions({ + apiKey: secretApiKey ?? '[YOUR API KEY]', + publishableKey: clientApiKey, + createRow: (key: string, value: string) => ({ key, value }), + }).filter(({ key }) => key !== 'add-source-header') const track = useTrack() const { mutate: testEdgeFunction, isPending } = useEdgeFunctionTestMutation({ @@ -141,40 +136,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester }) const method = useWatch({ control: form.control, name: 'method' }) - const { - fields: headerFields, - append: appendHeader, - remove: removeHeader, - } = useFieldArray({ - control: form.control, - name: 'headers', - }) - - const { - fields: queryParamFields, - append: appendQueryParam, - remove: removeQueryParam, - } = useFieldArray({ - control: form.control, - name: 'queryParams', - }) - - const addKeyValuePair = (type: 'headers' | 'queryParams') => { - if (type === 'headers') { - appendHeader({ key: '', value: '' }) - } else { - appendQueryParam({ key: '', value: '' }) - } - } - - const removeKeyValuePair = (index: number, type: 'headers' | 'queryParams') => { - if (type === 'headers') { - removeHeader(index) - } else { - removeQueryParam(index) - } - } - useShortcut( SHORTCUT_IDS.FUNCTION_DETAIL_SUBMIT_TEST, () => { @@ -195,34 +156,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester return } - let testAuthorization: string | undefined - const role = getImpersonatedRoleState().role - - if ( - projectRef !== undefined && - config?.jwt_secret !== undefined && - role !== undefined && - role.type === 'postgrest' - ) { - try { - const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role) - testAuthorization = 'Bearer ' + token - } catch (err: any) { - console.error('Failed to generate JWT:', { - error: err.message, - roleDetails: role, - }) - } - } - - // Construct custom headers - const customHeaders: Record = {} - values.headers.forEach(({ key, value }) => { - if (key && value) { - customHeaders[key] = value - } - }) - // Construct query parameters const queryString = values.queryParams .filter(({ key, value }) => key && value) @@ -235,80 +168,13 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester url: finalUrl, method: values.method, body: values.body, - headers: { - ...(accessToken && { - Authorization: `Bearer ${accessToken}`, - }), - 'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`, - 'Content-Type': 'application/json', - ...customHeaders, - }, + headers: buildEdgeFunctionTestHeaders({ + apiKey: clientApiKey, + customHeaders: values.headers, + }), }) } - const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => ( -
-
- - -
-
- {(type === 'headers' ? headerFields : queryParamFields).map((field, index) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- {(type === 'headers' ? headerFields : queryParamFields).length > 1 && ( -
-
- ))} -
-
- ) - return ( )} - {renderKeyValuePairs('headers', 'Headers')} - {renderKeyValuePairs('queryParams', 'Query Parameters')} +
+ + ({ key: '', value: '' })} + keyPlaceholder="Header name" + valuePlaceholder="Header value" + addLabel="Add header" + addActions={headerAddActions} + disabled={isPending} + /> +
+
+ + ({ key: '', value: '' })} + keyPlaceholder="Parameter name" + valuePlaceholder="Parameter value" + addLabel="Add parameter" + disabled={isPending} + /> +
@@ -484,10 +377,6 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
- +
- - - - - )} + )} + { const hasChannel = realtimeConfig.channelName.length > 0 const isListening = realtimeConfig.enabled + // Once a channel is set, MessagesTable renders its own empty states (including + // the "Broadcast a message" entry point), so sending doesn't depend on a + // message having arrived first. EmptyRealtime is only the pre-channel onboarding. + const showMessagesTable = hasChannel || (logData ?? []).length > 0 + const handleJoinChannel = useCallback(() => { if (!hasChannel) { setChannelPopoverOpen(true) @@ -98,7 +103,7 @@ export const RealtimeInspector = () => { />
- {(logData ?? []).length > 0 ? ( + {showMessagesTable ? ( { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + } +}) + +const PROFILE_CONTEXT: ProfileContextType = { + profile: { + id: 1, + auth0_id: 'auth0|test', + gotrue_id: 'gotrue-test', + username: 'testuser', + primary_email: 'test@example.com', + first_name: null, + last_name: null, + mobile: null, + is_alpha_user: false, + is_sso_user: false, + disabled_features: [], + free_project_limit: null, + }, + error: null, + isLoading: false, + isError: false, + isSuccess: true, +} + +const PROJECT: ProjectDetailResponse = { + cloud_provider: 'AWS_K8S', + connectionString: 'postgresql://postgres:password@db.default.supabase.co:5432/postgres', + db_host: 'db.default.supabase.co', + dbVersion: 'supabase-postgres-15.1.0', + high_availability: true, + id: 1, + infra_compute_size: 'large', + inserted_at: '2026-01-01T00:00:00.000Z', + integration_source: null, + is_branch_enabled: false, + is_physical_backups_enabled: false, + name: 'Production', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'https://default.supabase.co', + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00.000Z', +} + +const mockProject = (status: ProjectDetailResponse['status']) => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { ...PROJECT, status } satisfies ProjectDetailResponse, + }) +} + +// The /ha-admin passthrough paths are off-schema (see get-ha-admin.ts), so they +// can't go through the OpenAPI-typed addAPIMock. +const mockHaAdmin = ({ isHealthy }: { isHealthy: boolean }) => { + mswServer.use( + http.get(`${API_URL}/platform/projects/:ref/ha-admin/v1/gateways`, () => + isHealthy + ? HttpResponse.json({ gateways: [] }) + : HttpResponse.json({ message: 'upstream unavailable' }, { status: 500 }) + ), + http.get(`${API_URL}/platform/projects/:ref/ha-admin/v1/poolers`, () => + isHealthy + ? HttpResponse.json({ poolers: [] }) + : HttpResponse.json({ message: 'upstream unavailable' }, { status: 500 }) + ) + ) +} + +describe('HaInstanceConfiguration', () => { + test('shows the setup state instead of an error while the project is coming up', async () => { + mockProject('COMING_UP') + mockHaAdmin({ isHealthy: false }) + + customRender(, { profileContext: PROFILE_CONTEXT }) + + const statusRegion = await screen.findByRole('status') + expect(await within(statusRegion).findByText('Setting up project')).toBeInTheDocument() + expect(screen.queryByText('Failed to retrieve cluster topology')).not.toBeInTheDocument() + }) + + test('shows the setup state instead of the unavailable state while the project is coming up with an empty topology', async () => { + mockProject('COMING_UP') + mockHaAdmin({ isHealthy: true }) + + customRender(, { profileContext: PROFILE_CONTEXT }) + + expect(await screen.findByText('Setting up project')).toBeInTheDocument() + expect(screen.queryByText('Cluster topology unavailable')).not.toBeInTheDocument() + }) + + test('surfaces topology errors once the project is running', async () => { + mockProject('ACTIVE_HEALTHY') + mockHaAdmin({ isHealthy: false }) + + customRender(, { profileContext: PROFILE_CONTEXT }) + + expect(await screen.findByText('Failed to retrieve cluster topology')).toBeInTheDocument() + }) + + test('shows the unavailable state for an empty topology once the project is running', async () => { + mockProject('ACTIVE_HEALTHY') + mockHaAdmin({ isHealthy: true }) + + customRender(, { profileContext: PROFILE_CONTEXT }) + + expect(await screen.findByText('Cluster topology unavailable')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx index 0a5c068199827..8c31ccb71eae9 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query' import { useParams } from 'common' import { Loader2 } from 'lucide-react' import { useMemo } from 'react' +import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational' import { DiagramFlow } from './DiagramFlow' import { addShardNodes, generateHaNodesAndEdges } from './HaInstanceConfiguration.utils' @@ -12,6 +13,8 @@ import { AlertError } from '@/components/ui/AlertError' import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { haClusterGatewaysQueryOptions } from '@/data/ha-admin/ha-cluster-gateways-query' import { haClusterPoolersQueryOptions } from '@/data/ha-admin/ha-cluster-poolers-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from '@/lib/constants' const nodeTypes = { HA_GATEWAY: MultigatewayNode, @@ -32,6 +35,10 @@ const POOLER_STATUS_REFRESH_MS = 30_000 */ export const HaInstanceConfiguration = () => { const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const isProjectBuilding = + project?.status === PROJECT_STATUS.COMING_UP || project?.status === PROJECT_STATUS.UNKNOWN // Gateways poll on the same interval as poolers so the gateway count and // gateway→primary edge track cluster changes while the page stays open. @@ -70,11 +77,40 @@ export const HaInstanceConfiguration = () => { const isError = isErrorGateways || isErrorPoolers const error = gatewaysError ?? poolersError - if (isPending) { + // While the project is provisioning, the /ha-admin endpoints fail or return an + // empty topology as a matter of course — that's a project still booting, not an + // unhealthy cluster. + const isProvisioning = isProjectBuilding && (isError || (poolers ?? []).length === 0) + + // The initial load and the provisioning placeholder share one persistent + // role="status" live region so screen readers announce the transition between + // them — a live region only announces updates to content it already contains. + if (isPending || isProvisioning) { return ( -
- Loading cluster topology... -