mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
<!-- 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>
524 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|