mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: reports storage (#36442)
* init storage charts v2 * add storage chart endpoint * add storage charts to storage * cleanup storage reports * chore: try functions report * fix storage sql composition * clear menu * chore: use FilterBar to filter storage report * chore: fix api report product filtering * comment path * remove Response Errors storage chart * update filtering UI * fix filtering concatenation logic * refactor components to useReportFilters hook * cleanup * fix filter concatenation * add filerable text input * add AND between filters * fix padding * Temp remove filter bar * Nudges * Swap collapsible component * fix cache hit and cache miss queries * Link to cache guide * Add a flag for storage report --------- Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
1 parent
c54842b8f9
commit
8c41ec5e66
17 files changed
+1108
-289
No files matched your search
@@ -11,6 +11,7 @@ interface DropdownControlProps {
|
||||
onSelect: (value: string | number) => void
|
||||
side?: 'bottom' | 'left' | 'top' | 'right' | undefined
|
||||
align?: 'start' | 'center' | 'end' | undefined
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DropdownControl = ({
|
||||
@@ -19,10 +20,11 @@ export const DropdownControl = ({
|
||||
align,
|
||||
options,
|
||||
onSelect,
|
||||
className,
|
||||
}: PropsWithChildren<DropdownControlProps>) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger className={className}>{children}</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side={side} align={align}>
|
||||
<div className="dropdown-control" style={{ maxHeight: '30vh' }}>
|
||||
{options.length === 0 && <p className="dropdown-control__empty-text">No more items</p>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronDown, Database, Plus, RefreshCw, X } from 'lucide-react'
|
||||
import { ComponentProps, useState } from 'react'
|
||||
import { ChevronDown, Database, RefreshCw } from 'lucide-react'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
|
||||
import { useParams } from 'common'
|
||||
@@ -10,18 +10,18 @@ import { Auth, Realtime, Storage } from 'icons'
|
||||
import { BASE_PATH } from 'lib/constants'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Popover,
|
||||
Select,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import { DatePickerValue, LogsDatePicker } from '../Settings/Logs/Logs.DatePickers'
|
||||
import { REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
|
||||
import { ReportFilterPopover } from './ReportFilterPopover'
|
||||
import { useReportFilters } from './useReportFilters'
|
||||
|
||||
import type { ReportFilterItem } from './Reports.types'
|
||||
|
||||
interface ReportFilterBarProps {
|
||||
@@ -34,6 +34,8 @@ interface ReportFilterBarProps {
|
||||
datepickerTo?: string
|
||||
datepickerFrom?: string
|
||||
datepickerHelpers: typeof REPORTS_DATEPICKER_HELPERS
|
||||
selectedProduct?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PRODUCT_FILTERS = [
|
||||
@@ -87,35 +89,21 @@ const ReportFilterBar = ({
|
||||
onRemoveFilters,
|
||||
onRefresh,
|
||||
datepickerHelpers,
|
||||
selectedProduct,
|
||||
className,
|
||||
}: ReportFilterBarProps) => {
|
||||
const { ref } = useParams()
|
||||
const { data: loadBalancers } = useLoadBalancersQuery({ projectRef: ref })
|
||||
|
||||
const filterKeys = [
|
||||
'request.path',
|
||||
'request.method',
|
||||
'request.search',
|
||||
'request.headers.x_client_info',
|
||||
'request.headers.user_agent',
|
||||
'response.status_code',
|
||||
]
|
||||
const [showAdder, setShowAdder] = useState(false)
|
||||
//const { ref } = useParams()
|
||||
//const { data: loadBalancers } = useLoadBalancersQuery({ projectRef: ref })
|
||||
const [currentProductFilter, setCurrentProductFilter] = useState<
|
||||
null | (typeof PRODUCT_FILTERS)[number]
|
||||
>(null)
|
||||
const [addFilterValues, setAddFilterValues] = useState<ReportFilterItem>({
|
||||
key: filterKeys[0],
|
||||
compare: 'matches',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const resetFilterValues = () => {
|
||||
setAddFilterValues({
|
||||
key: filterKeys[0],
|
||||
compare: 'matches',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
// Use the custom hook for filter management
|
||||
// const { localFilters, filterProperties, handleFilterChange } = useReportFilters({
|
||||
// onAddFilter,
|
||||
// onRemoveFilters,
|
||||
// filters,
|
||||
// })
|
||||
|
||||
const handleDatepickerChange = (vals: DatePickerValue) => {
|
||||
onDatepickerChange(vals)
|
||||
@@ -152,9 +140,15 @@ const ReportFilterBar = ({
|
||||
text: defaultHelper.text,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProduct) {
|
||||
handleProductFilterChange(PRODUCT_FILTERS.find((p) => p.key === selectedProduct) ?? null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-row justify-start items-center flex-wrap gap-2">
|
||||
<div className={cn('flex flex-wrap md:items-center justify-between gap-2', className)}>
|
||||
<div className="flex gap-2">
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={isLoading}
|
||||
@@ -168,171 +162,78 @@ const ReportFilterBar = ({
|
||||
value={selectedRange}
|
||||
helpers={datepickerHelpers}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="default"
|
||||
className="inline-flex flex-row gap-2"
|
||||
iconRight={<ChevronDown size={14} />}
|
||||
>
|
||||
<span>
|
||||
{currentProductFilter === null ? 'All Requests' : currentProductFilter.label}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuItem onClick={() => handleProductFilterChange(null)}>
|
||||
<p>All Requests</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{PRODUCT_FILTERS.map((productFilter) => {
|
||||
const Icon = productFilter.icon
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={productFilter.key}
|
||||
className="space-x-2"
|
||||
disabled={productFilter.key === currentProductFilter?.key}
|
||||
onClick={() => handleProductFilterChange(productFilter)}
|
||||
>
|
||||
{productFilter.key === 'graphql' ? (
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/graphql.svg`}
|
||||
className="w-[20px] h-[20px] mr-2"
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
) : Icon !== null ? (
|
||||
<Icon size={20} strokeWidth={1.5} className="mr-2" />
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
<p
|
||||
className={cn(
|
||||
productFilter.key === currentProductFilter?.key ? 'font-bold' : '',
|
||||
'inline-block'
|
||||
)}
|
||||
>
|
||||
{productFilter.label}
|
||||
</p>
|
||||
<p className=" text-left text-foreground-light inline-block w-[180px]">
|
||||
{productFilter.description}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{filters
|
||||
.filter(
|
||||
(filter) =>
|
||||
filter.value !== currentProductFilter?.filterValue ||
|
||||
filter.key !== currentProductFilter?.filterKey
|
||||
)
|
||||
.map((filter) => (
|
||||
<div
|
||||
key={`${filter.key}-${filter.compare}-${filter.value}`}
|
||||
className="text-xs rounded border border-foreground-lighter bg-surface-300 px-2 h-7 flex flex-row justify-center gap-1 items-center"
|
||||
>
|
||||
{filter.key} {filter.compare} {filter.value}
|
||||
{!selectedProduct && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="!p-0 !space-x-0"
|
||||
onClick={() => onRemoveFilters([filter])}
|
||||
icon={<X className="text-foreground-light" />}
|
||||
type="default"
|
||||
className="inline-flex flex-row gap-2"
|
||||
iconRight={<ChevronDown size={14} />}
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
<span>
|
||||
{currentProductFilter === null ? 'All Requests' : currentProductFilter.label}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Popover
|
||||
align="end"
|
||||
header={
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<h5 className="text-sm text-foreground">Add Filter</h5>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuItem onClick={() => handleProductFilterChange(null)}>
|
||||
<p>All Requests</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{PRODUCT_FILTERS.map((productFilterItem) => {
|
||||
const Icon = productFilterItem.icon
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
onClick={() => {
|
||||
onAddFilter(addFilterValues)
|
||||
setShowAdder(false)
|
||||
resetFilterValues()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={showAdder}
|
||||
onOpenChange={(openValue) => setShowAdder(openValue)}
|
||||
overlay={
|
||||
<div className="px-3 py-3 flex flex-col gap-2">
|
||||
<Select
|
||||
size="tiny"
|
||||
value={addFilterValues.key}
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({ ...prev, key: e.target.value }))
|
||||
}}
|
||||
label="Attribute Filter"
|
||||
>
|
||||
{filterKeys.map((key) => (
|
||||
<Select.Option key={key} value={key}>
|
||||
{key}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="tiny"
|
||||
value={addFilterValues.compare}
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({
|
||||
...prev,
|
||||
compare: e.target.value as ReportFilterItem['compare'],
|
||||
}))
|
||||
}}
|
||||
label="Comparison"
|
||||
>
|
||||
{['matches', 'is'].map((value) => (
|
||||
<Select.Option key={value} value={value}>
|
||||
{value}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
size="tiny"
|
||||
label="Value"
|
||||
placeholder={
|
||||
addFilterValues.compare === 'matches'
|
||||
? 'Provide a regex expression'
|
||||
: 'Provide a string'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({ ...prev, value: e.target.value }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showClose
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<Plus className={`text-foreground-light `} />}
|
||||
>
|
||||
<span>Add filter</span>
|
||||
</Button>
|
||||
</Popover>
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={productFilterItem.key}
|
||||
className="space-x-2"
|
||||
disabled={productFilterItem.key === currentProductFilter?.key}
|
||||
onClick={() => handleProductFilterChange(productFilterItem)}
|
||||
>
|
||||
{productFilterItem.key === 'graphql' ? (
|
||||
<SVG
|
||||
src={`${BASE_PATH}/img/graphql.svg`}
|
||||
className="w-[20px] h-[20px] mr-2"
|
||||
preProcessor={(code) =>
|
||||
code.replace(/svg/, 'svg class="m-auto text-color-inherit"')
|
||||
}
|
||||
/>
|
||||
) : Icon !== null ? (
|
||||
<Icon size={20} strokeWidth={1.5} className="mr-2" />
|
||||
) : null}
|
||||
<div className="flex flex-col">
|
||||
<p
|
||||
className={cn(
|
||||
productFilterItem.key === currentProductFilter?.key ? 'font-bold' : '',
|
||||
'inline-block'
|
||||
)}
|
||||
>
|
||||
{productFilterItem.label}
|
||||
</p>
|
||||
<p className=" text-left text-foreground-light inline-block w-[180px]">
|
||||
{productFilterItem.description}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{/* <div className="order-last md:order-none flex-1">
|
||||
<ReportFilterPopover
|
||||
filterProperties={filterProperties}
|
||||
filters={localFilters}
|
||||
onFiltersChange={handleFilterChange}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<DatabaseSelector
|
||||
{/* <DatabaseSelector
|
||||
additionalOptions={
|
||||
(loadBalancers ?? []).length > 0 ? [{ id: `${ref}-all`, name: 'API Load Balancer' }] : []
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { ChevronDown, Filter as FilterIcon, Plus, X } from 'lucide-react'
|
||||
import { KeyboardEvent, useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
|
||||
import { DropdownControl } from 'components/grid/components/common/DropdownControl'
|
||||
import {
|
||||
Button,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverSeparator_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
Input,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
cn,
|
||||
} from 'ui'
|
||||
import type { ReportFilter, ReportFilterProperty } from './Reports.types'
|
||||
import { sizes } from 'ui/src/lib/commonCva'
|
||||
import defaultTheme from 'ui/src/lib/theme/defaultTheme'
|
||||
|
||||
const FilterableInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
availableOptions = [],
|
||||
}: {
|
||||
value: string | number
|
||||
onChange: (value: string | number) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
type?: string
|
||||
availableOptions?: string[]
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(String(value || ''))
|
||||
|
||||
// Ensure we always have a valid array
|
||||
const safeOptions = Array.isArray(availableOptions) ? availableOptions : []
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(String(value || ''))
|
||||
}, [value])
|
||||
|
||||
const handleInputChange = (newValue: string) => {
|
||||
setInputValue(newValue)
|
||||
onChange(newValue)
|
||||
}
|
||||
|
||||
const handleOptionSelect = (option: string) => {
|
||||
setInputValue(option)
|
||||
onChange(option)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
// When dropdown is open, let Command component handle Enter for selection
|
||||
if (event.key === 'Enter' && isOpen) {
|
||||
// Don't call onKeyDown to prevent interference with Command's selection
|
||||
return
|
||||
}
|
||||
|
||||
// When dropdown is closed, allow custom input
|
||||
if (event.key === 'Enter' && !isOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
onKeyDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<Command_Shadcn_ className="relative overflow-visible bg-transparent">
|
||||
<CommandInput_Shadcn_
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onValueChange={handleInputChange}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn('h-6 text-sm', defaultTheme.input.variants.standard, sizes.tiny)}
|
||||
wrapperClassName="!p-0 !border !border-control rounded-md"
|
||||
showSearchIcon={false}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-full left-0 right-0 z-50 mt-1 opacity-0 transition-opacity pointer-events-none',
|
||||
isOpen && safeOptions.length > 0 && 'opacity-100 pointer-events-auto'
|
||||
)}
|
||||
>
|
||||
<CommandList_Shadcn_ className="max-h-60 overflow-auto bg-surface-100 border border-border rounded-md shadow-lg">
|
||||
<CommandEmpty_Shadcn_ className="py-2 px-3 text-sm text-foreground-lighter">
|
||||
No matching options found. Press Enter to use "{inputValue}"
|
||||
</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{safeOptions.map((option, index) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={`${option}-${index}`}
|
||||
value={option}
|
||||
onSelect={() => handleOptionSelect(option)}
|
||||
className="px-3 py-2 text-sm cursor-pointer"
|
||||
>
|
||||
{option}
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</div>
|
||||
</Command_Shadcn_>
|
||||
{isOpen && <div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ReportFilterPopoverProps {
|
||||
buttonText?: string
|
||||
filters: ReportFilter[]
|
||||
filterProperties: ReportFilterProperty[]
|
||||
onFiltersChange: (filters: ReportFilter[]) => void
|
||||
portal?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ReportFilterRow = ({
|
||||
filter,
|
||||
filterIdx,
|
||||
filterProperties,
|
||||
onChange,
|
||||
onDelete,
|
||||
onKeyDown,
|
||||
}: {
|
||||
filter: ReportFilter
|
||||
filterIdx: number
|
||||
filterProperties: ReportFilterProperty[]
|
||||
onChange: (index: number, filter: ReportFilter) => void
|
||||
onDelete: (index: number) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}) => {
|
||||
const property = filterProperties.find((p) => p.name === filter.propertyName)
|
||||
|
||||
const propertyOptions = filterProperties.map((prop) => ({
|
||||
value: prop.name,
|
||||
label: prop.label,
|
||||
postLabel: prop.type,
|
||||
}))
|
||||
|
||||
const operatorOptions =
|
||||
property?.operators.map((op) => ({
|
||||
value: op,
|
||||
label: op,
|
||||
})) || []
|
||||
|
||||
const valueOptions = property?.options || []
|
||||
|
||||
const handlePropertyChange = (newPropertyName: string | number) => {
|
||||
const newProperty = filterProperties.find((p) => p.name === newPropertyName)
|
||||
if (newProperty) {
|
||||
onChange(filterIdx, {
|
||||
propertyName: newPropertyName,
|
||||
operator: newProperty.operators[0] || '=',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOperatorChange = (newOperator: string | number) => {
|
||||
onChange(filterIdx, {
|
||||
...filter,
|
||||
operator: newOperator,
|
||||
})
|
||||
}
|
||||
|
||||
const handleValueChange = (value: string | number) => {
|
||||
onChange(filterIdx, {
|
||||
...filter,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3">
|
||||
{filterIdx > 0 && (
|
||||
<div className="flex items-center gap-2 p-1 w-full">
|
||||
<div className="w-auto flex-1 bg-border h-px" />
|
||||
<span className="text-xs font-mono text-foreground-lighter">AND</span>
|
||||
<div className="w-auto flex-1 bg-border h-px" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center justify-between gap-x-2">
|
||||
<DropdownControl align="start" options={propertyOptions} onSelect={handlePropertyChange}>
|
||||
<Button
|
||||
asChild
|
||||
type="outline"
|
||||
icon={
|
||||
<div className="text-foreground-lighter">
|
||||
<ChevronDown strokeWidth={1.5} />
|
||||
</div>
|
||||
}
|
||||
className="w-32 justify-start"
|
||||
>
|
||||
<span>{property?.label ?? 'Select property'}</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
|
||||
<DropdownControl align="start" options={operatorOptions} onSelect={handleOperatorChange}>
|
||||
<Button
|
||||
asChild
|
||||
type="outline"
|
||||
icon={
|
||||
<div className="text-foreground-lighter">
|
||||
<ChevronDown strokeWidth={1.5} />
|
||||
</div>
|
||||
}
|
||||
className="w-20 justify-start"
|
||||
>
|
||||
<span>{filter.operator}</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
|
||||
{valueOptions?.length > 0 ? (
|
||||
<FilterableInput
|
||||
value={filter.value}
|
||||
onChange={handleValueChange}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={
|
||||
property?.placeholder || `Enter ${property?.type === 'number' ? 'number' : 'text'}`
|
||||
}
|
||||
availableOptions={valueOptions?.map((option) => String(option.value)) || []}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="tiny"
|
||||
className="flex-1"
|
||||
placeholder={
|
||||
property?.placeholder || `Enter ${property?.type === 'number' ? 'number' : 'text'}`
|
||||
}
|
||||
value={filter.value}
|
||||
type={property?.type === 'number' ? 'number' : 'text'}
|
||||
onChange={(event) => handleValueChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="px-1"
|
||||
icon={<X strokeWidth={1.5} />}
|
||||
onClick={() => onDelete(filterIdx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReportFilterPopover = ({
|
||||
buttonText,
|
||||
filters,
|
||||
filterProperties,
|
||||
onFiltersChange,
|
||||
portal = true,
|
||||
disabled = false,
|
||||
}: ReportFilterPopoverProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [localFilters, setLocalFilters] = useState<ReportFilter[]>(filters)
|
||||
|
||||
// Update local state when filters prop changes
|
||||
useMemo(() => {
|
||||
setLocalFilters(filters)
|
||||
}, [filters])
|
||||
|
||||
const displayButtonText =
|
||||
buttonText ??
|
||||
(filters.length > 0
|
||||
? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}`
|
||||
: 'Filter')
|
||||
|
||||
const onAddFilter = () => {
|
||||
const firstProperty = filterProperties[0]
|
||||
if (firstProperty) {
|
||||
setLocalFilters([
|
||||
...localFilters,
|
||||
{
|
||||
propertyName: firstProperty.name,
|
||||
operator: firstProperty.operators[0] || '=',
|
||||
value: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const onChangeFilter = useCallback((index: number, filter: ReportFilter) => {
|
||||
setLocalFilters((currentFilters) => [
|
||||
...currentFilters.slice(0, index),
|
||||
filter,
|
||||
...currentFilters.slice(index + 1),
|
||||
])
|
||||
}, [])
|
||||
|
||||
const onDeleteFilter = useCallback((index: number) => {
|
||||
setLocalFilters((currentFilters) => [
|
||||
...currentFilters.slice(0, index),
|
||||
...currentFilters.slice(index + 1),
|
||||
])
|
||||
}, [])
|
||||
|
||||
const onApplyFilters = () => {
|
||||
// Filter out empty values
|
||||
const validFilters = localFilters.filter(
|
||||
(f) => f.value !== null && f.value !== undefined && f.value !== ''
|
||||
)
|
||||
onFiltersChange(validFilters)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const onResetFilters = () => {
|
||||
setLocalFilters([])
|
||||
onFiltersChange([])
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') onApplyFilters()
|
||||
}
|
||||
|
||||
const hasChanges = !isEqual(localFilters, filters)
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
type={filters.length > 0 ? 'link' : 'text'}
|
||||
icon={<FilterIcon />}
|
||||
disabled={disabled}
|
||||
>
|
||||
{displayButtonText}
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-[500px]" side="bottom" align="start" portal={portal}>
|
||||
<div className="space-y-2 py-2">
|
||||
<div>
|
||||
{localFilters.map((filter, index) => (
|
||||
<ReportFilterRow
|
||||
key={`filter-${filter.propertyName}-${index}`}
|
||||
filter={filter}
|
||||
filterIdx={index}
|
||||
filterProperties={filterProperties}
|
||||
onChange={onChangeFilter}
|
||||
onDelete={onDeleteFilter}
|
||||
onKeyDown={handleEnterKeyDown}
|
||||
/>
|
||||
))}
|
||||
{localFilters.length === 0 && (
|
||||
<div className="space-y-1 px-3">
|
||||
<h5 className="text-sm text-foreground-light">No filters applied</h5>
|
||||
<p className="text-xs text-foreground-lighter">
|
||||
Add a filter below to narrow down the report data
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PopoverSeparator_Shadcn_ />
|
||||
<div className="px-3 flex flex-row justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<Plus />} type="text" onClick={onAddFilter}>
|
||||
Add filter
|
||||
</Button>
|
||||
{filters.length > 0 && (
|
||||
<Button type="text" onClick={onResetFilters}>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button disabled={!hasChanges} type="default" onClick={onApplyFilters}>
|
||||
Apply filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface ReportWidgetProps<T = any> {
|
||||
title: string
|
||||
description?: string
|
||||
error?: string | Object | null
|
||||
tooltip?: string
|
||||
tooltip?: string | ReactNode
|
||||
className?: string
|
||||
renderer: (props: ReportWidgetRendererProps) => ReactNode
|
||||
append?: (props: ReportWidgetRendererProps) => ReactNode
|
||||
|
||||
@@ -15,13 +15,13 @@ export const REPORTS_DATEPICKER_HELPERS: ReportsDatetimeHelper[] = [
|
||||
text: 'Last 10 minutes',
|
||||
calcFrom: () => dayjs().subtract(10, 'minute').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
availableIn: ['team', 'enterprise'],
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
},
|
||||
{
|
||||
text: 'Last 30 minutes',
|
||||
calcFrom: () => dayjs().subtract(30, 'minute').toISOString(),
|
||||
calcTo: () => dayjs().toISOString(),
|
||||
availableIn: ['team', 'enterprise'],
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
},
|
||||
{
|
||||
text: 'Last 60 minutes',
|
||||
@@ -82,13 +82,32 @@ export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true)
|
||||
const normalizedKey = [splitKey[splitKey.length - 2], splitKey[splitKey.length - 1]].join('.')
|
||||
const filterKey = filter.key.includes('.') ? normalizedKey : filter.key
|
||||
|
||||
if (filter.compare === 'matches') {
|
||||
return `REGEXP_CONTAINS(${filterKey}, '${filter.value}')`
|
||||
} else if (filter.compare === 'is') {
|
||||
return `${filterKey} = ${filter.value}`
|
||||
// Handle different comparison operators
|
||||
switch (filter.compare) {
|
||||
case 'matches':
|
||||
return `REGEXP_CONTAINS(${filterKey}, '${filter.value}')`
|
||||
case 'is':
|
||||
return `${filterKey} = ${filter.value}`
|
||||
case '!=':
|
||||
return `${filterKey} != ${filter.value}`
|
||||
case '>=':
|
||||
return `${filterKey} >= ${filter.value}`
|
||||
case '<=':
|
||||
return `${filterKey} <= ${filter.value}`
|
||||
case '>':
|
||||
return `${filterKey} > ${filter.value}`
|
||||
case '<':
|
||||
return `${filterKey} < ${filter.value}`
|
||||
default:
|
||||
// Fallback to exact match for unknown operators
|
||||
return `${filterKey} = ${filter.value}`
|
||||
}
|
||||
})
|
||||
.filter(Boolean) // Remove any null/undefined conditions
|
||||
.join(' AND ')
|
||||
|
||||
if (conditions === '') return ''
|
||||
|
||||
if (prepend) {
|
||||
return 'WHERE ' + conditions
|
||||
} else {
|
||||
@@ -281,7 +300,7 @@ export const PRESET_CONFIG: Record<Presets, PresetConfig> = {
|
||||
cacheHitRate: {
|
||||
queryType: 'logs',
|
||||
// storage report does not perform any filtering
|
||||
sql: (_filters) => `
|
||||
sql: (filters) => `
|
||||
-- reports-storage-cache-hit-rate
|
||||
SELECT
|
||||
timestamp_trunc(timestamp, hour) as timestamp,
|
||||
@@ -293,6 +312,7 @@ from edge_logs f
|
||||
cross join unnest(m.response) as res
|
||||
cross join unnest(res.headers) as h
|
||||
where starts_with(r.path, '/storage/v1/object') and r.method = 'GET'
|
||||
${generateRegexpWhere(filters, false)}
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`,
|
||||
@@ -300,7 +320,7 @@ order by timestamp desc
|
||||
topCacheMisses: {
|
||||
queryType: 'logs',
|
||||
// storage report does not perform any filtering
|
||||
sql: (_filters) => `
|
||||
sql: (filters) => `
|
||||
-- reports-storage-top-cache-misses
|
||||
SELECT
|
||||
r.path as path,
|
||||
@@ -311,9 +331,10 @@ from edge_logs f
|
||||
cross join unnest(m.request) as r
|
||||
cross join unnest(m.response) as res
|
||||
cross join unnest(res.headers) as h
|
||||
where starts_with(r.path, '/storage/v1/object')
|
||||
where starts_with(r.path, '/storage/v1/object')
|
||||
and r.method = 'GET'
|
||||
and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC')
|
||||
${generateRegexpWhere(filters, false)}
|
||||
group by path, search
|
||||
order by count desc
|
||||
limit 12
|
||||
|
||||
@@ -49,6 +49,21 @@ export interface PathsDatum {
|
||||
export interface ReportFilterItem {
|
||||
key: string
|
||||
value: string | number
|
||||
compare: 'matches' | 'is'
|
||||
compare: 'matches' | 'is' | '>=' | '<=' | '>' | '<' | '!='
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface ReportFilterProperty {
|
||||
label: string
|
||||
name: string
|
||||
type: 'string' | 'number'
|
||||
options?: Array<{ label: string; value: string }>
|
||||
operators: string[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface ReportFilter {
|
||||
propertyName: string | number
|
||||
operator: string | number
|
||||
value: string | number
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
Button,
|
||||
Collapsible,
|
||||
WarningIcon,
|
||||
Collapsible_Shadcn_,
|
||||
CollapsibleTrigger_Shadcn_,
|
||||
CollapsibleContent_Shadcn_,
|
||||
} from 'ui'
|
||||
import { queryParamsToObject } from '../Reports.utils'
|
||||
import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget'
|
||||
@@ -62,7 +65,7 @@ export const NetworkTrafficRenderer = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-col gap-12 w-full">
|
||||
<BarChart
|
||||
size="small"
|
||||
title="Ingress"
|
||||
@@ -346,8 +349,8 @@ interface RouteTdContentProps {
|
||||
search: string
|
||||
}
|
||||
const RouteTdContent = (datum: RouteTdContentProps) => (
|
||||
<Collapsible>
|
||||
<Collapsible.Trigger asChild>
|
||||
<Collapsible_Shadcn_>
|
||||
<CollapsibleTrigger_Shadcn_ asChild>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button asChild type="text" className=" !py-0 !p-1" title="Show more route details">
|
||||
<span>
|
||||
@@ -381,8 +384,8 @@ const RouteTdContent = (datum: RouteTdContentProps) => (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content className="pt-2">
|
||||
</CollapsibleTrigger_Shadcn_>
|
||||
<CollapsibleContent_Shadcn_ className="pt-2">
|
||||
{datum.search ? (
|
||||
<pre className={`syntax-highlight overflow-auto rounded bg-surface-100 p-2 !text-xs`}>
|
||||
<div
|
||||
@@ -395,6 +398,6 @@ const RouteTdContent = (datum: RouteTdContentProps) => (
|
||||
) : (
|
||||
<p className="text-xs text-foreground-lighter">No query parameters in this request</p>
|
||||
)}
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</CollapsibleContent_Shadcn_>
|
||||
</Collapsible_Shadcn_>
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
import type { ReportFilter, ReportFilterItem, ReportFilterProperty } from './Reports.types'
|
||||
|
||||
export enum ReportFilterKeys {
|
||||
PATH = 'request.path',
|
||||
SEARCH = 'request.search',
|
||||
X_CLIENT_INFO = 'request.headers.x_client_info',
|
||||
USER_AGENT = 'request.headers.user_agent',
|
||||
STATUS_CODE = 'response.status_code',
|
||||
}
|
||||
|
||||
export const REPORT_FILTER_PARAMS_PARSER = {
|
||||
[ReportFilterKeys.PATH]: parseAsString,
|
||||
[ReportFilterKeys.SEARCH]: parseAsString,
|
||||
[ReportFilterKeys.X_CLIENT_INFO]: parseAsString,
|
||||
[ReportFilterKeys.USER_AGENT]: parseAsString,
|
||||
[ReportFilterKeys.STATUS_CODE]: parseAsString,
|
||||
}
|
||||
|
||||
interface UseReportFiltersProps {
|
||||
onAddFilter: (filter: ReportFilterItem) => void
|
||||
onRemoveFilters: (filters: ReportFilterItem[]) => void
|
||||
filters: ReportFilterItem[]
|
||||
}
|
||||
|
||||
export const useReportFilters = ({
|
||||
onAddFilter,
|
||||
onRemoveFilters,
|
||||
filters,
|
||||
}: UseReportFiltersProps) => {
|
||||
// URL-safe operator mappings
|
||||
const URL_OPERATOR_MAP = {
|
||||
'=': 'eq',
|
||||
'!=': 'neq',
|
||||
'>=': 'gte',
|
||||
'<=': 'lte',
|
||||
'>': 'gt',
|
||||
'<': 'lt',
|
||||
CONTAINS: 'contains',
|
||||
'STARTS WITH': 'startswith',
|
||||
'ENDS WITH': 'endswith',
|
||||
}
|
||||
|
||||
const REVERSE_URL_OPERATOR_MAP = Object.fromEntries(
|
||||
Object.entries(URL_OPERATOR_MAP).map(([k, v]) => [v, k])
|
||||
)
|
||||
|
||||
// Parse encoded filter value (e.g., "gte:300" -> { operator: ">=", value: "300" })
|
||||
const parseFilterValue = useCallback((encodedValue: string | number | null) => {
|
||||
if (!encodedValue || typeof encodedValue !== 'string') {
|
||||
return { operator: '=', value: encodedValue?.toString() || '' }
|
||||
}
|
||||
|
||||
const parts = encodedValue.split(':')
|
||||
if (parts.length === 2) {
|
||||
const [urlOperator, value] = parts
|
||||
const operator = REVERSE_URL_OPERATOR_MAP[urlOperator] || '='
|
||||
return { operator, value }
|
||||
}
|
||||
|
||||
return { operator: '=', value: encodedValue }
|
||||
}, [])
|
||||
|
||||
const encodeFilterValue = useCallback((operator: string | number, value: string | number) => {
|
||||
const urlOperator = URL_OPERATOR_MAP[operator as keyof typeof URL_OPERATOR_MAP] || 'eq'
|
||||
return `${urlOperator}:${value}`
|
||||
}, [])
|
||||
|
||||
// Convert query filters to ReportFilter format
|
||||
const convertQueryFiltersToReportFilters = useCallback(
|
||||
(queryState: Record<string, string | number | null>): ReportFilter[] => {
|
||||
const filters: ReportFilter[] = []
|
||||
|
||||
Object.entries(queryState).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
const { operator, value: filterValue } = parseFilterValue(value)
|
||||
filters.push({
|
||||
propertyName: key,
|
||||
value: filterValue,
|
||||
operator: operator,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return filters
|
||||
},
|
||||
[parseFilterValue]
|
||||
)
|
||||
|
||||
// Convert operator from FilterBar format to ReportFilterItem format
|
||||
const getCompareOperator = useCallback(
|
||||
(operator: string | number): 'matches' | 'is' | '>=' | '<=' | '>' | '<' | '!=' => {
|
||||
switch (operator) {
|
||||
case '=':
|
||||
return 'is'
|
||||
case '!=':
|
||||
return '!='
|
||||
case '>=':
|
||||
return '>='
|
||||
case '<=':
|
||||
return '<='
|
||||
case '>':
|
||||
return '>'
|
||||
case '<':
|
||||
return '<'
|
||||
case 'CONTAINS':
|
||||
case 'STARTS WITH':
|
||||
case 'ENDS WITH':
|
||||
return 'matches'
|
||||
default:
|
||||
return 'matches'
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Convert ReportFilter to ReportFilterItem format for the report system
|
||||
const convertReportFiltersToReportFilterItems = useMemo(
|
||||
() =>
|
||||
(reportFilters: ReportFilter[]): ReportFilterItem[] => {
|
||||
const reportFilterItems: ReportFilterItem[] = []
|
||||
|
||||
reportFilters.forEach((filter) => {
|
||||
if (filter.value !== null && filter.value !== '' && filter.value !== undefined) {
|
||||
reportFilterItems.push({
|
||||
key: filter.propertyName.toString(),
|
||||
value: filter.value.toString(),
|
||||
compare: getCompareOperator(filter.operator),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return reportFilterItems
|
||||
},
|
||||
[getCompareOperator]
|
||||
)
|
||||
|
||||
// Convert ReportFilter back to query filters format
|
||||
const convertReportFiltersToQueryFilters = useMemo(
|
||||
() => (reportFilters: ReportFilter[]) => {
|
||||
const queryUpdate: Record<string, string | number | null> = {}
|
||||
|
||||
// Clear all existing filters first
|
||||
Object.keys(REPORT_FILTER_PARAMS_PARSER).forEach((key) => {
|
||||
queryUpdate[key] = null
|
||||
})
|
||||
|
||||
// Add new filters with encoded operator+value
|
||||
reportFilters.forEach((filter) => {
|
||||
if (filter.propertyName in REPORT_FILTER_PARAMS_PARSER) {
|
||||
const encodedValue = encodeFilterValue(filter.operator, filter.value as string | number)
|
||||
queryUpdate[filter.propertyName] = encodedValue
|
||||
}
|
||||
})
|
||||
|
||||
return queryUpdate
|
||||
},
|
||||
[encodeFilterValue]
|
||||
)
|
||||
|
||||
const [queryFilters, setQueryFilters] = useQueryStates(REPORT_FILTER_PARAMS_PARSER)
|
||||
const [localFilters, setLocalFilters] = useState<ReportFilter[]>([])
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const lastAppliedFilterState = useRef<string>('')
|
||||
const preventUrlSync = useRef(false)
|
||||
|
||||
// Initialize local state from URL params (only once on mount)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) {
|
||||
const initialFilters = convertQueryFiltersToReportFilters(queryFilters)
|
||||
setLocalFilters(initialFilters)
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}, [isInitialized, convertQueryFiltersToReportFilters, queryFilters])
|
||||
|
||||
// Sync URL changes back to local state (when URL changes externally)
|
||||
useEffect(() => {
|
||||
if (!isInitialized || preventUrlSync.current) return
|
||||
|
||||
const filtersFromUrl = convertQueryFiltersToReportFilters(queryFilters)
|
||||
const currentFilterJson = JSON.stringify(localFilters)
|
||||
const urlFilterJson = JSON.stringify(filtersFromUrl)
|
||||
|
||||
if (currentFilterJson !== urlFilterJson) {
|
||||
console.log('Syncing local filters from URL change:', { filtersFromUrl, localFilters })
|
||||
setLocalFilters(filtersFromUrl)
|
||||
}
|
||||
}, [queryFilters, isInitialized, convertQueryFiltersToReportFilters])
|
||||
|
||||
// Sync local filter changes back to URL state (only after initialization)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return
|
||||
|
||||
const queryUpdate = convertReportFiltersToQueryFilters(localFilters)
|
||||
|
||||
// Prevent feedback loop during URL updates
|
||||
preventUrlSync.current = true
|
||||
setQueryFilters(queryUpdate).finally(() => {
|
||||
preventUrlSync.current = false
|
||||
})
|
||||
}, [localFilters, isInitialized, setQueryFilters, convertReportFiltersToQueryFilters])
|
||||
|
||||
// Separate effect for report filter updates (only for filters with values)
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return
|
||||
|
||||
const reportFilterItems = convertReportFiltersToReportFilterItems(localFilters)
|
||||
|
||||
// Create a state string for comparison (only for the filters we manage)
|
||||
const newFilterState = reportFilterItems
|
||||
.map((filter) => `${filter.key}:${filter.value}:${filter.compare}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
if (lastAppliedFilterState.current !== newFilterState) {
|
||||
lastAppliedFilterState.current = newFilterState
|
||||
|
||||
// Separate product filters (managed by dropdown) from other filters (managed by ReportFilterPopover)
|
||||
const PRODUCT_FILTER_KEYS = ['request.path']
|
||||
const PRODUCT_FILTER_VALUES = ['/rest', '/auth', '/storage', '/realtime', '/graphql']
|
||||
|
||||
const productFilters = filters.filter(
|
||||
(f) =>
|
||||
PRODUCT_FILTER_KEYS.includes(f.key) && PRODUCT_FILTER_VALUES.includes(f.value.toString())
|
||||
)
|
||||
const otherFilters = filters.filter(
|
||||
(f) =>
|
||||
!(
|
||||
PRODUCT_FILTER_KEYS.includes(f.key) &&
|
||||
PRODUCT_FILTER_VALUES.includes(f.value.toString())
|
||||
)
|
||||
)
|
||||
|
||||
console.log('Filter Update:', {
|
||||
localFilters,
|
||||
reportFilterItems,
|
||||
productFilters,
|
||||
otherFilters,
|
||||
newFilterState,
|
||||
})
|
||||
|
||||
// Remove only the non-product filters that we manage
|
||||
if (otherFilters.length > 0) {
|
||||
onRemoveFilters(otherFilters)
|
||||
}
|
||||
|
||||
// Add all the new filters from the popover
|
||||
reportFilterItems.forEach((filter) => onAddFilter(filter))
|
||||
}
|
||||
}, [localFilters, isInitialized])
|
||||
|
||||
const handleFilterChange = (newFilters: ReportFilter[]) => {
|
||||
console.log('handleFilterChange called with:', newFilters)
|
||||
setLocalFilters(newFilters)
|
||||
}
|
||||
|
||||
// Get filter properties based on product type
|
||||
const getFilterProperties = (): ReportFilterProperty[] => {
|
||||
const baseProperties: ReportFilterProperty[] = [
|
||||
{
|
||||
label: 'Status Code',
|
||||
name: ReportFilterKeys.STATUS_CODE,
|
||||
type: 'number' as const,
|
||||
options: [
|
||||
{ label: '200', value: '200' },
|
||||
{ label: '300', value: '300' },
|
||||
{ label: '400', value: '400' },
|
||||
{ label: '401', value: '401' },
|
||||
{ label: '403', value: '403' },
|
||||
{ label: '404', value: '404' },
|
||||
{ label: '409', value: '409' },
|
||||
{ label: '411', value: '411' },
|
||||
{ label: '413', value: '413' },
|
||||
{ label: '416', value: '416' },
|
||||
{ label: '423', value: '423' },
|
||||
{ label: '500', value: '500' },
|
||||
{ label: '503', value: '503' },
|
||||
{ label: '504', value: '504' },
|
||||
],
|
||||
operators: ['=', '!=', '>', '<', '>=', '<='],
|
||||
placeholder: '200',
|
||||
},
|
||||
{
|
||||
label: 'User Agent',
|
||||
name: ReportFilterKeys.USER_AGENT,
|
||||
type: 'string' as const,
|
||||
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
|
||||
placeholder:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||
},
|
||||
{
|
||||
label: 'Search Params',
|
||||
name: ReportFilterKeys.SEARCH,
|
||||
type: 'string' as const,
|
||||
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
|
||||
placeholder: '?foo=bar',
|
||||
},
|
||||
{
|
||||
label: 'Client Info',
|
||||
name: ReportFilterKeys.X_CLIENT_INFO,
|
||||
type: 'string' as const,
|
||||
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
|
||||
placeholder: 'supabase-js/1.0.0',
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Path',
|
||||
name: ReportFilterKeys.PATH,
|
||||
type: 'string' as const,
|
||||
operators: ['=', '!=', 'CONTAINS', 'STARTS WITH', 'ENDS WITH'],
|
||||
placeholder: '/rest/v1/tablename',
|
||||
},
|
||||
...baseProperties,
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
localFilters,
|
||||
filterProperties: getFilterProperties(),
|
||||
handleFilterChange,
|
||||
ReportFilterKeys,
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,13 @@ const ReportsMenu = () => {
|
||||
const { profile } = useProfile()
|
||||
const { ref, id } = useParams()
|
||||
const pageKey = (id || router.pathname.split('/')[4]) as string
|
||||
const storageEnabled = useIsFeatureEnabled('project_storage:all')
|
||||
const authEnabled = useFlag('authreportv2')
|
||||
const edgeFnEnabled = useFlag('edgefunctionreport')
|
||||
const storageReportEnabled = useFlag('storagereport')
|
||||
|
||||
// b/c fly doesn't support storage
|
||||
const storageSupported = useIsFeatureEnabled('project_storage:all')
|
||||
const storageEnabled = storageReportEnabled && storageSupported
|
||||
|
||||
const canCreateCustomReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
|
||||
resource: { type: 'report', owner_id: profile?.id },
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface ComposedChartHandlerProps {
|
||||
showLegend?: boolean
|
||||
showTotal?: boolean
|
||||
showMaxValue?: boolean
|
||||
updateDateRange: UpdateDateRange
|
||||
updateDateRange?: UpdateDateRange
|
||||
valuePrecision?: number
|
||||
isVisible?: boolean
|
||||
docsUrl?: string
|
||||
|
||||
@@ -30,7 +30,7 @@ function Panel(props: PropsWithChildren<PanelProps>) {
|
||||
{props.title && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-100 border-b border-default flex items-center px-4 md:px-6 py-4',
|
||||
'bg-surface-100 border-b border-default flex items-center px-4 py-4',
|
||||
props.titleClasses
|
||||
)}
|
||||
>
|
||||
@@ -50,13 +50,13 @@ function Panel(props: PropsWithChildren<PanelProps>) {
|
||||
}
|
||||
|
||||
function Content({ children, className }: { children: ReactNode; className?: string | false }) {
|
||||
return <div className={cn('px-4 md:px-6 py-4', className)}>{children}</div>
|
||||
return <div className={cn('px-4 py-4', className)}>{children}</div>
|
||||
}
|
||||
|
||||
function Footer({ children }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className="bg-surface-100 border-t border-default">
|
||||
<div className="flex h-12 items-center px-4 md:px-6">{children}</div>
|
||||
<div className="flex h-12 items-center px-4">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,7 +91,7 @@ const PanelNotice = forwardRef<
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
'relative px-4 md:px-6 py-5 bg-studio flex flex-col lg:flex-row lg:justify-between gap-6 overflow-hidden lg:items-center',
|
||||
'relative px-4 py-5 bg-studio flex flex-col lg:flex-row lg:justify-between gap-6 overflow-hidden lg:items-center',
|
||||
layout === 'vertical' && '!flex-col !items-start gap-y-2',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,49 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
|
||||
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
|
||||
import { ReportFilterItem } from 'components/interfaces/Reports/Reports.types'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
|
||||
import type { LogsEndpointParams } from 'components/interfaces/Settings/Logs/Logs.types'
|
||||
|
||||
export const useStorageReport = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const [filters, setFilters] = useState<ReportFilterItem[]>([])
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.storage.queries>(
|
||||
const identifier = state.selectedDatabaseId
|
||||
|
||||
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.api.queries>(
|
||||
PRESET_CONFIG.api.queries,
|
||||
projectRef ?? 'default'
|
||||
)
|
||||
const storageQueryHooks = queriesFactory<keyof typeof PRESET_CONFIG.storage.queries>(
|
||||
PRESET_CONFIG.storage.queries,
|
||||
projectRef ?? 'default'
|
||||
)
|
||||
const cacheHitRate = queryHooks.cacheHitRate()
|
||||
const topCacheMisses = queryHooks.topCacheMisses()
|
||||
const activeHooks = [cacheHitRate, topCacheMisses]
|
||||
const totalRequests = queryHooks.totalRequests()
|
||||
const topRoutes = queryHooks.topRoutes()
|
||||
const errorCounts = queryHooks.errorCounts()
|
||||
const topErrorRoutes = queryHooks.topErrorRoutes()
|
||||
const responseSpeed = queryHooks.responseSpeed()
|
||||
const topSlowRoutes = queryHooks.topSlowRoutes()
|
||||
const networkTraffic = queryHooks.networkTraffic()
|
||||
const cacheHitRate = storageQueryHooks.cacheHitRate()
|
||||
const topCacheMisses = storageQueryHooks.topCacheMisses()
|
||||
const activeHooks = [
|
||||
totalRequests,
|
||||
topRoutes,
|
||||
errorCounts,
|
||||
topErrorRoutes,
|
||||
responseSpeed,
|
||||
topSlowRoutes,
|
||||
networkTraffic,
|
||||
cacheHitRate,
|
||||
topCacheMisses,
|
||||
]
|
||||
|
||||
const handleRefresh = async () => {
|
||||
activeHooks.forEach((hook) => hook.runQuery())
|
||||
@@ -25,7 +54,63 @@ export const useStorageReport = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const addFilter = (filter: ReportFilterItem) => {
|
||||
// use a deep equal when comparing objects.
|
||||
if (filters.some((f) => isEqual(f, filter))) return
|
||||
setFilters((prev) =>
|
||||
[...prev, filter].sort((a, b) => {
|
||||
const keyA = a.key.toLowerCase()
|
||||
const keyB = b.key.toLowerCase()
|
||||
if (keyA < keyB) {
|
||||
return -1
|
||||
}
|
||||
if (keyA > keyB) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
)
|
||||
}
|
||||
const removeFilter = (filter: ReportFilterItem) => removeFilters([filter])
|
||||
const removeFilters = (toRemove: ReportFilterItem[]) => {
|
||||
setFilters((prev) => {
|
||||
return prev.filter((f) => !toRemove.find((r) => isEqual(f, r)))
|
||||
})
|
||||
}
|
||||
|
||||
const formattedFilters: ReportFilterItem[] = [
|
||||
...filters,
|
||||
...(identifier !== undefined
|
||||
? [{ key: 'identifier', value: `'${identifier}'`, compare: 'is' } as ReportFilterItem]
|
||||
: []),
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (totalRequests.changeQuery) {
|
||||
totalRequests.changeQuery(PRESET_CONFIG.api.queries.totalRequests.sql(formattedFilters))
|
||||
}
|
||||
if (topRoutes.changeQuery) {
|
||||
topRoutes.changeQuery(PRESET_CONFIG.api.queries.topRoutes.sql(formattedFilters))
|
||||
}
|
||||
if (errorCounts.changeQuery) {
|
||||
errorCounts.changeQuery(PRESET_CONFIG.api.queries.errorCounts.sql(formattedFilters))
|
||||
}
|
||||
|
||||
if (topErrorRoutes.changeQuery) {
|
||||
topErrorRoutes.changeQuery(PRESET_CONFIG.api.queries.topErrorRoutes.sql(formattedFilters))
|
||||
}
|
||||
if (responseSpeed.changeQuery) {
|
||||
responseSpeed.changeQuery(PRESET_CONFIG.api.queries.responseSpeed.sql(formattedFilters))
|
||||
}
|
||||
|
||||
if (topSlowRoutes.changeQuery) {
|
||||
topSlowRoutes.changeQuery(PRESET_CONFIG.api.queries.topSlowRoutes.sql(formattedFilters))
|
||||
}
|
||||
|
||||
if (networkTraffic.changeQuery) {
|
||||
networkTraffic.changeQuery(PRESET_CONFIG.api.queries.networkTraffic.sql(formattedFilters))
|
||||
}
|
||||
|
||||
if (cacheHitRate.changeQuery) {
|
||||
cacheHitRate.changeQuery(PRESET_CONFIG.storage.queries.cacheHitRate.sql([]))
|
||||
}
|
||||
@@ -33,20 +118,47 @@ export const useStorageReport = () => {
|
||||
if (topCacheMisses.changeQuery) {
|
||||
topCacheMisses.changeQuery(PRESET_CONFIG.storage.queries.topCacheMisses.sql([]))
|
||||
}
|
||||
}, [])
|
||||
}, [JSON.stringify(formattedFilters)])
|
||||
|
||||
const isLoading = activeHooks.some((hook) => hook.isLoading)
|
||||
|
||||
return {
|
||||
data: {
|
||||
totalRequests: totalRequests.logData,
|
||||
errorCounts: errorCounts.logData,
|
||||
responseSpeed: responseSpeed.logData,
|
||||
topRoutes: topRoutes.logData,
|
||||
topErrorRoutes: topErrorRoutes.logData,
|
||||
topSlowRoutes: topSlowRoutes.logData,
|
||||
networkTraffic: networkTraffic.logData,
|
||||
cacheHitRate: cacheHitRate.logData,
|
||||
topCacheMisses: topCacheMisses.logData,
|
||||
},
|
||||
params: {
|
||||
totalRequests: totalRequests.params,
|
||||
errorCounts: errorCounts.params,
|
||||
responseSpeed: responseSpeed.params,
|
||||
topRoutes: topRoutes.params,
|
||||
topErrorRoutes: topErrorRoutes.params,
|
||||
topSlowRoutes: topSlowRoutes.params,
|
||||
networkTraffic: networkTraffic.params,
|
||||
cacheHitRate: cacheHitRate.params,
|
||||
topCacheMisses: topCacheMisses.params,
|
||||
},
|
||||
error: {
|
||||
totalRequest: totalRequests.error,
|
||||
errorCounts: errorCounts.error,
|
||||
responseSpeed: responseSpeed.error,
|
||||
topRoutes: topRoutes.error,
|
||||
topErrorRoute: topErrorRoutes.error,
|
||||
topSlowRoutes: topSlowRoutes.error,
|
||||
networkTraffic: networkTraffic.error,
|
||||
},
|
||||
mergeParams: handleSetParams,
|
||||
filters,
|
||||
addFilter,
|
||||
removeFilter,
|
||||
removeFilters,
|
||||
isLoading,
|
||||
refresh: handleRefresh,
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export const ApiReport: NextPageWithLayout = () => {
|
||||
isLoading={isLoading}
|
||||
params={params.responseSpeed}
|
||||
title="Response Speed"
|
||||
tooltip="Average response speed (in miliseconds) of a request"
|
||||
tooltip="Average response speed of a request (in ms)"
|
||||
data={data.responseSpeed || []}
|
||||
error={error.responseSpeed}
|
||||
renderer={ResponseSpeedChartRenderer}
|
||||
|
||||
@@ -1,93 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
|
||||
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
|
||||
import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar'
|
||||
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
|
||||
import {
|
||||
createFilteredDatePickerHelpers,
|
||||
REPORTS_DATEPICKER_HELPERS,
|
||||
} from 'components/interfaces/Reports/Reports.constants'
|
||||
import { createFilteredDatePickerHelpers } from 'components/interfaces/Reports/Reports.constants'
|
||||
import {
|
||||
CacheHitRateChartRenderer,
|
||||
TopCacheMissesRenderer,
|
||||
} from 'components/interfaces/Reports/renderers/StorageRenderers'
|
||||
import {
|
||||
DatePickerValue,
|
||||
LogsDatePicker,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
import { useStorageReport } from 'data/reports/storage-report-query'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { DatePickerValue } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import {
|
||||
NetworkTrafficRenderer,
|
||||
ResponseSpeedChartRenderer,
|
||||
TopApiRoutesRenderer,
|
||||
TotalRequestsChartRenderer,
|
||||
} from 'components/interfaces/Reports/renderers/ApiRenderers'
|
||||
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
|
||||
import ShimmerLine from 'components/ui/ShimmerLine'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useStorageReport } from 'data/reports/storage-report-query'
|
||||
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import Link from 'next/link'
|
||||
import { ExternalLinkIcon } from 'lucide-react'
|
||||
|
||||
export const StorageReport: NextPageWithLayout = () => {
|
||||
const report = useStorageReport()
|
||||
const organization = useSelectedOrganization()
|
||||
|
||||
const { isLoading, refresh } = report
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
filters,
|
||||
isLoading,
|
||||
params,
|
||||
mergeParams,
|
||||
removeFilters,
|
||||
addFilter,
|
||||
refresh,
|
||||
} = report
|
||||
|
||||
const plan = organization?.plan
|
||||
|
||||
const defaultHelper =
|
||||
REPORTS_DATEPICKER_HELPERS.find((h) => h.default) || REPORTS_DATEPICKER_HELPERS[0]
|
||||
|
||||
const [selectedRange, setSelectedRange] = useState<DatePickerValue>({
|
||||
to: defaultHelper.calcTo(),
|
||||
from: defaultHelper.calcFrom(),
|
||||
isHelper: true,
|
||||
text: defaultHelper.text,
|
||||
})
|
||||
|
||||
const datepickerHelpers = createFilteredDatePickerHelpers(plan?.id || 'free')
|
||||
|
||||
const handleDatepickerChange = (vals: DatePickerValue) => {
|
||||
report.mergeParams({
|
||||
mergeParams({
|
||||
iso_timestamp_start: vals.from || '',
|
||||
iso_timestamp_end: vals.to || '',
|
||||
})
|
||||
setSelectedRange(vals)
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportPadding>
|
||||
<ReportHeader title="Storage" />
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<ButtonTooltip
|
||||
type="default"
|
||||
disabled={isLoading}
|
||||
icon={<RefreshCw className={isLoading ? 'animate-spin' : ''} />}
|
||||
className="w-7"
|
||||
tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
|
||||
onClick={() => refresh()}
|
||||
/>
|
||||
|
||||
<LogsDatePicker
|
||||
onSubmit={handleDatepickerChange}
|
||||
value={selectedRange}
|
||||
helpers={datepickerHelpers}
|
||||
/>
|
||||
<section className="relative pt-20 -mt-2 flex flex-col gap-3">
|
||||
<div className="absolute inset-0 z-40 pointer-events-none flex flex-col gap-4">
|
||||
<div className="sticky top-0 bg dark:bg-200 pt-4 mb-4 flex flex-col items-center pointer-events-auto gap-4">
|
||||
<ReportFilterBar
|
||||
onRemoveFilters={removeFilters}
|
||||
onDatepickerChange={handleDatepickerChange}
|
||||
datepickerFrom={params.totalRequests.iso_timestamp_start}
|
||||
datepickerTo={params.totalRequests.iso_timestamp_end}
|
||||
onAddFilter={addFilter}
|
||||
onRefresh={refresh}
|
||||
isLoading={isLoading}
|
||||
filters={filters}
|
||||
selectedProduct="storage"
|
||||
datepickerHelpers={createFilteredDatePickerHelpers(plan?.id || 'free')}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="h-px w-full">
|
||||
<ShimmerLine active={report.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-2 w-full">
|
||||
<ShimmerLine active={report.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
<ReportWidget
|
||||
isLoading={isLoading}
|
||||
params={params.totalRequests}
|
||||
title="Total Requests"
|
||||
data={data.totalRequests || []}
|
||||
error={error.totalRequest}
|
||||
renderer={TotalRequestsChartRenderer}
|
||||
append={TopApiRoutesRenderer}
|
||||
appendProps={{ data: data.topRoutes || [], params: params.topRoutes }}
|
||||
/>
|
||||
<ReportWidget
|
||||
isLoading={isLoading}
|
||||
params={params.responseSpeed}
|
||||
title="Response Speed"
|
||||
tooltip="Average response speed of a request (in ms)"
|
||||
data={data.responseSpeed || []}
|
||||
error={error.responseSpeed}
|
||||
renderer={ResponseSpeedChartRenderer}
|
||||
appendProps={{ data: data.topSlowRoutes || [], params: params.topSlowRoutes }}
|
||||
append={TopApiRoutesRenderer}
|
||||
/>
|
||||
<ReportWidget
|
||||
isLoading={isLoading}
|
||||
params={params.networkTraffic}
|
||||
error={error.networkTraffic}
|
||||
title="Network Traffic"
|
||||
tooltip="Ingress and egress of requests and responses respectively"
|
||||
data={data.networkTraffic || []}
|
||||
renderer={NetworkTrafficRenderer}
|
||||
/>
|
||||
|
||||
<ReportWidget
|
||||
isLoading={report.isLoading}
|
||||
params={report.params.cacheHitRate}
|
||||
title="Request Caching"
|
||||
tooltip="The number of storage requests that are cached at the edge level. A higher number of hits is better."
|
||||
data={report.data.cacheHitRate || []}
|
||||
renderer={CacheHitRateChartRenderer}
|
||||
append={TopCacheMissesRenderer}
|
||||
appendProps={{ data: report.data.topCacheMisses || [] }}
|
||||
/>
|
||||
<ReportWidget
|
||||
isLoading={isLoading}
|
||||
params={params.cacheHitRate}
|
||||
title="Request Caching"
|
||||
tooltip={
|
||||
<div>
|
||||
The number of storage requests that are cached at the edge level. A higher number of
|
||||
hits is better.{' '}
|
||||
<span className="flex items-center gap-1 text-foreground-lighter">
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/storage/cdn/fundamentals"
|
||||
target="_blank"
|
||||
>
|
||||
Read More
|
||||
</Link>
|
||||
<ExternalLinkIcon className="w-3 h-3" />
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
data={data.cacheHitRate || []}
|
||||
renderer={CacheHitRateChartRenderer}
|
||||
append={TopCacheMissesRenderer}
|
||||
appendProps={{ data: data.topCacheMisses || [] }}
|
||||
/>
|
||||
</section>
|
||||
</ReportPadding>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,11 +145,11 @@
|
||||
}
|
||||
|
||||
.table-container tbody td:first-child {
|
||||
@apply pl-6 border-b border-l border-default;
|
||||
@apply pl-4 border-b border-l border-default;
|
||||
}
|
||||
|
||||
.table-container tbody td:last-child {
|
||||
@apply pr-6 border-b border-r border-default;
|
||||
@apply pr-4 border-b border-r border-default;
|
||||
}
|
||||
|
||||
.table-container tbody tr:last-child td:first-child {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useRef, KeyboardEvent, useMemo, useState, useCallback, useEffect } from 'react'
|
||||
import { Search, Sparkles } from 'lucide-react'
|
||||
import {
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
@@ -69,6 +70,7 @@ export type FilterBarProps = {
|
||||
onFreeformTextChange: (freeformText: string) => void
|
||||
filters: FilterGroup
|
||||
aiApiUrl?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
@@ -78,6 +80,7 @@ export function FilterBar({
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
aiApiUrl,
|
||||
className,
|
||||
}: FilterBarProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -353,7 +356,7 @@ export function FilterBar({
|
||||
...group,
|
||||
conditions: [
|
||||
...group.conditions,
|
||||
{ propertyName: property.name, value: null, operator: '=' },
|
||||
{ propertyName: property.name, value: '', operator: '=' },
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -643,7 +646,7 @@ export function FilterBar({
|
||||
activeInput?.type === 'group'
|
||||
? freeformText
|
||||
: activeInput?.type === 'value'
|
||||
? findConditionByPath(activeFilters, activeInput.path)?.value?.toString() || ''
|
||||
? (findConditionByPath(activeFilters, activeInput.path)?.value ?? '').toString()
|
||||
: ''
|
||||
|
||||
const items: CommandItem[] = []
|
||||
@@ -987,7 +990,7 @@ export function FilterBar({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-2 relative">
|
||||
<div className={cn('w-full space-y-2 relative', className)}>
|
||||
<div className="relative flex items-center w-full">
|
||||
<Search className="absolute left-1 top-1/2 transform -translate-y-1/2 text-foreground-muted w-4 h-4" />
|
||||
<div className="flex-1 flex flex-wrap items-center pl-6 pr-1 py-0 gap-1 h-full">
|
||||
|
||||
@@ -68,14 +68,14 @@ export function FilterCondition({
|
||||
/>
|
||||
<Input_Shadcn_
|
||||
ref={valueRef}
|
||||
value={condition.value?.toString() || ''}
|
||||
value={(condition.value ?? '').toString()}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
onFocus={onValueFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
className="border-none bg-transparent p-0 text-xs focus:outline-none focus:ring-0 focus:shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 font-mono h-6"
|
||||
style={{
|
||||
width: `${Math.max(condition.value?.toString().length || 0, 1)}ch`,
|
||||
width: `${Math.max((condition.value ?? '').toString().length, 1)}ch`,
|
||||
minWidth: '1ch',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
|
||||
Reference in new issue
Block a user