Files
supabase/apps/studio/components/interfaces/Observability/DatabaseConnections/ActivityRow.tsx
claude[bot] 4e280d4498 fix(studio): disambiguate query cancel telemetry and gate live-mode hotkey (#49137)
<!-- ccr-slack-attribution -->
_Requested by **Pam Chia** · [Slack
thread](https://supabase.slack.com/archives/C076KTY11DF/p1786930264662829?thread_ts=1786930264.662829&cid=C076KTY11DF)_

## 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. Two telemetry correctness fixes in the Database Connections
feature preview. No visual changes, no new events.

Linear:
[GROWTH-1107](https://linear.app/supabase/issue/GROWTH-1107/fix-database-connections-feature-preview-banner-dead-end-plus)

## What is the current behavior?

### 1. `query_cancel_button_clicked` cannot tell its two surfaces apart

"Cancel query" is reachable from two places on
`/observability/connections`. One is the three-dot dropdown menu on an
activity row. The other is inside the "Confirm to terminate this
session?" dialog, which offers "Cancel query" alongside "Terminate" when
the session is running a query.

**Before:** both buttons fire `query_cancel_button_clicked` with an
identical payload (`activityState`, `isBlocking`). In analysis the two
are one undifferentiated number, so there is no way to see whether
people cancel straight from the row or only after opening the terminate
dialog and reading the "Cancelling it may solve the problem without
closing the connection" warning. That warning is the main nudge away
from terminating, and today we cannot measure whether it lands.

### 2. The live-mode hotkey fires telemetry for users who do not have
the feature

**Before:** the Mod+J live-mode shortcut is registered whenever the page
mounts, regardless of whether the Database Connections feature preview
is enabled. The live badge, the toggle button and the activity query are
all gated on the feature, so a user without it can press Mod+J, emit
`database_connections_live_mode_clicked`, and see nothing change. Those
events inflate the metric with interactions that had no effect.

## What is the new behavior?

### 1. `query_cancel_button_clicked` carries an `origin`

**After:** the event reports which surface it came from, so the two
flows can be split in analysis. Nothing changes for the user.

`QueryCancelButtonClickedEvent` in
`packages/common/telemetry-constants.ts` gains a required `origin:
'dropdown_menu' | 'terminate_dialog'` property, following the shape
already used by `index_advisor_enable_button_clicked` (`origin: 'banner'
| 'dialog'`). Values are snake_case to match the dominant convention
among the existing `origin` unions in that file. In `ActivityRow.tsx`
the shared `onCancelQuery` handler now takes the origin as an argument
and each of the two call sites passes its own value. Because `track()`
is strictly typed per action, the required property is enforced at
compile time rather than by convention.

### 2. The live-mode hotkey is gated on the feature

**After:** Mod+J only does something, and only reports something, for
users who actually have Database Connections enabled. Everyone else is
unaffected, as before.

`useShortcut` already accepts an `enabled` option that disables the
hotkey and hides the command-menu entry. The registration in
`pages/project/[ref]/observability/connections.tsx` now passes `enabled:
isDatabaseConnectionsEnabled`, reusing the value already read from
`useIsDatabaseConnectionsEnabled()` and already used to gate the
activity query and the visible controls on the same page.

## Additional context

**Scope was reduced from the original plan.** GROWTH-1107 originally
covered four items. #49132 rewrote the Database Connections gating model
and superseded three of them, so only the two above remain:

- The feature preview banner is no longer flag-gated, so there is
nothing to gate on `topForPostgres`.
- `isEnabled` on `database_connections_banner_cta_button_clicked` is now
a real variable rather than a constant, since it is true on the new
"Explore Database Connections" variant. It stays as is.
- The wrong-feature fallback in the feature preview modal no longer
triggers for this preview.

Nothing in that area is touched here. GROWTH-1107 has been updated to
reflect the reduced scope.

**Validation** (run locally):

- `tsc --noEmit` in `packages/common` and in `apps/studio`. Studio
reports the same two pre-existing errors before and after this change
and none in the changed files.
- `eslint` on both changed studio files: clean. `lint:ratchet`: passes.
- `vitest --run
components/interfaces/Observability/DatabaseConnections`: 36 passed.
- Prettier check on all three files: clean.


## To test

Verified in a real browser on the studio-staging Vercel preview,
checking telemetry at the wire level (network inspection of `POST
/platform/telemetry/event`). Checks derived from the diff, covering both
fixes and their negative cases.

- [x] Mod+J with the Database Connections feature preview off: no
`database_connections_live_mode_clicked` request fired and no UI change;
the page stays on the enable-preview gate screen
- [x] Mod+J with the preview on: the live badge visibly toggles and
exactly one event fires per press (`newState: "disabled"` on the first
press since live mode starts on by default, then `"enabled"` on the
second)
- [x] "Cancel query" from the activity row dropdown on an active
`pg_sleep(120)` session: `query_cancel_button_clicked` with
`custom_properties:
{"activityState":"active","isBlocking":false,"origin":"dropdown_menu"}`
- [x] "Cancel query" inside the "Confirm to terminate this session?"
dialog: `query_cancel_button_clicked` with `custom_properties:
{"activityState":"active","isBlocking":false,"origin":"terminate_dialog"}`

Opening the terminate dialog in the last check also fired
`session_terminate_button_clicked`, correctly distinct from the cancel
event. No new console errors versus the page-load baseline across all
four checks.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 15:39:07 +08:00

524 lines
19 KiB
TypeScript

import { ChevronRight, CircleX, Minus, MoreVertical, StopCircle } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { Fragment, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Badge,
Button,
cn,
copyToClipboard,
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
HoverCard,
HoverCardContent,
HoverCardTrigger,
TableCell,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import {
QUERY_STATE_TOOLTIP,
WARN_DURATION_ACTIVE_QUERY,
WARN_DURATION_IDLE_TXN,
} from './DatabaseConnections.constants'
import {
getBadgeVariant,
getBlockChain,
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'
import { useDatabaseRolesQuery } from '@/data/database-roles/database-roles-query'
import { useDatabaseActivityQuery, type DatabaseActivity } from '@/data/database/activity-query'
import { useQueryCancelMutation } from '@/data/sql/cancel-query-mutation'
import { useSessionTerminateMutation } from '@/data/sql/terminate-session-mutation'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { formatSql } from '@/lib/formatSql'
import { useTrack } from '@/lib/telemetry/track'
export const GroupedActivityRow = ({ activity }: { activity: DatabaseActivity }) => {
const { data: project } = useSelectedProjectQuery()
const [view] = useQueryState('view', parseAsString.withDefault(''))
const [expanded, setExpanded] = useState<boolean>(false)
const { data } = useDatabaseActivityQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const queriesBlockedBy = getBlockingChain(activity.pid, data ?? [])
.map((pid) => data?.find((x) => x.pid === pid))
.filter((x) => x !== undefined)
return (
<>
<ActivityRow
activity={activity}
expanded={expanded}
onExpand={
queriesBlockedBy.length > 0 && view === 'blockers'
? () => setExpanded((prev) => !prev)
: undefined
}
/>
{expanded &&
view === 'blockers' &&
queriesBlockedBy.map((x, index) => (
<ActivityRow
nested
activity={x}
isLast={index === queriesBlockedBy.length - 1}
key={`${activity.pid}-${x.pid}`}
/>
))}
</>
)
}
export const ActivityRow = ({
activity,
expanded,
nested,
isLast,
onExpand,
}: {
activity: DatabaseActivity
expanded?: boolean
nested?: boolean
isLast?: boolean
onExpand?: () => void
}) => {
const track = useTrack()
const { data: project } = useSelectedProjectQuery()
const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false)
const { selectedPid, selectPid } = useSelectActivityPid()
const rowRef = useRef<HTMLTableRowElement>(null)
const { data } = useDatabaseActivityQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: roles } = useDatabaseRolesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const superuserRoles = roles?.filter((role) => role.isSuperuser).map((role) => role.name)
const { mutateAsync: cancelQuery } = useQueryCancelMutation({
onError: () => {}, // [Joshen] Error handled at call site
})
const { mutateAsync: terminateSession } = useSessionTerminateMutation({
onSuccess: () => {
toast.success(`Successfully terminated session (ID: ${activity.pid})`)
},
})
const durationSeconds = getDuration(activity)
const badgeVariant = getBadgeVariant(activity)
/**
* Queries in "active state": 30s threshold is long enough (most CRUD queries should be quick)
* Queries in "idle in transaction" state: This actively holds locks and blocks autovacuum while contributing nothing, so important to surface early at 10s threshold
*/
const queryRunningLongWarning =
!!durationSeconds &&
((activity.state === 'active' && durationSeconds >= WARN_DURATION_ACTIVE_QUERY) ||
((activity.state === 'idle in transaction' ||
activity.state === 'idle in transaction (aborted)') &&
durationSeconds >= WARN_DURATION_IDLE_TXN))
const onCancelQuery = async (origin: 'dropdown_menu' | 'terminate_dialog') => {
const isBlocking = (data ?? []).some((x) => x.blocked_by.includes(activity.pid))
track('query_cancel_button_clicked', {
activityState: activity.state,
isBlocking,
origin,
})
const toastId = toast.loading(`Cancelling query (ID: ${activity.pid})`)
try {
await cancelQuery({
pid: activity.pid,
backendStart: activity.backend_start,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
toast.success(`Successfully cancelled query (ID: ${activity.pid})`, { id: toastId })
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
toast.error(`Failed to cancel query: ${errorMessage}`, { id: toastId })
}
}
const onSelectTerminate = () => {
const isBlocking = (data ?? []).some((x) => x.blocked_by.includes(activity.pid))
track('session_terminate_button_clicked', {
activityState: activity.state,
isBlocking,
})
setShowTerminateConfirmDialog(true)
}
const onConfirmTerminate = async () => {
const isBlocking = (data ?? []).some((x) => x.blocked_by.includes(activity.pid))
track('session_terminate_submitted', { activityState: activity.state, isBlocking })
try {
await terminateSession({
pid: activity.pid,
backendStart: activity.backend_start,
projectRef: project?.ref,
connectionString: project?.connectionString,
})
} catch (error) {}
}
useEffect(() => {
if (selectedPid === activity.pid) {
rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [selectedPid, activity.pid])
return (
<>
<TableRow
ref={rowRef}
id={activity.pid.toString()}
key={activity.pid}
className={cn('[&>td]:py-3', nested && 'bg-alternative')}
>
<TableCell className="relative w-[70px]">
{selectedPid === activity.pid && (
<div className="absolute h-full bg-brand top-0 left-0 w-1 bg-foreground-lighter" />
)}
{/* Absolute (not inline in the flex row) so top-0/bottom-0 ignore the cell's padding and touch the adjacent row */}
{nested &&
(isLast ? (
<div className="absolute left-[27px] top-0 h-1/2 w-14 border-l border-b border-stronger rounded-bl-md" />
) : (
<>
<div className="absolute left-[27px] top-0 h-full border-l border-stronger" />
<div className="absolute left-[27px] top-1/2 w-14 border-b border-stronger" />
</>
))}
{/* Starts right below the expand button (row's own py-3 top padding + button height), reaches bottom-0 to touch the first nested row's border */}
{!!onExpand && expanded && (
<div className="absolute left-[27px] top-[43px] bottom-0 border-l border-stronger" />
)}
<div className="flex items-center">
{nested && <div className="w-12 mr-1" />}
{!!onExpand && (
<Button
aria-label="Expand row"
variant="outline"
className="px-1 mr-3"
onClick={onExpand}
icon={<ChevronRight className={cn('transition', expanded && 'rotate-90')} />}
/>
)}
{!nested ? (
<Tooltip>
<TooltipTrigger>
<Badge variant={badgeVariant}>{activity.state}</Badge>
</TooltipTrigger>
{activity.state && (
<TooltipContent side="bottom">
{QUERY_STATE_TOOLTIP[activity.state]}
</TooltipContent>
)}
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger className="translate-x-4">
<Badge variant="default">Waiting</Badge>
</TooltipTrigger>
<TooltipContent side="bottom">
Waiting for previous query to complete
</TooltipContent>
</Tooltip>
)}
</div>
</TableCell>
<TableCell className="max-w-[300px]">
<HoverCard openDelay={250} closeDelay={100}>
<HoverCardTrigger>
<p
className={cn(
'truncate',
!activity.query ? 'text-foreground-lighter' : 'font-mono tracking-tighter'
)}
>
{!!activity.query ? activity.query : 'No query'}
</p>
</HoverCardTrigger>
{activity.query && (
<HoverCardContent align="start" className="w-96 p-0">
<CodeBlock
hideLineNumbers
className={cn(
'max-w-96 border-none [&>code]:text-xs max-h-64',
'[&>code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11'
)}
wrapperClassName={cn('[&_pre]:px-4 [&_pre]:py-0')}
language="pgsql"
value={formatSql(activity.query)}
/>
</HoverCardContent>
)}
</HoverCard>
<div className="text-xs text-foreground-lighter flex items-center gap-x-1 mt-0.5 truncate">
<Tooltip>
<TooltipTrigger
className="cursor-pointer"
onClick={() => {
toast.success('Copied PID')
copyToClipboard(activity.pid.toString())
}}
>
<span>PID: {activity.pid}</span>
</TooltipTrigger>
<TooltipContent side="bottom">Click to copy</TooltipContent>
</Tooltip>
<span>·</span>
<span>{activity.role_name}</span>
{activity.application_name && (
<>
<span>·</span>
<span>{activity.application_name}</span>
</>
)}
</div>
</TableCell>
<TableCell>
<p
className={cn(
'tabular-nums truncate',
queryRunningLongWarning
? activity.state === 'active'
? 'text-warning'
: 'text-destructive'
: undefined
)}
>
{durationSeconds !== null ? (
formatDuration(durationSeconds * 1000, 0)
) : (
<Minus size={12} className="text-foreground-lighter" />
)}
</p>
</TableCell>
<TableCell>
{activity.blocked_by.length > 0 ? (
activity.blocked_by.map((pid, index) => {
const blockChain = getBlockChain(pid, data ?? [])
return (
<Fragment key={pid}>
{index > 0 && ', '}
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => selectPid(pid)}
>
{pid}
</HoverCardTrigger>
<HoverCardContent className="bg-alternative w-96 max-h-64 overflow-y-auto p-3 text-xs">
<p>
Blocked via {blockChain.length} hop{blockChain.length > 1 ? 's' : ''}
</p>
<div className="flex flex-col mt-2 gap-y-1.5">
{blockChain.map((chainPid, chainIndex) => {
const chainProcess = data?.find((x) => x.pid === chainPid)
const isLastProcess = chainIndex === blockChain.length - 1
return (
<div
key={chainPid}
className="flex items-center"
style={{
paddingLeft:
chainIndex === 0
? 0
: chainIndex === 1
? 3
: (chainIndex - 1) * 20 + 4,
}}
>
{chainIndex > 0 && (
<div
className={cn(
'w-3 h-4 border-l-1 border-b-1 border-stronger rounded-bl-md shrink-0 mr-1.5',
isLastProcess ? '-mt-12' : '-mt-8'
)}
/>
)}
<div className="truncate">
<div className="flex gap-x-1 items-center">
<span
role="button"
tabIndex={0}
className="cursor-pointer hover:underline"
onClick={() => selectPid(chainPid)}
>
PID: {chainPid}
</span>
{isLastProcess ? (
<Badge variant="warning">Holding lock</Badge>
) : (
<Badge variant="default">Waiting</Badge>
)}
</div>
<p
className={cn(
'font-mono tracking-tighter truncate',
isLastProcess ? 'text-foreground' : 'text-foreground-lighter'
)}
>
{chainProcess?.query}
</p>
{isLastProcess && (
<div className="flex gap-x-0.5 text-foreground-lighter truncate">
<span>{chainProcess?.role_name}</span>
<span>·</span>
{chainProcess?.application_name && (
<span>{chainProcess.application_name}</span>
)}
</div>
)}
</div>
</div>
)
})}
</div>
</HoverCardContent>
</HoverCard>
</Fragment>
)
})
) : (
<Minus size={12} className="text-foreground-lighter" />
)}
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="More actions"
variant="text"
className="px-1"
icon={<MoreVertical />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={
activity.state !== 'active' || superuserRoles?.includes(activity.role_name)
}
onClick={() => onCancelQuery('dropdown_menu')}
tooltip={{
content: {
side: 'left',
text:
activity.state !== 'active'
? 'No running queries to cancel'
: 'Unable to terminate queries run by superuser roles',
},
}}
>
<CircleX size={12} />
<span>Cancel query</span>
</DropdownMenuItemTooltip>
<DropdownMenuSeparator />
<DropdownMenuItemTooltip
className="gap-x-2"
disabled={superuserRoles?.includes(activity.role_name)}
onClick={onSelectTerminate}
tooltip={{
content: {
side: 'left',
text: 'Unable to terminate sessions owned by superuser roles',
},
}}
>
<StopCircle size={12} />
<span>Terminate session</span>
</DropdownMenuItemTooltip>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<AlertDialog open={showTerminateConfirmDialog} onOpenChange={setShowTerminateConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm to terminate this session?</AlertDialogTitle>
{activity.state === 'active' && (
<Admonition
type="warning"
className="border-x-0 rounded-none border-t-0"
title="This session is currently running a query"
description="Cancelling it may solve the problem without closing the connection."
/>
)}
<AlertDialogDescription>
Ending this session will close its connection and roll back any open transaction. The
application will need to reconnect.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className={cn(activity.state === 'active' && 'sm:justify-between')}>
<AlertDialogCancel>Back</AlertDialogCancel>
<div className="flex items-center gap-x-2">
{activity.state === 'active' && (
<AlertDialogAction
variant="default"
onClick={() => onCancelQuery('terminate_dialog')}
>
Cancel query
</AlertDialogAction>
)}
<AlertDialogAction variant="warning" onClick={onConfirmTerminate}>
Terminate
</AlertDialogAction>
</div>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}