import dayjs from 'dayjs' import { isEqual } from 'lodash' import { Minus, MoreVertical, StopCircle, X } from 'lucide-react' import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryState, useQueryStates } from 'nuqs' import { Fragment, useEffect, useState } from 'react' import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, Badge, Button, Card, cn, copyToClipboard, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, HoverCard, HoverCardContent, HoverCardTrigger, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Tooltip, TooltipContent, TooltipTrigger, } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ReportsSelectFilter } from '../../Reports/v2/ReportsSelectFilter' import { QUERY_STATE_TOOLTIP, WARN_DURATION_ACTIVE_QUERY, WARN_DURATION_IDLE_TXN, } from './DatabaseConnections.constants' import { formatDuration } from '@/components/interfaces/QueryPerformance/QueryPerformance.utils' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' 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 { useQueryAbortMutation } from '@/data/sql/abort-query-mutation' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { formatSql } from '@/lib/formatSql' const getDuration = (activity: DatabaseActivity) => { const { state } = activity if (state === 'active' && activity.query_start) { return dayjs().utc().diff(dayjs(activity.query_start).utc(), 'second') } if (state === 'idle' && activity.state_change) { return dayjs().utc().diff(dayjs(activity.state_change).utc(), 'second') } if ( (state === 'idle in transaction' || state === 'idle in transaction (aborted)') && activity.transaction_start ) { return dayjs().utc().diff(dayjs(activity.transaction_start).utc(), 'second') } return null } const getBadgeVariant = (activity: DatabaseActivity) => { const { state } = activity if (state === 'active') return 'success' if (state === 'idle in transaction' || state === 'idle in transaction (aborted)') return 'warning' return 'default' } const DEFAULT_ROLES_FILTER = ['anon', 'authenticated', 'postgres'] interface ActivityProps { live?: boolean } export const Activity = ({ live }: ActivityProps) => { const { data: project } = useSelectedProjectQuery() const [selectedPid] = useQueryState('pid', parseAsInteger) const [ { states: statesFilter, applications: applicationsFilter, roles: rolesFilter }, setQueryStates, ] = useQueryStates({ states: parseAsArrayOf(parseAsString, ',').withDefault([]), applications: parseAsArrayOf(parseAsString, ',').withDefault([]), roles: parseAsArrayOf(parseAsString, ',').withDefault(DEFAULT_ROLES_FILTER), }) const hasNoFiltersApplied = statesFilter.length === 0 && applicationsFilter.length === 0 && isEqual(rolesFilter, DEFAULT_ROLES_FILTER) const { data, isPending, isSuccess } = useDatabaseActivityQuery( { projectRef: project?.ref, connectionString: project?.connectionString, }, { refetchOnWindowFocus: live, refetchInterval: live ? 3000 : false } ) const { data: roles } = useDatabaseRolesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) 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) return matchesState && matchesRole && matchesApplication }) const stateOptions = [ 'Idle', 'Active', 'Idle in transaction', 'Idle in transaction (aborted)', 'Fastpath function call', 'Disabled', ].map((x) => ({ label: x, value: x.toLowerCase(), quantity: data?.filter( (y) => y.state === x.toLowerCase() && (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) ).length, })) const applicationOptions = Array.from(new Set(data?.map((x) => x.application_name) ?? [])) .sort() .map((x) => ({ label: x, value: x, quantity: data?.filter( (y) => y.application_name === x && (rolesFilter.length === 0 || rolesFilter.includes(y.role_name)) && (!statesFilter || statesFilter.length === 0 || (y.state !== null && statesFilter.includes(y.state))) ).length, })) .filter((x) => !!x.value) const priorityRoles = ['anon', 'authenticated', 'postgres'] const roleOptions = (roles ?? []) .map((x) => ({ label: x.name, value: x.name, quantity: data?.filter( (y) => y.role_name === x.name && (!statesFilter || statesFilter.length === 0 || (y.state !== null && statesFilter.includes(y.state))) && (applicationsFilter.length === 0 || applicationsFilter.includes(y.application_name)) ).length, })) .sort((a, b) => { const aIndex = priorityRoles.indexOf(a.value) const bIndex = priorityRoles.indexOf(b.value) if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex if (aIndex !== -1) return -1 if (bIndex !== -1) return 1 return 0 }) useEffect(() => { if (selectedPid && isSuccess) { document .getElementById(selectedPid.toString()) ?.scrollIntoView({ behavior: 'smooth', block: 'center' }) } }, [selectedPid, isSuccess]) return (

Sessions

setQueryStates({ states })} isLoading={isPending} popoverClassName="w-60" /> setQueryStates({ roles })} isLoading={isPending} popoverClassName="w-72" /> setQueryStates({ applications })} isLoading={isPending} popoverClassName="w-60" /> {!hasNoFiltersApplied && ( } onClick={() => setQueryStates({ states: [], roles: DEFAULT_ROLES_FILTER, applications: [] }) } aria-label="Reset filters" tooltip={{ content: { side: 'bottom', text: 'Reset filters' } }} /> )}
State Query · Session Duration Blocked by {isPending ? ( Array.from({ length: 3 }).map((_, i) => ( )) ) : (activities ?? []).length === 0 ? ( {hasNoFiltersApplied ? (

No active sessions

There are currently no active database connections for the anon, authenticated, and postgres roles.

) : (

No results found

There are no sessions that match the selected filters. Try adjusting or clearing them.

)}
) : null} {activities?.map((activity) => ( ))}
) } const ActivityRow = ({ activity }: { activity: DatabaseActivity }) => { const { data: project } = useSelectedProjectQuery() const [showTerminateConfirmDialog, setShowTerminateConfirmDialog] = useState(false) const [selectedPid, setSelectedPid] = useQueryState('pid', parseAsInteger) 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: abortQuery } = useQueryAbortMutation({ onSuccess: () => { toast.success(`Successfully aborted query (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 onConfirmTerminate = async () => { try { await abortQuery({ pid: activity.pid, projectRef: project?.ref, connectionString: project?.connectionString, }) } catch (error) {} } return ( <> {selectedPid === activity.pid && (
)} {activity.state} {activity.state && ( {QUERY_STATE_TOOLTIP[activity.state]} )}

{!!activity.query ? activity.query : 'No query'}

{activity.query && ( 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)} /> )}
{ toast.success('Copied PID') copyToClipboard(activity.pid.toString()) }} > PID: {activity.pid} Click to copy · {activity.role_name} {activity.application_name && ( <> · {activity.application_name} )}

{durationSeconds !== null ? ( formatDuration(durationSeconds * 1000, 0) ) : ( )}

{activity.blocked_by.length > 0 ? ( activity.blocked_by.map((pid, index) => { const blockedProcess = data?.find((x) => x.pid === pid) return ( {index > 0 && ', '} setSelectedPid(pid)} > {pid}

{blockedProcess?.query}

PID: {blockedProcess?.pid} · {blockedProcess?.role_name} {blockedProcess?.application_name && ( <> · {blockedProcess?.application_name} )}

) }) ) : ( )}