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 } +}