mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 21:05:06 +03:00
Move the user activity into the user panel.
This commit is contained in:
1 parent
397ecce23c
commit
8e8d2a9ca4
16 files changed
+196
-447
No files matched your search
@@ -276,7 +276,6 @@ These are the layout-only TanStack files. Most hold a single product layout comp
|
||||
- [x] A `routes/project/$ref/observability/query-performance.tsx` ← `pages/project/[ref]/observability/query-performance.tsx`
|
||||
- [x] A `routes/project/$ref/observability/realtime.tsx` ← `pages/project/[ref]/observability/realtime.tsx`
|
||||
- [x] A `routes/project/$ref/observability/storage.tsx` ← `pages/project/[ref]/observability/storage.tsx`
|
||||
- [x] A `routes/project/$ref/observability/user-activity.tsx` ← `pages/project/[ref]/observability/user-activity.tsx`
|
||||
|
||||
### Project shell — `/advisors/*`
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,153 @@
|
||||
import { useParams } from 'common'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
buildUserActivitySearch,
|
||||
isErrorLevel,
|
||||
mapLogRowToActivityEvent,
|
||||
type UserActivityEvent,
|
||||
} from './UserActivity.constants'
|
||||
import { UserActivityEventDetailSheet } from './UserActivityEventDetailSheet'
|
||||
import { UserActivityTimeline } from './UserActivityTimeline'
|
||||
import { PANEL_PADDING } from './Users.constants'
|
||||
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
|
||||
import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { User } from '@/data/auth/users-infinite-query'
|
||||
import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query'
|
||||
import { useReportDateRange } from '@/hooks/misc/useReportDateRange'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
|
||||
type UserActivityFilter = 'all' | 'errors'
|
||||
|
||||
interface UserActivityTabProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export const UserActivityTab = ({ user }: UserActivityTabProps) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const [filter, setFilter] = useState<UserActivityFilter>('all')
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [payloadEvent, setPayloadEvent] = useState<UserActivityEvent | null>(null)
|
||||
|
||||
const {
|
||||
selectedDateRange,
|
||||
datePickerValue,
|
||||
datePickerHelpers,
|
||||
showUpgradePrompt,
|
||||
setShowUpgradePrompt,
|
||||
handleDatePickerChange,
|
||||
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
|
||||
|
||||
const search = useMemo(
|
||||
() =>
|
||||
buildUserActivitySearch({
|
||||
user: user.id,
|
||||
date: [
|
||||
new Date(selectedDateRange.period_start.date),
|
||||
new Date(selectedDateRange.period_end.date),
|
||||
],
|
||||
}),
|
||||
[user.id, selectedDateRange]
|
||||
)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useUnifiedLogsInfiniteQuery({ projectRef, search })
|
||||
|
||||
const events = useMemo(() => {
|
||||
const rows = data?.pages?.flatMap((page) => page.data ?? []) ?? []
|
||||
const mapped = rows.map(mapLogRowToActivityEvent)
|
||||
return filter === 'errors' ? mapped.filter((event) => isErrorLevel(event.level)) : mapped
|
||||
}, [data?.pages, filter])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-6', PANEL_PADDING)}>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-2 gap-y-2">
|
||||
<div className="flex items-center border border-strong rounded-full w-min h-7">
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
|
||||
filter === 'all'
|
||||
? 'bg-overlay-hover text-foreground'
|
||||
: 'hover:bg-surface-200 text-foreground-light'
|
||||
)}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
<div className="h-full w-px border-r border-strong" />
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
|
||||
filter === 'errors'
|
||||
? 'bg-overlay-hover text-foreground'
|
||||
: 'hover:bg-surface-200 text-foreground-light'
|
||||
)}
|
||||
onClick={() => setFilter('errors')}
|
||||
>
|
||||
Errors only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LogsDatePicker
|
||||
onSubmit={handleDatePickerChange}
|
||||
value={datePickerValue}
|
||||
helpers={datePickerHelpers}
|
||||
open={showDatePicker}
|
||||
onOpenChange={setShowDatePicker}
|
||||
shortcutId={SHORTCUT_IDS.OBSERVABILITY_TOGGLE_DATE_PICKER}
|
||||
/>
|
||||
<UpgradePrompt
|
||||
show={showUpgradePrompt}
|
||||
setShowUpgradePrompt={setShowUpgradePrompt}
|
||||
title="Report date range"
|
||||
description="Report data can be stored for a maximum of 3 months depending on the plan that your project is on."
|
||||
source="userActivityDateRange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading || isPlaceholderData ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : isError ? (
|
||||
<AlertError error={error} subject="Failed to load user activity" />
|
||||
) : (
|
||||
<>
|
||||
<UserActivityTimeline
|
||||
events={events}
|
||||
projectRef={projectRef}
|
||||
onViewPayload={setPayloadEvent}
|
||||
dateRangeStart={selectedDateRange.period_start.date}
|
||||
dateRangeEnd={selectedDateRange.period_end.date}
|
||||
/>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="tiny"
|
||||
className="self-center"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<UserActivityEventDetailSheet event={payloadEvent} onClose={() => setPayloadEvent(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File renamed without changes.
@@ -1,4 +1,5 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { useFlag } from 'common'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { ResizablePanelGroup } from 'ui'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -27,6 +28,12 @@ vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
.mockReturnValue({ data: { ref: 'project-ref', connectionString: 'postgres://' } }),
|
||||
}))
|
||||
|
||||
// Defaults the `UserActivity` ConfigCat flag to off; individual tests override this.
|
||||
vi.mock('common', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('common')>()),
|
||||
useFlag: vi.fn().mockReturnValue(false),
|
||||
}))
|
||||
|
||||
// Heavy tab bodies — the sanctioned use of vi.mock. This spec only asserts tab
|
||||
// visibility, not what the tabs render.
|
||||
vi.mock('./UserOverview', () => ({
|
||||
@@ -35,6 +42,9 @@ vi.mock('./UserOverview', () => ({
|
||||
vi.mock('./UserLogs', () => ({
|
||||
UserLogs: () => <div data-testid="user-logs" />,
|
||||
}))
|
||||
vi.mock('./UserActivityTab', () => ({
|
||||
UserActivityTab: () => <div data-testid="user-activity" />,
|
||||
}))
|
||||
|
||||
const renderPanel = (disabledFeatures: string[] = []) => {
|
||||
// The user query flows through pg-meta SQL; mock it at the network boundary.
|
||||
@@ -88,4 +98,19 @@ describe('UserPanel', () => {
|
||||
expect(screen.queryByRole('tab', { name: 'Logs' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Raw JSON' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the Activity tab when the UserActivity flag is enabled', async () => {
|
||||
vi.mocked(useFlag).mockReturnValue(true)
|
||||
renderPanel([])
|
||||
|
||||
expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Activity' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Activity tab when the UserActivity flag is disabled', async () => {
|
||||
renderPanel([])
|
||||
|
||||
expect(await screen.findByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('tab', { name: 'Activity' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useFlag } from 'common'
|
||||
import { X } from 'lucide-react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { SimpleCodeBlock } from 'ui-patterns/SimpleCodeBlock'
|
||||
|
||||
import { UserActivityTab } from './UserActivityTab'
|
||||
import { UserLogs } from './UserLogs'
|
||||
import { UserOverview } from './UserOverview'
|
||||
import { PANEL_PADDING } from './Users.constants'
|
||||
@@ -26,13 +28,14 @@ import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
export const UserPanel = () => {
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const showLogs = useIsFeatureEnabled('logs:all')
|
||||
const showActivity = useFlag('UserActivity')
|
||||
|
||||
const [selectedId, setSelectedId] = useQueryState(
|
||||
'show',
|
||||
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
|
||||
)
|
||||
|
||||
const [view, setView] = useState<'overview' | 'raw' | 'logs'>('overview')
|
||||
const [view, setView] = useState<'overview' | 'raw' | 'logs' | 'activity'>('overview')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const { data: selectedUser, isPending } = useUserQuery({
|
||||
@@ -69,7 +72,7 @@ export const UserPanel = () => {
|
||||
<Tabs
|
||||
value={view}
|
||||
className="flex flex-col h-full"
|
||||
onValueChange={(value) => setView(value as 'overview' | 'raw' | 'logs')}
|
||||
onValueChange={(value) => setView(value as 'overview' | 'raw' | 'logs' | 'activity')}
|
||||
>
|
||||
{isPending ? (
|
||||
<div>
|
||||
@@ -95,6 +98,14 @@ export const UserPanel = () => {
|
||||
Logs
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showActivity && (
|
||||
<TabsTrigger
|
||||
value="activity"
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent shadow-none!"
|
||||
>
|
||||
Activity
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent shadow-none!"
|
||||
@@ -113,6 +124,11 @@ export const UserPanel = () => {
|
||||
{selectedUser && <UserLogs user={selectedUser} />}
|
||||
</TabsContent>
|
||||
)}
|
||||
{showActivity && (
|
||||
<TabsContent value="activity" className={cn('mt-0 grow min-h-0 overflow-y-auto')}>
|
||||
{selectedUser && <UserActivityTab user={selectedUser} />}
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent
|
||||
value="raw"
|
||||
className={cn('mt-0 grow min-h-0 overflow-y-auto', PANEL_PADDING)}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useParams } from 'common'
|
||||
import { UserSearch } from 'lucide-react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Badge, Button, cn } from 'ui'
|
||||
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
buildUserActivitySearch,
|
||||
isErrorLevel,
|
||||
mapLogRowToActivityEvent,
|
||||
type UserActivityEvent,
|
||||
} from './UserActivity.constants'
|
||||
import { UserActivityEventDetailSheet } from './UserActivityEventDetailSheet'
|
||||
import { UserActivitySelector } from './UserActivitySelector'
|
||||
import { UserActivityTimeline } from './UserActivityTimeline'
|
||||
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
|
||||
import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useUnifiedLogsInfiniteQuery } from '@/data/logs/unified-logs-infinite-query'
|
||||
import { useReportDateRange } from '@/hooks/misc/useReportDateRange'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
|
||||
type UserActivityFilter = 'all' | 'errors'
|
||||
|
||||
export const UserActivity = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useQueryState('user', parseAsString)
|
||||
const [filter, setFilter] = useState<UserActivityFilter>('all')
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [payloadEvent, setPayloadEvent] = useState<UserActivityEvent | null>(null)
|
||||
|
||||
const {
|
||||
selectedDateRange,
|
||||
datePickerValue,
|
||||
datePickerHelpers,
|
||||
showUpgradePrompt,
|
||||
setShowUpgradePrompt,
|
||||
handleDatePickerChange,
|
||||
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
|
||||
|
||||
const search = useMemo(
|
||||
() =>
|
||||
buildUserActivitySearch({
|
||||
user: selectedUserId ?? '',
|
||||
date: [
|
||||
new Date(selectedDateRange.period_start.date),
|
||||
new Date(selectedDateRange.period_end.date),
|
||||
],
|
||||
}),
|
||||
[selectedUserId, selectedDateRange]
|
||||
)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
isError,
|
||||
error,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useUnifiedLogsInfiniteQuery({ projectRef, search }, { enabled: !!selectedUserId })
|
||||
|
||||
const events = useMemo(() => {
|
||||
const rows = data?.pages?.flatMap((page) => page.data ?? []) ?? []
|
||||
const mapped = rows.map(mapLogRowToActivityEvent)
|
||||
return filter === 'errors' ? mapped.filter((event) => isErrorLevel(event.level)) : mapped
|
||||
}, [data?.pages, filter])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-6">
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<h1 className="text-xl text-foreground">User Activity</h1>
|
||||
<Badge variant="warning">Beta</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-2 gap-y-2">
|
||||
<UserActivitySelector
|
||||
projectRef={projectRef}
|
||||
connectionString={project?.connectionString}
|
||||
value={selectedUserId}
|
||||
onChange={setSelectedUserId}
|
||||
/>
|
||||
<div className="flex items-center border border-strong rounded-full w-min h-7">
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'text-xs w-[80px] h-full text-center rounded-l-full flex items-center justify-center transition',
|
||||
filter === 'all'
|
||||
? 'bg-overlay-hover text-foreground'
|
||||
: 'hover:bg-surface-200 text-foreground-light'
|
||||
)}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
<div className="h-full w-px border-r border-strong" />
|
||||
<button
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'text-xs w-[90px] h-full text-center rounded-r-full flex items-center justify-center transition',
|
||||
filter === 'errors'
|
||||
? 'bg-overlay-hover text-foreground'
|
||||
: 'hover:bg-surface-200 text-foreground-light'
|
||||
)}
|
||||
onClick={() => setFilter('errors')}
|
||||
>
|
||||
Errors only
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LogsDatePicker
|
||||
onSubmit={handleDatePickerChange}
|
||||
value={datePickerValue}
|
||||
helpers={datePickerHelpers}
|
||||
open={showDatePicker}
|
||||
onOpenChange={setShowDatePicker}
|
||||
shortcutId={SHORTCUT_IDS.OBSERVABILITY_TOGGLE_DATE_PICKER}
|
||||
/>
|
||||
<UpgradePrompt
|
||||
show={showUpgradePrompt}
|
||||
setShowUpgradePrompt={setShowUpgradePrompt}
|
||||
title="Report date range"
|
||||
description="Report data can be stored for a maximum of 3 months depending on the plan that your project is on."
|
||||
source="userActivityDateRange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedUserId ? (
|
||||
<EmptyStatePresentational
|
||||
icon={UserSearch}
|
||||
title="Select a user"
|
||||
description="Choose a user from the dropdown above to view their activity."
|
||||
/>
|
||||
) : isLoading || isPlaceholderData ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : isError ? (
|
||||
<AlertError error={error} subject="Failed to load user activity" />
|
||||
) : (
|
||||
<>
|
||||
<UserActivityTimeline
|
||||
events={events}
|
||||
projectRef={projectRef}
|
||||
onViewPayload={setPayloadEvent}
|
||||
dateRangeStart={selectedDateRange.period_start.date}
|
||||
dateRangeEnd={selectedDateRange.period_end.date}
|
||||
/>
|
||||
{hasNextPage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="tiny"
|
||||
className="self-center"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={() => fetchNextPage()}
|
||||
>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<UserActivityEventDetailSheet event={payloadEvent} onClose={() => setPayloadEvent(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
-203
@@ -1,203 +0,0 @@
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { useDebounce } from '@uidotdev/usehooks'
|
||||
import { ChevronDown, User as IconUser, Loader2, Search, X } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { getAvatarUrl, getDisplayName } from '@/components/interfaces/Auth/Users/Users.utils'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useUserQuery } from '@/data/auth/user-query'
|
||||
import { User, useUsersInfiniteQuery } from '@/data/auth/users-infinite-query'
|
||||
|
||||
interface UserActivitySelectorProps {
|
||||
projectRef: string | undefined
|
||||
connectionString: string | null | undefined
|
||||
value: string | null
|
||||
onChange: (userId: string | null) => void
|
||||
}
|
||||
|
||||
export const UserActivitySelector = ({
|
||||
projectRef,
|
||||
connectionString,
|
||||
value,
|
||||
onChange,
|
||||
}: UserActivitySelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const debouncedSearchText = useDebounce(searchText, 300)
|
||||
|
||||
// The last user picked from the list, kept locally so the trigger can show a display
|
||||
// name/avatar immediately without waiting on a lookup.
|
||||
const [resolvedUser, setResolvedUser] = useState<User | null>(null)
|
||||
|
||||
// On a fresh page load with only `?user=<id>` in the URL (no picked-user cache yet),
|
||||
// look the user up by id so the trigger can show their name instead of the raw id.
|
||||
const isResolved = resolvedUser?.id === value
|
||||
const { data: fetchedUser, isLoading: isLoadingUser } = useUserQuery(
|
||||
{ projectRef, connectionString, userId: value },
|
||||
{ enabled: !!value && !isResolved }
|
||||
)
|
||||
const displayUser = isResolved ? resolvedUser : (fetchedUser ?? null)
|
||||
const isResolvingUser = !!value && !displayUser && isLoadingUser
|
||||
|
||||
const {
|
||||
data,
|
||||
isSuccess,
|
||||
isPending: isLoading,
|
||||
isError,
|
||||
error,
|
||||
isFetching,
|
||||
isPlaceholderData,
|
||||
} = useUsersInfiniteQuery(
|
||||
{ projectRef, connectionString, keywords: debouncedSearchText.trim().toLowerCase() },
|
||||
{ placeholderData: keepPreviousData, enabled: open }
|
||||
)
|
||||
const users = useMemo(() => data?.pages.flatMap((page) => page.result) ?? [], [data?.pages])
|
||||
const isSearching = isPlaceholderData && isFetching
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
if (!user.id) return
|
||||
setResolvedUser(user)
|
||||
onChange(user.id)
|
||||
setOpen(false)
|
||||
setSearchText('')
|
||||
}
|
||||
|
||||
const displayName = displayUser
|
||||
? getDisplayName(displayUser, displayUser.email ?? displayUser.phone ?? displayUser.id ?? '')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="tiny"
|
||||
className="h-7 justify-start"
|
||||
iconRight={<ChevronDown className="text-foreground-muted" size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
{isResolvingUser ? (
|
||||
<span className="flex items-center gap-x-2">
|
||||
<Loader2 className="animate-spin text-foreground-lighter" size={14} />
|
||||
<span className="text-xs text-foreground-light">Loading user...</span>
|
||||
</span>
|
||||
) : value ? (
|
||||
<span className="flex items-center gap-x-2">
|
||||
<UserAvatar user={displayUser} />
|
||||
<span className="text-xs text-foreground">{displayName ?? value}</span>
|
||||
</span>
|
||||
) : (
|
||||
'Select a user'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-80" align="start">
|
||||
<div className="p-3 flex flex-col gap-y-2">
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
size="tiny"
|
||||
className="pr-10 border-none"
|
||||
placeholder="Search by id, email, phone, or name..."
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
value={searchText}
|
||||
autoFocus
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
{isSearching ? (
|
||||
<Loader2
|
||||
className="animate-spin text-foreground-lighter"
|
||||
size={16}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
) : (
|
||||
<Search className="text-foreground-lighter" size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
{searchText && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="tiny" variant="text" onClick={() => setSearchText('')}>
|
||||
<span className="sr-only">Clear search</span>
|
||||
<X size={12} />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-24">
|
||||
<Loader2 className="animate-spin" size={24} />
|
||||
<span className="text-foreground-light text-sm">Loading users...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve users" />}
|
||||
|
||||
{isSuccess &&
|
||||
(users.length > 0 ? (
|
||||
<ul className="divide-y max-h-[240px] overflow-y-auto -mx-3">
|
||||
{users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<UserActivitySelectorRow user={user} onClick={handleSelect} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-16">
|
||||
<p className="text-foreground-light text-xs" role="status">
|
||||
No users found
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const UserAvatar = ({ user }: { user: User | null }) => {
|
||||
const avatarUrl = user ? getAvatarUrl(user) : undefined
|
||||
if (avatarUrl) {
|
||||
return <img className="rounded-full w-5 h-5" src={avatarUrl} alt="" />
|
||||
}
|
||||
return (
|
||||
<div className="rounded-full w-[21px] h-[21px] bg-surface-300 border flex items-center justify-center text-foreground-lighter">
|
||||
<IconUser size={12} strokeWidth={2} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UserActivitySelectorRow = ({
|
||||
user,
|
||||
onClick,
|
||||
}: {
|
||||
user: User
|
||||
onClick: (user: User) => void
|
||||
}) => {
|
||||
const emailOrPhone = user.email || user.phone
|
||||
const displayName = getDisplayName(user, '')
|
||||
const showDisplayName = displayName && displayName !== emailOrPhone
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onClick(user)}
|
||||
className="w-full flex items-center gap-x-3 px-3 py-2 text-left hover:bg-surface-200"
|
||||
>
|
||||
<UserAvatar user={user} />
|
||||
<span className="text-sm flex flex-col">
|
||||
<span className="text-foreground">{emailOrPhone}</span>
|
||||
{showDisplayName && <span className="text-foreground-lighter text-xs">{displayName}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -48,7 +48,6 @@ export const useGenerateObservabilityMenu = () => {
|
||||
|
||||
const showOverview = useFlag('observabilityOverview')
|
||||
const topForPostgres = useFlag('topForPostgres')
|
||||
const userActivity = useFlag('UserActivity')
|
||||
const { isSupamonitorEnabled } = useSupamonitorStatus()
|
||||
const storageSupported = useIsFeatureEnabled('project_storage:all')
|
||||
|
||||
@@ -65,15 +64,6 @@ export const useGenerateObservabilityMenu = () => {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(userActivity
|
||||
? [
|
||||
{
|
||||
name: 'User Activity',
|
||||
key: 'user-activity',
|
||||
url: `${baseUrl}/user-activity${preservedQueryParams}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isSupamonitorEnabled
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { UserActivity } from '@/components/interfaces/Observability/UserActivity/UserActivity'
|
||||
import { ReportPadding } from '@/components/interfaces/Reports/ReportPadding'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const UserActivityPage: NextPageWithLayout = () => (
|
||||
<ReportPadding>
|
||||
<UserActivity />
|
||||
</ReportPadding>
|
||||
)
|
||||
|
||||
UserActivityPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<ObservabilityLayout title="User Activity">{page}</ObservabilityLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default UserActivityPage
|
||||
@@ -111,7 +111,6 @@ import { Route as ProjectRefSettingsAddonsRouteImport } from './routes/project/$
|
||||
import { Route as ProjectRefRealtimeSettingsRouteImport } from './routes/project/$ref/realtime/settings'
|
||||
import { Route as ProjectRefRealtimePoliciesRouteImport } from './routes/project/$ref/realtime/policies'
|
||||
import { Route as ProjectRefRealtimeInspectorRouteImport } from './routes/project/$ref/realtime/inspector'
|
||||
import { Route as ProjectRefObservabilityUserActivityRouteImport } from './routes/project/$ref/observability/user-activity'
|
||||
import { Route as ProjectRefObservabilityStorageRouteImport } from './routes/project/$ref/observability/storage'
|
||||
import { Route as ProjectRefObservabilityRealtimeRouteImport } from './routes/project/$ref/observability/realtime'
|
||||
import { Route as ProjectRefObservabilityQueryPerformanceRouteImport } from './routes/project/$ref/observability/query-performance'
|
||||
@@ -850,12 +849,6 @@ const ProjectRefRealtimeInspectorRoute =
|
||||
path: '/inspector',
|
||||
getParentRoute: () => ProjectRefRealtimeRoute,
|
||||
} as any)
|
||||
const ProjectRefObservabilityUserActivityRoute =
|
||||
ProjectRefObservabilityUserActivityRouteImport.update({
|
||||
id: '/user-activity',
|
||||
path: '/user-activity',
|
||||
getParentRoute: () => ProjectRefObservabilityRoute,
|
||||
} as any)
|
||||
const ProjectRefObservabilityStorageRoute =
|
||||
ProjectRefObservabilityStorageRouteImport.update({
|
||||
id: '/storage',
|
||||
@@ -2200,7 +2193,6 @@ export interface FileRoutesByFullPath {
|
||||
'/project/$ref/observability/query-performance': typeof ProjectRefObservabilityQueryPerformanceRoute
|
||||
'/project/$ref/observability/realtime': typeof ProjectRefObservabilityRealtimeRoute
|
||||
'/project/$ref/observability/storage': typeof ProjectRefObservabilityStorageRoute
|
||||
'/project/$ref/observability/user-activity': typeof ProjectRefObservabilityUserActivityRoute
|
||||
'/project/$ref/realtime/inspector': typeof ProjectRefRealtimeInspectorRoute
|
||||
'/project/$ref/realtime/policies': typeof ProjectRefRealtimePoliciesRoute
|
||||
'/project/$ref/realtime/settings': typeof ProjectRefRealtimeSettingsRoute
|
||||
@@ -2497,7 +2489,6 @@ export interface FileRoutesByTo {
|
||||
'/project/$ref/observability/query-performance': typeof ProjectRefObservabilityQueryPerformanceRoute
|
||||
'/project/$ref/observability/realtime': typeof ProjectRefObservabilityRealtimeRoute
|
||||
'/project/$ref/observability/storage': typeof ProjectRefObservabilityStorageRoute
|
||||
'/project/$ref/observability/user-activity': typeof ProjectRefObservabilityUserActivityRoute
|
||||
'/project/$ref/realtime/inspector': typeof ProjectRefRealtimeInspectorRoute
|
||||
'/project/$ref/realtime/policies': typeof ProjectRefRealtimePoliciesRoute
|
||||
'/project/$ref/realtime/settings': typeof ProjectRefRealtimeSettingsRoute
|
||||
@@ -2807,7 +2798,6 @@ export interface FileRoutesById {
|
||||
'/project/$ref/observability/query-performance': typeof ProjectRefObservabilityQueryPerformanceRoute
|
||||
'/project/$ref/observability/realtime': typeof ProjectRefObservabilityRealtimeRoute
|
||||
'/project/$ref/observability/storage': typeof ProjectRefObservabilityStorageRoute
|
||||
'/project/$ref/observability/user-activity': typeof ProjectRefObservabilityUserActivityRoute
|
||||
'/project/$ref/realtime/inspector': typeof ProjectRefRealtimeInspectorRoute
|
||||
'/project/$ref/realtime/policies': typeof ProjectRefRealtimePoliciesRoute
|
||||
'/project/$ref/realtime/settings': typeof ProjectRefRealtimeSettingsRoute
|
||||
@@ -3117,7 +3107,6 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/observability/query-performance'
|
||||
| '/project/$ref/observability/realtime'
|
||||
| '/project/$ref/observability/storage'
|
||||
| '/project/$ref/observability/user-activity'
|
||||
| '/project/$ref/realtime/inspector'
|
||||
| '/project/$ref/realtime/policies'
|
||||
| '/project/$ref/realtime/settings'
|
||||
@@ -3414,7 +3403,6 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/observability/query-performance'
|
||||
| '/project/$ref/observability/realtime'
|
||||
| '/project/$ref/observability/storage'
|
||||
| '/project/$ref/observability/user-activity'
|
||||
| '/project/$ref/realtime/inspector'
|
||||
| '/project/$ref/realtime/policies'
|
||||
| '/project/$ref/realtime/settings'
|
||||
@@ -3723,7 +3711,6 @@ export interface FileRouteTypes {
|
||||
| '/project/$ref/observability/query-performance'
|
||||
| '/project/$ref/observability/realtime'
|
||||
| '/project/$ref/observability/storage'
|
||||
| '/project/$ref/observability/user-activity'
|
||||
| '/project/$ref/realtime/inspector'
|
||||
| '/project/$ref/realtime/policies'
|
||||
| '/project/$ref/realtime/settings'
|
||||
@@ -4712,13 +4699,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectRefRealtimeInspectorRouteImport
|
||||
parentRoute: typeof ProjectRefRealtimeRoute
|
||||
}
|
||||
'/project/$ref/observability/user-activity': {
|
||||
id: '/project/$ref/observability/user-activity'
|
||||
path: '/user-activity'
|
||||
fullPath: '/project/$ref/observability/user-activity'
|
||||
preLoaderRoute: typeof ProjectRefObservabilityUserActivityRouteImport
|
||||
parentRoute: typeof ProjectRefObservabilityRoute
|
||||
}
|
||||
'/project/$ref/observability/storage': {
|
||||
id: '/project/$ref/observability/storage'
|
||||
path: '/storage'
|
||||
@@ -6605,7 +6585,6 @@ interface ProjectRefObservabilityRouteChildren {
|
||||
ProjectRefObservabilityQueryPerformanceRoute: typeof ProjectRefObservabilityQueryPerformanceRoute
|
||||
ProjectRefObservabilityRealtimeRoute: typeof ProjectRefObservabilityRealtimeRoute
|
||||
ProjectRefObservabilityStorageRoute: typeof ProjectRefObservabilityStorageRoute
|
||||
ProjectRefObservabilityUserActivityRoute: typeof ProjectRefObservabilityUserActivityRoute
|
||||
ProjectRefObservabilityIndexRoute: typeof ProjectRefObservabilityIndexRoute
|
||||
}
|
||||
|
||||
@@ -6628,8 +6607,6 @@ const ProjectRefObservabilityRouteChildren: ProjectRefObservabilityRouteChildren
|
||||
ProjectRefObservabilityQueryPerformanceRoute,
|
||||
ProjectRefObservabilityRealtimeRoute: ProjectRefObservabilityRealtimeRoute,
|
||||
ProjectRefObservabilityStorageRoute: ProjectRefObservabilityStorageRoute,
|
||||
ProjectRefObservabilityUserActivityRoute:
|
||||
ProjectRefObservabilityUserActivityRoute,
|
||||
ProjectRefObservabilityIndexRoute: ProjectRefObservabilityIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import UserActivityPage from '@/pages/project/[ref]/observability/user-activity'
|
||||
|
||||
export const Route = createFileRoute('/project/$ref/observability/user-activity')({
|
||||
component: ObservabilityUserActivityRoute,
|
||||
staticData: {
|
||||
observabilityLayoutTitle: 'User Activity',
|
||||
},
|
||||
})
|
||||
|
||||
function ObservabilityUserActivityRoute() {
|
||||
return <UserActivityPage dehydratedState={undefined} />
|
||||
}
|
||||
Reference in new issue
Block a user