diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 1af9461d3f93b..d1f9fbc227326 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -4,12 +4,12 @@ import { get, isEqual } from 'lodash' import uniqBy from 'lodash/uniqBy' import { useEffect } from 'react' +import { IS_PLATFORM } from 'common' import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator' import type { PlanId } from 'data/subscriptions/types' import logConstants from 'shared-data/logConstants' import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants' import type { Filters, LogData, LogsEndpointParams } from './Logs.types' -import { IS_PLATFORM } from 'common' /** * Convert a micro timestamp from number/string to iso timestamp diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index afc27de6f8c69..3b40d11085de5 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -223,6 +223,7 @@ const ProjectLinks = () => { const { securityLints, errorLints } = useLints() const showWarehouse = useFlag('warehouse') + const showUnifiedLogs = useFlag('unifiedLogs') const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() const activeRoute = router.pathname.split('/')[3] @@ -248,7 +249,7 @@ const ProjectLinks = () => { storage: storageEnabled, realtime: realtimeEnabled, }) - const otherRoutes = generateOtherRoutes(ref, project) + const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs }) const settingsRoutes = generateSettingsRoutes(ref, project) return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts b/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts new file mode 100644 index 0000000000000..4bc978188f40d --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts @@ -0,0 +1,617 @@ +import { useQuery } from '@tanstack/react-query' +import dayjs from 'dayjs' + +import { ARRAY_DELIMITER, SORT_DELIMITER } from 'components/ui/DataTable/DataTable.constants' +import { get } from 'data/fetchers' +import { getLogsChartQuery, getLogsCountQuery, getUnifiedLogsQuery } from './UnifiedLogs.queries' +import { BaseChartSchema, ColumnSchema, type FacetMetadataSchema } from './UnifiedLogs.schema' +import type { PageParam, SearchParamsType, UnifiedLogsMeta } from './UnifiedLogs.types' + +// Debug mode flag - set to true to enable detailed logs +const DEBUG_MODE = false + +export type ExtendedColumnSchema = ColumnSchema & { + timestamp: string // Original database timestamp + date: Date // Date object for display +} + +type InfiniteQueryMeta> = { + totalRowCount: number + filterRowCount: number + chartData: BaseChartSchema[] + facets: Record + metadata?: TMeta +} + +type InfiniteQueryResponse = { + data: TData + meta: InfiniteQueryMeta + prevCursor: number | null + nextCursor: number | null +} + +export function createApiQueryString(params: Record): string { + const queryParams = new URLSearchParams() + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue + + if (key === 'date' && Array.isArray(value) && value.length === 2) { + queryParams.set('dateStart', value[0].getTime().toString()) + queryParams.set('dateEnd', value[1].getTime().toString()) + } else if ( + [ + 'latency', + 'timing.dns', + 'timing.connection', + 'timing.tls', + 'timing.ttfb', + 'timing.transfer', + 'status', + ].includes(key) && + Array.isArray(value) && + value.length > 0 + ) { + if (value.length >= 2) { + queryParams.set(`${key}Start`, value[0].toString()) + queryParams.set(`${key}End`, value[value.length - 1].toString()) + } + } else if (Array.isArray(value)) { + if (value.length > 0) { + queryParams.set(key, value.join(ARRAY_DELIMITER)) + } + } else if (key === 'sort' && typeof value === 'object' && value !== null) { + queryParams.set(key, `${value.id}${SORT_DELIMITER}${value.desc ? 'desc' : 'asc'}`) + } else if (value instanceof Date) { + queryParams.set(key, value.getTime().toString()) + } else { + queryParams.set(key, String(value)) + } + } + + const queryString = queryParams.toString() + return queryString ? `?${queryString}` : '' +} + +// Add a new function to fetch chart data for the entire time period +export const useChartData = (search: SearchParamsType, projectRef: string) => { + // Create a stable query key object by removing nulls/undefined, uuid, and live + const queryKeyParams = Object.entries(search).reduce( + (acc, [key, value]) => { + if (!['uuid', 'live'].includes(key) && value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, + {} as Record + ) + + return useQuery({ + queryKey: [ + 'unified-logs-chart', + projectRef, + // Use JSON.stringify for a stable key representation + JSON.stringify(queryKeyParams), + ], + queryFn: async () => { + try { + // Use a default date range (last hour) if no date range is selected + let dateStart: string + let dateEnd: string + let startTime: Date + let endTime: Date + + if (search.date && search.date.length === 2) { + startTime = new Date(search.date[0]) + endTime = new Date(search.date[1]) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } else { + // Default to last hour + endTime = new Date() + startTime = new Date(endTime.getTime() - 60 * 60 * 1000) + dateStart = startTime.toISOString() + dateEnd = endTime.toISOString() + } + + // Get SQL query from utility function (with dynamic bucketing) + const sql = getLogsChartQuery(search) + + // Use the get function from data/fetchers for chart data + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + iso_timestamp_start: dateStart, + iso_timestamp_end: dateEnd, + project: projectRef, + sql, + }, + }, + }) + + if (error) { + if (DEBUG_MODE) console.error('API returned error for chart data:', error) + throw error + } + + // Process API results directly without additional bucketing + const chartData: Array<{ + timestamp: number + success: number + warning: number + error: number + }> = [] + + // Create a map to store data points by their timestamp + const dataByTimestamp = new Map< + number, + { + timestamp: number + success: number + warning: number + error: number + } + >() + + // Track the total count from the query results + // this uses the total_per_bucket field that was added to the chart query + let totalCount = 0 + + // Only process API results if we have them + if (data?.result) { + data.result.forEach((row: any) => { + // The API returns timestamps in microseconds (needs to be converted to milliseconds for JS Date) + const microseconds = Number(row.time_bucket) + const milliseconds = Math.floor(microseconds / 1000) + + // Add to total count - this comes directly from the query + totalCount += Number(row.total_per_bucket || 0) + + // Create chart data point + const dataPoint = { + timestamp: milliseconds, // Convert to milliseconds for the chart + success: Number(row.success) || 0, + warning: Number(row.warning) || 0, + error: Number(row.error) || 0, + } + + // Filter levels if needed + const levelFilter = search.level + if (levelFilter && levelFilter.length > 0) { + // Reset levels not in the filter + if (!levelFilter.includes('success')) dataPoint.success = 0 + if (!levelFilter.includes('warning')) dataPoint.warning = 0 + if (!levelFilter.includes('error')) dataPoint.error = 0 + } + + dataByTimestamp.set(milliseconds, dataPoint) + }) + } + + // Determine bucket size based on the truncation level in the SQL query + // We need to fill in missing data points + const startTimeMs = startTime.getTime() + const endTimeMs = endTime.getTime() + + // Calculate appropriate bucket size from the time range + const timeRangeHours = (endTimeMs - startTimeMs) / (1000 * 60 * 60) + + let bucketSizeMs: number + if (timeRangeHours > 72) { + // Day-level bucketing (for ranges > 3 days) + bucketSizeMs = 24 * 60 * 60 * 1000 + } else if (timeRangeHours > 12) { + // Hour-level bucketing (for ranges > 12 hours) + bucketSizeMs = 60 * 60 * 1000 + } else { + // Minute-level bucketing (for shorter ranges) + bucketSizeMs = 60 * 1000 + } + + // Fill in any missing buckets + for (let t = startTimeMs; t <= endTimeMs; t += bucketSizeMs) { + // Round to the nearest bucket boundary + const bucketTime = Math.floor(t / bucketSizeMs) * bucketSizeMs + + if (!dataByTimestamp.has(bucketTime)) { + // Create empty data point for this bucket + dataByTimestamp.set(bucketTime, { + timestamp: bucketTime, + success: 0, + warning: 0, + error: 0, + }) + } + } + + // Convert map to array + for (const dataPoint of dataByTimestamp.values()) { + chartData.push(dataPoint) + } + + // Sort by timestamp + chartData.sort((a, b) => a.timestamp - b.timestamp) + + // Add debugging info for totalCount + if (DEBUG_MODE) console.log(`Total count from chart query: ${totalCount}`) + + return { + chartData, + totalCount, + } + } catch (error) { + if (DEBUG_MODE) console.error('Error fetching chart data:', error) + throw error + } + }, + // Keep chart data fresh for 5 minutes + staleTime: 1000 * 60 * 5, + }) +} + +// The existing dataOptions function remains with buildChartData fallback +export const dataOptions = (search: SearchParamsType, projectRef: string) => { + // Create a stable query key object by removing nulls/undefined, uuid, and live + const queryKeyParams = Object.entries(search).reduce( + (acc, [key, value]) => { + if (!['uuid', 'live'].includes(key) && value !== null && value !== undefined) { + acc[key] = value + } + return acc + }, + {} as Record + ) + + // Calculate the appropriate initial cursor based on the selected date range + // const getInitialCursor = () => { + // if (search.date && search.date.length === 2) { + // // Use the end of the selected date range + // return new Date(search.date[1]).getTime() + // } else { + // // Default to current time if no date range is selected + // return new Date().getTime() + // } + // } + + // Simply return the options object + return { + queryKey: [ + 'unified-logs', + projectRef, + // Use JSON.stringify for a stable key representation + JSON.stringify(queryKeyParams), + ], + queryFn: async ({ pageParam }: { pageParam?: PageParam }) => { + try { + const cursorValue = pageParam?.cursor // Already in microseconds + const direction = pageParam?.direction + const isPagination = pageParam !== undefined + + // Extract date range from search or use default (last hour) + let isoTimestampStart: string + let isoTimestampEnd: string + + if (search.date && search.date.length === 2) { + isoTimestampStart = new Date(search.date[0]).toISOString() + isoTimestampEnd = new Date(search.date[1]).toISOString() + } else { + // Default to last hour + const now = new Date() + isoTimestampEnd = now.toISOString() + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000) + isoTimestampStart = oneHourAgo.toISOString() + } + + // Get the unified SQL query for logs data from utility function + let logsSql = getUnifiedLogsQuery(search) + + // Add ordering and limit + logsSql += `\nORDER BY timestamp DESC, id DESC` + logsSql += `\nLIMIT 50` + + // Get SQL query for counts from utility function + const countsSql = getLogsCountQuery(search) + + // First, fetch the counts data + const { data: countsData, error: countsError } = await get( + `/platform/projects/{ref}/analytics/endpoints/logs.all`, + { + params: { + path: { ref: projectRef }, + query: { + iso_timestamp_start: isoTimestampStart, + iso_timestamp_end: isoTimestampEnd, + project: projectRef, + sql: countsSql, + }, + }, + } + ) + + if (countsError) { + if (DEBUG_MODE) console.error('API returned error for counts data:', countsError) + throw countsError + } + + // Process count results into facets structure + const facets: Record = {} + const countsByDimension: Record> = {} + let totalCount = 0 + + // Group by dimension + if (countsData?.result) { + countsData.result.forEach((row: any) => { + const dimension = row.dimension + const value = row.value + const count = Number(row.count || 0) + + // Set total count if this is the total dimension + if (dimension === 'total' && value === 'all') { + totalCount = count + } + + // Initialize dimension map if not exists + if (!countsByDimension[dimension]) { + countsByDimension[dimension] = new Map() + } + + // Add count to the dimension map + countsByDimension[dimension].set(value, count) + }) + } + + // Convert dimension maps to facets structure + Object.entries(countsByDimension).forEach(([dimension, countsMap]) => { + // Skip the 'total' dimension as it's not a facet + if (dimension === 'total') return + + const dimensionTotal = Array.from(countsMap.values()).reduce( + (sum, count) => sum + count, + 0 + ) + + facets[dimension] = { + min: undefined, + max: undefined, + total: dimensionTotal, + rows: Array.from(countsMap.entries()).map(([value, count]) => ({ + value, + total: count, + })), + } + }) + + // Now, fetch the logs data with pagination + // ONLY convert to ISO when we're about to send to the API + let timestampStart: string + let timestampEnd: string + + if (isPagination && direction === 'prev') { + // Live mode: fetch logs newer than the cursor + timestampStart = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() // Convert microseconds to ISO for API + : isoTimestampStart + timestampEnd = new Date().toISOString() // Current time as ISO for API + } else if (isPagination && direction === 'next') { + // Regular pagination: fetch logs older than the cursor + timestampStart = isoTimestampStart + timestampEnd = cursorValue + ? new Date(Number(cursorValue) / 1000).toISOString() // Convert microseconds to ISO for API + : isoTimestampEnd + } else { + // Initial load: use the original date range + timestampStart = isoTimestampStart + timestampEnd = isoTimestampEnd + } + + if (DEBUG_MODE) { + console.log( + `๐Ÿ” Query function called: isPagination=${isPagination}, cursorValue=${cursorValue}, direction=${direction}, iso_timestamp_start=${timestampStart}, iso_timestamp_end=${timestampEnd}` + ) + console.log('๐Ÿ” Raw pageParam received:', pageParam) + } + + const { data: logsData, error: logsError } = await get( + `/platform/projects/{ref}/analytics/endpoints/logs.all`, + { + params: { + path: { ref: projectRef }, + query: { + iso_timestamp_start: timestampStart, + iso_timestamp_end: timestampEnd, + project: projectRef, + sql: logsSql, + }, + }, + } + ) + + if (logsError) { + if (DEBUG_MODE) console.error('API returned error for logs data:', logsError) + throw logsError + } + + // Process the logs results + const resultData = logsData?.result || [] + + if (DEBUG_MODE) console.log(`Received ${resultData.length} records from API`) + + // Define specific level types + type LogLevel = 'success' | 'warning' | 'error' + + // Transform results to expected schema + const result = resultData.map((row: any) => { + // Create a unique ID using the timestamp + const uniqueId = `${row.id || 'id'}-${row.timestamp}-${new Date().getTime()}` + + // Create a date object for display purposes + // The timestamp is in microseconds, need to convert to milliseconds for JS Date + const date = new Date(Number(row.timestamp) / 1000) + + // Use the level directly from SQL rather than determining it in TypeScript + const level = row.level as LogLevel + + return { + id: uniqueId, + uuid: uniqueId, + date, // Date object for display purposes + timestamp: row.timestamp, // Original timestamp from the database + level, + status: row.status || 200, + method: row.method, + host: row.host, + pathname: (row.url || '').replace(/^https?:\/\/[^\/]+/, '') || row.path || '', + event_message: row.event_message || row.body || '', + headers: + typeof row.headers === 'string' ? JSON.parse(row.headers || '{}') : row.headers || {}, + regions: row.region ? [row.region] : [], + log_type: row.log_type || '', + latency: row.latency || 0, + log_count: row.log_count || null, + logs: row.logs || [], + auth_user: row.auth_user || null, + } + }) + + // Just use the row timestamps directly for cursors + const lastRow = result.length > 0 ? result[result.length - 1] : null + const firstRow = result.length > 0 ? result[0] : null + const nextCursor = lastRow ? lastRow.timestamp : null + + // FIXED: Always provide prevCursor like DataTableDemo does + // This ensures live mode never breaks the infinite query chain + // DataTableDemo uses milliseconds, but our timestamps are in microseconds + const prevCursor = result.length > 0 ? firstRow!.timestamp : new Date().getTime() * 1000 + + // Determine if there might be more data + const pageLimit = 50 + + // HACK: Backend uses "timestamp > cursor" which can exclude records with identical timestamps + // THIS CAN SOMETIMES CAUSE 49 RECORDS INSTEAD OF 50 TO BE RETURNED + // TODO: Revisit this - ideally the backend should use composite cursors (timestamp+id) for proper pagination + // For now, we consider 49 records as a "full page" to ensure pagination works correctly + const hasMore = result.length >= pageLimit - 1 // Consider 49 or 50 records as a full page + + if (DEBUG_MODE) { + console.log( + `Pagination info: result.length=${result.length}, hasMore=${hasMore}, nextCursor=${nextCursor}, prevCursor=${prevCursor}` + ) + } + + // Create response with pagination info + const response = { + data: result, + meta: { + // Use the total count from our counts query + totalRowCount: totalCount, + filterRowCount: result.length, + chartData: buildChartData(result), + facets, // Use the facets from the counts query + metadata: { + currentPercentiles: {}, + logTypeCounts: calculateLogTypeCounts(result), + }, + }, + + nextCursor: hasMore ? nextCursor : null, + prevCursor, + } + + return response + } catch (error) { + if (DEBUG_MODE) console.error('Error fetching unified logs:', error) + throw error + } + }, + // Initial load with proper cursor for live mode pagination + // Use microseconds format to match database timestamps + initialPageParam: { cursor: new Date().getTime() * 1000, direction: 'next' } as PageParam, + getPreviousPageParam: ( + firstPage: InfiniteQueryResponse + ) => { + if (DEBUG_MODE) { + console.log('๐Ÿ” getPreviousPageParam called with:', { + hasFirstPage: !!firstPage, + dataLength: firstPage?.data?.length || 0, + prevCursor: firstPage?.prevCursor, + nextCursor: firstPage?.nextCursor, + firstPageKeys: firstPage ? Object.keys(firstPage) : 'no firstPage', + }) + } + + // Use the same logic as the working DataTableDemo + if (!firstPage.prevCursor) { + if (DEBUG_MODE) console.log('๐Ÿ” getPreviousPageParam returning: null (no prevCursor)') + return null + } + + const result = { cursor: firstPage.prevCursor, direction: 'prev' } as PageParam + if (DEBUG_MODE) console.log('๐Ÿ” getPreviousPageParam returning:', result) + return result + }, + getNextPageParam: ( + lastPage: InfiniteQueryResponse, + allPages: InfiniteQueryResponse[] + ) => { + // Only return a cursor if we actually have more data to fetch + if (!lastPage.nextCursor || lastPage.data.length === 0) return null + // Only trigger fetch when specifically requested, not during column resizing + return { cursor: lastPage.nextCursor, direction: 'next' } as PageParam + }, + // Configure React Query to be more stable + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchInterval: 0, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime) + } +} + +// Helper functions for chart data and log type counts +function buildChartData(logs: any[]) { + // Group logs by minute and count by level + const chartData = [] + const minuteGroups = new Map() + + // Sort by timestamp ascending + const sortedLogs = [...logs].sort((a, b) => a.rawTimestamp - b.rawTimestamp) + + for (const log of sortedLogs) { + const minute = dayjs(log.date).startOf('minute').valueOf() + + if (!minuteGroups.has(minute)) { + minuteGroups.set(minute, { + // Use number timestamp instead of Date object + timestamp: minute, + success: 0, + error: 0, + warning: 0, + }) + } + + const group = minuteGroups.get(minute) + // Ensure we're only using valid log levels that match the schema + const level = ['success', 'error', 'warning'].includes(log.level) ? log.level : 'success' + group[level] = (group[level] || 0) + 1 + } + + // Convert map to array + for (const data of minuteGroups.values()) { + chartData.push(data) + } + + return chartData.sort((a, b) => a.timestamp - b.timestamp) +} + +function calculateLogTypeCounts(logs: any[]) { + const counts: Record = {} + + logs.forEach((log) => { + const logType = log.log_type + counts[logType] = (counts[logType] || 0) + 1 + }) + + return counts +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx new file mode 100644 index 0000000000000..996d6290e596e --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -0,0 +1,90 @@ +import { + createParser, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, + parseAsStringLiteral, + parseAsTimestamp, +} from 'nuqs' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, + SLIDER_DELIMITER, + SORT_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { ChartConfig } from 'ui' +import { TooltipLabel } from './components/TooltipLabel' + +export const CHART_CONFIG = { + success: { + label: , + color: 'hsl(var(--foreground-muted))', + }, + warning: { + label: , + color: 'hsl(var(--warning-default))', + }, + error: { + label: , + color: 'hsl(var(--destructive-default))', + }, +} satisfies ChartConfig + +export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const +export const METHODS = ['GET', 'POST', 'PUT', 'DELETE'] as const +export const LOG_TYPES = [ + 'auth', + 'edge', + 'edge_function', + 'function_events', + 'postgres', + 'postgres_upgrade', + 'postgrest', + 'storage', + 'supavisor', +] as const + +const parseAsSort = createParser({ + parse(queryValue) { + const [id, desc] = queryValue.split(SORT_DELIMITER) + if (!id && !desc) return null + return { id, desc: desc === 'desc' } + }, + serialize(value) { + return `${value.id}.${value.desc ? 'desc' : 'asc'}` + }, +}) + +export const SEARCH_PARAMS_PARSER = { + // CUSTOM FILTERS + level: parseAsArrayOf(parseAsStringLiteral(LEVELS), ARRAY_DELIMITER), + log_type: parseAsArrayOf(parseAsString, ARRAY_DELIMITER), + latency: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.dns': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.connection': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.tls': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.ttfb': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + 'timing.transfer': parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + status: parseAsArrayOf(parseAsInteger, SLIDER_DELIMITER), + regions: parseAsArrayOf(parseAsStringLiteral(REGIONS), ARRAY_DELIMITER), + method: parseAsArrayOf(parseAsStringLiteral(METHODS), ARRAY_DELIMITER), + host: parseAsString, + pathname: parseAsString, + date: parseAsArrayOf(parseAsTimestamp, RANGE_DELIMITER), + + // REQUIRED FOR SORTING & PAGINATION + sort: parseAsSort, + size: parseAsInteger.withDefault(40), + start: parseAsInteger.withDefault(0), + + // REQUIRED FOR INFINITE SCROLLING (Live Mode and Load More) + direction: parseAsStringLiteral(['prev', 'next']).withDefault('next'), + cursor: parseAsTimestamp.withDefault(new Date()), + live: parseAsBoolean.withDefault(false), + + // REQUIRED FOR SELECTION + uuid: parseAsString, +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx new file mode 100644 index 0000000000000..11ff66d6d83b2 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -0,0 +1,184 @@ +import { format } from 'date-fns' +import { User } from 'lucide-react' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types' +import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils' +import { cn } from 'ui' +import { LOG_TYPES, METHODS } from './UnifiedLogs.constants' +import { ColumnSchema } from './UnifiedLogs.schema' +import { LogsMeta, SheetField } from './UnifiedLogs.types' +import { getLevelLabel } from './UnifiedLogs.utils' + +// instead of filterFields, maybe just 'fields' with a filterDisabled prop? +// that way, we could have 'message' or 'headers' field with label and value as well as type! +export const filterFields = [ + { + label: 'Time Range', + value: 'date', + type: 'timerange', + defaultOpen: true, + commandDisabled: true, + }, + { + label: 'Level', + value: 'level', + type: 'checkbox', + defaultOpen: true, + options: LEVELS.map((level) => ({ label: level, value: level })), + component: (props: Option) => { + // TODO: type `Option` with `options` values via Generics + const value = props.value as (typeof LEVELS)[number] + return ( +
+ + {props.label} + +
+
+ {getLevelLabel(value)} +
+
+ ) + }, + }, + { + label: 'Log Type', + value: 'log_type', + type: 'checkbox', + defaultOpen: true, + options: LOG_TYPES.map((type) => ({ label: type, value: type })), + component: (props: Option) => { + return ( +
+ + {props.label} + + {props.value} +
+ ) + }, + }, + { + label: 'Host', + value: 'host', + type: 'input', + }, + { + label: 'Pathname', + value: 'pathname', + type: 'input', + }, + { + label: 'Auth User', + value: 'auth_user', + type: 'input', + }, + { + label: 'Status Code', + value: 'status', + type: 'checkbox', + options: [ + { label: '200', value: 200 }, + { label: '400', value: 400 }, + { label: '404', value: 404 }, + { label: '500', value: 500 }, + ], // REMINDER: this is a placeholder to set the type in the client.tsx + component: (props: Option) => { + if (typeof props.value === 'boolean') return null + if (typeof props.value === 'undefined') return null + if (typeof props.value === 'string') return null + return ( + {props.value} + ) + }, + }, + { + label: 'Method', + value: 'method', + type: 'checkbox', + options: METHODS.map((region) => ({ label: region, value: region })), + component: (props: Option) => { + return {props.value} + }, + }, +] satisfies DataTableFilterField[] + +export const sheetFields = [ + { + id: 'uuid', + label: 'Request ID', + type: 'readonly', + skeletonClassName: 'w-64', + }, + { + id: 'date', + label: 'Date', + type: 'timerange', + component: (props) => { + const date = new Date(props.date) + const month = format(date, 'LLL') + const day = format(date, 'dd') + const year = format(date, 'y') + const time = format(date, 'HH:mm:ss') + + return ( +
+ {month} + ยท + {day} + ยท + {year} + ยท + {time} +
+ ) + }, + skeletonClassName: 'w-36', + }, + { + id: 'auth_user', + label: 'Auth User', + type: 'readonly', + condition: (props) => Boolean(props.auth_user), + component: (props) => ( +
+ + {props.auth_user} +
+ ), + skeletonClassName: 'w-56', + }, + { + id: 'status', + label: 'Status', + type: 'checkbox', + component: (props) => { + return ( + {props.status} + ) + }, + skeletonClassName: 'w-12', + }, + { + id: 'method', + label: 'Method', + type: 'checkbox', + component: (props) => { + return {props.method} + }, + skeletonClassName: 'w-10', + }, + { + id: 'host', + label: 'Host', + type: 'input', + skeletonClassName: 'w-24', + }, + { + id: 'pathname', + label: 'Pathname', + type: 'input', + skeletonClassName: 'w-56', + }, +] satisfies SheetField[] diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts new file mode 100644 index 0000000000000..1661458a02794 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts @@ -0,0 +1,44 @@ +import { useQueryState } from 'nuqs' +import { useEffect, useMemo, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +export const useResetFocus = () => { + useHotKey(() => { + // FIXME: some dedicated div[tabindex="0"] do not auto-unblur (e.g. the DataTableFilterResetButton) + // REMINDER: we cannot just document.activeElement?.blur(); as the next tab will focus the next element in line, + // which is not what we want. We want to reset entirely. + document.body.setAttribute('tabindex', '0') + document.body.focus() + document.body.removeAttribute('tabindex') + }, '.') +} + +export const useLiveMode = (data: TData[]) => { + const [live] = useQueryState('live', SEARCH_PARAMS_PARSER.live) + // REMINDER: used to capture the live mode on timestamp + const liveTimestamp = useRef(live ? new Date().getTime() : undefined) + + useEffect(() => { + if (live) liveTimestamp.current = new Date().getTime() + else liveTimestamp.current = undefined + }, [live]) + + const anchorRow = useMemo(() => { + if (!live) return undefined + + const item = data.find((item) => { + // return first item that is there if not liveTimestamp + if (!liveTimestamp.current) return true + // return first item that is after the liveTimestamp + if (item.date.getTime() > liveTimestamp.current) return false + return true + // return first item if no liveTimestamp + }) + + return item + }, [live, data]) + + return { row: anchorRow, timestamp: liveTimestamp.current } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts new file mode 100644 index 0000000000000..d2823d0d126e4 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -0,0 +1,565 @@ +import dayjs from 'dayjs' + +import { SearchParamsType } from './UnifiedLogs.types' + +// Pagination and control parameters +const PAGINATION_PARAMS = ['sort', 'start', 'size', 'uuid', 'cursor', 'direction', 'live'] as const + +// Special filter parameters that need custom handling +const SPECIAL_FILTER_PARAMS = ['date'] as const + +// Combined list of all parameters to exclude from standard filtering +const EXCLUDED_QUERY_PARAMS = [...PAGINATION_PARAMS, ...SPECIAL_FILTER_PARAMS] as const +const BASE_CONDITIONS_EXCLUDED_PARAMS = [...PAGINATION_PARAMS, 'date', 'level'] as const + +/** + * Builds query conditions from search parameters and returns WHERE clause + * @param search SearchParamsType object containing query parameters + * @returns Object with whereConditions array and formatted WHERE clause + */ +const buildQueryConditions = (search: SearchParamsType) => { + const whereConditions: string[] = [] + + // Process all search parameters for filtering + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters + if (EXCLUDED_QUERY_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + whereConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`) + return + } + + // Handle scalar values + if (value !== null && value !== undefined) { + whereConditions.push(`${key} = '${value}'`) + } + }) + + // Create final WHERE clause + const finalWhere = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '' + + return { whereConditions, finalWhere } +} + +/** + * Builds level-specific condition for different log types + */ +const buildLevelConditions = (logType: string, levelFilter: string[]) => { + const conditions = [] + + switch (logType) { + case 'edge': + if (levelFilter.includes('success')) + conditions.push('edge_logs_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('edge_logs_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('edge_logs_response.status_code >= 500') + break + case 'postgres': + if (levelFilter.includes('success')) conditions.push("pgl_parsed.error_severity = 'LOG'") + if (levelFilter.includes('warning')) conditions.push("pgl_parsed.error_severity = 'WARNING'") + if (levelFilter.includes('error')) conditions.push("pgl_parsed.error_severity = 'ERROR'") + break + case 'edge function': + if (levelFilter.includes('success')) + conditions.push('fel_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('fel_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('fel_response.status_code >= 500') + break + case 'auth': + if (levelFilter.includes('success')) + conditions.push('el_in_al_response.status_code BETWEEN 200 AND 299') + if (levelFilter.includes('warning')) + conditions.push('el_in_al_response.status_code BETWEEN 400 AND 499') + if (levelFilter.includes('error')) conditions.push('el_in_al_response.status_code >= 500') + break + case 'supavisor': + if (levelFilter.includes('success')) + conditions.push("LOWER(svl_metadata.level) NOT IN ('error', 'warn', 'warning')") + if (levelFilter.includes('warning')) + conditions.push( + "(LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning')" + ) + if (levelFilter.includes('error')) conditions.push("LOWER(svl_metadata.level) = 'error'") + break + } + + return conditions +} + +/** + * Creates WHERE clause for a specific log type including level filtering + */ +const createFilterWhereClause = ( + logType: string, + levelFilter: string[], + baseConditions: string[] +) => { + const hasLevelFilter = levelFilter.length > 0 + + let where = '' + + if (hasLevelFilter) { + const levelConditions = buildLevelConditions(logType, levelFilter) + + if (levelConditions.length > 0) { + if (baseConditions.length > 0) { + where = `WHERE (${levelConditions.join(' OR ')}) AND ${baseConditions.join(' AND ')}` + } else { + where = `WHERE (${levelConditions.join(' OR ')})` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + } else if (baseConditions.length > 0) { + where = `WHERE ${baseConditions.join(' AND ')}` + } + + // Special case for auth logs + if (logType === 'auth') { + if (where) { + where = where.replace('WHERE', 'WHERE al_metadata.request_id is not null AND') + } else { + where = 'WHERE al_metadata.request_id is not null' + } + } + + return where +} + +/** + * Builds base conditions array from search params + */ +const buildBaseConditions = (search: SearchParamsType): string[] => { + const baseConditions: string[] = [] + + Object.entries(search).forEach(([key, value]) => { + // Skip pagination/control parameters, date and level (handled separately) + if (BASE_CONDITIONS_EXCLUDED_PARAMS.includes(key as any)) { + return + } + + // Handle array filters (IN clause) + if (Array.isArray(value) && value.length > 0) { + baseConditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(', ')})`) + } + // Handle scalar values + else if (value !== null && value !== undefined) { + baseConditions.push(`${key} = '${value}'`) + } + }) + + return baseConditions +} + +/** + * Calculates how much the chart start datetime should be offset given the current datetime filter params + * and determines the appropriate bucketing level (minute, hour, day) + * Ported from the older implementation (apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts) + */ +const calculateChartBucketing = (search: SearchParamsType | Record): string => { + // Extract start and end times from the date array if available + const dateRange = search.date || [] + + // Handle timestamps that could be in various formats + const convertToMillis = (timestamp: any) => { + if (!timestamp) return null + // If timestamp is a Date object + if (timestamp instanceof Date) return timestamp.getTime() + + // If timestamp is a string that needs parsing + if (typeof timestamp === 'string') return dayjs(timestamp).valueOf() + + // If timestamp is already a number (unix timestamp) + // Check if microseconds (16 digits) and convert to milliseconds + if (typeof timestamp === 'number') { + const str = timestamp.toString() + if (str.length >= 16) return Math.floor(timestamp / 1000) + return timestamp + } + + return null + } + + let startMillis = convertToMillis(dateRange[0]) + let endMillis = convertToMillis(dateRange[1]) + + // Default values if not set + if (!startMillis) startMillis = dayjs().subtract(1, 'hour').valueOf() + if (!endMillis) endMillis = dayjs().valueOf() + + const startTime = dayjs(startMillis) + const endTime = dayjs(endMillis) + + let truncationLevel = 'MINUTE' + + const minuteDiff = endTime.diff(startTime, 'minute') + const hourDiff = endTime.diff(startTime, 'hour') + const dayDiff = endTime.diff(startTime, 'day') + + console.log(`Time difference: ${minuteDiff} minutes, ${hourDiff} hours, ${dayDiff} days`) + + // Adjust bucketing based on time range + if (dayDiff >= 2) { + truncationLevel = 'DAY' + } else if (hourDiff >= 12) { + truncationLevel = 'HOUR' + } else { + truncationLevel = 'MINUTE' + } + + return truncationLevel +} + +/** + * Edge logs query fragment + * + * excludes `/rest/` in the path + */ +const getEdgeLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'edge' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as path, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path does not include /rest/ + WHERE edge_logs_request.path NOT LIKE '%/rest/%' + + ` +} + +// Postgrest logs + +// WHERE pathname includes `/rest/` +const getPostgrestLogsQuery = () => { + return ` + select + id, + el.timestamp as timestamp, + 'postgrest' as log_type, + CAST(edge_logs_response.status_code AS STRING) as status, + CASE + WHEN edge_logs_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN edge_logs_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN edge_logs_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + edge_logs_request.path as path, + edge_logs_request.host as host, + null as event_message, + edge_logs_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from edge_logs as el + cross join unnest(metadata) as edge_logs_metadata + cross join unnest(edge_logs_metadata.request) as edge_logs_request + cross join unnest(edge_logs_metadata.response) as edge_logs_response + left join unnest(edge_logs_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + + -- ONLY include logs where the path includes /rest/ + WHERE edge_logs_request.path LIKE '%/rest/%' + ` +} + +/** + * Postgres logs query fragment + */ +const getPostgresLogsQuery = () => { + return ` + select + id, + pgl.timestamp as timestamp, + 'postgres' as log_type, + pgl_parsed.sql_state_code as status, + CASE + WHEN pgl_parsed.error_severity = 'LOG' THEN 'success' + WHEN pgl_parsed.error_severity = 'WARNING' THEN 'warning' + WHEN pgl_parsed.error_severity = 'ERROR' THEN 'error' + ELSE null + END as level, + null as path, + null as host, + event_message as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from postgres_logs as pgl + cross join unnest(pgl.metadata) as pgl_metadata + cross join unnest(pgl_metadata.parsed) as pgl_parsed + ` +} + +/** + * Edge function logs query fragment + */ +const getEdgeFunctionLogsQuery = () => { + return ` + select + id, + fel.timestamp as timestamp, + 'edge function' as log_type, + CAST(fel_response.status_code AS STRING) as status, + CASE + WHEN fel_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN fel_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN fel_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + fel_request.url as path, + fel_request.host as host, + COALESCE(function_logs_agg.last_event_message, '') as event_message, + fel_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + function_logs_agg.function_log_count as log_count, + function_logs_agg.logs as logs + from function_edge_logs as fel + cross join unnest(metadata) as fel_metadata + cross join unnest(fel_metadata.response) as fel_response + cross join unnest(fel_metadata.request) as fel_request + left join unnest(fel_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + left join ( + SELECT + fl_metadata.execution_id, + COUNT(fl.id) as function_log_count, + ANY_VALUE(fl.event_message) as last_event_message, + ARRAY_AGG(STRUCT(fl.id, fl.timestamp, fl.event_message, fl_metadata.level, fl_metadata.event_type)) as logs + FROM function_logs as fl + CROSS JOIN UNNEST(fl.metadata) as fl_metadata + WHERE fl_metadata.execution_id IS NOT NULL + GROUP BY fl_metadata.execution_id + ) as function_logs_agg on fel_metadata.execution_id = function_logs_agg.execution_id + ` +} + +/** + * Auth logs query fragment + */ +const getAuthLogsQuery = () => { + return ` + select + al.id as id, + el_in_al.timestamp as timestamp, + 'auth' as log_type, + CAST(el_in_al_response.status_code AS STRING) as status, + CASE + WHEN el_in_al_response.status_code BETWEEN 200 AND 299 THEN 'success' + WHEN el_in_al_response.status_code BETWEEN 400 AND 499 THEN 'warning' + WHEN el_in_al_response.status_code >= 500 THEN 'error' + ELSE 'success' + END as level, + el_in_al_request.path as path, + el_in_al_request.host as host, + null as event_message, + el_in_al_request.method as method, + authorization_payload.role as api_role, + COALESCE(sb.auth_user, null) as auth_user, + null as log_count, + null as logs + from auth_logs as al + cross join unnest(metadata) as al_metadata + left join ( + edge_logs as el_in_al + cross join unnest (metadata) as el_in_al_metadata + cross join unnest (el_in_al_metadata.response) as el_in_al_response + cross join unnest (el_in_al_response.headers) as el_in_al_response_headers + cross join unnest (el_in_al_metadata.request) as el_in_al_request + left join unnest(el_in_al_request.sb) as sb + left join unnest(sb.jwt) as jwt + left join unnest(jwt.authorization) as auth + left join unnest(auth.payload) as authorization_payload + ) + on al_metadata.request_id = el_in_al_response_headers.cf_ray + WHERE al_metadata.request_id is not null + ` +} + +/** + * Supavisor logs query fragment + */ +const getSupavisorLogsQuery = () => { + return ` + select + id, + svl.timestamp as timestamp, + 'supavisor' as log_type, + 'undefined' as status, + CASE + WHEN LOWER(svl_metadata.level) = 'error' THEN 'error' + WHEN LOWER(svl_metadata.level) = 'warn' OR LOWER(svl_metadata.level) = 'warning' THEN 'warning' + ELSE 'success' + END as level, + null as path, + null as host, + null as event_message, + null as method, + 'api_role' as api_role, + null as auth_user, + null as log_count, + null as logs + from supavisor_logs as svl + cross join unnest(metadata) as svl_metadata + ` +} + +/** + * Combine all log sources to create the unified logs CTE + */ +const getUnifiedLogsCTE = () => { + return ` +WITH unified_logs AS ( + ${getEdgeLogsQuery()} + union all + ${getPostgrestLogsQuery()} + union all + ${getPostgresLogsQuery()} + union all + ${getEdgeFunctionLogsQuery()} + union all + ${getAuthLogsQuery()} + union all + ${getSupavisorLogsQuery()} +) + ` +} + +/** + * Unified logs SQL query + */ +export const getUnifiedLogsQuery = (search: SearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // The unified SQL query with UNION ALL statements + const sql = ` +${getUnifiedLogsCTE()} +SELECT + id, + timestamp, + log_type, + status, + level, + path, + host, + event_message, + method, + api_role, + auth_user, + log_count, + logs +FROM unified_logs +${finalWhere} +` + + return sql +} + +/** + * Get a count query for the total logs within the timeframe + * Also returns facets for all filter dimensions + */ +export const getLogsCountQuery = (search: SearchParamsType): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search) + + // Create a count query using the same unified logs CTE + const sql = ` +${getUnifiedLogsCTE()} +-- Get total count +SELECT 'total' as dimension, 'all' as value, COUNT(*) as count +FROM unified_logs +${finalWhere} + +UNION ALL + +-- Get counts by level +SELECT 'level' as dimension, level as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY level + +UNION ALL + +-- Get counts by log_type +SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +GROUP BY log_type + +UNION ALL + +-- Get counts by method +SELECT 'method' as dimension, method as value, COUNT(*) as count +FROM unified_logs +${finalWhere} +WHERE method IS NOT NULL +GROUP BY method +` + + return sql +} + +/** + * Enhanced logs chart query with dynamic bucketing based on time range + * Incorporates dynamic bucketing from the older implementation + */ +export const getLogsChartQuery = (search: SearchParamsType | Record): string => { + // Use the buildQueryConditions helper + const { finalWhere } = buildQueryConditions(search as SearchParamsType) + + // Determine appropriate bucketing level based on time range + const truncationLevel = calculateChartBucketing(search) + + return ` +${getUnifiedLogsCTE()} +SELECT + TIMESTAMP_TRUNC(timestamp, ${truncationLevel}) as time_bucket, + COUNTIF(level = 'success') as success, + COUNTIF(level = 'warning') as warning, + COUNTIF(level = 'error') as error, + COUNT(*) as total_per_bucket +FROM unified_logs +${finalWhere} +GROUP BY time_bucket +ORDER BY time_bucket ASC +` +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts new file mode 100644 index 0000000000000..6e7459128dd26 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' + +import { + ARRAY_DELIMITER, + LEVELS, + RANGE_DELIMITER, +} from 'components/ui/DataTable/DataTable.constants' +import { LOG_TYPES, METHODS, REGIONS } from './UnifiedLogs.constants' + +export const columnSchema = z.object({ + id: z.string(), + log_type: z.enum(LOG_TYPES), + uuid: z.string(), + method: z.enum(METHODS), + host: z.string(), + pathname: z.string(), + level: z.enum(LEVELS), + status: z.number(), + date: z.date(), + timestamp: z.number(), + event_message: z.string().optional(), + log_count: z.number().optional(), // used to count function logs for a given execution_id + logs: z.array(z.any()).optional(), // array of function logs + auth_user: z.string().optional(), +}) + +export type ColumnSchema = z.infer + +export const columnFilterSchema = z.object({ + level: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(LEVELS).array()) + .optional(), + method: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(METHODS).array()) + .optional(), + host: z.string().optional(), + pathname: z.string().optional(), + status: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.coerce.number().array()) + .optional(), + regions: z + .string() + .transform((val) => val.split(ARRAY_DELIMITER)) + .pipe(z.enum(REGIONS).array()) + .optional(), + date: z + .string() + .transform((val) => val.split(RANGE_DELIMITER).map(Number)) + .pipe(z.coerce.date().array()) + .optional(), + auth_user: z.string().optional(), +}) + +export type ColumnFilterSchema = z.infer + +export const facetMetadataSchema = z.object({ + rows: z.array(z.object({ value: z.any(), total: z.number() })), + total: z.number(), + min: z.number().optional(), + max: z.number().optional(), +}) + +export type FacetMetadataSchema = z.infer + +export type BaseChartSchema = { timestamp: number; [key: string]: number } + +export const timelineChartSchema = z.object({ + timestamp: z.number(), // UNIX + ...LEVELS.reduce( + (acc, level) => ({ + ...acc, + [level]: z.number().default(0), + }), + {} as Record<(typeof LEVELS)[number], z.ZodNumber> + ), + // REMINDER: make sure to have the `timestamp` field in the object +}) satisfies z.ZodType + +export type TimelineChartSchema = z.infer diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx new file mode 100644 index 0000000000000..3344d5d77e801 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -0,0 +1,413 @@ +import { useInfiniteQuery } from '@tanstack/react-query' +import { + ColumnFiltersState, + getCoreRowModel, + getFacetedRowModel, + getFilteredRowModel, + getSortedRowModel, + getFacetedMinMaxValues as getTTableFacetedMinMaxValues, + getFacetedUniqueValues as getTTableFacetedUniqueValues, + Row, + RowSelectionState, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table' +import { useQueryStates } from 'nuqs' +import { useEffect, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { arrSome, inDateRange } from 'components/ui/DataTable/DataTable.utils' +import { DataTableFilterCommand } from 'components/ui/DataTable/DataTableFilters/DataTableFilterCommand' +import { DataTableHeaderLayout } from 'components/ui/DataTable/DataTableHeaderLayout' +import { DataTableInfinite } from 'components/ui/DataTable/DataTableInfinite' +import { DataTableSheetDetails } from 'components/ui/DataTable/DataTableSheetDetails' +import { DataTableSideBarLayout } from 'components/ui/DataTable/DataTableSideBarLayout' +import { DataTableToolbar } from 'components/ui/DataTable/DataTableToolbar' +import { FilterSideBar } from 'components/ui/DataTable/FilterSideBar' +import { LiveButton } from 'components/ui/DataTable/LiveButton' +import { LiveRow } from 'components/ui/DataTable/LiveRow' +import { DataTableProvider } from 'components/ui/DataTable/providers/DataTableProvider' +import { RefreshButton } from 'components/ui/DataTable/RefreshButton' +import { TimelineChart } from 'components/ui/DataTable/TimelineChart' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { + ChartConfig, + cn, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + Separator, + Tabs_Shadcn_ as Tabs, + TabsContent_Shadcn_ as TabsContent, + TabsList_Shadcn_ as TabsList, + TabsTrigger_Shadcn_ as TabsTrigger, +} from 'ui' +import { COLUMNS } from './components/Columns' +import { MemoizedDataTableSheetContent } from './components/DataTableSheetContent' +import { FunctionLogsTab } from './components/FunctionLogsTab' +import { dataOptions, useChartData } from './QueryOptions' +import { CHART_CONFIG, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' +import { filterFields as defaultFilterFields, sheetFields } from './UnifiedLogs.fields' +import { useLiveMode, useResetFocus } from './UnifiedLogs.hooks' +import { getFacetedUniqueValues, getLevelRowClassName, logEventBus } from './UnifiedLogs.utils' + +// Debug mode flag - set to true to enable detailed logs +const DEBUG_FILTER_PROCESSING = false + +export const UnifiedLogs = () => { + useResetFocus() + + const { ref: projectRef } = useParams() + const [search, setSearch] = useQueryStates(SEARCH_PARAMS_PARSER) + + const { sort, start, size, uuid, cursor, direction, live, ...filter } = search + const defaultColumnSorting = sort ? [sort] : [] + const defaultColumnVisibility = { uuid: false } + const defaultRowSelection = search.uuid ? { [search.uuid]: true } : {} + const defaultColumnFilters = Object.entries(filter) + .map(([key, value]) => ({ id: key, value })) + .filter(({ value }) => value ?? undefined) + + const [topBarHeight, setTopBarHeight] = useState(0) + const [activeTab, setActiveTab] = useState('details') + const [sorting, setSorting] = useState(defaultColumnSorting) + const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) + const [rowSelection, setRowSelection] = useState(defaultRowSelection) + + const [columnVisibility, setColumnVisibility] = useLocalStorageQuery( + 'data-table-visibility', + defaultColumnVisibility + ) + const [columnOrder, setColumnOrder] = useLocalStorageQuery( + 'data-table-column-order', + [] + ) + + // [Joshen] This needs to move to the data folder to follow our proper RQ structure + const { data, isFetching, isLoading, fetchNextPage, hasNextPage, fetchPreviousPage, refetch } = + // @ts-ignore + useInfiniteQuery(dataOptions(search, projectRef ?? '')) + + const flatData = useMemo(() => { + return data?.pages?.flatMap((page) => page.data ?? []) ?? [] + }, [data?.pages]) + const liveMode = useLiveMode(flatData) + + // Add the chart data query for the entire time period + const { data: chartDataResult } = useChartData(search, projectRef ?? '') + + // REMINDER: meta data is always the same for all pages as filters do not change(!) + const lastPage = data?.pages?.[data?.pages.length - 1] + // Use the totalCount from chartDataResult which gives us the actual count of logs in the time period + // instead of the hardcoded 10000 value + const totalDBRowCount = chartDataResult?.totalCount || lastPage?.meta?.totalRowCount + const filterDBRowCount = lastPage?.meta?.filterRowCount + const metadata = lastPage?.meta?.metadata + // Use chart data from the separate query if available, fallback to the default + const chartData = chartDataResult?.chartData || lastPage?.meta?.chartData + const facets = lastPage?.meta?.facets + const totalFetched = flatData?.length + + // Create a filtered version of the chart config based on selected levels + const filteredChartConfig = useMemo(() => { + const levelFilter = search.level || ['success', 'warning', 'error'] + return Object.fromEntries( + Object.entries(CHART_CONFIG).filter(([key]) => levelFilter.includes(key as any)) + ) as ChartConfig + }, [search.level]) + + const getRowClassName = ( + row: Row + ) => { + const rowTimestamp = row.original.timestamp + const isPast = rowTimestamp <= (liveMode.timestamp || -1) + const levelClassName = getLevelRowClassName(row.original.level as any) + return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100') + } + + const table = useReactTable({ + data: flatData, + columns: COLUMNS, + state: { + columnFilters, + sorting, + columnVisibility, + rowSelection, + columnOrder, + }, + enableMultiRowSelection: false, + columnResizeMode: 'onChange', + filterFns: { inDateRange, arrSome }, + meta: { getRowClassName }, + getRowId: (row) => row.uuid, + onColumnVisibilityChange: setColumnVisibility, + onColumnFiltersChange: setColumnFilters, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnOrderChange: setColumnOrder, + getSortedRowModel: getSortedRowModel(), + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getTTableFacetedUniqueValues(), + getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), + // Here, manually override the filter function for the level column + // to prevent client-side filtering since it's already filtered on the server + // Always return true to pass all level values + // columnFilterFns: { level: () => true }, + // debugAll: process.env.NEXT_PUBLIC_TABLE_DEBUG === 'true', + }) + + const selectedRow = useMemo(() => { + if ((isLoading || isFetching) && !flatData.length) return + const selectedRowKey = Object.keys(rowSelection)?.[0] + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [rowSelection, table, isLoading, isFetching, flatData]) + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + // REMINDER: this is currently needed for the cmdk search + // TODO: auto search via API when the user changes the filter instead of hardcoded + const filterFields = useMemo(() => { + return defaultFilterFields.map((field) => { + const facetsField = facets?.[field.value] + if (!facetsField) return field + if (field.options && field.options.length > 0) return field + + // REMINDER: if no options are set, we need to set them via the API + const options = facetsField.rows.map(({ value }) => ({ label: `${value}`, value })) + + if (field.type === ('slider' as any)) { + return { + ...(field as any), + min: facetsField.min ?? (field as any).min, + max: facetsField.max ?? (field as any).max, + options, + } + } + + return { ...field, options } + }) + }, [facets]) + + useEffect(() => { + if (DEBUG_FILTER_PROCESSING) console.log('========== FILTER CHANGE DETECTED ==========') + if (DEBUG_FILTER_PROCESSING) console.log('Raw columnFilters:', JSON.stringify(columnFilters)) + + // Check for level filters specifically + const levelColumnFilter = columnFilters.find((filter) => filter.id === 'level') + if (DEBUG_FILTER_PROCESSING) console.log('Level column filter:', levelColumnFilter) + + const columnFiltersWithNullable = filterFields.map((field) => { + const filterValue = columnFilters.find((filter) => filter.id === field.value) + if (DEBUG_FILTER_PROCESSING) console.log(`Processing field ${field.value}:`, filterValue) + if (!filterValue) return { id: field.value, value: null } + return { id: field.value, value: filterValue.value } + }) + + // Debug level filter specifically + const levelFilter = columnFiltersWithNullable.find((f) => f.id === 'level') + if (DEBUG_FILTER_PROCESSING) console.log('Level filter after mapping:', levelFilter) + + if (DEBUG_FILTER_PROCESSING) + console.log('All column filters after mapping:', columnFiltersWithNullable) + + const search = columnFiltersWithNullable.reduce( + (prev, curr) => { + if (DEBUG_FILTER_PROCESSING) + console.log(`Processing filter for URL: ${curr.id}`, { + value: curr.value, + type: Array.isArray(curr.value) ? 'array' : typeof curr.value, + isEmpty: Array.isArray(curr.value) && curr.value.length === 0, + isNull: curr.value === null, + }) + + // Add to search parameters + prev[curr.id as string] = curr.value + return prev + }, + {} as Record + ) + + if (DEBUG_FILTER_PROCESSING) console.log('Final search object to be set in URL:', search) + if (DEBUG_FILTER_PROCESSING) console.log('Level value in final search:', search.level) + if (DEBUG_FILTER_PROCESSING) console.log('Is level in search object:', 'level' in search) + + // Set the search state without any console logs + if (DEBUG_FILTER_PROCESSING) console.log('CALLING setSearch with:', JSON.stringify(search)) + setSearch(search) + if (DEBUG_FILTER_PROCESSING) console.log('========== END FILTER PROCESSING ==========') + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters]) + + useEffect(() => { + setSearch({ sort: sorting?.[0] || null }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sorting]) + + // TODO: can only share uuid within the first batch + useEffect(() => { + if (isLoading || isFetching) return + if (Object.keys(rowSelection)?.length && !selectedRow) { + setSearch({ uuid: null }) + setRowSelection({}) + } else { + setSearch({ uuid: Object.keys(rowSelection)?.[0] || null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rowSelection, selectedRow, isLoading, isFetching]) + + // Set up event listener for trace tab selection + useEffect(() => { + const unsubscribe = logEventBus.on('selectTraceTab', (rowId) => { + setRowSelection({ [rowId]: true }) + setActiveTab('trace') + }) + return () => { + unsubscribe() + } + }, [setRowSelection]) + + return ( + + + +
+ + + [ + , + fetchPreviousPage ? ( + + ) : null, + ]} + /> + + + + + + + + + +
+ { + if (!liveMode.timestamp) return null + if ((props?.row as any).original.uuid !== liveMode?.row?.uuid) return null + return + }} + setColumnOrder={setColumnOrder} + setColumnVisibility={setColumnVisibility} + searchParamsParser={SEARCH_PARAMS_PARSER} + /> +
+
+ {selectedRow?.original?.logs && selectedRow?.original?.logs?.length > 0 && ( + <> + + +
+
+

+ Function Logs ( + {selectedRow?.original?.logs && + Array.isArray(selectedRow?.original?.logs) + ? selectedRow?.original?.logs?.length + : 0} + ) +

+
+
+ +
+
+
+ + )} +
+
+ + {selectedRowKey && ( + <> + + +
+ + + + Log Details + + + + + + + +
+
+ + )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts new file mode 100644 index 0000000000000..d041f8c22efa2 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts @@ -0,0 +1,61 @@ +import { type inferParserType } from 'nuqs' + +import { LOG_TYPES, SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' + +type Percentile = 50 | 75 | 90 | 95 | 99 + +export type LogType = (typeof LOG_TYPES)[number] + +export type UnifiedLogSchema = { + id: string + timestamp: Date + log_type: LogType + code: string + level: string + path: string | null + event_message: string + method: string + api_role: string + auth_user: string | null +} + +export type LogsMeta = { + currentPercentiles: Record +} + +export type UnifiedLogsMeta = { + logTypeCounts: Record + currentPercentiles: Record +} + +export type PageParam = { cursor: number; direction: 'next' | 'prev' } + +export type SearchParamsType = inferParserType + +export type SearchParams = { + [key: string]: string | string[] | undefined +} + +/** ----------------------------------------- */ + +export type SheetField> = { + id: keyof TData + label: string + // FIXME: rethink that! I dont think we need this as there is no input type + // REMINDER: readonly if we only want to copy the value (e.g. uuid) + // TODO: we might have some values that are not in the data but can be computed + type: 'readonly' | 'input' | 'checkbox' | 'slider' | 'timerange' + component?: ( + // REMINDER: this is used to pass additional data like the `InfiniteQueryMeta` + props: TData & { + metadata?: { + totalRows: number + filterRows: number + totalRowsFetched: number + } & TMeta + } + ) => JSX.Element | null | string + condition?: (props: TData) => boolean + className?: string + skeletonClassName?: string +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts new file mode 100644 index 0000000000000..a32831a39a81e --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts @@ -0,0 +1,75 @@ +import { type Table as TTable } from '@tanstack/react-table' + +import { LEVELS } from 'components/ui/DataTable/DataTable.constants' +import { cn } from 'ui' +import { FacetMetadataSchema } from './UnifiedLogs.schema' + +export const logEventBus = { + listeners: new Map void>>(), + + on(event: 'selectTraceTab', callback: (rowId: string) => void) { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + this.listeners.get(event)?.add(callback) + return () => this.listeners.get(event)?.delete(callback) + }, + + emit(event: 'selectTraceTab', rowId: string) { + this.listeners.get(event)?.forEach((callback) => callback(rowId)) + }, +} + +export const getFacetedUniqueValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + return new Map(facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || []) + } +} + +export const getFacetedMinMaxValues = (facets?: Record) => { + return (table: TTable, columnId: string) => { + const min = facets?.[columnId]?.min + const max = facets?.[columnId]?.max + if (typeof min === 'number' && typeof max === 'number') return [min, max] + if (typeof min === 'number') return [min, min] + if (typeof max === 'number') return [max, max] + return undefined + } +} + +export const getLevelLabel = (value: (typeof LEVELS)[number]): string => { + switch (value) { + case 'success': + return '2xx' + case 'warning': + return '4xx' + case 'error': + return '5xx' + default: + return 'Unknown' + } +} + +export function getLevelRowClassName(value: (typeof LEVELS)[number]): string { + switch (value) { + case 'success': + return '' + case 'warning': + return cn( + 'bg-warning/5 hover:bg-warning/10 data-[state=selected]:bg-warning/20 focus-visible:bg-warning/10', + 'dark:bg-warning/10 dark:hover:bg-warning/20 dark:data-[state=selected]:bg-warning/30 dark:focus-visible:bg-warning/20' + ) + case 'error': + return cn( + 'bg-destructive/5 hover:bg-destructive/10 data-[state=selected]:bg-destructive/20 focus-visible:bg-destructive/10', + 'dark:bg-error/10 dark:hover:bg-destructive/20 dark:data-[state=selected]:bg-destructive/30 dark:focus-visible:bg-destructive/20' + ) + case 'info': + return cn( + 'bg-info/5 hover:bg-info/10 data-[state=selected]:bg-info/20 focus-visible:bg-info/10', + 'dark:bg-info/10 dark:hover:bg-info/20 dark:data-[state=selected]:bg-info/30 dark:focus-visible:bg-info/20' + ) + default: + return '' + } +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx new file mode 100644 index 0000000000000..0d9f329dd9255 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx @@ -0,0 +1,37 @@ +import { User } from 'lucide-react' + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' + +interface AuthUserHoverCardProps { + authUser: string +} + +export const AuthUserHoverCard = ({ authUser }: AuthUserHoverCardProps) => { + if (!authUser) return null + + return ( + + + + + +
+
+ +

User Details

+
+
+
+ ID/Email: {authUser} +
+ {/* Additional user details can be added here if available */} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx new file mode 100644 index 0000000000000..f49f017179f58 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -0,0 +1,193 @@ +import { ColumnDef } from '@tanstack/react-table' + +import { DataTableColumnHeader } from 'components/ui/DataTable/DataTableColumn/DataTableColumnHeader' +import { DataTableColumnLevelIndicator } from 'components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' +import { AuthUserHoverCard } from './AuthUserHoverCard' +import { HoverCardTimestamp } from './HoverCardTimestamp' +import { LogTypeIcon } from './LogTypeIcon' +import { TextWithTooltip } from './TextWithTooltip' + +export const COLUMNS: ColumnDef[] = [ + { + accessorKey: 'level', + header: '', + cell: ({ row }) => { + const level = row.getValue('level') + return + }, + enableHiding: false, + enableResizing: false, + filterFn: (row, columnId, filterValue) => true, + size: 48, + minSize: 48, + maxSize: 48, + meta: { + headerClassName: + 'w-[--header-level-size] max-w-[--header-level-size] min-w-[--header-level-size]', + cellClassName: 'w-[--col-level-size] max-w-[--col-level-size] min-w-[--col-level-size]', + }, + }, + { + accessorKey: 'date', + header: ({ column }) => , + cell: ({ row }) => { + const date = new Date(row.getValue('date')) + return + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 190, + minSize: 190, + maxSize: 190, + meta: { + headerClassName: + 'w-[--header-date-size] max-w-[--header-date-size] min-w-[--header-date-size]', + cellClassName: + 'font-mono w-[--col-date-size] max-w-[--col-date-size] min-w-[--col-date-size]', + }, + }, + { + accessorKey: 'log_type', + header: '', + cell: ({ row }) => { + const logType = row.getValue('log_type') + return ( +
+ +
+ ) + }, + enableHiding: false, + filterFn: (row, columnId, filterValue) => true, + size: 48, + minSize: 48, + maxSize: 48, + enableResizing: false, + meta: { + headerClassName: + 'w-[--header-log_type-size] max-w-[--header-log_type-size] min-w-[--header-log_type-size]', + cellClassName: + 'text-right px-0 relative justify-end items-center font-mono w-[--col-log_type-size] max-w-[--col-log_type-size] min-w-[--col-log_type-size]', + }, + }, + { + // TODO: make it a type of MethodSchema! + accessorKey: 'method', + header: 'Method', + filterFn: 'arrIncludesSome', + cell: ({ row }) => { + const value = row.getValue('method') + return {value} + }, + enableResizing: false, + size: 69, + minSize: 69, + meta: { + cellClassName: + 'font-mono text-muted-foreground w-[--col-method-size] max-w-[--col-method-size] min-w-[--col-method-size]', + headerClassName: + 'w-[--header-method-size] max-w-[--header-method-size] min-w-[--header-method-size]', + }, + }, + { + accessorKey: 'status', + header: '', + cell: ({ row }) => { + const value = row.getValue('status') + return ( +
+ {row.original.auth_user && ( +
+ +
+ )} + ('level')} + /> +
+ ) + }, + filterFn: (row, columnId, filterValue) => true, + enableResizing: false, + size: 60, + minSize: 60, + meta: { + headerClassName: + 'w-[--header-status-size] max-w-[--header-status-size] min-w-[--header-status-size]', + cellClassName: + 'font-mono w-[--col-status-size] max-w-[--col-status-size] min-w-[--col-status-size]', + }, + }, + + { + accessorKey: 'host', + header: 'Host', + cell: ({ row }) => { + const value = row.getValue('host') + return + }, + size: 220, + minSize: 220, + meta: { + cellClassName: 'font-mono w-[--col-host-size] max-w-[--col-host-size]', + headerClassName: 'min-w-[--header-host-size] w-[--header-host-size]', + }, + }, + { + accessorKey: 'pathname', + header: 'Pathname', + cell: ({ row }) => { + const value = row.getValue('pathname') ?? '' + return + }, + size: 320, + minSize: 320, + meta: { + cellClassName: 'font-mono w-[--col-pathname-size] max-w-[--col-pathname-size]', + headerClassName: 'min-w-[--header-pathname-size] w-[--header-pathname-size]', + }, + }, + { + accessorKey: 'event_message', + header: ({ column }) => , + cell: ({ row }) => { + const value = row.getValue('event_message') + const logCount = row.original.log_count + + return ( +
+ {logCount && ( + + + + {logCount} + + + + {logCount} {logCount === 1 ? 'log' : 'logs'} for this execution + + + )} + {value && ( + + + + )} +
+ ) + }, + enableResizing: true, + size: 400, + minSize: 400, + meta: { + headerClassName: + 'w-[--header-event_message-size] max-w-[--header-event_message-size] min-w-[--header-event_message-size]', + cellClassName: + 'font-mono w-[--col-event_message-size] max-w-[--col-event_message-size] min-w-[--col-event_message-size]', + }, + }, +] diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx new file mode 100644 index 0000000000000..cdd1800a1d5e6 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx @@ -0,0 +1,105 @@ +import { Table } from '@tanstack/react-table' +import { memo } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { DataTableSheetRowAction } from 'components/ui/DataTable/DataTableSheetRowAction' +import { cn, Skeleton } from 'ui' +import { SheetField } from '../UnifiedLogs.types' + +interface SheetDetailsContentSkeletonProps { + fields: SheetField[] +} + +const SheetDetailsContentSkeleton = ({ + fields, +}: SheetDetailsContentSkeletonProps) => { + return ( +
+ {fields.map((field) => ( +
+
{field.label}
+
+ +
+
+ ))} +
+ ) +} + +interface DataTableSheetContentProps extends React.HTMLAttributes { + data?: TData + table: Table + fields: SheetField[] + filterFields: DataTableFilterField[] + metadata?: TMeta & { + totalRows: number + filterRows: number + totalRowsFetched: number + } +} + +export function DataTableSheetContent({ + data, + table, + className, + fields, + filterFields, + metadata, + ...props +}: DataTableSheetContentProps) { + if (!data) return + + return ( +
+ {fields.map((field) => { + if (field.condition && !field.condition(data)) return null + + const Component = field.component + const value = String(data[field.id]) + + return ( +
+ {field.type === 'readonly' ? ( +
+
{field.label}
+
+ {Component ? : value} +
+
+ ) : ( + +
{field.label}
+
+ {Component ? : value} +
+
+ )} +
+ ) + })} +
+ ) +} + +export const MemoizedDataTableSheetContent = memo(DataTableSheetContent, (prev, next) => { + // REMINDER: only check if data is the same, rest is useless + return prev.data === next.data +}) as typeof DataTableSheetContent diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx new file mode 100644 index 0000000000000..3607f940d9b82 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx @@ -0,0 +1,55 @@ +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { HoverCardTimestamp } from './HoverCardTimestamp' + +interface FunctionLogEntry { + id: string + timestamp: string + event_message: string + level: string + event_type: string +} + +interface FunctionLogsTabProps { + logs?: FunctionLogEntry[] +} + +export const FunctionLogsTab = ({ logs = [] }: FunctionLogsTabProps) => { + if (!logs || logs.length === 0) { + return ( +
+

No function logs found

+
+ ) + } + + return ( +
+
+ {logs.map((log) => { + const date = new Date(Number(log.timestamp) / 1000) + // Map the log level to our standard levels + + return ( +
+
+
+ + + + {/* {log.event_type || 'Log'} */} +
+
+ {log.event_message} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx new file mode 100644 index 0000000000000..337fe574ed663 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx @@ -0,0 +1,14 @@ +import { TimestampInfo } from 'ui-patterns' + +interface HoverCardTimestampProps { + date: Date + className?: string +} + +export function HoverCardTimestamp({ date, className }: HoverCardTimestampProps) { + return ( +
+ +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx new file mode 100644 index 0000000000000..a14bb8bbd4555 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx @@ -0,0 +1,65 @@ +import { BookHeart, Box, Cpu, Database, Globe } from 'lucide-react' + +import { Auth, EdgeFunctions, Storage } from 'icons' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { type LOG_TYPES } from '../UnifiedLogs.constants' + +interface LogTypeIconProps { + type: (typeof LOG_TYPES)[number] + size?: number + strokeWidth?: number + className?: string +} + +export const LogTypeIcon = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + const iconMap = { + edge: () => , + postgrest: () => , + auth: () => , + edge_function: () => ( + + ), + postgres: () => , + function_events: () => ( + + ), + supavisor: () => , + postgres_upgrade: () => , + storage: () => , + + // cron: () => , + } + + const IconComponent = + iconMap[type] || (() => ) + + return ( + + + + + +
{type}
+
+
+ ) +} + +export const LogTypeIconWithText = ({ + type, + size = 16, + strokeWidth = 1.5, + className, +}: LogTypeIconProps) => { + return ( +
+ + {type} +
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx new file mode 100644 index 0000000000000..fae42e933ac5c --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx @@ -0,0 +1,53 @@ +import { useEffect, useRef, useState } from 'react' +import { cn, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from 'ui' + +interface TextWithTooltipProps { + text: string | number + className?: string +} + +export function TextWithTooltip({ text, className }: TextWithTooltipProps) { + const [isTruncated, setIsTruncated] = useState(false) + const textRef = useRef(null) + + useEffect(() => { + const checkTruncation = () => { + if (textRef.current) { + const { scrollWidth, clientWidth } = textRef.current + setIsTruncated(scrollWidth > clientWidth) + } + } + + const resizeObserver = new ResizeObserver(() => { + checkTruncation() + }) + + if (textRef.current) { + resizeObserver.observe(textRef.current) + } + + checkTruncation() + + return () => { + resizeObserver.disconnect() + } + }, []) + + return ( + + + +
+ {text} +
+
+ + {text} + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx new file mode 100644 index 0000000000000..7745d22b86405 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx @@ -0,0 +1,10 @@ +import { getLevelLabel } from '../UnifiedLogs.utils' + +export const TooltipLabel = ({ level }: { level: 'success' | 'warning' | 'error' }) => { + return ( +
+
{level}
+
{getLevelLabel(level)}
+
+ ) +} diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index a677ab116e73c..8b9b80ffef26f 100644 --- a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx @@ -29,7 +29,7 @@ export const AssistantButton = () => { }, }} > - + ) } diff --git a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 7296bb61f6fa0..2edee7ba163a5 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx @@ -2,13 +2,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' +import { LOCAL_STORAGE_KEYS } from 'common' import NoPermission from 'components/ui/NoPermission' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { withAuth } from 'hooks/misc/withAuth' import ProjectLayout from '../ProjectLayout/ProjectLayout' import { LogsSidebarMenuV2 } from './LogsSidebarMenuV2' -import { LOCAL_STORAGE_KEYS } from 'common' interface LogsLayoutProps { title?: string diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index 993484f71975d..f47831e49e34c 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -80,22 +80,27 @@ export function SidebarCollapsible({ } export function LogsSidebarMenuV2() { + const router = useRouter() + const { ref } = useParams() as { ref: string } + const warehouseEnabled = useFlag('warehouse') + const [searchText, setSearchText] = useState('') const [createCollectionOpen, setCreateCollectionOpen] = useState(false) const canCreateCollection = useCheckPermissions(PermissionAction.ANALYTICS_WRITE, 'logflare') - const router = useRouter() - const { ref } = useParams() as { ref: string } + const { data: tenantData } = useWarehouseTenantQuery({ projectRef: ref }) + const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled, realtimeAll: realtimeEnabled, } = useIsFeatureEnabled(['project_storage:all', 'project_auth:all', 'realtime:all']) - const warehouseEnabled = useFlag('warehouse') + const { data: whCollections, isLoading: whCollectionsLoading } = useWarehouseCollectionsQuery( { projectRef: ref }, { enabled: IS_PLATFORM && warehouseEnabled && !!tenantData } ) + const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan() const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index d8903ee448cf8..e610bb3a4e170 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx @@ -14,14 +14,12 @@ import { useProjectsQuery } from 'data/projects/projects-query' import { useNotificationsStateSnapshot } from 'state/notifications' import { Button, - CriticalIcon, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, - WarningIcon, cn, } from 'ui' import NotificationRow from './NotificationRow' @@ -103,41 +101,33 @@ export const NotificationsPopoverV2 = () => { text: 'Notifications', }, }} - type={hasNewNotifications ? 'outline' : 'text'} - className={cn( - 'rounded-none h-[30px] w-[32px]', - // !hasCritical || !hasWarning || !hasNewNotifications ? 'w-[26px]' : '', - 'group', - hasNewNotifications ? 'rounded-full px-1.5' : 'px-1', - hasCritical - ? 'border-destructive-500 hover:border-destructive-600 hover:bg-destructive-300' - : hasWarning - ? 'border-warning-500 hover:border-warning-600 hover:bg-warning-300' - : '' - )} + type="text" + className={cn('rounded-none h-[30px] w-[32px] group relative')} icon={ - hasCritical ? ( - - ) : hasWarning ? ( - - ) : hasNewNotifications ? ( -
+ 9 ? 'px-0.5 w-auto' : 'w-4' + '!h-[18px] !w-[18px] text-foreground-light group-hover:text-foreground' )} - > -

{summary?.unread_count}

-
- ) : null - } - iconRight={ - + /> + {hasCritical && ( +
+
+
+ )} + {hasWarning && !hasCritical && ( +
+
+
+ )} + {!!hasNewNotifications && !hasCritical && !hasWarning && ( +
+
+
+ )} +
} /> diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 96df83e1bb9b7..ac33ba4ff1da4 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,4 +1,4 @@ -import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' +import { Blocks, FileText, Lightbulb, List, Logs, Settings } from 'lucide-react' import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils' @@ -122,10 +122,16 @@ export const generateProductRoutes = ( ] } -export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => { +export const generateOtherRoutes = ( + ref?: string, + project?: Project, + features?: { unifiedLogs?: boolean } +): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` + const showUnifiedLogs = features?.unifiedLogs ?? false + return [ { key: 'advisors', @@ -149,6 +155,16 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/logs`), }, + ...(showUnifiedLogs + ? [ + { + key: 'unified-logs', + label: 'Unified Logs', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/unified-logs`), + }, + ] + : []), { key: 'api', label: 'API Docs', diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx new file mode 100644 index 0000000000000..27ba57f0ce085 --- /dev/null +++ b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx @@ -0,0 +1,9 @@ +import { PropsWithChildren } from 'react' + +import ProjectLayout from '../ProjectLayout/ProjectLayout' + +const UnifiedLogsLayout = ({ children }: PropsWithChildren) => { + return {children} +} + +export default UnifiedLogsLayout diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8b2fceca792c8..e15ee1d9eed9b 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -39,13 +39,20 @@ const ChartHeader = ({ {title} {docsUrl && ( - - - + + + + + + + + Read docs + + )}
) diff --git a/apps/studio/components/ui/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts new file mode 100644 index 0000000000000..da947462782c6 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.constants.ts @@ -0,0 +1,50 @@ +import { addDays, addHours, endOfDay, startOfDay } from 'date-fns' + +import { DatePreset } from './DataTable.types' + +export const ARRAY_DELIMITER = ',' +export const SLIDER_DELIMITER = '-' +export const SPACE_DELIMITER = '_' +export const RANGE_DELIMITER = '-' +export const SORT_DELIMITER = '.' + +export const LEVELS = ['success', 'warning', 'error', 'info'] as const + +export const presets = [ + { + label: 'Today', + from: startOfDay(new Date()), + to: endOfDay(new Date()), + shortcut: 'd', // day + }, + { + label: 'Yesterday', + from: startOfDay(addDays(new Date(), -1)), + to: endOfDay(addDays(new Date(), -1)), + shortcut: 'y', + }, + { + label: 'Last hour', + from: addHours(new Date(), -1), + to: new Date(), + shortcut: 'h', + }, + { + label: 'Last 7 days', + from: startOfDay(addDays(new Date(), -7)), + to: endOfDay(new Date()), + shortcut: 'w', + }, + { + label: 'Last 14 days', + from: startOfDay(addDays(new Date(), -14)), + to: endOfDay(new Date()), + shortcut: 'b', // bi-weekly + }, + { + label: 'Last 30 days', + from: startOfDay(addDays(new Date(), -30)), + to: endOfDay(new Date()), + shortcut: 'm', + }, +] satisfies DatePreset[] diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts new file mode 100644 index 0000000000000..9ee5cada84957 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.types.ts @@ -0,0 +1,61 @@ +// TODO: we could type the value(!) especially when using enums +export type Option = { + label: string + value: string | boolean | number | undefined +} + +export type DatePreset = { + label: string + from: Date + to: Date + shortcut: string +} + +export type Input = { + type: 'input' + options?: Option[] +} + +export type Checkbox = { + type: 'checkbox' + component?: (props: Option) => JSX.Element | null + options?: Option[] +} + +export type Slider = { + type: 'slider' + min: number + max: number + // if options is undefined, we will provide all the steps between min and max + options?: Option[] +} + +export type Timerange = { + type: 'timerange' + options?: Option[] // required for TS + presets?: DatePreset[] +} + +export type Base = { + label: string + value: keyof TData + /** + * Defines if the accordion in the filter bar is open by default + */ + defaultOpen?: boolean + /** + * Defines if the command input is disabled for this field + */ + commandDisabled?: boolean +} + +export type DataTableCheckboxFilterField = Base & Checkbox +export type DataTableSliderFilterField = Base & Slider +export type DataTableInputFilterField = Base & Input +export type DataTableTimerangeFilterField = Base & Timerange + +export type DataTableFilterField = + | DataTableCheckboxFilterField + | DataTableSliderFilterField + | DataTableInputFilterField + | DataTableTimerangeFilterField diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts new file mode 100644 index 0000000000000..12997306dab9a --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts @@ -0,0 +1,129 @@ +import { FilterFn } from '@tanstack/react-table' +import { isAfter, isBefore, isSameDay } from 'date-fns' + +import { LEVELS } from './DataTable.constants' + +export function formatCompactNumber(value: number) { + if (value >= 100 && value < 1000) { + return value.toString() // Keep the number as is if it's in the hundreds + } else if (value >= 1000 && value < 1000000) { + return (value / 1000).toFixed(1) + 'k' // Convert to 'k' for thousands + } else if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' // Convert to 'M' for millions + } else { + return value.toString() // Optionally handle numbers less than 100 if needed + } +} + +export function isArrayOfNumbers(arr: any): arr is number[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'number') +} + +export function isArrayOfDates(arr: any): arr is Date[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => item instanceof Date) +} + +export function isArrayOfStrings(arr: any): arr is string[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'string') +} + +export function isArrayOfBooleans(arr: any): arr is boolean[] { + if (!Array.isArray(arr)) return false + return arr.every((item) => typeof item === 'boolean') +} + +export const inDateRange: FilterFn = (row, columnId, value) => { + const date = new Date(row.getValue(columnId)) + const [start, end] = value as Date[] + + if (isNaN(date.getTime())) return false + + // if no end date, check if it's the same day + if (!end) return isSameDay(date, start) + + return isAfter(date, start) && isBefore(date, end) +} + +inDateRange.autoRemove = (val: any) => !Array.isArray(val) || !val.length || !isArrayOfDates(val) + +export const arrSome: FilterFn = (row, columnId, filterValue) => { + if (!Array.isArray(filterValue)) return false + return filterValue.some((val) => row.getValue(columnId) === val) +} + +arrSome.autoRemove = (val: any) => !Array.isArray(val) || !val?.length + +export function getLevelColor( + value: (typeof LEVELS)[number] +): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case 'success': + return { + text: 'text-muted', + bg: 'bg-muted', + border: 'border-muted', + } + case 'warning': + return { + text: 'text-warning', + bg: 'bg-warning', + border: 'border-warning', + } + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive', + border: 'border-destructive', + } + case 'info': + default: + return { + text: 'text-info', + bg: 'bg-info', + border: 'border-info', + } + } +} + +export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> { + switch (value) { + case '1': + case 'info': + return { + text: 'text-blue-500', + bg: '', + border: 'border-blue-200 dark:border-blue-800', + } + case '2': + case 'success': + return { + text: 'text-foreground-lighter', + bg: '', + border: 'border-green-200 dark:border-green-800', + } + case '4': + case 'warning': + case 'redirect': + return { + text: 'text-warning-600 dark:text-warning', + bg: 'bg-warning-300 dark:bg-waning-200', + border: 'border border-warning-400/50 dark:border-warning-400/50', + } + case '5': + case 'error': + return { + text: 'text-destructive', + bg: 'bg-destructive-300 dark:bg-destructive-300/50', + border: 'border border-destructive-400/50 dark:border-destructive-400/50', + } + default: + return { + text: 'text-foreground-lighter', + bg: '', + border: '', + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx new file mode 100644 index 0000000000000..7bd07547b55c2 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx @@ -0,0 +1,54 @@ +import { type Column } from '@tanstack/react-table' +import { ChevronDown, ChevronUp } from 'lucide-react' + +import { Button, cn, type ButtonProps } from 'ui' + +interface DataTableColumnHeaderProps extends ButtonProps { + column: Column + title: string +} + +export const DataTableColumnHeader = ({ + column, + title, + className, + ...props +}: DataTableColumnHeaderProps) => { + if (!column.getCanSort()) { + return
{title}
+ } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx new file mode 100644 index 0000000000000..c0038b7c3dde2 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx @@ -0,0 +1,17 @@ +import { cn } from 'ui' +import { LEVELS } from '../DataTable.constants' +import { getLevelColor } from '../DataTable.utils' + +export const DataTableColumnLevelIndicator = ({ + value, + className, +}: { + value: (typeof LEVELS)[number] + className?: string +}) => { + return ( +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx new file mode 100644 index 0000000000000..175e3a477a852 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx @@ -0,0 +1,35 @@ +import { Minus } from 'lucide-react' + +import { cn } from 'ui' +import { getStatusColor } from '../DataTable.utils' + +export const DataTableColumnStatusCode = ({ + value, + level, + className, +}: { + value?: number | string + level?: string + className?: string +}) => { + const colors = getStatusColor(level) + if (!value) { + return + } + + return ( +
+
+ {value} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx new file mode 100644 index 0000000000000..26fbbdc57d349 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx @@ -0,0 +1,118 @@ +import { Search } from 'lucide-react' +import { useState } from 'react' + +import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui' +import type { DataTableCheckboxFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { InputWithAddons } from '../InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterCheckbox({ + value: _value, + options, + component, +}: DataTableCheckboxFilterField) { + const value = _value as string + const [inputValue, setInputValue] = useState('') + const { table, columnFilters, isLoading, getFacetedUniqueValues } = useDataTable() + const column = table.getColumn(value) + // REMINDER: avoid using column?.getFilterValue() + const filterValue = columnFilters.find((i) => i.id === value)?.value + const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues() + + const Component = component + + // filter out the options based on the input value + const filterOptions = options?.filter( + (option) => inputValue === '' || option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + + // CHECK: it could be filterValue or searchValue + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + // REMINDER: if no options are defined, while fetching data, we should show a skeleton + if (isLoading && !filterOptions?.length) + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) + + if (!filterOptions?.length) return null + + return ( +
+ {options && options.length > 4 ? ( + } + containerClassName="h-9 rounded-lg" + value={inputValue} + onChange={(e) => setInputValue(e.target.value)} + /> + ) : null} + {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */} +
+ {filterOptions + // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default + // .sort((a, b) => a.label.localeCompare(b.label)) + .map((option, index) => { + const checked = filters.includes(option.value) + + return ( +
+ { + const newValue = checked + ? [...(filters || []), option.value] + : filters?.filter((value) => option.value !== value) + column?.setFilterValue(newValue?.length ? newValue : undefined) + }} + /> + +
+ ) + })} +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx new file mode 100644 index 0000000000000..b29d2410f19a7 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx @@ -0,0 +1,398 @@ +import { formatDistanceToNow } from 'date-fns' +import { LoaderCircle, Search, X } from 'lucide-react' +import { ParserBuilder } from 'nuqs' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useHotKey } from 'hooks/ui/useHotKey' +import { + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + CommandSeparator_Shadcn_ as CommandSeparator, + Separator, +} from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { formatCompactNumber } from '../DataTable.utils' +import { Kbd } from '../primitives/Kbd' +import { useDataTable } from '../providers/DataTableProvider' +import { + columnFiltersParser, + getFieldOptions, + getFilterValue, + getWordByCaretPosition, + replaceInputByFieldType, +} from './DataTableFilters.utils' + +// FIXME: there is an issue on cmdk if I wanna only set a single slider value... + +interface DataTableFilterCommandProps { + // TODO: maybe use generics for the parser + searchParamsParser: Record> +} + +export function DataTableFilterCommand({ searchParamsParser }: DataTableFilterCommandProps) { + const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable() + const columnFilters = table.getState().columnFilters + const inputRef = useRef(null) + const [open, setOpen] = useState(false) + const [currentWord, setCurrentWord] = useState('') + const filterFields = useMemo( + () => _filterFields?.filter((i) => !i.commandDisabled), + [_filterFields] + ) + const columnParser = useMemo( + () => columnFiltersParser({ searchParamsParser, filterFields }), + [searchParamsParser, filterFields] + ) + const [inputValue, setInputValue] = useState(columnParser.serialize(columnFilters)) + const [lastSearches, setLastSearches] = useLocalStorage< + { + search: string + timestamp: number + }[] + >('data-table-command', []) + + const trimmedInputValue = inputValue.trim() + + useHotKey(() => setOpen((open) => !open), 'k') + + useEffect(() => { + // TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing + if (currentWord !== '' && open) return + // reset + if (currentWord !== '' && !open) setCurrentWord('') + // avoid recursion + if (trimmedInputValue === '' && !open) return + + const searchParams = columnParser.parse(inputValue) + + const currentFilters = table.getState().columnFilters + const currentEnabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return !field?.commandDisabled + }) + const currentDisabledFilters = currentFilters.filter((filter) => { + const field = _filterFields?.find((field) => field.value === filter.id) + return field?.commandDisabled + }) + + const commandDisabledFilterKeys = currentDisabledFilters.reduce( + (prev, curr) => { + prev[curr.id] = curr.value + return prev + }, + {} as Record + ) + + for (const key of Object.keys(searchParams)) { + const value = searchParams[key as keyof typeof searchParams] + table.getColumn(key)?.setFilterValue(value) + } + const currentFiltersToReset = currentEnabledFilters.filter((filter) => { + return !(filter.id in searchParams) + }) + for (const filter of currentFiltersToReset) { + table.getColumn(filter.id)?.setFilterValue(undefined) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputValue, open, currentWord]) + + useEffect(() => { + // REMINDER: only update the input value if the command is closed (avoids jumps while open) + if (!open) { + setInputValue(columnParser.serialize(columnFilters)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [columnFilters, filterFields, open]) + + useEffect(() => { + if (open) { + inputRef?.current?.focus() + } + }, [open]) + + return ( +
+ + div]:border-none bg', + open ? 'visible' : 'hidden' + )} + filter={(value, search, keywords) => + getFilterValue({ value, search, keywords, currentWord }) + } + > + { + if (e.key === 'Escape') inputRef?.current?.blur() + }} + onBlur={() => { + setOpen(false) + // FIXME: doesnt reflect the jumps + // FIXME: will save non-existing searches + // TODO: extract into function + const search = inputValue.trim() + if (!search) return + const timestamp = Date.now() + const searchIndex = lastSearches.findIndex((item) => item.search === search) + if (searchIndex !== -1) { + lastSearches[searchIndex].timestamp = timestamp + setLastSearches(lastSearches) + return + } + setLastSearches([...lastSearches, { search, timestamp }]) + return + }} + onInput={(e) => { + const caretPosition = e.currentTarget?.selectionStart || -1 + const value = e.currentTarget?.value || '' + const word = getWordByCaretPosition({ value, caretPosition }) + setCurrentWord(word) + }} + placeholder="Search data table..." + className="text-foreground" + /> +
+
+ {/* default height is 300px but in case of more, we'd like to tease the user */} + + + {filterFields.map((field) => { + if (typeof field.value !== 'string') return null + if (inputValue.includes(`${field.value}:`)) return null + // TBD: should we handle this in the component? + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => { + if (currentWord.trim() === '') { + const input = `${prev}${value}` + return `${input}:` + } + // lots of cheat + const isStarting = currentWord === prev + const prefix = isStarting ? '' : ' ' + const input = prev.replace(`${prefix}${currentWord}`, `${prefix}${value}`) + return `${input}:` + }) + setCurrentWord(`${value}:`) + }} + className="group" + > + {field.value} + + + ) + })} + + + + {filterFields?.map((field) => { + if (typeof field.value !== 'string') return null + if (!currentWord.includes(`${field.value}:`)) return null + + const column = table.getColumn(field.value) + const facetedValue = + getFacetedUniqueValues?.(table, field.value) || column?.getFacetedUniqueValues() + + const options = getFieldOptions({ field }) + + return options.map((optionValue) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + setInputValue((prev) => + replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, + }) + ) + setCurrentWord('') + }} + > + {`${optionValue}`} + {facetedValue?.has(optionValue) ? ( + + {formatCompactNumber(facetedValue.get(optionValue) || 0)} + + ) : null} + + ) + }) + })} + + + + {lastSearches + ?.sort((a, b) => b.timestamp - a.timestamp) + .slice(0, 5) + .map((item) => { + return ( + { + e.preventDefault() + e.stopPropagation() + }} + onSelect={(value) => { + const search = value.replace('suggestion:', '') + setInputValue(`${search} `) + setCurrentWord('') + }} + className="group" + > + {item.search} + + {formatDistanceToNow(item.timestamp, { + addSuffix: true, + })} + + + + ) + })} + + No results found. + +
+
+ + Use โ†‘ โ†“ to navigate + + + Enter to query + + + Esc to close + + + + Union: regions:a,b + + + Range: p95:59-340 + +
+ {lastSearches.length ? ( + + ) : null} +
+
+
+
+
+ ) +} + +function CommandItemSuggestions({ field }: { field: DataTableFilterField }) { + const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable() + const value = field.value as string + switch (field.type) { + case 'checkbox': { + return ( + + {getFacetedUniqueValues + ? Array.from(getFacetedUniqueValues(table, value)?.keys() || []) + .map((value) => `[${value}]`) + .join(' ') + : field.options?.map(({ value }) => `[${value}]`).join(' ')} + + ) + } + case 'slider': { + const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max] + return ( + + [{min} - {max}] + + ) + } + case 'input': { + return ( + + [{`${String(field.value)}`} input] + + ) + } + default: { + return null + } + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx new file mode 100644 index 0000000000000..c5030af015321 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx @@ -0,0 +1,74 @@ +import { + Accordion_Shadcn_ as Accordion, + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, +} from 'ui' + +import { DataTableFilterCheckbox } from './DataTableFilterCheckbox' +import { DataTableFilterInput } from './DataTableFilterInput' +import { DataTableFilterResetButton } from './DataTableFilterResetButton' +import { DataTableFilterSlider } from './DataTableFilterSlider' +import { DataTableFilterTimerange } from './DataTableFilterTimerange' + +import { useDataTable } from '../providers/DataTableProvider' + +// FIXME: use @container (especially for the slider element) to restructure elements + +// TODO: only pass the columns to generate the filters! +// https://tanstack.com/table/v8/docs/framework/react/examples/filters + +export function DataTableFilterControls() { + const { filterFields } = useDataTable() + return ( + defaultOpen) + ?.map(({ value }) => value as string)} + > + {filterFields?.map((field) => { + const value = field.value as string + return ( + + +
+
+

{field.label}

+ {value !== field.label.toLowerCase() && !field.commandDisabled ? ( +

+ {value} +

+ ) : null} +
+ +
+
+ + {/* REMINDER: avoid the focus state to be cut due to overflow-hidden */} + {/* REMINDER: need to move within here because of accordion height animation */} +
+ {(() => { + switch (field.type) { + case 'checkbox': { + return + } + case 'slider': { + return + } + case 'input': { + return + } + case 'timerange': { + return + } + } + })()} +
+
+
+ ) + })} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx new file mode 100644 index 0000000000000..835d77cc54e3f --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx @@ -0,0 +1,75 @@ +import { VisuallyHidden } from '@radix-ui/react-visually-hidden' +import { FilterIcon } from 'lucide-react' +import { useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { + Button, + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { useMediaQuery } from '../hooks/useMediaQuery' +import { Kbd } from '../primitives/Kbd' +import { DataTableFilterControls } from './DataTableFilterControls' + +export function DataTableFilterControlsDrawer() { + const triggerButtonRef = useRef(null) + const isMobile = useMediaQuery('(max-width: 640px)') + + useHotKey(() => { + triggerButtonRef.current?.click() + }, 'b') + + return ( + + + + + + + + + +

+ Toggle controls with{' '} + + โŒ˜ + B + +

+
+
+
+ + + + Filters + Adjust your table filters + + +
+ +
+ + + + + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx new file mode 100644 index 0000000000000..21d3e217d5f3a --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx @@ -0,0 +1,52 @@ +import { Search } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableInputFilterField } from '../DataTable.types' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../InputWithAddons' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'string' ? filterValue : null +} + +export function DataTableFilterInput({ value: _value }: DataTableInputFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + const newValue = debouncedInput?.trim() === '' ? null : debouncedInput + if (debouncedInput === null) return + column?.setFilterValue(newValue) + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.trim() !== filters) { + setInput(filters) + } + }, [filters]) + + return ( +
+ + } + containerClassName="h-9 rounded-lg" + name={value} + id={value} + value={input || ''} + onChange={(e) => setInput(e.target.value)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx new file mode 100644 index 0000000000000..d066553265306 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx @@ -0,0 +1,41 @@ +import { X } from 'lucide-react' + +import { Button } from 'ui' +import type { DataTableFilterField } from '../DataTable.types' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterResetButton({ value: _value }: DataTableFilterField) { + const { columnFilters, table } = useDataTable() + const value = _value as string + const column = table.getColumn(value) + const filterValue = columnFilters.find((f) => f.id === value)?.value + + // TODO: check if we could useMemo + const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : [] + + if (filters.length === 0) return null + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx new file mode 100644 index 0000000000000..14d8d23d6bca9 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' + +import { Label_Shadcn_ as Label } from 'ui' +import type { DataTableSliderFilterField } from '../DataTable.types' +import { isArrayOfNumbers } from '../DataTable.utils' +import { useDebounce } from '../hooks/useDebounce' +import { InputWithAddons } from '../InputWithAddons' +import { Slider } from '../primitives/Slider' +import { useDataTable } from '../providers/DataTableProvider' + +function getFilter(filterValue: unknown) { + return typeof filterValue === 'number' + ? [filterValue, filterValue] + : Array.isArray(filterValue) && isArrayOfNumbers(filterValue) + ? filterValue.length === 1 + ? [filterValue[0], filterValue[0]] + : filterValue + : null +} + +// TODO: discuss if we even need the `defaultMin` and `defaultMax` +export function DataTableFilterSlider({ + value: _value, + min: defaultMin, + max: defaultMax, +}: DataTableSliderFilterField) { + const value = _value as string + const { table, columnFilters, getFacetedMinMaxValues } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + const filters = getFilter(filterValue) + const [input, setInput] = useState(filters) + const [min, max] = getFacetedMinMaxValues?.(table, value) || + column?.getFacetedMinMaxValues() || [defaultMin, defaultMax] + + const debouncedInput = useDebounce(input, 500) + + useEffect(() => { + if (debouncedInput?.length === 2) { + column?.setFilterValue(debouncedInput) + } + }, [debouncedInput]) + + useEffect(() => { + if (debouncedInput?.length !== 2) { + } else if (!filters) { + setInput(null) + } else if (debouncedInput[0] !== filters[0] || debouncedInput[1] !== filters[1]) { + setInput(filters) + } + }, [filters]) + + return ( +
+
+
+ + setInput((prev) => [Number(e.target.value), prev?.[1] || max])} + /> +
+
+ + setInput((prev) => [prev?.[0] || min, Number(e.target.value)])} + /> +
+
+ setInput(values)} + /> +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx new file mode 100644 index 0000000000000..9981ed31e61dc --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react' +import type { DateRange } from 'react-day-picker' + +import type { DataTableTimerangeFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' +import { DatePickerWithRange } from '../DatePickerWithRange' +import { useDataTable } from '../providers/DataTableProvider' + +export function DataTableFilterTimerange({ + value: _value, + presets, +}: DataTableTimerangeFilterField) { + const value = _value as string + const { table, columnFilters } = useDataTable() + const column = table.getColumn(value) + const filterValue = columnFilters.find((i) => i.id === value)?.value + + const date: DateRange | undefined = useMemo( + () => + filterValue instanceof Date + ? { from: filterValue, to: undefined } + : Array.isArray(filterValue) && isArrayOfDates(filterValue) + ? { from: filterValue?.[0], to: filterValue?.[1] } + : undefined, + [filterValue] + ) + + const setDate = (date: DateRange | undefined) => { + if (!date) return // TODO: remove from search params if columnFilter is removed + if (date.from && !date.to) { + column?.setFilterValue([date.from]) + } + if (date.to && date.from) { + column?.setFilterValue([date.from, date.to]) + } + } + + return +} diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts new file mode 100644 index 0000000000000..c65b6106b07e6 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts @@ -0,0 +1,259 @@ +import { ColumnFiltersState } from '@tanstack/react-table' +import { ParserBuilder } from 'nuqs' + +import { ARRAY_DELIMITER, RANGE_DELIMITER, SLIDER_DELIMITER } from '../DataTable.constants' +import type { DataTableFilterField } from '../DataTable.types' +import { isArrayOfDates } from '../DataTable.utils' + +/** + * Extracts the word from the given string at the specified caret position. + */ +export function getWordByCaretPosition({ + value, + caretPosition, +}: { + value: string + caretPosition: number +}) { + let start = caretPosition + let end = caretPosition + + while (start > 0 && value[start - 1] !== ' ') start-- + while (end < value.length && value[end] !== ' ') end++ + + const word = value.substring(start, end) + return word +} + +export function replaceInputByFieldType({ + prev, + currentWord, + optionValue, + value, + field, +}: { + prev: string + currentWord: string + optionValue?: string | number | boolean | undefined // FIXME: use DataTableFilterField["options"][number]; + value: string + field: DataTableFilterField +}) { + switch (field.type) { + case 'checkbox': { + if (currentWord.includes(ARRAY_DELIMITER)) { + const words = currentWord.split(ARRAY_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER)) + return `${input.trim()} ` + } + } + case 'slider': { + if (currentWord.includes(SLIDER_DELIMITER)) { + const words = currentWord.split(SLIDER_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER)) + return `${input.trim()} ` + } + } + case 'timerange': { + if (currentWord.includes(RANGE_DELIMITER)) { + const words = currentWord.split(RANGE_DELIMITER) + words[words.length - 1] = `${optionValue}` + const input = prev.replace(currentWord, words.join(RANGE_DELIMITER)) + return `${input.trim()} ` + } + } + default: { + const input = prev.replace(currentWord, value) + return `${input.trim()} ` + } + } +} + +export function getFieldOptions({ field }: { field: DataTableFilterField }) { + switch (field.type) { + case 'slider': { + return field.options?.length + ? field.options + .map(({ value }) => value) + .sort((a, b) => Number(a) - Number(b)) + .filter(notEmpty) + : Array.from({ length: field.max - field.min + 1 }, (_, i) => field.min + i) || [] + } + default: { + return field.options?.map(({ value }) => value).filter(notEmpty) || [] + } + } +} + +export function getFilterValue({ + value, + search, + currentWord, +}: { + value: string + search: string + keywords?: string[] | undefined + currentWord: string +}): number { + /** + * @example value "suggestion:public:true regions,ams,gru,fra" + */ + if (value.startsWith('suggestion:')) { + const rawValue = value.toLowerCase().replace('suggestion:', '') + if (rawValue.includes(search)) return 1 + return 0 + } + + /** */ + if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1 + + /** + * @example checkbox [filter, query] = ["regions", "ams,gru,fra"] + * @example slider [filter, query] = ["p95", "0-3000"] + * @example input [filter, query] = ["name", "api"] + */ + const [filter, query] = currentWord.toLowerCase().split(':') + if (query && value.startsWith(`${filter}:`)) { + if (query.includes(ARRAY_DELIMITER)) { + /** + * array of n elements + * @example queries = ["ams", "gru", "fra"] + */ + const queries = query.split(ARRAY_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (queries.some((item, i) => item === rawValue && i !== queries.length - 1)) return 0 + if (queries.some((item) => rawValue.includes(item))) return 1 + } + if (query.includes(SLIDER_DELIMITER)) { + /** + * range between 2 elements + * @example queries = ["0", "3000"] + */ + const queries = query.split(SLIDER_DELIMITER) + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + + const rawValueAsNumber = Number.parseInt(rawValue) + const queryAsNumber = Number.parseInt(queries[0]) + + if (queryAsNumber < rawValueAsNumber) { + if (rawValue.includes(queries[1])) return 1 + return 0 + } + return 0 + } + const rawValue = value.toLowerCase().replace(`${filter}:`, '') + if (rawValue.includes(query)) return 1 + } + return 0 +} + +export function getFieldValueByType({ + field, + value, +}: { + field?: DataTableFilterField + value: unknown +}) { + if (!field) return null + + switch (field.type) { + case 'slider': { + if (Array.isArray(value)) { + return value.join(SLIDER_DELIMITER) + } + return value + } + case 'checkbox': { + if (Array.isArray(value)) { + return value.join(ARRAY_DELIMITER) + } + // REMINER: inversed logic + if (typeof value === 'string') { + return value.split(ARRAY_DELIMITER) + } + return value + } + case 'timerange': { + if (Array.isArray(value)) { + if (isArrayOfDates(value)) { + return value.map((date) => date.getTime()).join(RANGE_DELIMITER) + } + return value.join(RANGE_DELIMITER) + } + if (value instanceof Date) { + return value.getTime() + } + return value + } + default: { + return value + } + } +} + +export function notEmpty(value: TValue | null | undefined): value is TValue { + return value !== null && value !== undefined +} + +export function columnFiltersParser({ + searchParamsParser, + filterFields, +}: { + searchParamsParser: Record> + filterFields: DataTableFilterField[] +}) { + return { + parse: (inputValue: string) => { + // Use regex to properly extract field:value pairs + // This properly handles spaces within values + const filterPairs: Record = {} + + // This regex properly extracts field:value pairs where: + // 1. Values can contain spaces (both normal spaces and URL-encoded + characters) + // 2. It finds word characters followed by a colon as the field name + // 3. It then captures everything (including spaces and + characters) until + // it finds another field:value pattern or the end of string + // This solves the issue with values like "edge function" or "edge+function" in URLs + const regex = /(\w+):([^]*?)(?=\s+\w+:|$)/g + let match + + console.log('DataTableFilterCommand parsing input:', inputValue) + + while ((match = regex.exec(inputValue)) !== null) { + const [_, fieldName, fieldValue] = match + if (fieldName && fieldValue) { + filterPairs[fieldName] = fieldValue.trim() + console.log(`Parsed filter pair: ${fieldName} = ${fieldValue.trim()}`) + } + } + + const searchParams = Object.entries(filterPairs).reduce( + (prev, [key, value]) => { + const parser = searchParamsParser[key] + if (!parser) return prev + + prev[key] = parser.parse(value) + return prev + }, + {} as Record + ) + + return searchParams + }, + serialize: (columnFilters: ColumnFiltersState) => { + const values = columnFilters.reduce((prev, curr) => { + const { commandDisabled } = filterFields?.find((field) => curr.id === field.value) || { + commandDisabled: true, + } // if column filter is not found, disable the command by default + const parser = searchParamsParser[curr.id] + + if (commandDisabled || !parser) return prev + + return `${prev}${curr.id}:${parser.serialize(curr.value)} ` + }, '') + + return values + }, + } +} diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx new file mode 100644 index 0000000000000..5222d38f14c9c --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx @@ -0,0 +1,35 @@ +import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react' +import { cn } from 'ui' + +export const DataTableHeaderLayout = forwardRef< + HTMLDivElement, + PropsWithChildren<{ + setTopBarHeight: (height: number) => void + }> +>(({ setTopBarHeight, ...props }, ref) => { + const topBarRef = useRef(null) + + useEffect(() => { + const observer = new ResizeObserver(() => { + const rect = topBarRef.current?.getBoundingClientRect() + if (rect) { + setTopBarHeight(rect.height) + } + }) + + const topBar = topBarRef.current + if (!topBar) return + + observer.observe(topBar) + return () => observer.unobserve(topBar) + }, [topBarRef]) + + return ( +
+ ) +}) +DataTableHeaderLayout.displayName = 'DataTableHeaderLayout' diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx new file mode 100644 index 0000000000000..7546227e63740 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -0,0 +1,238 @@ +import { type FetchNextPageOptions } from '@tanstack/react-query' +import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table' +import { flexRender } from '@tanstack/react-table' +import { LoaderCircle } from 'lucide-react' +import { useQueryState } from 'nuqs' +import { Fragment, memo, ReactNode, UIEvent, useCallback, useRef } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { useDataTable } from './providers/DataTableProvider' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table' + +// TODO: add a possible chartGroupBy +export interface DataTableInfiniteProps { + columns: ColumnDef[] + defaultColumnVisibility?: VisibilityState + totalRows?: number + filterRows?: number + totalRowsFetched?: number + isFetching?: boolean + isLoading?: boolean + hasNextPage?: boolean + fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise + renderLiveRow?: (props?: { row: Row }) => ReactNode + setColumnOrder: (columnOrder: string[]) => void + setColumnVisibility: (columnVisibility: VisibilityState) => void + + // [Joshen] See if we can type this properly + searchParamsParser: any +} + +export function DataTableInfinite({ + columns, + defaultColumnVisibility = {}, + isFetching, + isLoading, + fetchNextPage, + hasNextPage, + totalRows = 0, + filterRows = 0, + totalRowsFetched = 0, + renderLiveRow, + setColumnOrder, + setColumnVisibility, + searchParamsParser, +}: DataTableInfiniteProps) { + const { table } = useDataTable() + const tableRef = useRef(null) + + const headerGroups = table.getHeaderGroups() + + const onScroll = useCallback( + (e: UIEvent) => { + const onPageBottom = + Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >= + e.currentTarget.scrollHeight + + if (onPageBottom && !isFetching && totalRowsFetched < filterRows) { + fetchNextPage() + } + }, + [fetchNextPage, isFetching, filterRows, totalRowsFetched] + ) + + useHotKey(() => { + setColumnOrder([]) + setColumnVisibility(defaultColumnVisibility) + }, 'u') + + return ( + <> + + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const sort = header.column.getIsSorted() + const canResize = header.column.getCanResize() + const onResize = header.getResizeHandler() + const headerClassName = (header.column.columnDef.meta as any)?.headerClassName + + return ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + {canResize && ( +
header.column.resetSize()} + onMouseDown={onResize} + onTouchStart={onResize} + className={cn( + 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center', + 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border' + )} + /> + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558 + + {renderLiveRow?.({ row: row as any })} + + + )) + ) : ( + + {renderLiveRow?.()} + + + No results. + + + + )} + + + {hasNextPage || isFetching || isLoading ? ( +
+ +

+ Showing{' '} + + {formatCompactNumber(totalRowsFetched)} + {' '} + of{' '} + {formatCompactNumber(totalRows)}{' '} + rows +

+
+ ) : ( +

+ No more data to load ( + {formatCompactNumber(filterRows)}{' '} + of {formatCompactNumber(totalRows)}{' '} + rows) +

+ )} +
+
+
+
+ + ) +} + +/** + * REMINDER: this is the heaviest component in the table if lots of rows + * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height) + * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader + */ + +function DataTableRow({ + row, + table, + selected, + searchParamsParser, +}: { + row: Row + table: TTable + selected?: boolean + searchParamsParser: any +}) { + // REMINDER: rerender the row when live mode is toggled - used to opacity the row + // via the `getRowClassName` prop - but for some reasons it wil render the row on data fetch + useQueryState('live', searchParamsParser.live) + const rowClassName = (table.options.meta as any)?.getRowClassName?.(row) + + return ( + row.toggleSelected()} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + row.toggleSelected() + } + }} + className={cn(rowClassName)} + > + {row.getVisibleCells().map((cell) => { + const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ) + })} + + ) +} + +const MemoizedRow = memo( + DataTableRow, + (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected +) as typeof DataTableRow diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx new file mode 100644 index 0000000000000..06a80799f08b2 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx @@ -0,0 +1,37 @@ +import { X } from 'lucide-react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableResetButton() { + const { table } = useDataTable() + useHotKey(table.resetColumnFilters, 'Escape') + + return ( + + + + + + +

+ Reset filters with{' '} + + โŒ˜ + Esc + +

+
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx new file mode 100644 index 0000000000000..922d38bc3ee9f --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx @@ -0,0 +1,135 @@ +import { ChevronDown, ChevronUp, X } from 'lucide-react' +import { ReactNode, useCallback, useEffect, useMemo } from 'react' + +import { + Button, + cn, + Separator, + Skeleton, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from 'ui' +import { Kbd } from './primitives/Kbd' +import { useDataTable } from './providers/DataTableProvider' + +export interface DataTableSheetDetailsProps { + title?: ReactNode + titleClassName?: string + children?: ReactNode +} + +export function DataTableSheetDetails({ + title, + titleClassName, + children, +}: DataTableSheetDetailsProps) { + const { table, rowSelection, isLoading } = useDataTable() + + const selectedRowKey = Object.keys(rowSelection)?.[0] + + const selectedRow = useMemo(() => { + if (isLoading && !selectedRowKey) return + return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) + }, [selectedRowKey, isLoading]) + + const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRow?.id) + + const nextId = useMemo(() => table.getCoreRowModel().flatRows[index + 1]?.id, [index, isLoading]) + + const prevId = useMemo(() => table.getCoreRowModel().flatRows[index - 1]?.id, [index, isLoading]) + + const onPrev = useCallback(() => { + if (prevId) table.setRowSelection({ [prevId]: true }) + }, [prevId, isLoading]) + + const onNext = useCallback(() => { + if (nextId) table.setRowSelection({ [nextId]: true }) + }, [nextId, isLoading]) + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (!selectedRowKey) return + + // REMINDER: prevent dropdown navigation inside of sheet to change row selection + const activeElement = document.activeElement + const isMenuActive = activeElement?.closest('[role="menu"]') + + if (isMenuActive) return + + if (e.key === 'ArrowUp') { + e.preventDefault() + onPrev() + } + if (e.key === 'ArrowDown') { + e.preventDefault() + onNext() + } + } + + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, [selectedRowKey, onNext, onPrev]) + + return ( +
+
+
+ {isLoading && !selectedRowKey ? : title} +
+
+ + + +
+
+ + {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx new file mode 100644 index 0000000000000..47f02763d75e4 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx @@ -0,0 +1,187 @@ +import { Table } from '@tanstack/react-table' +import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns' +import { + CalendarClock, + CalendarDays, + CalendarSearch, + ChevronLeft, + ChevronRight, + Copy, + Equal, + Search, +} from 'lucide-react' +import { ComponentPropsWithRef } from 'react' + +import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types' +import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from 'ui' + +interface DataTableSheetRowActionProps> + extends ComponentPropsWithRef { + fieldValue: TFields['value'] + filterFields: TFields[] + value: string | number + table: Table +} + +export function DataTableSheetRowAction>({ + fieldValue, + filterFields, + value, + children, + className, + table, + onKeyDown, + ...props +}: DataTableSheetRowActionProps) { + const { copy, isCopied } = useCopyToClipboard() + const field = filterFields.find((field) => field.value === fieldValue) + const column = table.getColumn(fieldValue.toString()) + + if (!field || !column) return null + + function renderOptions() { + if (!field) return null + switch (field.type) { + case 'checkbox': + return ( + { + // FIXME: + const filterValue = column?.getFilterValue() as undefined | Array + const newValue = filterValue?.includes(value) + ? filterValue + : [...(filterValue || []), value] + + column?.setFilterValue(newValue) + }} + className="flex items-center gap-2" + > + + Include + + ) + case 'input': + return ( + column?.setFilterValue(value)} + className="flex items-center gap-2" + > + + Include + + ) + case 'slider': + return ( + + column?.setFilterValue([0, value])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Less or equal than + + column?.setFilterValue([value, 5000])} + className="flex items-center gap-2" + > + {/* FIXME: change icon as it is not clear */} + + Greater or equal than + + column?.setFilterValue([value])} + className="flex items-center gap-2" + > + + Equal to + + + ) + case 'timerange': + const date = new Date(value) + return ( + + column?.setFilterValue([date])} + className="flex items-center gap-2" + > + + Exact timestamp + + { + const start = startOfHour(date) + const end = endOfHour(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same hour + + { + const start = startOfDay(date) + const end = endOfDay(date) + column?.setFilterValue([start, end]) + }} + className="flex items-center gap-2" + > + + Same day + + + ) + default: + return null + } + } + + return ( + + { + if (e.key === 'ArrowDown') { + // REMINDER: default behavior is to open the dropdown menu + // But because we use it to navigate between rows, we need to prevent it + // and only use "Enter" to select the option + e.preventDefault() + } + onKeyDown?.(e) + }} + {...props} + > + {children} + {isCopied ? ( +
Value copied
+ ) : null} +
+ + {renderOptions()} + + copy(String(value), { timeout: 1000 })} + className="flex items-center gap-2" + > + + Copy value + + +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx new file mode 100644 index 0000000000000..ec9162a8fede3 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx @@ -0,0 +1,66 @@ +import { ReactNode, useMemo } from 'react' + +import { cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableSideBarLayoutProps { + children: ReactNode + className?: string + topBarHeight?: number +} + +export function DataTableSideBarLayout({ + children, + className, + topBarHeight = 0, +}: DataTableSideBarLayoutProps) { + const { table } = useDataTable() + + /** + * https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance + * Instead of calling `column.getSize()` on every render for every header + * and especially every data cell (very expensive), + * we will calculate all column sizes at once at the root table level in a useMemo + * and pass the column sizes down as CSS variables to the element. + */ + const columnSizeVars = useMemo(() => { + const headers = table.getFlatHeaders() + const colSizes: { [key: string]: string } = {} + for (let i = 0; i < headers.length; i++) { + const header = headers[i]! + // REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns") + colSizes[`--header-${header.id.replace('.', '-')}-size`] = `${header.getSize()}px` + colSizes[`--col-${header.column.id.replace('.', '-')}-size`] = `${header.column.getSize()}px` + } + return colSizes + }, [ + // TODO: check if we need this + table.getState().columnSizingInfo, + table.getState().columnSizing, + table.getState().columnVisibility, + ]) + + return ( +
+ {children} +
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx new file mode 100644 index 0000000000000..906e89dee001a --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx @@ -0,0 +1,82 @@ +import { PanelLeftClose } from 'lucide-react' +import { ReactNode, useMemo } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui' +import { formatCompactNumber } from './DataTable.utils' +import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer' +import { DataTableResetButton } from './DataTableResetButton' +import { DataTableViewOptions } from './DataTableViewOptions' +import { Kbd } from './primitives/Kbd' +import { useControls } from './providers/ControlsProvider' +import { useDataTable } from './providers/DataTableProvider' + +interface DataTableToolbarProps { + renderActions?: () => ReactNode +} + +export function DataTableToolbar({ renderActions }: DataTableToolbarProps) { + const { table, isLoading, columnFilters } = useDataTable() + const { open, setOpen } = useControls() + const filters = table.getState().columnFilters + + useHotKey(() => setOpen((prev) => !prev), 'b') + + const rows = useMemo( + () => ({ + total: table.getCoreRowModel().rows.length, + filtered: table.getFilteredRowModel().rows.length, + }), + [isLoading, columnFilters] + ) + + return ( +
+
+ + + + + + +

+ Toggle controls with{' '} + + โŒ˜ + B + +

+
+
+
+
+ +
+
+

+ {formatCompactNumber(rows.filtered)} of{' '} + {formatCompactNumber(rows.total)} row(s){' '} + filtered +

+

+ {formatCompactNumber(rows.filtered)}{' '} + row(s) +

+
+
+
+ {filters.length ? : null} + {renderActions?.()} + +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx new file mode 100644 index 0000000000000..069cd02d143e3 --- /dev/null +++ b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx @@ -0,0 +1,104 @@ +import { Check, GripVertical, Settings2 } from 'lucide-react' +import { useMemo, useState } from 'react' + +import { + Button, + cn, + Command_Shadcn_ as Command, + CommandEmpty_Shadcn_ as CommandEmpty, + CommandGroup_Shadcn_ as CommandGroup, + CommandInput_Shadcn_ as CommandInput, + CommandItem_Shadcn_ as CommandItem, + CommandList_Shadcn_ as CommandList, + Popover_Shadcn_ as Popover, + PopoverContent_Shadcn_ as PopoverContent, + PopoverTrigger_Shadcn_ as PopoverTrigger, +} from 'ui' +import { Sortable, SortableDragHandle, SortableItem } from './primitives/Sortable' +import { useDataTable } from './providers/DataTableProvider' + +export function DataTableViewOptions() { + const { table, enableColumnOrdering } = useDataTable() + const [open, setOpen] = useState(false) + const [drag, setDrag] = useState(false) + const [search, setSearch] = useState('') + + const columnOrder = table.getState().columnOrder + + const sortedColumns = useMemo( + () => + table.getAllColumns().sort((a, b) => { + return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id) + }), + [columnOrder] + ) + + return ( + + + + + +
+
+ +
+
+ +
+ + +
+ + +
+
+ + ) +} + +function DatePresets({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + return ( +
+

Date Range

+
+ {presets.map(({ label, shortcut, from, to }) => { + const isActive = selected?.from === from && selected?.to === to + return ( + + ) + })} +
+
+ ) +} + +function DatePresetsSelect({ + selected, + onSelect, + presets, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void + presets: DatePreset[] +}) { + function findPreset(from?: Date, to?: Date) { + return presets.find((p) => p.from === from && p.to === to)?.shortcut + } + const [value, setValue] = useState(findPreset(selected?.from, selected?.to)) + + useEffect(() => { + const preset = findPreset(selected?.from, selected?.to) + if (preset === value) return + setValue(preset) + }, [selected, presets]) + + return ( + + ) +} + +// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times +function CustomDateRange({ + selected, + onSelect, +}: { + selected: DateRange | undefined + onSelect: (date: DateRange | undefined) => void +}) { + const [dateFrom, setDateFrom] = useState(selected?.from) + const [dateTo, setDateTo] = useState(selected?.to) + const debounceDateFrom = useDebounce(dateFrom, 1000) + const debounceDateTo = useDebounce(dateTo, 1000) + + const formatDateForInput = (date: Date | undefined): string => { + if (!date) return '' + const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + return utcDate.toISOString().slice(0, 16) + } + + useEffect(() => { + onSelect({ from: debounceDateFrom, to: debounceDateTo }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debounceDateFrom, debounceDateTo]) + + return ( +
+

Custom Range

+
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateFrom(newDate) + } + }} + disabled={!selected?.from} + /> +
+
+ + { + const newDate = new Date(e.target.value) + if (!Number.isNaN(newDate.getTime())) { + setDateTo(newDate) + } + }} + disabled={!selected?.to} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx new file mode 100644 index 0000000000000..d5be1d62a3de9 --- /dev/null +++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx @@ -0,0 +1,28 @@ +import { cn } from 'ui' +import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls' +import { DataTableResetButton } from './DataTableResetButton' +import { useDataTable } from './providers/DataTableProvider' + +export function FilterSideBar() { + const { table } = useDataTable() + + return ( +
+
+
+

Filters

+
{table.getState().columnFilters.length ? : null}
+
+
+
+ +
+
+ ) +} diff --git a/apps/studio/components/ui/DataTable/InputWithAddons.tsx b/apps/studio/components/ui/DataTable/InputWithAddons.tsx new file mode 100644 index 0000000000000..0b27280f9c83a --- /dev/null +++ b/apps/studio/components/ui/DataTable/InputWithAddons.tsx @@ -0,0 +1,40 @@ +import { cn } from 'ui' + +import { forwardRef, InputHTMLAttributes, ReactNode } from 'react' + +export interface InputWithAddonsProps extends InputHTMLAttributes { + leading?: ReactNode + trailing?: ReactNode + containerClassName?: string +} + +const InputWithAddons = forwardRef( + ({ leading, trailing, containerClassName, className, ...props }, ref) => { + return ( +
+ {leading ? ( +
{leading}
+ ) : null} + + {trailing ? ( +
{trailing}
+ ) : null} +
+ ) + } +) +InputWithAddons.displayName = 'InputWithAddons' + +export { InputWithAddons } diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx new file mode 100644 index 0000000000000..ebb1c3f04e2b5 --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveButton.tsx @@ -0,0 +1,71 @@ +import type { FetchPreviousPageOptions } from '@tanstack/react-query' +import { CirclePause, CirclePlay } from 'lucide-react' +import { useQueryStates } from 'nuqs' +import { useEffect } from 'react' + +import { useHotKey } from 'hooks/ui/useHotKey' +import { Button, cn } from 'ui' +import { useDataTable } from './providers/DataTableProvider' + +const REFRESH_INTERVAL = 10_000 + +interface LiveButtonProps { + searchParamsParser: any + fetchPreviousPage?: (options?: FetchPreviousPageOptions | undefined) => Promise +} + +export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) { + const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser) + const { table } = useDataTable() + useHotKey(handleClick, 'j') + + useEffect(() => { + let timeoutId: NodeJS.Timeout + + async function fetchData() { + if (live) { + await fetchPreviousPage?.() + timeoutId = setTimeout(fetchData, REFRESH_INTERVAL) + } else { + clearTimeout(timeoutId) + } + } + + fetchData() + + return () => { + clearTimeout(timeoutId) + } + }, [live, fetchPreviousPage]) + + // REMINDER: make sure to reset live when date is set + // TODO: test properly + useEffect(() => { + if ((date || sort) && live) { + setSearch((prev) => ({ ...prev, live: null })) + } + }, [date, sort]) + + function handleClick() { + setSearch((prev) => ({ + ...prev, + live: !prev.live, + date: null, + sort: null, + })) + table.getColumn('date')?.setFilterValue(undefined) + table.resetSorting() + } + + return ( + + ) +} diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx new file mode 100644 index 0000000000000..76c63acd8be0c --- /dev/null +++ b/apps/studio/components/ui/DataTable/LiveRow.tsx @@ -0,0 +1,18 @@ +import { TableCell, TableRow } from 'ui' +import { DataTableColumnLevelIndicator } from './DataTableColumn/DataTableColumnLevelIndicator' + +export function LiveRow({ colSpan }: { colSpan: number }) { + return ( + + + + + + Live Mode + + + ) +} diff --git a/apps/studio/components/ui/DataTable/RefreshButton.tsx b/apps/studio/components/ui/DataTable/RefreshButton.tsx new file mode 100644 index 0000000000000..6aaf245d3aaf1 --- /dev/null +++ b/apps/studio/components/ui/DataTable/RefreshButton.tsx @@ -0,0 +1,29 @@ +import { LoaderCircle, RefreshCcw } from 'lucide-react' +import { Button } from 'ui' + +import { useDataTable } from './providers/DataTableProvider' + +interface RefreshButtonProps { + onClick: () => void +} + +export function RefreshButton({ onClick }: RefreshButtonProps) { + const { isLoading } = useDataTable() + + return ( +
) diff --git a/packages/ui/src/components/shadcn/ui/tooltip.tsx b/packages/ui/src/components/shadcn/ui/tooltip.tsx index 05541046483c9..bafd969590fc6 100644 --- a/packages/ui/src/components/shadcn/ui/tooltip.tsx +++ b/packages/ui/src/components/shadcn/ui/tooltip.tsx @@ -7,6 +7,8 @@ import { cn } from '../../../lib/utils/cn' const TooltipProvider = TooltipPrimitive.Provider +const TooltipPortal = TooltipPrimitive.Portal + const Tooltip = (props: React.ComponentPropsWithoutRef) => ( ) @@ -38,4 +40,4 @@ const TooltipContent = React.forwardRef< )) TooltipContent.displayName = TooltipPrimitive.Content.displayName -export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } +export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, TooltipPortal } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 805af505a668e..e6bec8f8f350d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,9 @@ importers: '@dnd-kit/core': specifier: ^6.1.0 version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@dnd-kit/sortable': specifier: ^8.0.0 version: 8.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -702,6 +705,15 @@ importers: '@number-flow/react': specifier: ^0.3.2 version: 0.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': + specifier: ^1.2.0 + version: 1.2.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-visually-hidden': + specifier: ^1.1.3 + version: 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: ^8.52.1 version: 8.52.1(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0)(supports-color@8.1.1))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0) @@ -738,6 +750,9 @@ importers: '@tanstack/react-query-devtools': specifier: 4.35.7 version: 4.35.7(@tanstack/react-query@4.35.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -870,6 +885,9 @@ importers: react-datepicker: specifier: ^4.18.0 version: 4.18.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-day-picker: + specifier: ^8.8.0 + version: 8.8.0(date-fns@2.30.0)(react@18.3.1) react-dnd: specifier: ^16.0.1 version: 16.0.1(@types/hoist-non-react-statics@3.3.2)(@types/node@22.13.14)(@types/react@18.3.3)(react@18.3.1) @@ -3036,6 +3054,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + '@dnd-kit/sortable@7.0.2': resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==} peerDependencies: @@ -5538,6 +5562,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dialog@1.1.7': + resolution: {integrity: sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-direction@1.0.1': resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} peerDependencies: @@ -5595,6 +5632,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dismissable-layer@1.1.6': + resolution: {integrity: sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-dismissable-layer@1.1.7': resolution: {integrity: sha512-j5+WBUdhccJsmH5/H0K6RncjDtoALSEr6jbkaZu+bjw6hOPOhHycr6vEUujl+HBK8kjUfWcoCJXxP6e4lUlMZw==} peerDependencies: @@ -5687,6 +5737,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-focus-scope@1.1.3': + resolution: {integrity: sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-scope@1.1.4': resolution: {integrity: sha512-r2annK27lIW5w9Ho5NyQgqs0MmgZSTIKXWpVCJaLC1q2kZrZkcqnmHkCHMEmv8XLvsLlurKMPT+kbKkRkm/xVA==} peerDependencies: @@ -5909,6 +5972,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-portal@1.1.5': + resolution: {integrity: sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-portal@1.1.6': resolution: {integrity: sha512-XmsIl2z1n/TsYFLIdYam2rmFwf9OC/Sh2avkbmVMDuBZIe7hSpM0cYnWPAo7nHOVx8zTuwDZGByfcqLdnzp3Vw==} peerDependencies: @@ -5961,6 +6037,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-presence@1.1.3': + resolution: {integrity: sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-presence@1.1.4': resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} peerDependencies: @@ -6013,6 +6102,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.0.3': + resolution: {integrity: sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-primitive@2.1.0': resolution: {integrity: sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==} peerDependencies: @@ -6302,6 +6404,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.1.1': + resolution: {integrity: sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -6463,6 +6574,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-visually-hidden@1.1.3': + resolution: {integrity: sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-visually-hidden@1.2.0': resolution: {integrity: sha512-rQj0aAWOpCdCMRbI6pLQm8r7S2BM3YhTa0SzOYD55k+hJA8oo9J+H+9wLM9oMlZWOX/wJWPTzfDfmZkf7LvCfg==} peerDependencies: @@ -7830,6 +7954,13 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.6': resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==} peerDependencies: @@ -7913,6 +8044,10 @@ packages: '@tanstack/store@0.7.0': resolution: {integrity: sha512-CNIhdoUsmD2NolYuaIs8VfWM467RK6oIBAW4nPEKZhg1smZ+/CwtCdpURgp7nxSqOaV9oKkzdWD80+bC66F/Jg==} + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.6': resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==} @@ -8942,6 +9077,10 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -8981,6 +9120,10 @@ packages: resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + arraybuffer.prototype.slice@1.0.4: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} @@ -9171,11 +9314,11 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -9936,14 +10079,26 @@ packages: resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} engines: {node: '>=12'} + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + data-view-byte-length@1.0.2: resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} engines: {node: '>= 0.4'} + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + data-view-byte-offset@1.0.1: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} @@ -10467,10 +10622,18 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -10486,10 +10649,18 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} @@ -10497,6 +10668,10 @@ packages: es-shim-unscopables@1.0.2: resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + es-to-primitive@1.3.0: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} @@ -11150,6 +11325,9 @@ packages: debug: optional: true + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -11279,6 +11457,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + function.prototype.name@1.1.8: resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} @@ -11307,6 +11489,14 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.2.7: + resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} + engines: {node: '>= 0.4'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -11338,6 +11528,10 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -11415,6 +11609,10 @@ packages: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} engines: {node: '>=8'} + globalthis@1.0.3: + resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} + engines: {node: '>= 0.4'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} @@ -11434,6 +11632,9 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -11585,10 +11786,18 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + has-proto@1.2.0: resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} engines: {node: '>= 0.4'} + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -11924,6 +12133,10 @@ packages: resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} engines: {node: '>=12.0.0'} + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -11972,6 +12185,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -11986,6 +12203,9 @@ packages: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + is-bigint@1.1.0: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} @@ -11994,6 +12214,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -12013,10 +12237,18 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + is-date-object@1.1.0: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} @@ -12049,6 +12281,9 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + is-finalizationregistry@1.1.1: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} @@ -12090,6 +12325,9 @@ packages: is-lower-case@2.0.2: resolution: {integrity: sha512-bVcMJy4X5Og6VZfdOZstSexlEy20Sr0k/p/b2IlQJlfdKAQuMpiv5w2Ccxb8sKdRUNAG1PnHVHjFSdRDVS6NlQ==} + is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -12104,6 +12342,10 @@ packages: is-node-process@1.2.0: resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -12153,6 +12395,10 @@ packages: is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -12169,6 +12415,10 @@ packages: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + is-shared-array-buffer@1.0.4: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} @@ -12181,14 +12431,26 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + is-symbol@1.1.1: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -12208,14 +12470,23 @@ packages: is-upper-case@2.0.2: resolution: {integrity: sha512-44pxmxAvnnAOwBg4tHPnkfvgjPwbc5QIsSstNU+YcJ1ovxVzCWpSGosPJOZh/a1tdl81fbgnLc9LLv+x2ywbPQ==} + is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + is-weakref@1.1.1: resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} engines: {node: '>= 0.4'} + is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + is-weakset@2.0.4: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} @@ -13837,6 +14108,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.1: + resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -13848,6 +14122,10 @@ packages: object-to-formdata@4.5.1: resolution: {integrity: sha512-QiM9D0NiU5jV6J6tjE1g7b4Z2tcUnKs1OPUi4iMb2zH+7jwlcUrASghgkFk9GtzqNNq8rTQJtT8AzjBAvLoNMw==} + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + object.assign@4.1.7: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} @@ -15117,6 +15395,10 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + reflect.getprototypeof@1.0.6: + resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} + engines: {node: '>= 0.4'} + refractor@3.6.0: resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} @@ -15135,6 +15417,10 @@ packages: regex@6.0.1: resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -15397,6 +15683,10 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -15634,6 +15924,10 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -15943,6 +16237,10 @@ packages: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + string.prototype.trimend@1.0.8: resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} @@ -16582,18 +16880,34 @@ packages: type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + typed-array-byte-length@1.0.3: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + typed-array-byte-offset@1.0.4: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + typed-array-length@1.0.7: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} @@ -16642,6 +16956,9 @@ packages: un-eval@1.2.0: resolution: {integrity: sha512-Wlj/pum6dQtGTPD/lclDtoVPkSfpjPfy1dwnnKw/sZP5DpBH9fLhBgQfsqNhe5/gS1D+vkZUuB771NRMUPA5CA==} + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -17236,18 +17553,32 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} + which-builtin-type@1.1.3: + resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} + engines: {node: '>= 0.4'} + which-builtin-type@1.2.1: resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} engines: {node: '>= 0.4'} + which-collection@1.0.1: + resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + which-collection@1.0.2: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + which-typed-array@1.1.19: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} @@ -19152,6 +19483,13 @@ snapshots: react-dom: 18.3.1(react@18.3.1) tslib: 2.6.2 + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + '@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': dependencies: '@dnd-kit/core': 6.0.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -19586,10 +19924,10 @@ snapshots: dependencies: '@graphiql/toolkit': 0.9.1(@types/node@22.13.14)(graphql-ws@5.14.1(graphql@16.10.0))(graphql@16.10.0) '@headlessui/react': 1.7.17(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dropdown-menu': 2.0.6(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/codemirror': 5.60.10 clsx: 1.2.1 codemirror: 5.65.15 @@ -22455,6 +22793,28 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-dialog@1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-portal': 1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.2.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.1(@types/react@18.3.3)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.3(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-direction@1.0.1(@types/react@18.3.3)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 @@ -22508,6 +22868,19 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-dismissable-layer@1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-dismissable-layer@1.1.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -22594,6 +22967,17 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-focus-scope@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-focus-scope@1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) @@ -22876,6 +23260,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-portal@1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-portal@1.1.6(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -22917,6 +23311,16 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-presence@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-presence@1.1.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.3)(react@18.3.1) @@ -22955,6 +23359,15 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-primitive@2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.2.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-primitive@2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-slot': 1.2.0(@types/react@18.3.3)(react@18.3.1) @@ -23291,6 +23704,13 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + '@radix-ui/react-use-controllable-state@1.1.1(@types/react@18.3.3)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.3)(react@18.3.1)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.3)(react@18.3.1) @@ -23417,6 +23837,15 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-visually-hidden@1.1.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-visually-hidden@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -23796,7 +24225,7 @@ snapshots: '@rollup/pluginutils': 5.1.4(rollup@4.38.0) commondir: 1.0.1 estree-walker: 2.0.2 - fdir: 6.4.3(picomatch@4.0.2) + fdir: 6.4.5(picomatch@4.0.2) is-reference: 1.2.1 magic-string: 0.30.17 picomatch: 4.0.2 @@ -25527,6 +25956,12 @@ snapshots: react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.5.0(react@18.3.1) + '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + '@tanstack/react-virtual@3.13.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/virtual-core': 3.13.6 @@ -25749,6 +26184,8 @@ snapshots: '@tanstack/store@0.7.0': {} + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.6': {} '@tanstack/virtual-file-routes@1.114.12': {} @@ -27053,6 +27490,11 @@ snapshots: aria-query@5.3.2: {} + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -27060,12 +27502,12 @@ snapshots: array-includes@3.1.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 + get-intrinsic: 1.2.7 + is-string: 1.0.7 array-timsort@1.0.3: {} @@ -27073,34 +27515,34 @@ snapshots: array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.0.2 array.prototype.findlastindex@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.0.2 array.prototype.flat@1.3.2: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-shim-unscopables: 1.0.2 array.prototype.flatmap@1.3.2: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-shim-unscopables: 1.0.2 array.prototype.flatmap@1.3.3: @@ -27112,12 +27554,23 @@ snapshots: array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-errors: 1.3.0 es-shim-unscopables: 1.0.2 + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 @@ -27305,12 +27758,12 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.12: + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.1: dependencies: balanced-match: 1.0.2 @@ -27430,17 +27883,17 @@ snapshots: call-bind@1.0.7: dependencies: - es-define-property: 1.0.1 + es-define-property: 1.0.0 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.4 set-function-length: 1.2.2 call-bind@1.0.8: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 set-function-length: 1.2.2 call-bound@1.0.4: @@ -28154,18 +28607,36 @@ snapshots: whatwg-url: 11.0.0 optional: true + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + data-view-byte-length@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + data-view-byte-offset@1.0.1: dependencies: call-bound: 1.0.4 @@ -28277,9 +28748,9 @@ snapshots: define-data-property@1.1.4: dependencies: - es-define-property: 1.0.1 + es-define-property: 1.0.0 es-errors: 1.3.0 - gopd: 1.2.0 + gopd: 1.0.1 define-lazy-prop@2.0.0: {} @@ -28523,6 +28994,55 @@ snapshots: dependencies: stackframe: 1.3.4 + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.3 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.1 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -28580,6 +29100,10 @@ snapshots: unbox-primitive: 1.1.0 which-typed-array: 1.1.19 + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.2.4 + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -28593,7 +29117,7 @@ snapshots: es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 globalthis: 1.0.4 gopd: 1.2.0 has-property-descriptors: 1.0.2 @@ -28605,10 +29129,20 @@ snapshots: es-module-lexer@1.6.0: {} + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + es-set-tostringtag@2.1.0: dependencies: es-errors: 1.3.0 @@ -28620,11 +29154,17 @@ snapshots: dependencies: hasown: 2.0.2 + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + es-to-primitive@1.3.0: dependencies: is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 + is-date-object: 1.0.5 + is-symbol: 1.0.4 es5-ext@0.10.64: dependencies: @@ -29386,6 +29926,10 @@ snapshots: follow-redirects@1.15.9: {} + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -29512,6 +30056,13 @@ snapshots: function-bind@1.1.2: {} + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + function.prototype.name@1.1.8: dependencies: call-bind: 1.0.8 @@ -29555,6 +30106,27 @@ snapshots: get-caller-file@2.0.5: {} + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-intrinsic@1.2.7: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -29585,6 +30157,12 @@ snapshots: get-stream@8.0.1: {} + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -29697,6 +30275,10 @@ snapshots: dependencies: type-fest: 0.20.2 + globalthis@1.0.3: + dependencies: + define-properties: 1.2.1 + globalthis@1.0.4: dependencies: define-properties: 1.2.1 @@ -29730,6 +30312,10 @@ snapshots: globrex@0.1.2: {} + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -29908,12 +30494,16 @@ snapshots: has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.1 + es-define-property: 1.0.0 + + has-proto@1.0.3: {} has-proto@1.2.0: dependencies: dunder-proto: 1.0.1 + has-symbols@1.0.3: {} + has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -30421,6 +31011,12 @@ snapshots: through: 2.3.8 wrap-ansi: 6.2.0 + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -30479,6 +31075,11 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -30493,6 +31094,10 @@ snapshots: dependencies: has-tostringtag: 1.0.2 + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + is-bigint@1.1.0: dependencies: has-bigints: 1.0.2 @@ -30501,6 +31106,11 @@ snapshots: dependencies: binary-extensions: 2.2.0 + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -30516,12 +31126,20 @@ snapshots: dependencies: hasown: 2.0.2 + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 get-intrinsic: 1.3.0 is-typed-array: 1.1.15 + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + is-date-object@1.1.0: dependencies: call-bound: 1.0.4 @@ -30543,6 +31161,10 @@ snapshots: is-extglob@2.1.1: {} + is-finalizationregistry@1.0.2: + dependencies: + call-bind: 1.0.8 + is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 @@ -30575,6 +31197,8 @@ snapshots: dependencies: tslib: 2.8.1 + is-map@2.0.2: {} + is-map@2.0.3: {} is-module@1.0.0: {} @@ -30583,6 +31207,10 @@ snapshots: is-node-process@1.2.0: {} + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -30621,6 +31249,11 @@ snapshots: dependencies: '@types/estree': 1.0.7 + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -30636,6 +31269,10 @@ snapshots: is-set@2.0.3: {} + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 @@ -30644,17 +31281,29 @@ snapshots: is-stream@3.0.0: {} + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + is-string@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + is-symbol@1.1.1: dependencies: call-bound: 1.0.4 has-symbols: 1.1.0 safe-regex-test: 1.1.0 + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.19 @@ -30671,12 +31320,23 @@ snapshots: dependencies: tslib: 2.8.1 + is-weakmap@2.0.1: {} + is-weakmap@2.0.2: {} + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + is-weakref@1.1.1: dependencies: call-bound: 1.0.4 + is-weakset@2.0.2: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + is-weakset@2.0.4: dependencies: call-bound: 1.0.4 @@ -30754,7 +31414,7 @@ snapshots: dependencies: define-data-property: 1.1.4 es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 @@ -30979,7 +31639,7 @@ snapshots: dependencies: array-includes: 3.1.8 array.prototype.flat: 1.3.2 - object.assign: 4.1.7 + object.assign: 4.1.5 object.values: 1.2.0 katex@0.16.21: @@ -32412,31 +33072,31 @@ snapshots: minimatch@10.0.1: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimatch@3.1.2: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.11 minimatch@5.1.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimatch@7.4.6: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimatch@8.0.4: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimatch@9.0.3: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimatch@9.0.5: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.1 minimist@1.2.8: {} @@ -33091,12 +33751,21 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.1: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} object-to-formdata@4.5.1: {} + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + object.assign@4.1.7: dependencies: call-bind: 1.0.8 @@ -33115,16 +33784,16 @@ snapshots: object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 object.pick@1.3.0: dependencies: @@ -33132,7 +33801,7 @@ snapshots: object.values@1.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -33963,8 +34632,7 @@ snapshots: punycode@2.3.0: {} - punycode@2.3.1: - optional: true + punycode@2.3.1: {} qs-esm@7.0.2: {} @@ -34630,6 +35298,16 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + reflect.getprototypeof@1.0.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + which-builtin-type: 1.1.3 + refractor@3.6.0: dependencies: hastscript: 6.0.0 @@ -34650,6 +35328,13 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -35049,11 +35734,18 @@ snapshots: dependencies: mri: 1.2.0 + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 call-bound: 1.0.4 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 has-symbols: 1.1.0 isarray: 2.0.5 @@ -35068,9 +35760,9 @@ snapshots: safe-regex-test@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 es-errors: 1.3.0 - is-regex: 1.2.1 + is-regex: 1.1.4 safe-regex-test@1.1.0: dependencies: @@ -35218,8 +35910,8 @@ snapshots: define-data-property: 1.1.4 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 has-property-descriptors: 1.0.2 set-function-name@2.0.2: @@ -35389,17 +36081,24 @@ snapshots: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 object-inspect: 1.13.4 side-channel-weakmap@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 object-inspect: 1.13.4 side-channel-map: 1.0.1 + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + side-channel@1.1.0: dependencies: es-errors: 1.3.0 @@ -35706,9 +36405,9 @@ snapshots: string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 string.prototype.matchall@4.0.12: dependencies: @@ -35718,7 +36417,7 @@ snapshots: es-abstract: 1.24.0 es-errors: 1.3.0 es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 + get-intrinsic: 1.2.7 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 @@ -35730,12 +36429,12 @@ snapshots: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.23.3 string.prototype.trim@1.2.10: dependencies: @@ -35747,11 +36446,18 @@ snapshots: es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + string.prototype.trimend@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.0.0 string.prototype.trimend@1.0.9: dependencies: @@ -35762,9 +36468,9 @@ snapshots: string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.7 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.0.0 string_decoder@1.1.1: dependencies: @@ -36208,7 +36914,7 @@ snapshots: tinyglobby@0.2.12: dependencies: - fdir: 6.4.3(picomatch@4.0.2) + fdir: 6.4.5(picomatch@4.0.2) picomatch: 4.0.2 tinyglobby@0.2.14: @@ -36285,7 +36991,7 @@ snapshots: tough-cookie@4.1.4: dependencies: psl: 1.9.0 - punycode: 2.3.0 + punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 @@ -36490,38 +37196,70 @@ snapshots: type@2.7.3: {} + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-typed-array: 1.1.15 + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + typed-array-byte-length@1.0.3: dependencies: call-bind: 1.0.8 - for-each: 0.3.5 + for-each: 0.3.3 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 call-bind: 1.0.8 - for-each: 0.3.5 + for-each: 0.3.3 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + typed-array-length@1.0.7: dependencies: call-bind: 1.0.8 - for-each: 0.3.5 + for-each: 0.3.3 gopd: 1.2.0 is-typed-array: 1.1.15 possible-typed-array-names: 1.0.0 - reflect.getprototypeof: 1.0.10 + reflect.getprototypeof: 1.0.6 typed.js@2.0.16: {} @@ -36548,6 +37286,13 @@ snapshots: un-eval@1.2.0: {} + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -36630,7 +37375,7 @@ snapshots: pkg-types: 1.3.1 scule: 1.3.0 strip-literal: 3.0.0 - tinyglobby: 0.2.12 + tinyglobby: 0.2.14 unplugin: 2.2.2 unplugin-utils: 0.2.4 @@ -37384,6 +38129,14 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -37392,6 +38145,21 @@ snapshots: is-string: 1.1.1 is-symbol: 1.1.1 + which-builtin-type@1.1.3: + dependencies: + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.19 + which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 @@ -37408,6 +38176,13 @@ snapshots: which-collection: 1.0.2 which-typed-array: 1.1.19 + which-collection@1.0.1: + dependencies: + is-map: 2.0.2 + is-set: 2.0.3 + is-weakmap: 2.0.1 + is-weakset: 2.0.2 + which-collection@1.0.2: dependencies: is-map: 2.0.3 @@ -37415,6 +38190,14 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + which-typed-array@1.1.19: dependencies: available-typed-arrays: 1.0.7