mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
example filter bar in use
This commit is contained in:
1 parent
3a73c717f8
commit
0f4e98ca27
1 file changed
+308
-178
@@ -1,24 +1,71 @@
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal, X } from 'lucide-react'
|
||||
import { Eye, EyeOff, RefreshCw, Search, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import CSVButton from 'components/ui/CSVButton'
|
||||
import DatabaseSelector from 'components/ui/DatabaseSelector'
|
||||
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { Button, Input, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui'
|
||||
import { Calendar } from 'ui'
|
||||
import DatePickers from './Logs.DatePickers'
|
||||
import {
|
||||
FILTER_OPTIONS,
|
||||
LOG_ROUTES_WITH_REPLICA_SUPPORT,
|
||||
LogsTableName,
|
||||
PREVIEWER_DATEPICKER_HELPERS,
|
||||
SQL_FILTER_TEMPLATES,
|
||||
} from './Logs.constants'
|
||||
import type { Filters, LogSearchCallback, LogTemplate } from './Logs.types'
|
||||
import LogsFilterPopover from './LogsFilterPopover'
|
||||
import { FilterBar } from 'ui-patterns/FilterBar'
|
||||
import type {
|
||||
FilterGroup,
|
||||
FilterCondition,
|
||||
FilterProperty,
|
||||
CustomOptionProps,
|
||||
} from 'ui-patterns/FilterBar'
|
||||
|
||||
interface CustomDateRangePickerProps {
|
||||
value?: { from: Date; to?: Date }
|
||||
onChange: (range: { from: Date; to?: Date } | undefined) => void
|
||||
}
|
||||
|
||||
function CustomDateRangePicker({ onChange, onCancel }: CustomOptionProps) {
|
||||
const [dateRange, setDateRange] = useState<any | undefined>()
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<Calendar
|
||||
initialFocus
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 py-3 px-4 border-t">
|
||||
<Button type="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
if (dateRange?.from) {
|
||||
const from = dayjs(dateRange.from).toISOString()
|
||||
const to = dateRange.to ? dayjs(dateRange.to).toISOString() : dayjs().toISOString()
|
||||
onChange(`${from}|${to}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PreviewFilterPanelProps {
|
||||
defaultSearchValue?: string
|
||||
@@ -43,9 +90,31 @@ interface PreviewFilterPanelProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs control panel header + wrapper
|
||||
*/
|
||||
function useDebounce<T extends (...args: any[]) => void>(callback: T, delay: number) {
|
||||
const timeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback(
|
||||
(...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(...args)
|
||||
}, delay)
|
||||
},
|
||||
[callback, delay]
|
||||
) as T
|
||||
}
|
||||
|
||||
const PreviewFilterPanel = ({
|
||||
isLoading,
|
||||
newCount,
|
||||
@@ -68,163 +137,223 @@ const PreviewFilterPanel = ({
|
||||
}: PreviewFilterPanelProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const { data: loadBalancers } = useLoadBalancersQuery({ projectRef: ref })
|
||||
|
||||
// [Joshen] These are the routes tested that can show replica logs
|
||||
const showDatabaseSelector =
|
||||
IS_PLATFORM && LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)
|
||||
|
||||
const hasEdits = search !== defaultSearchValue
|
||||
const filterProperties = useMemo(() => {
|
||||
const tableFilters = FILTER_OPTIONS[table]
|
||||
if (!tableFilters) return []
|
||||
|
||||
// Sync local state with provided default value
|
||||
useEffect(() => {
|
||||
if (search !== defaultSearchValue) {
|
||||
setSearch(defaultSearchValue)
|
||||
const properties: FilterProperty[] = []
|
||||
|
||||
// Add search query as a special filter
|
||||
properties.push({
|
||||
label: 'Search',
|
||||
name: 'search_query',
|
||||
type: 'string' as const,
|
||||
operators: ['='],
|
||||
})
|
||||
|
||||
// Add date range filter
|
||||
properties.push({
|
||||
label: 'Time Range',
|
||||
name: 'timerange',
|
||||
type: 'string' as const,
|
||||
operators: ['='],
|
||||
options: [
|
||||
{
|
||||
label: 'Last 15 minutes',
|
||||
value: `${dayjs().subtract(15, 'minute').toISOString()}|${dayjs().toISOString()}`,
|
||||
},
|
||||
{
|
||||
label: 'Last 30 minutes',
|
||||
value: `${dayjs().subtract(30, 'minute').toISOString()}|${dayjs().toISOString()}`,
|
||||
},
|
||||
{
|
||||
label: 'Last hour',
|
||||
value: `${dayjs().subtract(1, 'hour').toISOString()}|${dayjs().toISOString()}`,
|
||||
},
|
||||
{
|
||||
label: 'Last 3 hours',
|
||||
value: `${dayjs().subtract(3, 'hour').toISOString()}|${dayjs().toISOString()}`,
|
||||
},
|
||||
{
|
||||
label: 'Last 24 hours',
|
||||
value: `${dayjs().subtract(24, 'hour').toISOString()}|${dayjs().toISOString()}`,
|
||||
},
|
||||
{
|
||||
label: 'Custom Range...',
|
||||
component: (props: CustomOptionProps) => <CustomDateRangePicker {...props} />,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Add table-specific filters
|
||||
Object.entries(tableFilters).forEach(([key, filterSet]) => {
|
||||
properties.push({
|
||||
label: filterSet.label,
|
||||
name: filterSet.key,
|
||||
type: 'string' as const,
|
||||
operators: ['='],
|
||||
options: filterSet.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.key,
|
||||
description: option.description,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
return properties
|
||||
}, [table])
|
||||
|
||||
// Convert Logs.Filters to FilterBar.FilterGroup
|
||||
const filterBarFilters = useMemo(() => {
|
||||
const conditions: FilterCondition[] = []
|
||||
|
||||
// Handle search query as a special case
|
||||
if (filters.search_query) {
|
||||
conditions.push({
|
||||
propertyName: 'search_query',
|
||||
operator: '=',
|
||||
value: String(filters.search_query),
|
||||
})
|
||||
}
|
||||
}, [defaultSearchValue])
|
||||
|
||||
const RefreshButton = () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
title="refresh"
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
icon={
|
||||
<div className="relative">
|
||||
{newCount > 0 && (
|
||||
<div className="absolute -top-3 right-3 flex items-center justify-center">
|
||||
<div className="absolute z-20">
|
||||
<p style={{ fontSize: '0.6rem' }} className="text-white">
|
||||
{newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-4 w-4 animate-ping rounded-full bg-green-800 opacity-60"></div>
|
||||
<div className="z-60 absolute top-0 right-0 h-full w-full rounded-full bg-green-900 opacity-80"></div>
|
||||
</div>
|
||||
)}
|
||||
<RefreshCw />
|
||||
</div>
|
||||
// Handle date range - only add from defaultValues if no timerange filter exists
|
||||
if ((defaultFromValue || defaultToValue) && !filters.timerange) {
|
||||
conditions.push({
|
||||
propertyName: 'timerange',
|
||||
operator: '=',
|
||||
value: `${defaultFromValue || ''}|${defaultToValue || ''}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert other filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (key === 'search_query') return // Skip search_query as it's handled above
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// If the filter object is empty, add a condition with no value
|
||||
if (Object.keys(value).length === 0) {
|
||||
conditions.push({
|
||||
propertyName: key,
|
||||
operator: '=',
|
||||
value: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Handle active filters
|
||||
Object.entries(value as Record<string, boolean>).forEach(([subKey, isEnabled]) => {
|
||||
if (isEnabled) {
|
||||
conditions.push({
|
||||
propertyName: key,
|
||||
operator: '=',
|
||||
value: subKey,
|
||||
})
|
||||
}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Refresh logs
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
} else if (value !== undefined && value !== null) {
|
||||
conditions.push({
|
||||
propertyName: key,
|
||||
operator: '=',
|
||||
value: String(value),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const handleDatepickerChange = ({ to, from }: Partial<Parameters<LogSearchCallback>[1]>) => {
|
||||
onSearch('datepicker-change', { to, from })
|
||||
return {
|
||||
logicalOperator: 'AND',
|
||||
conditions,
|
||||
} as FilterGroup
|
||||
}, [filters, defaultFromValue, defaultToValue])
|
||||
|
||||
const handleFilterChange = (filterGroup: FilterGroup) => {
|
||||
const newFilters: Filters = {}
|
||||
let hasTimeRange = false
|
||||
|
||||
filterGroup.conditions.forEach((condition) => {
|
||||
if (!('propertyName' in condition)) return
|
||||
|
||||
const propertyName = condition.propertyName
|
||||
if (!propertyName) return
|
||||
|
||||
if (propertyName === 'search_query') {
|
||||
newFilters.search_query = condition.value ? String(condition.value) : undefined
|
||||
} else if (propertyName === 'timerange') {
|
||||
hasTimeRange = true
|
||||
if (condition.value) {
|
||||
const value = String(condition.value)
|
||||
const [from, to] = value.split('|')
|
||||
newFilters.timerange = { [value]: true }
|
||||
onSearch('datepicker-change', { from, to })
|
||||
} else {
|
||||
newFilters.timerange = {}
|
||||
onSearch('datepicker-change', { from: '', to: '' })
|
||||
}
|
||||
} else {
|
||||
if (!newFilters[propertyName]) {
|
||||
newFilters[propertyName] = {}
|
||||
}
|
||||
|
||||
if (!condition.value) {
|
||||
return
|
||||
}
|
||||
|
||||
;(newFilters[propertyName] as Record<string, boolean>)[condition.value] = true
|
||||
}
|
||||
})
|
||||
|
||||
// If timerange was completely removed from the filter group
|
||||
if (!hasTimeRange) {
|
||||
onSearch('datepicker-change', { from: '', to: '' })
|
||||
}
|
||||
|
||||
onFiltersChange(newFilters)
|
||||
}
|
||||
|
||||
const handleInputSearch = (query: string) => onSearch('search-input-change', { query })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between',
|
||||
condensedLayout ? ' p-3' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<form
|
||||
id="log-panel-search"
|
||||
onSubmit={(e) => {
|
||||
// prevent redirection
|
||||
e.preventDefault()
|
||||
handleInputSearch(search)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="w-60"
|
||||
size="tiny"
|
||||
placeholder="Search events"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
handleInputSearch(e.target.value)
|
||||
}}
|
||||
icon={
|
||||
<div className="text-foreground-lighter">
|
||||
<Search size={14} />
|
||||
</div>
|
||||
}
|
||||
value={search}
|
||||
actions={
|
||||
<div className="flex items-center gap-x-1 mr-0.5">
|
||||
{hasEdits && (
|
||||
<ButtonTooltip
|
||||
icon={<span>↲</span>}
|
||||
type="text"
|
||||
className="px-1 h-[20px]"
|
||||
onClick={() => handleInputSearch(search)}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Search for events' } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{search.length > 0 && (
|
||||
<ButtonTooltip
|
||||
icon={<X />}
|
||||
type="text"
|
||||
className="p-[1px] h-[20px]"
|
||||
onClick={() => handleInputSearch('')}
|
||||
tooltip={{ content: { side: 'bottom', text: 'Clear search' } }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
<div className={cn('flex w-full flex-col gap-2', condensedLayout ? ' p-3' : '', className)}>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<div className="flex-1">
|
||||
<FilterBar
|
||||
filterProperties={filterProperties}
|
||||
filters={filterBarFilters}
|
||||
onFilterChange={handleFilterChange}
|
||||
freeformText={defaultSearchValue}
|
||||
onFreeformTextChange={(value) => onSearch('search-input-change', { query: value })}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<RefreshButton />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
title="refresh"
|
||||
type="default"
|
||||
className="px-1.5"
|
||||
icon={
|
||||
<div className="relative">
|
||||
{newCount > 0 && (
|
||||
<div className="absolute -top-3 right-3 flex items-center justify-center">
|
||||
<div className="absolute z-20">
|
||||
<p style={{ fontSize: '0.6rem' }} className="text-white">
|
||||
{newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-4 w-4 animate-ping rounded-full bg-green-800 opacity-60"></div>
|
||||
<div className="z-60 absolute top-0 right-0 h-full w-full rounded-full bg-green-900 opacity-80"></div>
|
||||
</div>
|
||||
)}
|
||||
<RefreshCw />
|
||||
</div>
|
||||
}
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
|
||||
<DatePickers
|
||||
onChange={handleDatepickerChange}
|
||||
to={defaultToValue}
|
||||
from={defaultFromValue}
|
||||
helpers={PREVIEWER_DATEPICKER_HELPERS}
|
||||
/>
|
||||
|
||||
{FILTER_OPTIONS[table] !== undefined && (
|
||||
<div className="flex items-center">
|
||||
{FILTER_OPTIONS[table] &&
|
||||
Object.values(FILTER_OPTIONS[table]).map((x, i: number) => {
|
||||
const classes = []
|
||||
|
||||
if (Object.values(FILTER_OPTIONS[table]).length >= 2) {
|
||||
if (i === 0) {
|
||||
classes.push('rounded-tr-none rounded-br-none')
|
||||
} else if (i === Object.values(FILTER_OPTIONS[table]).length - 1) {
|
||||
classes.push('rounded-tl-none rounded-bl-none')
|
||||
} else {
|
||||
classes.push('rounded-none')
|
||||
}
|
||||
}
|
||||
|
||||
const lastItemIndex = Object.values(FILTER_OPTIONS[table]).length - 1
|
||||
const align = i === 0 ? 'start' : i === lastItemIndex ? 'end' : 'center'
|
||||
|
||||
return (
|
||||
<LogsFilterPopover
|
||||
buttonClassName={classes.join(' ')}
|
||||
key={`${x.key}-filter`}
|
||||
options={x}
|
||||
onFiltersChange={onFiltersChange}
|
||||
filters={filters}
|
||||
align={align}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => onToggleEventChart()}
|
||||
@@ -232,38 +361,39 @@ const PreviewFilterPanel = ({
|
||||
>
|
||||
Chart
|
||||
</Button>
|
||||
</div>
|
||||
<CSVButton data={csvData} disabled={!Boolean(csvData)} title="Download data" />
|
||||
</div>
|
||||
|
||||
{showDatabaseSelector ? (
|
||||
<div className="flex items-center justify-center gap-x-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="px-1.5" type="default" icon={<Terminal />}>
|
||||
<Link href={queryUrl} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Open query in Logs Explorer
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DatabaseSelector
|
||||
onSelectId={onSelectedDatabaseChange}
|
||||
additionalOptions={
|
||||
table === LogsTableName.EDGE
|
||||
? (loadBalancers ?? []).length > 0
|
||||
? [{ id: `${ref}-all`, name: 'API Load Balancer' }]
|
||||
: []
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<CSVButton data={csvData} disabled={!Boolean(csvData)} title="Download data" />
|
||||
|
||||
{showDatabaseSelector ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button asChild className="px-1.5" type="default" icon={<Terminal />}>
|
||||
<Link href={queryUrl} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Open query in Logs Explorer
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DatabaseSelector
|
||||
onSelectId={onSelectedDatabaseChange}
|
||||
additionalOptions={
|
||||
table === LogsTableName.EDGE
|
||||
? (loadBalancers ?? []).length > 0
|
||||
? [{ id: `${ref}-all`, name: 'API Load Balancer' }]
|
||||
: []
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild type="default" onClick={onExploreClick}>
|
||||
<Link href={queryUrl}>Explore via query</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild type="default" onClick={onExploreClick}>
|
||||
<Link href={queryUrl}>Explore via query</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user