Add destination
@@ -166,8 +166,8 @@ export function OrgAuditLogDrains() {
}
/>
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx
index 73428c7208e9c..24d693d77ae2d 100644
--- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx
@@ -1,20 +1,19 @@
import { isEqual } from 'lodash'
import { Search, X } from 'lucide-react'
-import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { ReportsSelectFilter } from '../../Reports/v2/ReportsSelectFilter'
import { GroupedActivityRow } from './ActivityRow'
+import { filterActivities } from './DatabaseConnections.utils'
+import { DEFAULT_ROLES_FILTER, useActivityFilters } from './useActivityFilters'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
import { useDatabaseActivityQuery } from '@/data/database/activity-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useTrack } from '@/lib/telemetry/track'
-const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres']
-
interface ActivityProps {
live?: boolean
}
@@ -23,22 +22,16 @@ export const Activity = ({ live }: ActivityProps) => {
const track = useTrack()
const { data: project } = useSelectedProjectQuery()
- const [
- {
+ const {
+ filters: {
search: searchFilter,
states: statesFilter,
applications: applicationsFilter,
roles: rolesFilter,
view: viewFilter,
},
- setQueryStates,
- ] = useQueryStates({
- search: parseAsString.withDefault(''),
- states: parseAsArrayOf(parseAsString, ',').withDefault([]),
- applications: parseAsArrayOf(parseAsString, ',').withDefault([]),
- roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER),
- view: parseAsString.withDefault(''),
- })
+ setFilters: setQueryStates,
+ } = useActivityFilters()
const hasNoFiltersApplied =
searchFilter.length === 0 &&
@@ -66,23 +59,15 @@ export const Activity = ({ live }: ActivityProps) => {
// Pids referenced in some other activity's blocked_by - i.e. they are blocking something
const blockingPids = new Set((data ?? []).flatMap((x) => x.blocked_by))
- const activities = data?.filter((activity) => {
- const matchesState =
- !statesFilter ||
- statesFilter.length === 0 ||
- (activity.state !== null && statesFilter.includes(activity.state))
- const matchesRole = rolesFilter.length === 0 || rolesFilter.includes(activity.role_name)
- const matchesApplication =
- applicationsFilter.length === 0 || applicationsFilter.includes(activity.application_name)
- // In the blocked view, only show root blockers - activities blocking others while not
- // themselves blocked. Everything they block is shown nested under them instead.
- const matchesView =
- viewFilter !== 'blockers' ||
- (activity.blocked_by.length === 0 && blockingPids.has(activity.pid))
- return (
- matchesState && matchesRole && matchesApplication && matchesView && matchesSearch(activity)
- )
- })
+ const activities = data
+ ? filterActivities(data, {
+ search: searchFilter,
+ states: statesFilter,
+ applications: applicationsFilter,
+ roles: rolesFilter,
+ view: viewFilter,
+ })
+ : undefined
const rootBlockers = (data ?? []).filter(
(x) =>
x.blocked_by.length === 0 &&
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx
index a5a85b0478a68..8f40dd2a16545 100644
--- a/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx
@@ -1,6 +1,6 @@
import { ChevronRight, CircleX, Minus, MoreVertical, StopCircle } from 'lucide-react'
-import { parseAsInteger, parseAsString, useQueryState } from 'nuqs'
-import { Fragment, useState } from 'react'
+import { parseAsString, useQueryState } from 'nuqs'
+import { Fragment, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
@@ -42,6 +42,7 @@ import {
getBlockingChain,
getDuration,
} from './DatabaseConnections.utils'
+import { useSelectActivityPid } from './useSelectActivityPid'
import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils'
import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
@@ -110,7 +111,8 @@ export const ActivityRow = ({
const track = useTrack()
const { data: project } = useSelectedProjectQuery()
const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false)
- const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger)
+ const { selectedPid, selectPid } = useSelectActivityPid()
+ const rowRef = useRef
(null)
const { data } = useDatabaseActivityQuery({
projectRef: project?.ref,
@@ -191,9 +193,16 @@ export const ActivityRow = ({
} catch (error) {}
}
+ useEffect(() => {
+ if (selectedPid === activity.pid) {
+ rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ }, [selectedPid, activity.pid])
+
return (
<>
td]:py-3', nested && 'bg-alternative')}
@@ -338,7 +347,7 @@ export const ActivityRow = ({
setSelectedPid(pid)}
+ onClick={() => selectPid(pid)}
>
{pid}
@@ -378,7 +387,7 @@ export const ActivityRow = ({
role="button"
tabIndex={0}
className="cursor-pointer hover:underline"
- onClick={() => setSelectedPid(chainPid)}
+ onClick={() => selectPid(chainPid)}
>
PID: {chainPid}
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts
index bef91c2c0ae35..de93df0362f08 100644
--- a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.test.ts
@@ -1,8 +1,22 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { getBlockChain, getBlockingChain, getConnectionMetrics } from './DatabaseConnections.utils'
+import {
+ filterActivities,
+ getBlockChain,
+ getBlockingChain,
+ getConnectionMetrics,
+ type ActivityFilters,
+} from './DatabaseConnections.utils'
import { type DatabaseActivity } from '@/data/database/activity-query'
+const EMPTY_FILTERS: ActivityFilters = {
+ search: '',
+ states: [],
+ applications: [],
+ roles: [],
+ view: '',
+}
+
const NOW = '2024-01-15T12:00:00Z'
const secondsAgo = (seconds: number) =>
@@ -332,3 +346,86 @@ describe('getBlockingChain', () => {
expect(getBlockingChain(1, activities)).toEqual([2])
})
})
+
+describe('filterActivities', () => {
+ it('returns everything when no filters are applied', () => {
+ const activities = [activity({ pid: 1 }), activity({ pid: 2 })]
+
+ expect(filterActivities(activities, EMPTY_FILTERS)).toHaveLength(2)
+ })
+
+ it('filters by role', () => {
+ const activities = [
+ activity({ pid: 1, role_name: 'anon' }),
+ activity({ pid: 2, role_name: 'postgres' }),
+ ]
+
+ const result = filterActivities(activities, { ...EMPTY_FILTERS, roles: ['anon'] })
+
+ expect(result.map((x) => x.pid)).toEqual([1])
+ })
+
+ it('filters by state', () => {
+ const activities = [activity({ pid: 1, state: 'active' }), activity({ pid: 2, state: 'idle' })]
+
+ const result = filterActivities(activities, { ...EMPTY_FILTERS, states: ['idle'] })
+
+ expect(result.map((x) => x.pid)).toEqual([2])
+ })
+
+ it('filters by application', () => {
+ const activities = [
+ activity({ pid: 1, application_name: 'studio' }),
+ activity({ pid: 2, application_name: 'psql' }),
+ ]
+
+ const result = filterActivities(activities, { ...EMPTY_FILTERS, applications: ['psql'] })
+
+ expect(result.map((x) => x.pid)).toEqual([2])
+ })
+
+ it('filters by search matching the query text, case-insensitively', () => {
+ const activities = [
+ activity({ pid: 1, query: 'select * from users' }),
+ activity({ pid: 2, query: 'select * from orders' }),
+ ]
+
+ const result = filterActivities(activities, { ...EMPTY_FILTERS, search: 'USERS' })
+
+ expect(result.map((x) => x.pid)).toEqual([1])
+ })
+
+ it('excludes activities with a null query from a search filter', () => {
+ const activities = [activity({ pid: 1, query: null })]
+
+ expect(filterActivities(activities, { ...EMPTY_FILTERS, search: 'anything' })).toEqual([])
+ })
+
+ it('in blockers view, only keeps root blockers - excludes leaf/idle activities', () => {
+ const activities = [
+ activity({ pid: 1, blocked_by: [] }), // blocks pid 2, not itself blocked - root blocker
+ activity({ pid: 2, blocked_by: [1] }), // itself blocked - not a root blocker
+ activity({ pid: 3, blocked_by: [] }), // not blocked, and blocks nobody
+ ]
+
+ const result = filterActivities(activities, { ...EMPTY_FILTERS, view: 'blockers' })
+
+ expect(result.map((x) => x.pid)).toEqual([1])
+ })
+
+ it('combines multiple filters with AND semantics', () => {
+ const activities = [
+ activity({ pid: 1, role_name: 'anon', state: 'active' }),
+ activity({ pid: 2, role_name: 'anon', state: 'idle' }),
+ activity({ pid: 3, role_name: 'postgres', state: 'active' }),
+ ]
+
+ const result = filterActivities(activities, {
+ ...EMPTY_FILTERS,
+ roles: ['anon'],
+ states: ['active'],
+ })
+
+ expect(result.map((x) => x.pid)).toEqual([1])
+ })
+})
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts
index 75b0cd50d7dde..8fa79fb756837 100644
--- a/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/DatabaseConnections.utils.ts
@@ -160,6 +160,43 @@ export const getConnectionMetrics = (activities: DatabaseActivity[]): Connection
}
}
+export type ActivityFilters = {
+ search: string
+ states: string[]
+ applications: string[]
+ roles: string[]
+ view: string
+}
+
+// Applies the Sessions table's filters to the raw pg_stat_activity rows. Shared so that anything
+// selecting a pid (e.g. the Overview metric cards) can check whether that pid would actually be
+// visible under the current filters, without duplicating this predicate.
+export const filterActivities = (
+ activities: DatabaseActivity[],
+ filters: ActivityFilters
+): DatabaseActivity[] => {
+ const { search, states, applications, roles, view } = filters
+
+ // Pids referenced in some other activity's blocked_by - i.e. they are blocking something
+ const blockingPids = new Set(activities.flatMap((x) => x.blocked_by))
+
+ return activities.filter((activity) => {
+ const matchesState =
+ states.length === 0 || (activity.state !== null && states.includes(activity.state))
+ const matchesRole = roles.length === 0 || roles.includes(activity.role_name)
+ const matchesApplication =
+ applications.length === 0 || applications.includes(activity.application_name)
+ // In the blocked view, only show root blockers - activities blocking others while not
+ // themselves blocked. Everything they block is shown nested under them instead.
+ const matchesView =
+ view !== 'blockers' || (activity.blocked_by.length === 0 && blockingPids.has(activity.pid))
+ const matchesSearch =
+ !search || (activity.query?.toLowerCase().includes(search.toLowerCase()) ?? false)
+
+ return matchesState && matchesRole && matchesApplication && matchesView && matchesSearch
+ })
+}
+
export const getBlockChain = (pid: number, activities: DatabaseActivity[]) => {
const chain = [pid]
const visited = new Set([pid])
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx
index 378ae54317ea5..f98543b1c3640 100644
--- a/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/Overview.tsx
@@ -1,4 +1,3 @@
-import { parseAsInteger, useQueryState } from 'nuqs'
import { cn } from 'ui'
import {
MetricCard,
@@ -9,6 +8,7 @@ import {
} from 'ui-patterns/MetricCard'
import { getConnectionMetrics } from './DatabaseConnections.utils'
+import { useSelectActivityPid } from './useSelectActivityPid'
import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils'
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
import { useDatabaseActivityQuery } from '@/data/database/activity-query'
@@ -23,7 +23,7 @@ interface OverviewProps {
export const Overview = ({ live }: OverviewProps) => {
const track = useTrack()
const { data: project } = useSelectedProjectQuery()
- const [, setSelectedPid] = useQueryState('pid', parseAsInteger)
+ const { selectPid } = useSelectActivityPid()
const { data, isPending: isLoadingActivity } = useDatabaseActivityQuery(
{
@@ -68,27 +68,22 @@ export const Overview = ({ live }: OverviewProps) => {
}
)
- const onSelectPid = (pid: number) => {
- setSelectedPid(pid)
- document.getElementById(pid.toString())?.scrollIntoView({ behavior: 'smooth', block: 'center' })
- }
-
const onSelectLongestBlocked = () => {
if (!longestBlockedQuery) return
track('database_connections_overview_metric_card_clicked', { type: 'longest_blocked' })
- onSelectPid(longestBlockedQuery.activity.pid)
+ selectPid(longestBlockedQuery.activity.pid)
}
const onSelectTopBlocker = () => {
if (!queryBlockingTheMostQueries) return
track('database_connections_overview_metric_card_clicked', { type: 'top_blocker' })
- onSelectPid(queryBlockingTheMostQueries.activity.pid)
+ selectPid(queryBlockingTheMostQueries.activity.pid)
}
const onSelectLongestRunning = () => {
if (!longestRunningQuery) return
track('database_connections_overview_metric_card_clicked', { type: 'longest_running' })
- onSelectPid(longestRunningQuery.activity.pid)
+ selectPid(longestRunningQuery.activity.pid)
}
return (
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/useActivityFilters.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/useActivityFilters.ts
new file mode 100644
index 0000000000000..cad43026f269f
--- /dev/null
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/useActivityFilters.ts
@@ -0,0 +1,25 @@
+import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
+
+export const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres']
+
+export const EMPTY_ACTIVITY_FILTERS = {
+ search: '',
+ states: [] as string[],
+ applications: [] as string[],
+ roles: [] as string[],
+ view: '',
+}
+
+// URL-backed so the Sessions table (Activity.tsx) and anything that selects a pid (Overview.tsx,
+// ActivityRow.tsx) read/write the same filter state without prop drilling.
+export const useActivityFilters = () => {
+ const [filters, setFilters] = useQueryStates({
+ search: parseAsString.withDefault(''),
+ states: parseAsArrayOf(parseAsString, ',').withDefault([]),
+ applications: parseAsArrayOf(parseAsString, ',').withDefault([]),
+ roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER),
+ view: parseAsString.withDefault(''),
+ })
+
+ return { filters, setFilters }
+}
diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/useSelectActivityPid.ts b/apps/studio/components/interfaces/Observability/DatabaseConnections/useSelectActivityPid.ts
new file mode 100644
index 0000000000000..089525016388e
--- /dev/null
+++ b/apps/studio/components/interfaces/Observability/DatabaseConnections/useSelectActivityPid.ts
@@ -0,0 +1,26 @@
+import { parseAsInteger, useQueryState } from 'nuqs'
+
+import { filterActivities } from './DatabaseConnections.utils'
+import { EMPTY_ACTIVITY_FILTERS, useActivityFilters } from './useActivityFilters'
+import { useDatabaseActivityQuery } from '@/data/database/activity-query'
+import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
+
+// Selecting a pid should always bring it into view. If the current filters would hide it (e.g.
+// its role isn't in the default roles filter), clear all filters so it's guaranteed to render.
+export const useSelectActivityPid = () => {
+ const { data: project } = useSelectedProjectQuery()
+ const { data } = useDatabaseActivityQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+ const { filters, setFilters } = useActivityFilters()
+ const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger)
+
+ const selectPid = (pid: number) => {
+ const isVisible = filterActivities(data ?? [], filters).some((x) => x.pid === pid)
+ if (!isVisible) setFilters(EMPTY_ACTIVITY_FILTERS)
+ setSelectedPid(pid)
+ }
+
+ return { selectedPid, selectPid }
+}
diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx
index 55f5898d54c21..79915d4e6f11c 100644
--- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx
@@ -181,9 +181,7 @@ export const RegionSelector = ({
const selectedRegionLabel = selectedRegion?.name
? getDisplayNameForSmartRegion(selectedRegion.name)
: dbRegion
- const triggerLabel = isLoadingAvailableRegions
- ? 'Loading available regions...'
- : selectedRegionLabel
+ const triggerLabel = isLoading ? 'Loading available regions...' : selectedRegionLabel
const affectingIncidents = incidents.filter((incident) => {
const affectedRegions = incident.cache?.affected_regions ?? []
@@ -247,9 +245,7 @@ export const RegionSelector = ({
>
{dbRegion !== undefined && (
- {isLoadingAvailableRegions && (
-
- )}
+ {isLoading &&
}
{selectedRegion?.code && (
// For some reason, Safari considered the empty string alt text on this icon as misspelled (with VoiceOver)
// Only way to fix it is to set the role. Not needed for the combobox options
diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx
index cacfee532a109..efe82a0f521fb 100644
--- a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx
+++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx
@@ -511,7 +511,7 @@ export function useQueryInsightsTableColumns({
{
setSelectedTriageRow(props.rowIdx)
setSheetView('indexes')
@@ -524,7 +524,8 @@ export function useQueryInsightsTableColumns({
}
/>
diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx
index 01ce9af2de293..f649e93c9aea6 100644
--- a/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx
+++ b/apps/studio/components/interfaces/RoleImpersonationSelector/UserImpersonationSelector.tsx
@@ -34,12 +34,12 @@ import { useCustomAccessTokenHookDetails } from '@/hooks/misc/useCustomAccessTok
import { useLocalStorage } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
-import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
+import { type RoleImpersonationController } from '@/state/role-impersonation-state'
import type { ResponseError } from '@/types'
type AuthenticatorAssuranceLevels = 'aal1' | 'aal2'
-export const UserImpersonationSelector = () => {
+export const UserImpersonationSelector = ({ state }: { state: RoleImpersonationController }) => {
const [searchText, setSearchText] = useState('')
const [aal, setAal] = useState('aal1')
const [externalUserId, setExternalUserId] = useState('')
@@ -53,7 +53,6 @@ export const UserImpersonationSelector = () => {
[]
)
- const state = useRoleImpersonationStateSnapshot()
const debouncedSearchText = useDebounce(searchText, 300)
const { data: project } = useSelectedProjectQuery()
@@ -179,7 +178,7 @@ export const UserImpersonationSelector = () => {
impersonatingUser.email ?? impersonatingUser.phone ?? impersonatingUser.id ?? 'Unknown'
)
: isExternalAuthImpersonating
- ? state.role.externalAuth.sub
+ ? isExternalAuthImpersonating.sub
: undefined
// Clear all search history
@@ -210,7 +209,7 @@ export const UserImpersonationSelector = () => {
)}
{isExternalAuthImpersonating && (
{
+ // valtio's Snapshot<> type is deep-readonly (incl. nested arrays), which isn't
+ // structurally assignable to RoleImpersonationController's plain array fields — same
+ // rationale as the cast in useGetImpersonatedRoleState.
+ const state = useRoleImpersonationStateSnapshot() as unknown as RoleImpersonationController
+
+ return
+}
+
+type RoleImpersonationSelectorInterfaceProps = RoleImpersonationSelectorProps & {
+ state: RoleImpersonationController
+}
+
+export const RoleImpersonationSelectorInterface = ({
+ state,
+ orientation,
serviceRoleLabel = 'Postgres',
disallowAuthenticatedOption = false,
- orientation = 'horizontal',
-}: RoleImpersonationSelectorProps) => {
+ header = 'Impersonate a database role',
+}: RoleImpersonationSelectorInterfaceProps) => {
const isVertical = orientation === 'vertical'
- const state = useRoleImpersonationStateSnapshot()
-
- const [selectedOption, setSelectedOption] = useState(() => {
- if (
- state.role?.type === 'postgrest' &&
- (state.role.role === 'anon' || state.role.role === 'authenticated')
- ) {
- return state.role.role
- }
- return 'service_role'
- })
+ const [selectedOption, setSelectedOption] = useState(() =>
+ state.role?.type === 'postgrest' &&
+ (state.role.role === 'anon' || state.role.role === 'authenticated')
+ ? state.role.role
+ : 'service_role'
+ )
const isAuthenticatedOptionFullySelected = Boolean(
selectedOption === 'authenticated' &&
@@ -155,7 +171,7 @@ export const RoleImpersonationSelector = ({
{selectedOption === 'authenticated' && (
-
+
)}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx
index 2bb4f249667b4..2725c004c3ae6 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/QuerySourceMenu.tsx
@@ -149,7 +149,10 @@ export const QuerySourceMenu = ({ id, runSource, canCreateLogsSnippet }: QuerySo
/>
)}
-
+ sessionSnap.setLimit(Number(val))}
+ />
>
)}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx
index 9015dbada1357..01d7b88b19975 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RowLimitSubMenu.tsx
@@ -7,11 +7,14 @@ import {
} from 'ui'
import { ROWS_PER_PAGE_OPTIONS } from '../../SQLEditor.constants'
-import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
-export const RowLimitSubMenu = () => {
- const sessionSnap = useSqlEditorSessionSnapshot()
- const currentLabel = ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label
+interface RowLimitSubMenuProps {
+ value: number
+ onValueChange: (value: string) => void
+}
+
+export const RowLimitSubMenu = ({ value, onValueChange }: RowLimitSubMenuProps) => {
+ const currentLabel = ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === value)?.label
return (
@@ -22,10 +25,7 @@ export const RowLimitSubMenu = () => {
- sessionSnap.setLimit(Number(val))}
- >
+
{ROWS_PER_PAGE_OPTIONS.map((option) => (
{option.label}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx
index a44a95d2d6010..1f2b714a315b9 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/QuerySourceMenu/RunAsSubMenu.tsx
@@ -1,12 +1,25 @@
import { DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from 'ui'
-import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector'
-import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
+import { RoleImpersonationSelectorInterface } from '@/components/interfaces/RoleImpersonationSelector'
+import {
+ useRoleImpersonationStateSnapshot,
+ type RoleImpersonationController,
+} from '@/state/role-impersonation-state'
const SERVICE_ROLE_LABEL = 'postgres'
-export const RunAsSubMenu = () => {
- const state = useRoleImpersonationStateSnapshot()
+type RunAsSubMenuProps =
+ | {
+ controlled?: false
+ }
+ | {
+ controlled: true
+ state: RoleImpersonationController
+ }
+
+export const RunAsSubMenu = (props: RunAsSubMenuProps) => {
+ const globalState = useRoleImpersonationStateSnapshot() as unknown as RoleImpersonationController
+ const state = props.controlled ? props.state : globalState
const currentRole = state.role?.role ?? SERVICE_ROLE_LABEL
return (
@@ -20,10 +33,11 @@ export const RunAsSubMenu = () => {
{/* Stops propagation so the authenticated-user search input isn't swallowed by the
dropdown's typeahead. */}
e.stopPropagation()}>
-
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
index 2839970088052..8eda0ce5e837b 100644
--- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
+++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
@@ -110,7 +110,7 @@ export const RestartServerButton = () => {
variant="default"
className={cn(
'flex-1 px-3 hover:z-10 focus-visible:z-10 @lg:flex-none',
- canRestartProject && canRestart ? 'rounded-r-none' : ''
+ canRestartProject && canRestart ? 'rounded-r-none focus-visible:rounded-r-sm' : ''
)}
disabled={
project === undefined ||
@@ -143,7 +143,7 @@ export const RestartServerButton = () => {
}
disabled={!canRestartProject}
/>
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts
index 7cc18dd989c9f..5e184b8399e62 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.test.ts
@@ -1,69 +1,82 @@
import { describe, expect, it } from 'vitest'
-import { getConnectionStatusUi, type PrivateLinkConnectionStatus } from './AWSPrivateLink.utils'
+import {
+ getConnectionsAttention,
+ getConnectionsAttentionCopy,
+ getConnectionStatusUi,
+ getConnectionTitle,
+ type PrivateLinkConnectionStatus,
+} from './AWSPrivateLink.utils'
describe('getConnectionStatusUi', () => {
it.each([
- [
- 'ASSOCIATION_ACCEPTED',
- {
- badge: 'Connected',
- badgeVariant: 'success',
- title: 'This connection is active',
- },
- ],
- [
- 'READY',
- {
- badge: 'Ready',
- badgeVariant: 'success',
- title: 'Waiting for the AWS account owner to accept',
- description: 'This request expires after 12 hours.',
- },
- ],
- [
- 'CREATING',
- {
- badge: 'Creating',
- badgeVariant: 'warning',
- title: 'This connection is being created',
- },
- ],
- [
- 'DELETING',
- {
- badge: 'Deleting',
- badgeVariant: 'destructive',
- title: 'This connection is being deleted',
- },
- ],
- [
- 'ASSOCIATION_REQUEST_EXPIRED',
- {
- badge: 'Expired',
- badgeVariant: 'destructive',
- title: 'This request has expired',
- },
- ],
- [
- 'CREATION_FAILED',
- {
- badge: 'Failed',
- badgeVariant: 'destructive',
- title: "Couldn't create this connection",
- },
- ],
+ ['ASSOCIATION_ACCEPTED', { badge: 'Connected', badgeVariant: 'success' }],
+ ['READY', { badge: 'Waiting', badgeVariant: 'warning' }],
+ ['CREATING', { badge: 'Creating', badgeVariant: 'default' }],
+ ['DELETING', { badge: 'Deleting', badgeVariant: 'warning' }],
+ ['ASSOCIATION_REQUEST_EXPIRED', { badge: 'Expired', badgeVariant: 'destructive' }],
+ ['CREATION_FAILED', { badge: 'Failed', badgeVariant: 'destructive' }],
] as const satisfies ReadonlyArray<
- [PrivateLinkConnectionStatus, Partial>]
+ [PrivateLinkConnectionStatus, ReturnType]
>)('maps %s', (status, expected) => {
- expect(getConnectionStatusUi(status)).toMatchObject(expected)
+ expect(getConnectionStatusUi(status)).toEqual(expected)
})
it('returns unknown copy when status is missing', () => {
- const ui = getConnectionStatusUi()
+ expect(getConnectionStatusUi()).toEqual({ badge: 'Unknown', badgeVariant: 'default' })
+ })
+})
+
+describe('getConnectionTitle', () => {
+ it('uses the customer nickname when present', () => {
+ expect(
+ getConnectionTitle({
+ account_name: 'Production VPC',
+ aws_account_id: '123456789012',
+ })
+ ).toBe('Production VPC')
+ })
+
+ it('falls back to the AWS account ID for an unnamed connection', () => {
+ expect(getConnectionTitle({ aws_account_id: '123456789012' })).toBe('123456789012')
+ })
+
+ it('falls back to the AWS account ID for a blank nickname', () => {
+ expect(
+ getConnectionTitle({
+ account_name: ' ',
+ aws_account_id: '123456789012',
+ })
+ ).toBe('123456789012')
+ })
+})
+
+describe('getConnectionsAttentionCopy', () => {
+ it('returns null when nothing needs attention', () => {
+ expect(getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 0 })).toBeNull()
+ })
+
+ it('warns when a connection is waiting', () => {
+ const copy = getConnectionsAttentionCopy({ waitingCount: 1, expiredCount: 0 })
+ expect(copy?.type).toBe('warning')
+ expect(copy?.title).toBe('Waiting for the AWS account owner')
+ expect(copy?.showAcceptLink).toBe(true)
+ })
+
+ it('uses destructive copy when only expired', () => {
+ const copy = getConnectionsAttentionCopy({ waitingCount: 0, expiredCount: 2 })
+ expect(copy?.type).toBe('destructive')
+ expect(copy?.title).toBe('Connection requests expired')
+ expect(copy?.showAcceptLink).toBe(false)
+ })
- expect(ui.badge).toBe('Unknown')
- expect(ui.badgeVariant).toBe('default')
- expect(ui.title).toBe("Couldn't determine this connection's status")
+ it('counts statuses from a list', () => {
+ expect(
+ getConnectionsAttention([
+ { status: 'READY' },
+ { status: 'ASSOCIATION_ACCEPTED' },
+ { status: 'ASSOCIATION_REQUEST_EXPIRED' },
+ ])
+ ).toEqual({ waitingCount: 1, expiredCount: 1 })
})
})
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts
index 07baa806d76d7..8820e95f2877a 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLink.utils.ts
@@ -5,54 +5,38 @@ export type PrivateLinkConnectionStatus = AWSAccount['status']
type BadgeVariant = 'success' | 'warning' | 'destructive' | 'default'
export type ConnectionStatusUi = {
- title: string
- description: string
badge: string
badgeVariant: BadgeVariant
}
const CONNECTION_STATUS_UI: Record = {
ASSOCIATION_ACCEPTED: {
- title: 'This connection is active',
- description: 'The AWS account owner has accepted the resource share.',
badge: 'Connected',
badgeVariant: 'success',
},
READY: {
- title: 'Waiting for the AWS account owner to accept',
- description: 'This request expires after 12 hours.',
- badge: 'Ready',
- badgeVariant: 'success',
+ badge: 'Waiting',
+ badgeVariant: 'warning',
},
CREATING: {
- title: 'This connection is being created',
- description: '',
badge: 'Creating',
- badgeVariant: 'warning',
+ badgeVariant: 'default',
},
DELETING: {
- title: 'This connection is being deleted',
- description: '',
badge: 'Deleting',
- badgeVariant: 'destructive',
+ badgeVariant: 'warning',
},
ASSOCIATION_REQUEST_EXPIRED: {
- title: 'This request has expired',
- description: 'Add a new connection to try again.',
badge: 'Expired',
badgeVariant: 'destructive',
},
CREATION_FAILED: {
- title: "Couldn't create this connection",
- description: 'Add a new connection to try again.',
badge: 'Failed',
badgeVariant: 'destructive',
},
}
const UNKNOWN_STATUS_UI: ConnectionStatusUi = {
- title: "Couldn't determine this connection's status",
- description: '',
badge: 'Unknown',
badgeVariant: 'default',
}
@@ -61,3 +45,63 @@ export function getConnectionStatusUi(status?: PrivateLinkConnectionStatus): Con
if (!status) return UNKNOWN_STATUS_UI
return CONNECTION_STATUS_UI[status] ?? UNKNOWN_STATUS_UI
}
+
+export function getConnectionTitle(
+ account: Pick
+): string {
+ const nickname = account.account_name?.trim()
+ if (nickname) return nickname
+ return account.aws_account_id
+}
+
+export type ConnectionsAttention = {
+ waitingCount: number
+ expiredCount: number
+}
+
+export function getConnectionsAttention(
+ accounts: Array> | undefined
+): ConnectionsAttention {
+ const waitingCount = accounts?.filter((account) => account.status === 'READY').length ?? 0
+ const expiredCount =
+ accounts?.filter((account) => account.status === 'ASSOCIATION_REQUEST_EXPIRED').length ?? 0
+
+ return { waitingCount, expiredCount }
+}
+
+export function getConnectionsAttentionCopy(attention: ConnectionsAttention): {
+ type: 'warning' | 'destructive'
+ title: string
+ description: string
+ showAcceptLink: boolean
+} | null {
+ const { waitingCount, expiredCount } = attention
+ if (waitingCount === 0 && expiredCount === 0) return null
+
+ if (expiredCount > 0 && waitingCount === 0) {
+ return {
+ type: 'destructive',
+ title: expiredCount === 1 ? 'A connection request expired' : 'Connection requests expired',
+ description: 'Add a new connection to try again. AWS can no longer accept this share.',
+ showAcceptLink: false,
+ }
+ }
+
+ if (waitingCount > 0 && expiredCount > 0) {
+ return {
+ type: 'warning',
+ title: 'Some connections need attention',
+ description:
+ 'Accept waiting resource shares in AWS within 12 hours. Expired requests need a new connection.',
+ showAcceptLink: true,
+ }
+ }
+
+ return {
+ type: 'warning',
+ title:
+ waitingCount === 1 ? 'Waiting for the AWS account owner' : 'Waiting for AWS account owners',
+ description: 'Accept the resource share in AWS within 12 hours.',
+ showAcceptLink: true,
+ }
+}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx
index 225ad2ca66627..0894c39c797b6 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAccountItem.tsx
@@ -1,106 +1,37 @@
-import { Edit, MoreVertical, Trash } from 'lucide-react'
-import {
- Badge,
- Button,
- CardContent,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from 'ui'
+import { Badge } from 'ui'
-import { getConnectionStatusUi } from './AWSPrivateLink.utils'
+import { getConnectionStatusUi, getConnectionTitle } from './AWSPrivateLink.utils'
+import { ResourceItem } from '@/components/ui/Resource/ResourceItem'
+import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
-interface AWSPrivateLinkAccountItemProps {
- aws_account_id: string
- account_name?: string
- database_type?: 'PRIMARY' | 'READ_REPLICA'
- database_identifier?: string
- resource_access_manager_resource_config_id?: string
- resource_access_manager_resource_config_arn?: string
- resource_access_manager_share_arn?: string
- status:
- | 'CREATING'
- | 'READY'
- | 'ASSOCIATION_REQUEST_EXPIRED'
- | 'ASSOCIATION_ACCEPTED'
- | 'CREATION_FAILED'
- | 'DELETING'
- shared_at: string | null
- onEdit: () => void
- onDelete: () => void
-}
-
export const AWSPrivateLinkAccountItem = ({
- aws_account_id,
- account_name,
- database_type,
- database_identifier,
- resource_access_manager_resource_config_id,
- resource_access_manager_resource_config_arn,
- resource_access_manager_share_arn,
- status,
- onEdit,
- onDelete,
-}: AWSPrivateLinkAccountItemProps) => {
- const databaseTarget =
+ account,
+ onView,
+}: {
+ account: AWSAccount
+ onView: () => void
+}) => {
+ const { account_name, aws_account_id, database_identifier, database_type, status } = account
+ const title = getConnectionTitle({ account_name, aws_account_id })
+ const statusUi = getConnectionStatusUi(status)
+ const replicaId = database_identifier ? formatDatabaseID(database_identifier) : undefined
+ const showDatabase = database_type === 'READ_REPLICA' || title === aws_account_id
+ const databaseLabel =
database_type === 'READ_REPLICA'
- ? `Read replica (ID: ${database_identifier ? formatDatabaseID(database_identifier) : 'Unknown identifier'})`
+ ? `Read replica${replicaId ? ` (ID: ${replicaId})` : ''}`
: 'Primary database'
- const statusUi = getConnectionStatusUi(status)
return (
-
-
- {account_name &&
{account_name}
}
-
Database: {databaseTarget}
-
Destination account: {aws_account_id}
- {resource_access_manager_resource_config_id && (
-
-
Resource configuration:
-
-
- {resource_access_manager_resource_config_id}
-
- {(resource_access_manager_resource_config_arn ||
- resource_access_manager_share_arn) && (
-
- {resource_access_manager_resource_config_arn && (
- Resource config ARN: {resource_access_manager_resource_config_arn}
- )}
- {resource_access_manager_share_arn && (
- Resource share ARN: {resource_access_manager_share_arn}
- )}
-
- )}
-
-
- )}
+
{statusUi.badge}}
+ >
+
+
{title}
+ {showDatabase &&
{databaseLabel}
}
-
- {statusUi.badge}
-
-
-
- } />
-
-
-
-
- View connection
-
-
-
-
- Delete connection
-
-
-
-
+
)
}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx
new file mode 100644
index 0000000000000..70e00e5c023c5
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkAttentionAdmonition.tsx
@@ -0,0 +1,42 @@
+import { SquareArrowOutUpRight } from 'lucide-react'
+import Link from 'next/link'
+import { Button } from 'ui'
+import { Admonition } from 'ui-patterns/Admonition'
+
+import { getConnectionsAttention, getConnectionsAttentionCopy } from './AWSPrivateLink.utils'
+import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
+import { DOCS_URL } from '@/lib/constants'
+
+export function AWSPrivateLinkAttentionAdmonition({
+ accounts,
+ className,
+}: {
+ accounts: Array
> | undefined
+ className?: string
+}) {
+ const copy = getConnectionsAttentionCopy(getConnectionsAttention(accounts))
+ if (!copy) return null
+
+ return (
+ } asChild>
+
+ View instructions
+
+
+ )
+ }
+ />
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx
index 53c293ecddc66..97f1b0a28282d 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkForm.tsx
@@ -1,11 +1,8 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useFlag } from 'common'
-import { ExternalLink } from 'lucide-react'
-import Link from 'next/link'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
- Badge,
Button,
Form,
FormControl,
@@ -24,12 +21,11 @@ import {
SheetSection,
SheetTitle,
} from 'ui'
-import { Admonition } from 'ui-patterns/Admonition'
import { Input as CopyableInput } from 'ui-patterns/DataInputs/Input'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
-import { getConnectionStatusUi } from './AWSPrivateLink.utils'
+import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition'
import { InlineLink } from '@/components/ui/InlineLink'
import { useAWSAccountCreateMutation } from '@/data/aws-accounts/aws-account-create-mutation'
import type { AWSAccount } from '@/data/aws-accounts/aws-accounts-query'
@@ -55,9 +51,15 @@ interface AWSPrivateLinkFormProps {
account?: AWSAccount
open: boolean
onOpenChange: (open: boolean) => void
+ onDelete?: () => void
}
-export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLinkFormProps) => {
+export const AWSPrivateLinkForm = ({
+ account,
+ open,
+ onOpenChange,
+ onDelete,
+}: AWSPrivateLinkFormProps) => {
const isNew = !account
const { data: project } = useSelectedProjectQuery()
const showPrivateLinkReadReplica = useFlag('privatelinkReadReplica')
@@ -72,7 +74,6 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi
const readReplicas = databases.filter((database) => database.identifier !== project?.ref)
const showDatabaseTarget = showPrivateLinkReadReplica || !isNew
- const statusUi = getConnectionStatusUi(account?.status)
const formValues: FormValues = {
awsAccountId: account?.aws_account_id ?? '',
databaseIdentifier: account?.database_identifier ?? project?.ref ?? '',
@@ -138,36 +139,7 @@ export const AWSPrivateLinkForm = ({ account, open, onOpenChange }: AWSPrivateLi
className="flex flex-col flex-1 min-h-0"
>
- {!isNew && account && (
-
- {statusUi.title}
-
- {statusUi.badge}
-
- >
- }
- description={statusUi.description}
- actions={
- account.status === 'READY' && (
- }>
-
- How to accept
-
-
- )
- }
- />
- )}
+ {!isNew && account && }
-
- handleOpenChange(false)}
- >
- Cancel
-
- {isNew && (
-
- Add connection
-
+
+ {!isNew ? (
+ <>
+
+ Delete
+
+ handleOpenChange(false)}>
+ Close
+
+ >
+ ) : (
+ <>
+ handleOpenChange(false)}
+ >
+ Cancel
+
+
+ Add connection
+
+ >
)}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx
index ecc6759da4229..3688e994d5409 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx
+++ b/apps/studio/components/interfaces/Settings/Integrations/AWSPrivateLink/AWSPrivateLinkSection.tsx
@@ -1,7 +1,20 @@
+import { Plus } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
-import { Button, Card, CardContent, cn } from 'ui'
-import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ Button,
+ Card,
+ CardContent,
+ cn,
+} from 'ui'
import {
PageSection,
PageSectionContent,
@@ -13,6 +26,7 @@ import {
import { IntegrationSectionIcon } from '../IntegrationsSettings'
import { AWSPrivateLinkAccountItem } from './AWSPrivateLinkAccountItem'
+import { AWSPrivateLinkAttentionAdmonition } from './AWSPrivateLinkAttentionAdmonition'
import { AWSPrivateLinkForm } from './AWSPrivateLinkForm'
import { ResourceList } from '@/components/ui/Resource/ResourceList'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
@@ -36,6 +50,7 @@ export const AWSPrivateLinkSection = () => {
onSuccess: () => {
toast.success('Connection will be deleted shortly')
setShowDeleteModal(false)
+ setShowForm(false)
setSelectedAccount(undefined)
},
})
@@ -53,11 +68,6 @@ export const AWSPrivateLinkSection = () => {
setShowForm(true)
}
- const onDeleteAccount = (account: AWSAccount) => {
- setSelectedAccount(account)
- setShowDeleteModal(true)
- }
-
const onConfirmDelete = () => {
if (selectedAccount && project) {
deleteAccount({
@@ -71,16 +81,23 @@ export const AWSPrivateLinkSection = () => {
}
}
+ const deleteDatabaseCopy =
+ selectedAccount?.database_type === 'READ_REPLICA'
+ ? selectedAccount.database_identifier
+ ? `the read replica (ID: ${formatDatabaseID(selectedAccount.database_identifier)})`
+ : 'a read replica'
+ : 'the primary database'
+
return (
<>
-
+
AWS PrivateLink
- Connect to this project from your AWS VPC without using the public internet.
+ Private connectivity from a connected AWS VPC, without the public internet.
@@ -89,7 +106,7 @@ export const AWSPrivateLinkSection = () => {
{promptPlanUpgrade && (
{
Connections
-
+ } onClick={onAddAccount}>
Add connection
+
{(accounts?.length ?? 0) > 0 ? (
{accounts?.map((account) => (
onEditAccount(account)}
- onDelete={() => onDeleteAccount(account)}
+ account={account}
+ onView={() => onEditAccount(account)}
/>
))}
@@ -126,31 +143,36 @@ export const AWSPrivateLinkSection = () => {
-
+
setShowDeleteModal(true)}
+ />
- setShowDeleteModal(false)}
- onConfirm={onConfirmDelete}
+ {
+ if (!open && !isDeleting) setShowDeleteModal(false)
+ }}
>
-
- This removes the PrivateLink connection for {selectedAccount?.aws_account_id}.
- Applications using this private path will lose access.
-
-
- Database:{' '}
- {selectedAccount &&
- ` ${
- selectedAccount.database_type === 'READ_REPLICA'
- ? `Read replica (ID: ${selectedAccount.database_identifier ? formatDatabaseID(selectedAccount.database_identifier) : 'Unknown identifier'})`
- : 'Primary database'
- }`}
-
-
+
+
+ Delete connection
+
+ This removes the PrivateLink connection for{' '}
+ {selectedAccount?.aws_account_id} on{' '}
+ {deleteDatabaseCopy}. Applications using this private path will lose access.
+
+
+
+ Cancel
+
+ Delete
+
+
+
+
>
)
}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx
index d56634f0111eb..6de70c98b1529 100644
--- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx
+++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx
@@ -388,6 +388,13 @@ export const GitHubIntegrationConnectionForm = ({
isDeletingConnection ||
isLoadingRepositoryOptions
+ let repositoryDescription = 'Select the repository to connect to your project'
+ if (connection) {
+ repositoryDescription = 'Change the connected repository'
+ } else if (gitHubAuthorization === null) {
+ repositoryDescription = 'Connect GitHub to link a repository to this project'
+ }
+
return (
<>