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:
Francesco SansalvadoreandTerry Sutton authored and GitHub committed 2025-07-03 13:43:24 +02:00
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
+4 -4
View File
@@ -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>
)
}
+2 -2
View File
@@ -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}