Skip to content

Commit 22f937c

Browse files
authored
Chore/async filters for unified logs (supabase#37200)
* Refactor retrieval of log counts * Async filters * Clean up * Clean up * Fix
1 parent 65c6a68 commit 22f937c

File tree

12 files changed

+339
-103
lines changed

12 files changed

+339
-103
lines changed

apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { User } from 'lucide-react'
33

44
import { LEVELS } from 'components/ui/DataTable/DataTable.constants'
55
import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types'
6-
import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils'
6+
import { getLevelColor } from 'components/ui/DataTable/DataTable.utils'
77
import { cn } from 'ui'
88
import { LOG_TYPES, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants'
99
import { ColumnSchema } from './UnifiedLogs.schema'
@@ -41,22 +41,15 @@ export const filterFields = [
4141
value: 'status',
4242
type: 'checkbox',
4343
defaultOpen: true,
44-
options: [
45-
{ label: '2xx', value: 200 },
46-
{ label: '4xx', value: 400 },
47-
{ label: '4xx', value: 500 },
48-
], // REMINDER: this is a placeholder to set the type in the client.tsx
44+
options: [],
45+
hasDynamicOptions: true,
4946
component: (props: Option) => {
5047
if (typeof props.value === 'boolean') return null
5148
if (typeof props.value === 'undefined') return null
5249

5350
const statusValue = String(props.value)
5451
const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS]
5552

56-
// Convert string status codes to numbers for HTTP status styling
57-
const statusCode = typeof props.value === 'string' ? parseInt(props.value, 10) : props.value
58-
const isHttpStatus = !isNaN(statusCode) && statusCode >= 100 && statusCode < 600
59-
6053
return (
6154
<div className="flex items-center gap-2 w-full min-w-0">
6255
<span className="flex-shrink-0 text-foreground">{statusValue}</span>
@@ -110,7 +103,9 @@ export const filterFields = [
110103
value: 'host',
111104
type: 'checkbox',
112105
defaultOpen: false,
113-
options: [], // Will be populated dynamically from facets
106+
options: [],
107+
hasDynamicOptions: true,
108+
hasAsyncSearch: true,
114109
component: (props: Option) => {
115110
return (
116111
<span className="truncate block text-[0.75rem]" title={props.value as string}>
@@ -123,8 +118,10 @@ export const filterFields = [
123118
label: 'Pathname',
124119
value: 'pathname',
125120
type: 'checkbox',
126-
defaultOpen: true,
127-
options: [], // Will be populated dynamically from facets
121+
defaultOpen: false,
122+
options: [],
123+
hasDynamicOptions: true,
124+
hasAsyncSearch: true,
128125
component: (props: Option) => {
129126
return (
130127
<span className="truncate block w-full text-[0.75rem]" title={props.value as string}>
@@ -138,7 +135,9 @@ export const filterFields = [
138135
value: 'auth_user',
139136
type: 'checkbox',
140137
defaultOpen: false,
141-
options: [], // Will be populated dynamically from facets
138+
options: [],
139+
hasDynamicOptions: true,
140+
hasAsyncSearch: true,
142141
component: (props: Option) => {
143142
return (
144143
<div className="flex items-center gap-2 min-w-0">

apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts

Lines changed: 65 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,7 @@ const getSupabaseStorageLogsQuery = () => {
459459
/**
460460
* Combine all log sources to create the unified logs CTE
461461
*/
462-
const getUnifiedLogsCTE = () => {
462+
export const getUnifiedLogsCTE = () => {
463463
return `
464464
WITH unified_logs AS (
465465
${getPostgrestLogsQuery()}
@@ -510,39 +510,72 @@ ${finalWhere}
510510
* Get a count query for the total logs within the timeframe
511511
* Uses proper faceted search behavior where facets show "what would I get if I selected ONLY this option"
512512
*/
513-
export const getLogsCountQuery = (search: QuerySearchParamsType): string => {
514-
const { finalWhere } = buildQueryConditions(search)
515513

516-
// Helper function to build WHERE clause excluding a specific field
517-
const buildFacetWhere = (excludeField: string): string => {
518-
const conditions: string[] = []
514+
// Helper function to build WHERE clause excluding a specific field
515+
const buildFacetWhere = (search: QuerySearchParamsType, excludeField: string): string => {
516+
const conditions: string[] = []
519517

520-
Object.entries(search).forEach(([key, value]) => {
521-
if (key === excludeField) return // Skip the field we're getting facets for
522-
if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params
518+
Object.entries(search).forEach(([key, value]) => {
519+
if (key === excludeField) return // Skip the field we're getting facets for
520+
if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params
523521

524-
// Handle array filters (IN clause)
525-
if (Array.isArray(value) && value.length > 0) {
526-
conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`)
527-
return
528-
}
522+
// Handle array filters (IN clause)
523+
if (Array.isArray(value) && value.length > 0) {
524+
conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`)
525+
return
526+
}
529527

530-
// Handle scalar values
531-
if (value !== null && value !== undefined) {
532-
if (['host', 'pathname'].includes(key)) {
533-
conditions.push(`${key} LIKE '%${value}%'`)
534-
} else {
535-
conditions.push(`${key} = '${value}'`)
536-
}
528+
// Handle scalar values
529+
if (value !== null && value !== undefined) {
530+
if (['host', 'pathname'].includes(key)) {
531+
conditions.push(`${key} LIKE '%${value}%'`)
532+
} else {
533+
conditions.push(`${key} = '${value}'`)
537534
}
538-
})
535+
}
536+
})
539537

540-
return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
541-
}
538+
return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
539+
}
540+
541+
export const getFacetCountCTE = ({
542+
search,
543+
facet,
544+
facetSearch,
545+
}: {
546+
search: QuerySearchParamsType
547+
facet: string
548+
facetSearch?: string
549+
}) => {
550+
const MAX_FACETS_QUANTITY = 20
551+
552+
return `
553+
${facet}_count AS (
554+
SELECT '${facet}' as dimension, ${facet} as value, COUNT(*) as count
555+
FROM unified_logs
556+
${buildFacetWhere(search, `${facet}`) || `WHERE ${facet} IS NOT NULL`}
557+
${buildFacetWhere(search, `${facet}`) ? ` AND ${facet} IS NOT NULL` : ''}
558+
${!!facetSearch ? `AND ${facet} LIKE '%${facetSearch}%'` : ''}
559+
GROUP BY ${facet}
560+
LIMIT ${MAX_FACETS_QUANTITY}
561+
)
562+
`.trim()
563+
}
564+
565+
export const getLogsCountQuery = (search: QuerySearchParamsType): string => {
566+
const { finalWhere } = buildQueryConditions(search)
542567

543568
// Create a count query using the same unified logs CTE
544569
const sql = `
545-
${getUnifiedLogsCTE()}
570+
${getUnifiedLogsCTE()},
571+
${getFacetCountCTE({ search, facet: 'log_type' })},
572+
${getFacetCountCTE({ search, facet: 'method' })},
573+
${getFacetCountCTE({ search, facet: 'level' })},
574+
${getFacetCountCTE({ search, facet: 'status' })},
575+
${getFacetCountCTE({ search, facet: 'host' })},
576+
${getFacetCountCTE({ search, facet: 'pathname' })},
577+
${getFacetCountCTE({ search, facet: 'auth_user' })}
578+
546579
-- Get total count
547580
SELECT 'total' as dimension, 'all' as value, COUNT(*) as count
548581
FROM unified_logs
@@ -551,65 +584,37 @@ ${finalWhere}
551584
UNION ALL
552585
553586
-- Get counts by log_type (exclude log_type filter to avoid self-filtering)
554-
SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count
555-
FROM unified_logs
556-
${buildFacetWhere('log_type') || 'WHERE log_type IS NOT NULL'}
557-
${buildFacetWhere('log_type') ? ' AND log_type IS NOT NULL' : ''}
558-
GROUP BY log_type
587+
SELECT dimension, value, count from log_type_count
559588
560589
UNION ALL
561590
562591
-- Get counts by method (exclude method filter to avoid self-filtering)
563-
SELECT 'method' as dimension, method as value, COUNT(*) as count
564-
FROM unified_logs
565-
${buildFacetWhere('method') || 'WHERE method IS NOT NULL'}
566-
${buildFacetWhere('method') ? ' AND method IS NOT NULL' : ''}
567-
GROUP BY method
592+
SELECT dimension, value, count from method_count
568593
569594
UNION ALL
570595
571596
-- Get counts by level (exclude level filter to avoid self-filtering)
572-
SELECT 'level' as dimension, level as value, COUNT(*) as count
573-
FROM unified_logs
574-
${buildFacetWhere('level') || 'WHERE level IS NOT NULL'}
575-
${buildFacetWhere('level') ? ' AND level IS NOT NULL' : ''}
576-
GROUP BY level
597+
SELECT dimension, value, count from level_count
577598
578599
UNION ALL
579600
580601
-- Get counts by status (exclude status filter to avoid self-filtering)
581-
SELECT 'status' as dimension, status as value, COUNT(*) as count
582-
FROM unified_logs
583-
${buildFacetWhere('status') || 'WHERE status IS NOT NULL'}
584-
${buildFacetWhere('status') ? ' AND status IS NOT NULL' : ''}
585-
GROUP BY status
602+
SELECT dimension, value, count from status_count
586603
587604
UNION ALL
588605
589606
-- Get counts by host (exclude host filter to avoid self-filtering)
590-
SELECT 'host' as dimension, host as value, COUNT(*) as count
591-
FROM unified_logs
592-
${buildFacetWhere('host') || 'WHERE host IS NOT NULL'}
593-
${buildFacetWhere('host') ? ' AND host IS NOT NULL' : ''}
594-
GROUP BY host
607+
SELECT dimension, value, count from host_count
595608
596609
UNION ALL
597610
598611
-- Get counts by pathname (exclude pathname filter to avoid self-filtering)
599-
SELECT 'pathname' as dimension, pathname as value, COUNT(*) as count
600-
FROM unified_logs
601-
${buildFacetWhere('pathname') || 'WHERE pathname IS NOT NULL'}
602-
${buildFacetWhere('pathname') ? ' AND pathname IS NOT NULL' : ''}
603-
GROUP BY pathname
612+
SELECT dimension, value, count from pathname_count
604613
605614
UNION ALL
606615
607616
-- Get counts by auth_user (exclude auth_user filter to avoid self-filtering)
608-
SELECT 'auth_user' as dimension, auth_user as value, COUNT(*) as count
609-
FROM unified_logs
610-
${buildFacetWhere('auth_user') || 'WHERE auth_user IS NOT NULL'}
611-
${buildFacetWhere('auth_user') ? ' AND auth_user IS NOT NULL' : ''}
612-
GROUP BY auth_user
617+
SELECT dimension, value, count from auth_user_count
613618
`
614619

615620
return sql

apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ export const UnifiedLogs = () => {
7171
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(defaultColumnFilters)
7272
const [rowSelection, setRowSelection] = useState<RowSelectionState>(defaultRowSelection)
7373

74-
const [showBottomLogsPanel, setShowBottomLogsPanelState] = useState(false)
75-
7674
const [columnVisibility, setColumnVisibility] = useLocalStorageQuery<VisibilityState>(
7775
'data-table-visibility',
7876
defaultColumnVisibility
@@ -203,7 +201,11 @@ export const UnifiedLogs = () => {
203201
}, [isLoading, isFetching, flatData.length, table, selectedRowKey])
204202

205203
// REMINDER: this is currently needed for the cmdk search
204+
// [Joshen] This is where facets are getting dynamically loaded
206205
// TODO: auto search via API when the user changes the filter instead of hardcoded
206+
207+
// Will need to refactor this bit
208+
// - Each facet just handles its own state, rather than getting passed down like this
207209
const filterFields = useMemo(() => {
208210
return defaultFilterFields.map((field) => {
209211
const facetsField = facets?.[field.value]
@@ -289,6 +291,7 @@ export const UnifiedLogs = () => {
289291
rowSelection={rowSelection}
290292
columnOrder={columnOrder}
291293
columnVisibility={columnVisibility}
294+
searchParameters={searchParameters}
292295
enableColumnOrdering={true}
293296
isFetching={isFetching}
294297
isLoading={isLoading}

apps/studio/components/ui/DataTable/DataTable.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export type Base<TData> = {
5050
* Defines if the command input is disabled for this field
5151
*/
5252
commandDisabled?: boolean
53+
hasDynamicOptions?: boolean
54+
hasAsyncSearch?: boolean
5355
}
5456

5557
export type DataTableCheckboxFilterField<TData> = Base<TData> & Checkbox

apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { DataTableCheckboxFilterField } from '../DataTable.types'
66
import { formatCompactNumber } from '../DataTable.utils'
77
import { InputWithAddons } from '../primitives/InputWithAddons'
88
import { useDataTable } from '../providers/DataTableProvider'
9+
import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader'
910

1011
export function DataTableFilterCheckbox<TData>({
1112
value: _value,
@@ -34,31 +35,13 @@ export function DataTableFilterCheckbox<TData>({
3435
const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : []
3536

3637
// REMINDER: if no options are defined, while fetching data, we should show a skeleton
37-
if (isLoading && !filterOptions?.length)
38-
return (
39-
<div className="grid divide-y rounded border border-border">
40-
{Array.from({ length: 3 }).map((_, index) => (
41-
<div key={index} className="flex items-center justify-between gap-2 px-2 py-2.5">
42-
<Skeleton className="h-4 w-4 rounded-sm" />
43-
<Skeleton className="h-4 w-full rounded-sm" />
44-
</div>
45-
))}
46-
</div>
47-
)
38+
if (isLoading && !filterOptions?.length) return <DataTableFilterCheckboxLoader />
4839

4940
// Show empty state when no original options are available (not due to search filtering)
5041
if (!options?.length)
5142
return (
52-
<div className="flex items-center justify-center px-2 py-6 text-center">
53-
<div className="flex flex-col items-center gap-2">
54-
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
55-
<Search className="h-4 w-4 text-muted-foreground" />
56-
</div>
57-
<div className="space-y-1">
58-
<p className="text-sm text-muted-foreground">No options available</p>
59-
<p className="text-xs text-muted-foreground/70">Try adjusting your filters</p>
60-
</div>
61-
</div>
43+
<div className="flex items-center justify-center px-2 py-4 text-center border border-border rounded">
44+
<p className="text-xs text-foreground-light">No options available</p>
6245
</div>
6346
)
6447

@@ -77,9 +60,9 @@ export function DataTableFilterCheckbox<TData>({
7760
<div className="max-h-[200px] overflow-y-auto rounded border border-border empty:border-none">
7861
{filterOptions.length === 0 && inputValue !== '' ? (
7962
<div className="flex items-center justify-center px-2 py-4 text-center">
80-
<div className="space-y-1">
81-
<p className="text-sm text-muted-foreground">No results found</p>
82-
<p className="text-xs text-muted-foreground/70">Try a different search term</p>
63+
<div className="space-y-0.5">
64+
<p className="text-xs text-foreground">No results found</p>
65+
<p className="text-xs text-foreground-lighter">Try a different search term</p>
8366
</div>
8467
</div>
8568
) : (

0 commit comments

Comments
 (0)