diff --git a/apps/studio/components/grid/components/common/DropdownControl.tsx b/apps/studio/components/grid/components/common/DropdownControl.tsx index fe724a76f37..502f688651a 100644 --- a/apps/studio/components/grid/components/common/DropdownControl.tsx +++ b/apps/studio/components/grid/components/common/DropdownControl.tsx @@ -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) => { return ( - {children} + {children}
{options.length === 0 &&

No more items

} diff --git a/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx b/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx index 918bce39d8f..2e5d654836b 100644 --- a/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx +++ b/apps/studio/components/interfaces/Reports/ReportFilterBar.tsx @@ -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({ - 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 ( -
-
+
+
- - - - - - handleProductFilterChange(null)}> -

All Requests

-
- - {PRODUCT_FILTERS.map((productFilter) => { - const Icon = productFilter.icon - - return ( - handleProductFilterChange(productFilter)} - > - {productFilter.key === 'graphql' ? ( - - code.replace(/svg/, 'svg class="m-auto text-color-inherit"') - } - /> - ) : Icon !== null ? ( - - ) : null} -
-

- {productFilter.label} -

-

- {productFilter.description} -

-
-
- ) - })} -
-
- {filters - .filter( - (filter) => - filter.value !== currentProductFilter?.filterValue || - filter.key !== currentProductFilter?.filterKey - ) - .map((filter) => ( -
- {filter.key} {filter.compare} {filter.value} + {!selectedProduct && ( + + -
- ))} - -
Add Filter
+ + + handleProductFilterChange(null)}> +

All Requests

+
+ + {PRODUCT_FILTERS.map((productFilterItem) => { + const Icon = productFilterItem.icon - -
- } - open={showAdder} - onOpenChange={(openValue) => setShowAdder(openValue)} - overlay={ -
- - - { - setAddFilterValues((prev) => ({ ...prev, value: e.target.value })) - }} - /> -
- } - showClose - > - - + return ( + handleProductFilterChange(productFilterItem)} + > + {productFilterItem.key === 'graphql' ? ( + + code.replace(/svg/, 'svg class="m-auto text-color-inherit"') + } + /> + ) : Icon !== null ? ( + + ) : null} +
+

+ {productFilterItem.label} +

+

+ {productFilterItem.description} +

+
+
+ ) + })} + + + )}
+ {/*
+ +
*/} - 0 ? [{ id: `${ref}-all`, name: 'API Load Balancer' }] : [] } - /> + /> */}
) } diff --git a/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx b/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx new file mode 100644 index 00000000000..c06679415d7 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/ReportFilterPopover.tsx @@ -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) => 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) => { + 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 ( +
+ + 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} + /> +
0 && 'opacity-100 pointer-events-auto' + )} + > + + + No matching options found. Press Enter to use "{inputValue}" + + + {safeOptions.map((option, index) => ( + handleOptionSelect(option)} + className="px-3 py-2 text-sm cursor-pointer" + > + {option} + + ))} + + +
+
+ {isOpen &&
setIsOpen(false)} />} +
+ ) +} + +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) => 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 ( +
+ {filterIdx > 0 && ( +
+
+ AND +
+
+ )} +
+ +
+ } + className="w-32 justify-start" + > + {property?.label ?? 'Select property'} + + + + +
+ } + className="w-20 justify-start" + > + {filter.operator} + + + + {valueOptions?.length > 0 ? ( + String(option.value)) || []} + /> + ) : ( + handleValueChange(event.target.value)} + onKeyDown={onKeyDown} + /> + )} +
+
+ ) +} + +export const ReportFilterPopover = ({ + buttonText, + filters, + filterProperties, + onFiltersChange, + portal = true, + disabled = false, +}: ReportFilterPopoverProps) => { + const [open, setOpen] = useState(false) + const [localFilters, setLocalFilters] = useState(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) { + if (event.key === 'Enter') onApplyFilters() + } + + const hasChanges = !isEqual(localFilters, filters) + + return ( + + + + + +
+
+ {localFilters.map((filter, index) => ( + + ))} + {localFilters.length === 0 && ( +
+
No filters applied
+

+ Add a filter below to narrow down the report data +

+
+ )} +
+ +
+
+ + {filters.length > 0 && ( + + )} +
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Reports/ReportWidget.tsx b/apps/studio/components/interfaces/Reports/ReportWidget.tsx index 8f5b07c2189..01f96d9939c 100644 --- a/apps/studio/components/interfaces/Reports/ReportWidget.tsx +++ b/apps/studio/components/interfaces/Reports/ReportWidget.tsx @@ -13,7 +13,7 @@ export interface ReportWidgetProps { title: string description?: string error?: string | Object | null - tooltip?: string + tooltip?: string | ReactNode className?: string renderer: (props: ReportWidgetRendererProps) => ReactNode append?: (props: ReportWidgetRendererProps) => ReactNode diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 3e2f58af16f..32df6f8d496 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -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 = { 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 diff --git a/apps/studio/components/interfaces/Reports/Reports.types.ts b/apps/studio/components/interfaces/Reports/Reports.types.ts index 6767386aefd..0283558801e 100644 --- a/apps/studio/components/interfaces/Reports/Reports.types.ts +++ b/apps/studio/components/interfaces/Reports/Reports.types.ts @@ -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 +} diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index da1ac4be568..397b383d98e 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -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 ( -
+
( - - + +
- - + + {datum.search ? (
           
( ) : (

No query parameters in this request

)} - - + + ) diff --git a/apps/studio/components/interfaces/Reports/useReportFilters.ts b/apps/studio/components/interfaces/Reports/useReportFilters.ts new file mode 100644 index 00000000000..2d207dcc4c3 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/useReportFilters.ts @@ -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): 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 = {} + + // 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([]) + + const [isInitialized, setIsInitialized] = useState(false) + const lastAppliedFilterState = useRef('') + 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, + } +} diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index 966e75bbcc4..baf79f16ac6 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -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 }, diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index 1c90a17dd37..ea20384af40 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -37,7 +37,7 @@ export interface ComposedChartHandlerProps { showLegend?: boolean showTotal?: boolean showMaxValue?: boolean - updateDateRange: UpdateDateRange + updateDateRange?: UpdateDateRange valuePrecision?: number isVisible?: boolean docsUrl?: string diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index 616b368015f..e17362b66fb 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -30,7 +30,7 @@ function Panel(props: PropsWithChildren) { {props.title && (
@@ -50,13 +50,13 @@ function Panel(props: PropsWithChildren) { } function Content({ children, className }: { children: ReactNode; className?: string | false }) { - return
{children}
+ return
{children}
} function Footer({ children }: { children: ReactNode; className?: string }) { return (
-
{children}
+
{children}
) } @@ -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 )} diff --git a/apps/studio/data/reports/storage-report-query.ts b/apps/studio/data/reports/storage-report-query.ts index ff96099ba8f..19a2ac23030 100644 --- a/apps/studio/data/reports/storage-report-query.ts +++ b/apps/studio/data/reports/storage-report-query.ts @@ -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([]) + const state = useDatabaseSelectorStateSnapshot() - const queryHooks = queriesFactory( + const identifier = state.selectedDatabaseId + + const queryHooks = queriesFactory( + PRESET_CONFIG.api.queries, + projectRef ?? 'default' + ) + const storageQueryHooks = queriesFactory( 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, } diff --git a/apps/studio/pages/project/[ref]/reports/api-overview.tsx b/apps/studio/pages/project/[ref]/reports/api-overview.tsx index 16a527d3e06..f81e1ff5156 100644 --- a/apps/studio/pages/project/[ref]/reports/api-overview.tsx +++ b/apps/studio/pages/project/[ref]/reports/api-overview.tsx @@ -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} diff --git a/apps/studio/pages/project/[ref]/reports/storage.tsx b/apps/studio/pages/project/[ref]/reports/storage.tsx index a2ece5db2f0..57d0036ad9a 100644 --- a/apps/studio/pages/project/[ref]/reports/storage.tsx +++ b/apps/studio/pages/project/[ref]/reports/storage.tsx @@ -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({ - 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 ( -
-
- } - className="w-7" - tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} - onClick={() => refresh()} - /> - - +
+
+
+ +
+ +
+
-
- -
-
+ + + - + + The number of storage requests that are cached at the edge level. A higher number of + hits is better.{' '} + + + Read More + + + +
+ } + data={data.cacheHitRate || []} + renderer={CacheHitRateChartRenderer} + append={TopCacheMissesRenderer} + appendProps={{ data: data.topCacheMisses || [] }} + /> +
) } diff --git a/apps/studio/styles/ui.scss b/apps/studio/styles/ui.scss index 334d4fd1c87..886f96393d4 100644 --- a/apps/studio/styles/ui.scss +++ b/apps/studio/styles/ui.scss @@ -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 { diff --git a/packages/ui-patterns/src/FilterBar/FilterBar.tsx b/packages/ui-patterns/src/FilterBar/FilterBar.tsx index f1aa41910af..cb8555559e8 100644 --- a/packages/ui-patterns/src/FilterBar/FilterBar.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterBar.tsx @@ -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(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 ( -
+
diff --git a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx index f958534f860..a86fb0dbc88 100644 --- a/packages/ui-patterns/src/FilterBar/FilterCondition.tsx +++ b/packages/ui-patterns/src/FilterBar/FilterCondition.tsx @@ -68,14 +68,14 @@ export function FilterCondition({ /> 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}