From 60be899fdbfbdee2e26c8dae990c6338bc82a8aa Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 17 Aug 2026 17:45:02 +0800 Subject: [PATCH] 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 ## 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. --- .../DatabaseConnections/Activity.tsx | 45 +++------ .../DatabaseConnections/ActivityRow.tsx | 19 +++- .../DatabaseConnections.utils.test.ts | 99 ++++++++++++++++++- .../DatabaseConnections.utils.ts | 37 +++++++ .../DatabaseConnections/Overview.tsx | 15 +-- .../DatabaseConnections/useActivityFilters.ts | 25 +++++ .../useSelectActivityPid.ts | 26 +++++ 7 files changed, 220 insertions(+), 46 deletions(-) create mode 100644 apps/studio/components/interfaces/Observability/DatabaseConnections/useActivityFilters.ts create mode 100644 apps/studio/components/interfaces/Observability/DatabaseConnections/useSelectActivityPid.ts diff --git a/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx b/apps/studio/components/interfaces/Observability/DatabaseConnections/Activity.tsx index 73428c7208e..24d693d77ae 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 a5a85b0478a..8f40dd2a165 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 bef91c2c0ae..de93df0362f 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 75b0cd50d7d..8fa79fb7568 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 378ae54317e..f98543b1c36 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 00000000000..cad43026f26 --- /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 00000000000..08952501638 --- /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 } +}