Selecting a PID from the overview card should clear filters if not visible in the UI (#49135)

## Context

For Database Connections - the PIDs on the overview cards are selectable
such that clicking on them should scroll the browser down to where the
row is.

However, if the selected PID isn't rendered due to the applied filters,
clicking on it will seemingly do nothing. Changes here hence opt to
remove all filters then scroll to the selected PID into view, so that
users can always quickly find which PID the overview card is
referencing.

Also chucked in some refactors to centralize the management of filters,
and functionality of selecting a PID into their own hooks

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added shared filtering for database activity by state, role,
application, search text, and view.
* Activity filters are now preserved in the URL for easier navigation
and sharing.
* Selecting activity metrics or process IDs now automatically reveals
the relevant activity row.
* Blocker view highlights root activities that are blocking other
queries.

* **Bug Fixes**
* Improved selection behavior when the chosen activity is hidden by
active filters.

* **Tests**
* Added coverage for individual, combined, case-insensitive, and
blocker-specific filtering scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-08-17 17:45:02 +08:00
1 parent 397965cfae
commit 60be899fdb
7 files changed
+220 -46

No files matched your search

@@ -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 &&
@@ -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<HTMLTableRowElement>(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 (
<>
<TableRow
ref={rowRef}
id={activity.pid.toString()}
key={activity.pid}
className={cn('[&>td]:py-3', nested && 'bg-alternative')}
@@ -338,7 +347,7 @@ export const ActivityRow = ({
<HoverCard openDelay={150} closeDelay={100}>
<HoverCardTrigger
className={cn(InlineLinkClassName, 'cursor-pointer')}
onClick={() => setSelectedPid(pid)}
onClick={() => selectPid(pid)}
>
{pid}
</HoverCardTrigger>
@@ -378,7 +387,7 @@ export const ActivityRow = ({
role="button"
tabIndex={0}
className="cursor-pointer hover:underline"
onClick={() => setSelectedPid(chainPid)}
onClick={() => selectPid(chainPid)}
>
PID: {chainPid}
</span>
@@ -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])
})
})
@@ -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])
@@ -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 (
@@ -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 }
}
@@ -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 }
}