From f223b455d68fe913f4075fe1c6026fd5205f971c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 20 Jun 2025 12:46:17 +0800 Subject: [PATCH 01/84] Unified Logs (Part 1) (#36298) * init new unified page * moar logs * init * add infinite and live logs example * Update useLogsPreview.tsx * add more sources * wrapped auth logs with edge logs * add role and user id * move unified logs * init * move demo pages. create a new directory to work in * extracted beta unified logs into own components * add example base page and components * add new files to use actual logging query * more organization * change import * adds new logs page. adds new query * add data table to UI pacakges * revert * table styles * text size * add timestamp, table, icons for log types, status code styling * add host * add log count to edge functions * starts to add dynamic filtering * spiking trace UI * Update status-code.ts * add new linik * now using POST * fix chart data for default 1 hour view * update API to accept POST requests * new filters * Update level.ts * fixed up chart to work on level filter. split up the logic into new files * prep for log type * prepped query for WHERE * fix: issue with white space in url param column parsing * level param now being removed correctly. * fix issue with chart showing wrong buckets for different time ranges * remove old query * refactor the queries into function for each source * total count fixed * lots of layout * start fixing log counts * comment out min and max for a while * added trace logging prototype in * random trace logs added for demo * added logs and ui to view logs if any * add Auth user * fix the live logs issue * some left over code * Midway * First pass refactor + clean up + reorganize files * Fix TS issues * Remove unused files * Clean up * Final clean up * more clean up * More clean up * Remove unused packages * Fix * Lint * Add feature flag for unified logs * Refactor * Remove trace UI * Snake case log types * more clean up * More clean up * Fix ts * more clean up * fixes * add flag check and redirect if flag is false * Update middleware.ts * Nit lint * Fix * Last refactors --------- Co-authored-by: Jonathan Summers-Muir --- .../interfaces/Settings/Logs/Logs.utils.ts | 2 +- apps/studio/components/interfaces/Sidebar.tsx | 3 +- .../interfaces/UnifiedLogs/QueryOptions.ts | 617 ++++++++++++ .../UnifiedLogs/UnifiedLogs.constants.tsx | 90 ++ .../UnifiedLogs/UnifiedLogs.fields.tsx | 184 ++++ .../UnifiedLogs/UnifiedLogs.hooks.ts | 44 + .../UnifiedLogs/UnifiedLogs.queries.ts | 565 +++++++++++ .../UnifiedLogs/UnifiedLogs.schema.ts | 85 ++ .../interfaces/UnifiedLogs/UnifiedLogs.tsx | 413 ++++++++ .../UnifiedLogs/UnifiedLogs.types.ts | 61 ++ .../UnifiedLogs/UnifiedLogs.utils.ts | 75 ++ .../components/AuthUserHoverCard.tsx | 37 + .../UnifiedLogs/components/Columns.tsx | 193 ++++ .../components/DataTableSheetContent.tsx | 105 ++ .../components/FunctionLogsTab.tsx | 55 ++ .../components/HoverCardTimestamp.tsx | 14 + .../UnifiedLogs/components/LogTypeIcon.tsx | 65 ++ .../components/TextWithTooltip.tsx | 53 + .../UnifiedLogs/components/TooltipLabel.tsx | 10 + .../layouts/LogsLayout/LogsLayout.tsx | 2 +- .../layouts/LogsLayout/LogsSidebarMenuV2.tsx | 11 +- .../NavigationBar/NavigationBar.utils.tsx | 20 +- .../UnifiedLogsLayout/UnifiedLogsLayout.tsx | 9 + .../ui/DataTable/DataTable.constants.ts | 50 + .../ui/DataTable/DataTable.types.ts | 61 ++ .../ui/DataTable/DataTable.utils.ts | 129 +++ .../DataTableColumn/DataTableColumnHeader.tsx | 54 + .../DataTableColumnLevelIndicator.tsx | 17 + .../DataTableColumnStatusCode.tsx | 35 + .../DataTableFilterCheckbox.tsx | 118 +++ .../DataTableFilterCommand.tsx | 398 ++++++++ .../DataTableFilterControls.tsx | 74 ++ .../DataTableFilterControlsDrawer.tsx | 75 ++ .../DataTableFilters/DataTableFilterInput.tsx | 52 + .../DataTableFilterResetButton.tsx | 41 + .../DataTableFilterSlider.tsx | 99 ++ .../DataTableFilterTimerange.tsx | 39 + .../DataTableFilters.utils.ts | 259 +++++ .../ui/DataTable/DataTableHeaderLayout.tsx | 35 + .../ui/DataTable/DataTableInfinite.tsx | 238 +++++ .../ui/DataTable/DataTableResetButton.tsx | 37 + .../ui/DataTable/DataTableSheetDetails.tsx | 135 +++ .../ui/DataTable/DataTableSheetRowAction.tsx | 187 ++++ .../ui/DataTable/DataTableSideBarLayout.tsx | 66 ++ .../ui/DataTable/DataTableToolbar.tsx | 82 ++ .../ui/DataTable/DataTableViewOptions.tsx | 104 ++ .../ui/DataTable/DatePickerWithRange.tsx | 267 +++++ .../components/ui/DataTable/FilterSideBar.tsx | 28 + .../ui/DataTable/InputWithAddons.tsx | 40 + .../components/ui/DataTable/LiveButton.tsx | 71 ++ .../components/ui/DataTable/LiveRow.tsx | 18 + .../components/ui/DataTable/RefreshButton.tsx | 29 + apps/studio/components/ui/DataTable/Table.tsx | 116 +++ .../components/ui/DataTable/TimelineChart.tsx | 166 ++++ .../components/ui/DataTable/hooks/README.md | 3 + .../ui/DataTable/hooks/useComposedRefs.ts | 32 + .../ui/DataTable/hooks/useDebounce.ts | 16 + .../ui/DataTable/hooks/useMediaQuery.ts | 19 + .../ui/DataTable/primitives/Kbd.tsx | 21 + .../ui/DataTable/primitives/Slider.tsx | 34 + .../ui/DataTable/primitives/Sortable.tsx | 330 +++++++ .../DataTable/providers/ControlsProvider.tsx | 36 + .../DataTable/providers/DataTableProvider.tsx | 79 ++ apps/studio/hooks/misc/useLocalStorage.ts | 45 +- apps/studio/hooks/ui/useCopyToClipboard.ts | 46 + apps/studio/hooks/ui/useHotKey.ts | 17 + apps/studio/package.json | 6 + .../pages/project/[ref]/unified-logs.tsx | 29 + apps/studio/styles/main.scss | 4 +- packages/api-types/types/platform.d.ts | 50 +- packages/ui/index.tsx | 1 + .../ui/src/components/shadcn/ui/table.tsx | 10 +- .../ui/src/components/shadcn/ui/tooltip.tsx | 4 +- pnpm-lock.yaml | 925 ++++++++++++++++-- 74 files changed, 7440 insertions(+), 100 deletions(-) create mode 100644 apps/studio/components/interfaces/UnifiedLogs/QueryOptions.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.hooks.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.types.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.utils.ts create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/AuthUserHoverCard.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/DataTableSheetContent.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/FunctionLogsTab.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/HoverCardTimestamp.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/LogTypeIcon.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/TextWithTooltip.tsx create mode 100644 apps/studio/components/interfaces/UnifiedLogs/components/TooltipLabel.tsx create mode 100644 apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTable.constants.ts create mode 100644 apps/studio/components/ui/DataTable/DataTable.types.ts create mode 100644 apps/studio/components/ui/DataTable/DataTable.utils.ts create mode 100644 apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts create mode 100644 apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableInfinite.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableResetButton.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableToolbar.tsx create mode 100644 apps/studio/components/ui/DataTable/DataTableViewOptions.tsx create mode 100644 apps/studio/components/ui/DataTable/DatePickerWithRange.tsx create mode 100644 apps/studio/components/ui/DataTable/FilterSideBar.tsx create mode 100644 apps/studio/components/ui/DataTable/InputWithAddons.tsx create mode 100644 apps/studio/components/ui/DataTable/LiveButton.tsx create mode 100644 apps/studio/components/ui/DataTable/LiveRow.tsx create mode 100644 apps/studio/components/ui/DataTable/RefreshButton.tsx create mode 100644 apps/studio/components/ui/DataTable/Table.tsx create mode 100644 apps/studio/components/ui/DataTable/TimelineChart.tsx create mode 100644 apps/studio/components/ui/DataTable/hooks/README.md create mode 100644 apps/studio/components/ui/DataTable/hooks/useComposedRefs.ts create mode 100644 apps/studio/components/ui/DataTable/hooks/useDebounce.ts create mode 100644 apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts create mode 100644 apps/studio/components/ui/DataTable/primitives/Kbd.tsx create mode 100644 apps/studio/components/ui/DataTable/primitives/Slider.tsx create mode 100644 apps/studio/components/ui/DataTable/primitives/Sortable.tsx create mode 100644 apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx create mode 100644 apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx create mode 100644 apps/studio/hooks/ui/useCopyToClipboard.ts create mode 100644 apps/studio/hooks/ui/useHotKey.ts create mode 100644 apps/studio/pages/project/[ref]/unified-logs.tsx diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 1af9461d3f9..d1f9fbc2273 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 afc27de6f8c..3b40d11085d 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 00000000000..4bc978188f4 --- /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 00000000000..996d6290e59 --- /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 00000000000..11ff66d6d83 --- /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 00000000000..1661458a027 --- /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 00000000000..d2823d0d126 --- /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 00000000000..6e7459128dd --- /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 00000000000..3344d5d77e8 --- /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 00000000000..d041f8c22ef --- /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 00000000000..a32831a39a8 --- /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 00000000000..0d9f329dd92 --- /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 00000000000..f49f017179f --- /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 00000000000..cdd1800a1d5 --- /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 00000000000..3607f940d9b --- /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 00000000000..337fe574ed6 --- /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 00000000000..a14bb8bbd45 --- /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 00000000000..fae42e933ac --- /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 00000000000..7745d22b864 --- /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/LogsLayout/LogsLayout.tsx b/apps/studio/components/layouts/LogsLayout/LogsLayout.tsx index 7296bb61f6f..2edee7ba163 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 993484f7197..f47831e49e3 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/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 96df83e1bb9..ac33ba4ff1d 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 00000000000..27ba57f0ce0 --- /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/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts new file mode 100644 index 00000000000..da947462782 --- /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 00000000000..9ee5cada849 --- /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 00000000000..12997306dab --- /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 00000000000..7bd07547b55 --- /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 00000000000..c0038b7c3dd --- /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 00000000000..175e3a477a8 --- /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 00000000000..26fbbdc57d3 --- /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 00000000000..b29d2410f19 --- /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 00000000000..c5030af0153 --- /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 00000000000..835d77cc54e --- /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 00000000000..21d3e217d5f --- /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 00000000000..d0665532653 --- /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 00000000000..14d8d23d6bc --- /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 00000000000..9981ed31e61 --- /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 00000000000..c65b6106b07 --- /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 00000000000..5222d38f14c --- /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 00000000000..7546227e637 --- /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 00000000000..06a80799f08 --- /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 00000000000..922d38bc3ee --- /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 00000000000..47f02763d75 --- /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 00000000000..ec9162a8fed --- /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 00000000000..906e89dee00 --- /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 00000000000..069cd02d143 --- /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 00000000000..d5be1d62a3d --- /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 00000000000..0b27280f9c8 --- /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 00000000000..ebb1c3f04e2 --- /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 00000000000..76c63acd8be --- /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 00000000000..6aaf245d3aa --- /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 05541046483..bafd969590f 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 805af505a66..e6bec8f8f35 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 From 4ae82572f22d1df518f87ca27ba3986df7cfc0b1 Mon Sep 17 00:00:00 2001 From: Jonathan Summers-Muir Date: Fri, 20 Jun 2025 16:09:43 +0800 Subject: [PATCH 02/84] fix: reverts local storage (#36551) * Update useLocalStorage.ts * Fix incomplete revert of useLocalStorage --------- Co-authored-by: Joshen Lim --- apps/studio/hooks/misc/useLocalStorage.ts | 45 +++++++---------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/apps/studio/hooks/misc/useLocalStorage.ts b/apps/studio/hooks/misc/useLocalStorage.ts index 1d7a203d09a..9df2b0de56f 100644 --- a/apps/studio/hooks/misc/useLocalStorage.ts +++ b/apps/studio/hooks/misc/useLocalStorage.ts @@ -1,7 +1,7 @@ // Reference: https://usehooks.com/useLocalStorage/ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Dispatch, SetStateAction, useCallback, useState, useEffect } from 'react' +import { Dispatch, SetStateAction, useCallback, useState } from 'react' export function useLocalStorage(key: string, initialValue: T) { // State to store our value @@ -58,39 +58,25 @@ export function useLocalStorageQuery(key: string, initialValue: T) { const queryClient = useQueryClient() const queryKey = ['localStorage', key] - // During SSR, return initial value immediately to prevent hydration mismatch - const [isClient, setIsClient] = useState(false) - - useEffect(() => { - setIsClient(true) - }, []) - const { error, data: storedValue = initialValue, isSuccess, isLoading, isError, - } = useQuery( - queryKey, - () => { - if (typeof window === 'undefined') { - return initialValue - } - - const item = window.localStorage.getItem(key) - - if (!item) { - return initialValue - } - - return JSON.parse(item) as T - }, - { - enabled: isClient, // Only run query on client side - staleTime: Infinity, // Prevent unnecessary refetches + } = useQuery(queryKey, () => { + if (typeof window === 'undefined') { + return initialValue } - ) + + const item = window.localStorage.getItem(key) + + if (!item) { + return initialValue + } + + return JSON.parse(item) as T + }) const setValue: Dispatch> = (value) => { const valueToStore = value instanceof Function ? value(storedValue) : value @@ -103,8 +89,5 @@ export function useLocalStorageQuery(key: string, initialValue: T) { queryClient.invalidateQueries(queryKey) } - // Return initial value during SSR, actual value on client - const finalValue = isClient ? storedValue : initialValue - - return [finalValue, setValue, { isSuccess, isLoading, isError, error }] as const + return [storedValue, setValue, { isSuccess, isLoading, isError, error }] as const } From fd2a12ad382399a82940b153051061da825abca1 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 20 Jun 2025 18:39:03 +1000 Subject: [PATCH 03/84] Header icons refine (#36549) header refine --- .../layouts/AppLayout/AssistantButton.tsx | 2 +- .../NotificationsPopover.tsx | 58 ++++++++----------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/apps/studio/components/layouts/AppLayout/AssistantButton.tsx b/apps/studio/components/layouts/AppLayout/AssistantButton.tsx index a677ab116e7..8b9b80ffef2 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/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx index d8903ee448c..e610bb3a4e1 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 && ( +
+
+
+ )} +
} /> From dd69049efac0b0658cad25db274b6bbbba7d2afc Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 20 Jun 2025 11:46:11 +0200 Subject: [PATCH 04/84] update db charts (#36534) --- .../components/ui/Charts/ChartHeader.tsx | 21 ++-- apps/studio/data/reports/database-charts.ts | 108 +++++------------- 2 files changed, 42 insertions(+), 87 deletions(-) diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 8b2fceca792..e15ee1d9eed 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/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts index 718534e7eca..0d3be727670 100644 --- a/apps/studio/data/reports/database-charts.ts +++ b/apps/studio/data/reports/database-charts.ts @@ -45,7 +45,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep showLegend: true, hideChartType: false, defaultChartStyle: 'line', - showMaxValue: true, + showMaxValue: false, showGrid: true, syncId: 'database-reports', valuePrecision: 2, @@ -59,36 +59,21 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'Used', tooltip: - 'Total RAM currently in use by the system and applications. High usage may indicate memory pressure.', + 'RAM in use by Postgres and the operating system. Sustained high usage may indicate memory pressure', }, { attribute: 'ram_usage_cache_and_buffers', provider: 'infra-monitoring', label: 'Cache + buffers', tooltip: - 'RAM used for caching and buffering disk operations. Higher values improve read performance.', + 'RAM used by the operating system page cache and PostgreSQL buffers to accelerate disk reads/writes', }, { attribute: 'ram_usage_free', provider: 'infra-monitoring', label: 'Free', tooltip: - 'Available unused RAM. A small amount is always reserved. High unused RAM present with erratic Used RAM may indicate unoptimized queries disrupting cache.', - }, - { - attribute: 'ram_usage_swap', - provider: 'infra-monitoring', - label: 'Swap', - omitFromTotal: false, // we can set this to true if we want to omit the swap from the total - tooltip: - 'Memory swapped to disk when RAM is full. An instance has 1GB of SWAP. High swap with high disk I/O signals memory stress.', - }, - { - attribute: 'ram_usage_total', - provider: 'infra-monitoring', - label: 'Max', - isMaxValue: true, - tooltip: 'Total RAM', + 'Unallocated memory available for use. A small portion is always reserved by the operating system', }, ], }, @@ -116,7 +101,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'System', format: '%', tooltip: - 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead.', + 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead', }, { attribute: 'cpu_usage_busy_user', @@ -124,7 +109,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'User', format: '%', tooltip: - 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries.', + 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries', }, { attribute: 'cpu_usage_busy_iowait', @@ -132,7 +117,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'IOwait', format: '%', tooltip: - 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks.', + 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks', }, { attribute: 'cpu_usage_busy_irqs', @@ -147,7 +132,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep label: 'Other', format: '%', tooltip: - 'CPU time spent on other tasks (e.g., background processes, software interrupts).', + 'CPU time spent on other tasks (e.g., background processes, software interrupts)', }, { attribute: 'cpu_usage_max', @@ -161,7 +146,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'disk-iops', - label: 'Disk IOps', + label: 'Disk Input/Output operations per second (IOPS)', syncId: 'database-reports', hide: false, showTooltip: true, @@ -180,16 +165,16 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep { attribute: 'disk_iops_write', provider: 'infra-monitoring', - label: 'IOps write/s', + label: 'write IOPS', tooltip: - 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity.', + 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity', }, { attribute: 'disk_iops_read', provider: 'infra-monitoring', - label: 'IOps read/s', + label: 'read IOPS', tooltip: - 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching.', + 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching', }, { attribute: 'disk_iops_max', @@ -225,7 +210,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'IO Usage', tooltip: - 'The actual number of IO operations per second that the database is currently using.', + 'The actual number of IO operations per second that the database is currently using', }, ], }, @@ -252,7 +237,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', format: 'bytes', label: 'System', - tooltip: 'Reserved space for the system to ensure your database runs smoothly.', + tooltip: 'Reserved space for the system to ensure your database runs smoothly', }, { attribute: 'disk_fs_used_wal', @@ -260,7 +245,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep format: 'bytes', label: 'WAL', tooltip: - 'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database.', + 'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database', }, { @@ -268,7 +253,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', format: 'bytes', label: 'Database', - tooltip: 'Disk usage by your database (tables, indexes, data, ...).', + tooltip: 'Disk usage by your database (tables, indexes, data, ...)', }, { attribute: 'disk_fs_size', @@ -306,43 +291,6 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }), ], }, - { - id: 'network-traffic', - label: 'Network Traffic', - syncId: 'database-reports', - valuePrecision: 1, - showTooltip: true, - showLegend: true, - showMaxValue: false, - showGrid: true, - YAxisProps: { - width: 65, - tickFormatter: (value: any) => formatBytes(value, 1), - }, - hideChartType: false, - defaultChartStyle: 'line', - hideHighlightedValue: true, - showTotal: false, - attributes: [ - { - attribute: 'network_receive_bytes', - provider: 'infra-monitoring', - label: 'Ingress', - manipulateValue: (value: number) => value * -1, - tooltip: - 'Data received by your database from clients. High values may indicate frequent queries, large data inserts, or many incoming connections.', - stackId: '1', - }, - { - attribute: 'network_transmit_bytes', - provider: 'infra-monitoring', - label: 'Egress', - tooltip: - 'Data sent from your database to clients. High values may indicate large query results or numerous outgoing connections.', - stackId: '2', - }, - ], - }, { id: 'client-connections', label: 'Database client connections', @@ -352,6 +300,8 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep showLegend: true, showMaxValue: true, hideChartType: false, + showGrid: true, + YAxisProps: { width: 30 }, defaultChartStyle: 'line', attributes: [ { @@ -359,39 +309,37 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep provider: 'infra-monitoring', label: 'Postgres', tooltip: - 'Direct connections to the Postgres database from your application and external clients.', + 'Direct connections to the Postgres database from your application and external clients', }, { attribute: 'client_connections_authenticator', provider: 'infra-monitoring', label: 'PostgREST', - tooltip: 'Connections magaged by PostgREST to auto-generate RESTful API.', + tooltip: 'Connection pool managed by PostgREST', }, { attribute: 'client_connections_supabase_admin', provider: 'infra-monitoring', - label: 'Admin', + label: 'Reserved', tooltip: - 'Administrative connections used by various Supabase services for internal operations and maintenance tasks.', + 'Administrative connections used by various Supabase services for internal operations and maintenance tasks', }, { attribute: 'client_connections_supabase_auth_admin', provider: 'infra-monitoring', label: 'Auth', - tooltip: - 'Administrative connections used by Supabase Auth service for user management and authentication operations.', + tooltip: 'Connection pool managed by Supabase Auth', }, { attribute: 'client_connections_supabase_storage_admin', provider: 'infra-monitoring', label: 'Storage', - tooltip: - 'Administrative connections used by Supabase Storage service for file operations and bucket management.', + tooltip: 'Connection pool managed by Supabase Storage', }, { attribute: 'client_connections_other', provider: 'infra-monitoring', - label: 'Other', + label: 'Other roles', tooltip: "Miscellaneous database connections that don't fall into other categories.", }, { @@ -406,7 +354,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'pgbouncer-connections', - label: 'Pooler client connections', + label: 'Dedicated Pooler client connections', syncId: 'database-reports', valuePrecision: 0, hide: false, @@ -437,7 +385,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep }, { id: 'supavisor-connections-active', - label: 'Supavisor client connections', + label: 'Shared Pooler (Supavisor) client connections', syncId: 'database-reports', valuePrecision: 0, hide: isFreePlan, From aeaf831648a2e681cac15b09b3443bd83e12ad9c Mon Sep 17 00:00:00 2001 From: Etienne Stalmans Date: Fri, 20 Jun 2025 14:51:11 +0200 Subject: [PATCH 05/84] feat: add UI to show MFA enforcement on org (#36439) Disables organization selection if the org requires MFA but the user does not have MFA enabled. Add badges / hints to show that user has access to an Organization but access is disabled because of MFA access requirement Co-authored-by: Joshen Lim --- .../interfaces/App/RouteValidationWrapper.tsx | 15 ++++++- .../Billing/Payment/AddPaymentMethodForm.tsx | 2 +- .../BillingSettings/BillingEmail.tsx | 2 +- .../Organization/OrganizationCard.tsx | 35 ++++++++++++++-- apps/studio/components/interfaces/Sidebar.tsx | 13 +++++- .../AppLayout/OrganizationDropdown.tsx | 1 + apps/studio/components/ui/ActionCard.tsx | 11 +++-- .../data/organizations/organizations-query.ts | 6 +-- apps/studio/pages/org/[slug]/index.tsx | 42 +++++++++++++------ apps/studio/types/base.ts | 18 ++------ packages/common/auth.tsx | 6 +++ 11 files changed, 109 insertions(+), 42 deletions(-) diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index f87aa4aa0c6..22b473f6d9f 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' import { toast } from 'sonner' -import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsLoggedIn, useIsMFAEnabled, useParams } from 'common' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import useLatest from 'hooks/misc/useLatest' @@ -18,6 +18,7 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { const isLoggedIn = useIsLoggedIn() const snap = useAppStateSnapshot() + const isUserMFAEnabled = useIsMFAEnabled() const organization = useSelectedOrganization() @@ -122,7 +123,17 @@ const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { }, [isSuccessStorage, ref]) useEffect(() => { - if (organization) setLastVisitedOrganization(organization.slug) + if (organization) { + setLastVisitedOrganization(organization.slug) + + if ( + organization.organization_requires_mfa && + !isUserMFAEnabled && + router.pathname !== '/org/[slug]' + ) { + router.push(`/org/${organization.slug}`) + } + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [organization]) diff --git a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx index a197cfee60c..cb470dcb3e5 100644 --- a/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx +++ b/apps/studio/components/interfaces/Billing/Payment/AddPaymentMethodForm.tsx @@ -118,7 +118,7 @@ const AddPaymentMethodForm = ({ {showSetDefaultCheckbox && ( diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 8af7d72ade4..778dd9933ed 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -91,7 +91,7 @@ const BillingEmail = () => { useEffect(() => { if (billingCustomer) { form.reset({ - billingEmail: billing_email, + billingEmail: billing_email ?? '', additionalBillingEmails: billingCustomer.additional_emails ?? [], }) } diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx index dad92b5c257..e636c1e34ba 100644 --- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx +++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx @@ -1,9 +1,11 @@ -import { Boxes } from 'lucide-react' +import { Boxes, Lock } from 'lucide-react' import Link from 'next/link' +import { useIsMFAEnabled } from 'common' import { ActionCard } from 'components/ui/ActionCard' import { useProjectsQuery } from 'data/projects/projects-query' import { Organization } from 'types' +import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' export const OrganizationCard = ({ organization, @@ -12,17 +14,44 @@ export const OrganizationCard = ({ organization: Organization href?: string }) => { + const isUserMFAEnabled = useIsMFAEnabled() const { data: allProjects = [] } = useProjectsQuery() + const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length + const isMfaRequired = organization.organization_requires_mfa return ( div]:w-full [&>div]:items-center')} icon={} title={organization.name} - description={`${organization.plan.name} Plan${numProjects > 0 ? `${' '}โ€ข${' '}${numProjects} project${numProjects > 1 ? 's' : ''}` : ''}`} + description={ +
+
+ {organization.plan.name} Plan + {numProjects > 0 && ( + <> + โ€ข + + {numProjects} project{numProjects > 1 ? 's' : ''} + + + )} +
+ {isMfaRequired && ( + + + + + + MFA enforced + + + )} +
+ } /> ) diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 3b40d11085d..11ef4f28fa6 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react' -import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useIsMFAEnabled, useParams } from 'common' import { generateOtherRoutes, generateProductRoutes, @@ -18,6 +18,7 @@ import { useHideSidebar } from 'hooks/misc/useHideSidebar' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLints } from 'hooks/misc/useLints' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useFlag } from 'hooks/ui/useFlag' import { Home } from 'icons' import { useAppStateSnapshot } from 'state/app-state' @@ -158,10 +159,12 @@ export function SideBarNavLink({ route, active, onClick, + disabled, ...props }: { route: any active?: boolean + disabled?: boolean onClick?: () => void } & ComponentPropsWithoutRef) { const [sidebarBehaviour] = useLocalStorageQuery( @@ -170,6 +173,7 @@ export function SideBarNavLink({ ) const buttonProps = { + disabled, tooltip: sidebarBehaviour === 'closed' ? route.label : '', isActive: active, className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''), @@ -188,7 +192,7 @@ export function SideBarNavLink({ return ( - {route.link ? ( + {route.link && !disabled ? ( {content} @@ -352,6 +356,10 @@ const OrganizationLinks = () => { const router = useRouter() const { slug } = useParams() + const org = useSelectedOrganization() + const isUserMFAEnabled = useIsMFAEnabled() + const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled + const activeRoute = router.pathname.split('/')[3] const navMenuItems = [ @@ -399,6 +407,7 @@ const OrganizationLinks = () => { {navMenuItems.map((item, i) => ( { const href = !!routeSlug ? router.pathname.replace('[slug]', org.slug) : `/org/${org.slug}` + return ( void @@ -28,9 +29,13 @@ export const ActionCard = (card: { > {card.icon}
-
+

{card.title}

-
{card.description}
+ {typeof card.description === 'string' ? ( +
{card.description}
+ ) : ( + card.description + )}
diff --git a/apps/studio/data/organizations/organizations-query.ts b/apps/studio/data/organizations/organizations-query.ts index f9c9e17eb0d..85f32ab775c 100644 --- a/apps/studio/data/organizations/organizations-query.ts +++ b/apps/studio/data/organizations/organizations-query.ts @@ -6,9 +6,9 @@ import { useProfile } from 'lib/profile' import type { Organization, ResponseError } from 'types' import { organizationKeys } from './keys' -export function castOrganizationResponseToOrganization( - org: components['schemas']['OrganizationResponse'] -): Organization { +export type OrganizationBase = components['schemas']['OrganizationResponse'] + +export function castOrganizationResponseToOrganization(org: OrganizationBase): Organization { return { ...org, billing_email: org.billing_email ?? 'Unknown', diff --git a/apps/studio/pages/org/[slug]/index.tsx b/apps/studio/pages/org/[slug]/index.tsx index 7b0f68f82d0..7e8a9767a98 100644 --- a/apps/studio/pages/org/[slug]/index.tsx +++ b/apps/studio/pages/org/[slug]/index.tsx @@ -1,15 +1,23 @@ import { useState } from 'react' +import { useIsMFAEnabled } from 'common' import { ProjectList } from 'components/interfaces/Home/ProjectList' import HomePageActions from 'components/interfaces/HomePageActions' import DefaultLayout from 'components/layouts/DefaultLayout' import OrganizationLayout from 'components/layouts/OrganizationLayout' import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold' +import { InlineLink } from 'components/ui/InlineLink' import { useAutoProjectsPrefetch } from 'data/projects/projects-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { PROJECT_STATUS } from 'lib/constants' import type { NextPageWithLayout } from 'types' +import { Admonition } from 'ui-patterns' const ProjectsPage: NextPageWithLayout = () => { + const org = useSelectedOrganization() + const isUserMFAEnabled = useIsMFAEnabled() + const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled + const [search, setSearch] = useState('') const [filterStatus, setFilterStatus] = useState([ PROJECT_STATUS.ACTIVE_HEALTHY, @@ -20,22 +28,32 @@ const ProjectsPage: NextPageWithLayout = () => { return ( -
- - -
- +

+ Set up MFA on your account through your{' '} + account preferences to access this + organization +

+ + ) : ( +
+ setFilterStatus(['ACTIVE_HEALTHY', 'INACTIVE'])} + setFilterStatus={setFilterStatus} /> + +
+ setFilterStatus(['ACTIVE_HEALTHY', 'INACTIVE'])} + /> +
-
+ )} ) } diff --git a/apps/studio/types/base.ts b/apps/studio/types/base.ts index 4ab48786fcc..53d471ae055 100644 --- a/apps/studio/types/base.ts +++ b/apps/studio/types/base.ts @@ -1,24 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { OrganizationBase } from 'data/organizations/organizations-query' import { PlanId } from 'data/subscriptions/types' import jsonLogic from 'json-logic-js' -export interface Organization { - id: number - slug: string - name: string - billing_email: string - is_owner?: boolean - opt_in_tags: string[] - subscription_id?: string | null - restriction_status: 'grace_period' | 'grace_period_over' | 'restricted' | null - restriction_data: Record | null +export interface Organization extends OrganizationBase { managed_by: 'supabase' | 'vercel-marketplace' | 'aws-marketplace' partner_id?: string - plan: { - id: PlanId - name: string - } - usage_billing_enabled: boolean + plan: { id: PlanId; name: string } } /** diff --git a/packages/common/auth.tsx b/packages/common/auth.tsx index 642925856d4..2c35f5c52ad 100644 --- a/packages/common/auth.tsx +++ b/packages/common/auth.tsx @@ -116,6 +116,12 @@ export const useIsLoggedIn = () => { return user !== null } +export const useIsMFAEnabled = () => { + const user = useUser() + + return user !== null && user.factors && user.factors.length > 0 +} + export const signOut = async () => await gotrueClient.signOut() export const logOut = async () => { From 7f39249b6d191f773455e8e85ef451cb5ff2f310 Mon Sep 17 00:00:00 2001 From: Oliver Rice Date: Fri, 20 Jun 2025 08:33:14 -0500 Subject: [PATCH 06/84] SLA Revamp with product-specific definitions (#36495) * feat: comprehensive SLA restructure with product-specific definitions * prettier fixes * feat: comprehensive SLA restructure with product-specific definitions * feat: add database extension version requirement to Postgres SLA exclusions --- apps/www/pages/sla.mdx | 398 ++++++++++++++++++++++++++++++++--------- 1 file changed, 309 insertions(+), 89 deletions(-) diff --git a/apps/www/pages/sla.mdx b/apps/www/pages/sla.mdx index b8394dd5334..e69876d16ce 100644 --- a/apps/www/pages/sla.mdx +++ b/apps/www/pages/sla.mdx @@ -12,16 +12,298 @@ export const meta = { ## Enterprise Platform Uptime SLA -The following Service Level Agreement, which is incorporated into and forms part of the Subscription Agreement between Supabase, Inc. ("Supabase") and Customer (the "Agreement"), will apply to the Services for Enterprise Customers specified in an Order Form during the applicable Subscription Term: +The following Service Level Agreement, which is incorporated into and forms part of the Subscription Agreement between Supabase, Inc. ("Supabase") and Customer (the "Agreement"), will apply to the Services for Enterprise Customers specified in an Order Form during the applicable Subscription Term. -### 1. Uptime Commitment +--- -Supabase will provide Actual Availability for at least ninety-nine and nine tenths percent (99.9%) of the total time in each calendar month during the Subscription Term, as measured by Supabase (the **"Uptime Commitment"**). +## 1. Definitions -### 2. Service Credits +All capitalized terms used but not defined in this SLA have the meaning set forth in the Agreement. -If the Uptime Commitment is not met during any particular calendar month during the Subscription Term, then Customer will be eligible for a service credit ("Service Credit"), provided that Customer reports to Supabase such failure to meet the Uptime Commitment and requests such Service Credit in accordance with this Exhibit. The amount of any Service Credit due hereunder shall be calculated as follows: -X \* Y, where X = the total fees due from Customer to Supabase for the affected Services for the relevant calendar month (regardless of when billed or payable), and Y = the Credit Percentage corresponding with the Actual Availability provided (as a percentage of total time) for the relevant calendar month, as set forth in the table below. +**Availability Metrics:** + +Defines the measurements used to calculate service uptime under this SLA. + +- **Scheduled Availability:** + + The total time (in minutes) that the applicable service is generally accessible and available to permitted users. + +- **Scheduled Downtime/Maintenance Windows:** + + Periods of time that Supabase has communicated in advance, during which the service may be temporarily unavailable due to planned maintenance, upgrades, or other scheduled activities. These windows are not counted as Unscheduled Downtime for SLA purposes. + +- **Unscheduled Downtime:** + + The total time (in minutes) that the service is not accessible or available, excluding periods attributable to any causes listed under SLA Exclusions. + +- **Actual Availability:** + + The result of subtracting Unscheduled Downtime from Scheduled Availability. + +**Release Maturity Levels:** Indicates the stage of release for a given product or feature and whether it is covered by the SLA. + +- **GA (General Availability):** The product is fully released and covered by the SLA. +- **Beta:** The product or feature is in limited release. Beta products are not covered by the SLA. +- **Alpha:** The product or feature is in early release. Alpha products are not covered by the SLA. + +For the current release stage of each Supabase product and feature see [Supabase features](https://supabase.com/features). + +**SLA Scope:** The level at which service availability is measured and the impact required to constitute an SLA breach. + +- **Global:** The service is deployed from a centralized global infrastructure; SLA is breached if more than 1% of projects worldwide are affected during a downtime event. +- **Regional:** The service is deployed within individual geographic regions; SLA is breached if more than 1% of projects in a single region are affected. +- **Project:** The service is deployed on a dedicated, per-project basis; SLA applies individually to each project. + +--- + +## 2. Uptime Commitment + +Supabase will provide Actual Availability for at least ninety-nine and nine tenths percent (99.9%) of the total time in each calendar month during the Subscription Term, as measured by Supabase (the "Uptime Commitment"). Each product is individually covered by a 99.9% uptime commitment for customers with an Enterprise tier subscription. + +--- + +## 3. SLA Definition & Exclusions + +This Service Level Agreement applies only to the services and products specifically listed as covered, and is subject to the definitions, scopes, and exclusions outlined in this document. + +Supabase is not responsible for outages or service interruptions caused by factors outside of its reasonable control. The following categories of events are excluded from this SLA: + +- **Third-Party Vendors:** + + Issues attributable to external vendors or cloud providers, including AWS, Cloudflare, GCP, Azure, GitHub, or other similar providers. + +- **Integration Partners:** + + Failures or downtime related to third-party integration partners, such as Resend for email delivery, or other external service failures. + +- **General Factors Outside Our Control:** + + Events such as force majeure, internet service provider (ISP) outages, or other issues outside Supabase's reasonable control. + +- **Customer Actions or Inactions:** + + Resource limitations, misconfigurations, or failures to follow operational guidelines provided in Supabase documentation; delays in recovery due to insufficient I/O capacity; issues caused by customer's equipment or software; or account suspension or termination in accordance with Supabase Terms. + +Product-specific exclusions and further detail are provided in the following sections. + +--- + +### Product-Specific + +### Postgres + +- **SLA Scope:** Project +- **Dependencies:** None + +**Downtime Definition:** + +Any period during which the managed Postgres database for a given project is not generally accessible for permitted users to perform read or write operations. + +**Exclusions:** + +- Use of user-defined, unofficial, or unsupported Postgres extensions. +- Use of Postgres versions older than the two most recent major releases officially supported by Supabase. +- Use of outdated database extension versions; customers must be running the most recent version of database extensions for those to be included in SLA coverage. +- Customer's failure to provision sufficient CPU, memory, or storage resources for expected workloads. +- Excessively large numbers of tables or objects that significantly impact recovery times. +- Insufficient I/O capacity for the database workload as provisioned by the customer. +- Outages caused by customer-initiated schema changes or migrations that impact database integrity or operability. +- Issues caused by customer's equipment, networks, or software. +- Downtime related to suspension or termination of account per Supabase Terms. + +--- + +### Auth + +- **SLA Scope:** Project +- **Dependencies:** Postgres + +**Downtime Definition:** + +Any period during which the Auth service is unavailable for performing authentication or authorization operations for permitted users of a production system. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Inappropriately provisioned compute resources for anticipated auth workloads. +- Customer-initiated modifications to database objects, roles, or relationships in the `auth` schema. +- Outages resulting from integration with third-party providers (OAuth, OpenID, email, SMS, CAPTCHA, password strength checking, geolocation, etc.). +- Outages due to overly permissive rate-limiting configurations set by the customer. +- Email sending issues when using the default (provisional) configuration [not intended for production use](https://supabase.com/docs/guides/auth/auth-smtp). +- Issues caused by using retracted or unofficial Supabase libraries, frameworks, or proxies. +- Issues that would have been resolved by upgrading to a newer minor or patch version of official Supabase libraries or tools. + +--- + +### Data APIs (PostgREST) + +- **SLA Scope:** Project +- **Dependencies:** Postgres, Auth + +**Downtime Definition:** + +Any period during which the Data APIs (including PostgREST endpoints) are unavailable for permitted users to perform API calls against the database. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Customer misconfiguration of API permissions, security policies, or database schema. +- Use of unofficial or unsupported client libraries, API versions, or modifications. +- Failures resulting from customer's network, application, or API client errors. +- Outages that could have been resolved by upgrading to the latest supported version of Supabase Data API components. + +--- + +### Storage + +- **SLA Scope:** Regional +- **Dependencies:** Postgres, Pooler + +**Downtime Definition:** + +Any period during which the Storage service is unavailable for permitted users to upload, download, or manage files and buckets in a region. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Customer misconfiguration of storage settings or connection pools (e.g., low `max_clients` or `pool_size`). +- Use of unofficial or unsupported client libraries or modifications. +- Customer-initiated schema changes in the storage schema or cross-schema relationships impacting availability. +- Deletion of objects or buckets by the customer via the Storage API. +- Outages that could have been resolved by upgrading to the latest supported version of Supabase Storage components. + +--- + +### Pooler (PgBouncer & Supavisor) + +- **SLA Scope:** Regional +- **Dependencies:** Postgres + +**Downtime Definition:** + +Any period during which the database connection pooling layer is unavailable for permitted users, resulting in inability to connect to Postgres. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Customer's failure to provision sufficient pooler capacity (e.g., `max_clients`, `pool_size`) for actual workload. +- Custom changes to connection pooling settings outside recommended operational guidelines. +- Issues arising from customer's network or database client configuration. +- Failures resolvable by updating to a supported version of official Supabase pooling components. + +--- + +### Management API + +- **SLA Scope:** Regional +- **Dependencies:** None + +**Downtime Definition:** + +Any period during which the Supabase Management API is unavailable for permitted users to perform management, provisioning, or configuration actions in a region. + +**Exclusions:** + +- Customer loss or compromise of personal access tokens or confidential information. +- Use of the Management API in violation of Supabase fair-use policy. +- Failures that could have been resolved by upgrading to a newer version of official Supabase management tooling. + +--- + +### Branching + +- **SLA Scope:** Regional +- **Dependencies:** Postgres, Management API + +**Downtime Definition:** + +Any period during which the branching functionality (including creation, deletion, or promotion of branches) is unavailable for permitted users within a given region. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed under Dependencies. +- Failures due to unsupported schema or configurations within branches. +- Customer misuse or unsupported use of branching features, including but not limited to version pinning, manual overrides, or undocumented patterns. +- Issues resulting from user-initiated migrations that introduce data loss or instability, including those merged into production environments. +- Failures in applying configuration or updates older than 90 days. +- Failures in applying configuration or service updates (e.g., Auth settings) to branches with stale or diverged states. + +--- + +### Realtime + +- **SLA Scope:** Regional +- **Dependencies:** Postgres, Auth + +**Downtime Definition:** + +Any period during which the Realtime service is unavailable for permitted users to send and receive event notifications or subscribe to database changes in a region. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Customer's failure to provision sufficient compute resources for Realtime workloads. +- The Realtime service does not guarantee delivery of messages (at-least-once, exactly-once, or at-most-once delivery). +- Issues resulting from the use of unofficial, outdated, or unsupported client libraries or event-handling frameworks. + +--- + +### Functions + +- **SLA Scope:** Regional +- **Dependencies:** None + +**Downtime Definition:** + +Any period during which Supabase Edge Functions are unavailable to be executed, created, updated, or deleted by permitted users in a region. + +**Exclusions:** + +- Outages caused by user code errors, infinite loops, or unsupported packages. +- Failures due to integration with external dependencies or services. +- Failures resulting from downstream dependencies explicitly invoked by the user within their function logic (e.g., Postgres queries, HTTP requests to PostgREST, Auth, Storage, or third-party services). These are considered outside the scope of the Functions SLA. + +--- + +### Studio + +- **SLA Scope:** Global +- **Dependencies:** Postgres, Management API, Logging, Auth, Realtime, Functions, Storage + +**Downtime Definition:** + +Any period during which Supabase Studio is unavailable for permitted users to manage projects, view logs, or interact with platform resources globally. + +**Exclusions:** + +- Unavailability caused by upstream service outages listed in Dependencies. +- Failures caused by unsupported browser versions or extensions. + +--- + +### Logging + +- **SLA Scope:** Global +- **Dependencies:** None + +**Downtime Definition:** + +Any period during which the Logging service is unavailable for permitted users to collect, query, or retrieve log data globally. + +**Exclusions:** + +- Integration failures with external log ingestion partners or third-party tools. +- Outages resulting from customer misconfiguration of log collection or retention policies. + +--- + +## 4. Service Credits + +If the Uptime Commitment is not met during any particular calendar month, Customer is eligible for a service credit ("Service Credit") upon request. The amount will be: + +` * ` + +where Credit Percentage is derived from the table below: | Actual Availability | Credit Percentage | | -------------------------------------------------- | ----------------- | @@ -30,117 +312,55 @@ X \* Y, where X = the total fees due from Customer to Supabase for the affected | Less than 98.0% but greater than or equal to 96.0% | 20% | | Less than 96.0% | 30% | -### 3. Credit Requests and Payment +--- -To request a Service Credit, Customer must send an email to Supabase at support@supabase.io within thirty (30) days of the end of the month in which the Uptime Commitment was not met. Customer must include either its account ID or registered email address, and the previously reported dates and times that there was no Service Availability. If Supabase confirms that Customer is eligible for a Service Credit, Supabase will issue a credit to Customer's account within thirty (30) days. Service Credits are not refunds, cannot be exchanged into a cash amount, and may only be used against future billing charges. Except as set forth in Section 4 below, the Service Credits shall be Customer's sole and exclusive remedy, and Supabase's sole and exclusive liability, for any failure by Supabase to meet the Uptime Commitment. +## 5. Credit Requests and Payment -### 4. Definitions +To request a Service Credit, Customer must send an email to Supabase at support@supabase.io within thirty (30) days of the end of the month in which the Uptime Commitment was not met. The request must include: -All capitalized words used but not defined in this Service Level Agreement have the meaning set forth in the Agreement. +(a) the affected organization, service(s), region(s), and project(s); -#### 4.1 Scheduled Availability +(b) the specific dates and times (in 5-minute intervals) during which the service was unavailable; and -"Scheduled Availability" means the time, in minutes, that the applicable Services are generally accessible and available to Customer's Permitted Users. +(c) supporting logs or monitoring data showing failed requests or clear unavailability. -#### 4.2 Unscheduled Downtime +Supabase reserves the right to validate any claim using its own internal monitoring systems and may deny claims that are unsupported, inaccurate, or inconsistent with internal metrics. -"Unscheduled Downtime" means the time, in minutes, that the applicable Services are not generally accessible and available to Customer's Permitted Users, excluding inaccessibility or unavailability due to Customer's or Permitted Users' acts or omissions, force majeure events, scheduled maintenance disclosed with at least 24 hours' notice by email, hacking or virus attacks, reasonable emergency maintenance or other product specific exclusions listed under SLA Exclusions. +If Supabase confirms that Customer is eligible for a Service Credit, Supabase will issue a credit to Customer's account within thirty (30) days. Service Credits are not refunds, cannot be exchanged into cash, and may only be applied to future billing charges. Except as set forth in Section 6 below, the Service Credits constitute Customer's sole and exclusive remedy, and Supabase's sole and exclusive liability, for any failure to meet the Uptime Commitment. -#### 4.3 Actual Availability +Notwithstanding anything to the contrary in this SLA, the total amount of Service Credits issued to Customer under this SLA shall not exceed twenty percent (20%) of the total fees paid by Customer for the affected services under the applicable Order Form during the preceding twelve (12) month period. Any Service Credits calculated in excess of this cap will be forfeited and shall have no cash or credit value. -"Actual Availability" means Scheduled Availability less Unscheduled Downtime. +--- -#### 4.4 Production +## 7. Support -"Production" is defined as a system serving live customer-facing or business systems with existing deployed and functional features. +Supabase provides Support Service Level Agreements for Team and Enterprise customers. -"Development", "Staging", "uat", "pre-production" or new feature implementation even if in a production environment, are not considered Production. - -### SLA Exclusions - -#### General Service Exclusions - -- (i) Caused by factors outside of our reasonable control, including but not limited to any force majeure event or Internet access, ISP provider issues, and/or related problems beyond the demarcation point of Supabase. For the avoidance of doubt, this list is not exhaustive, and we will endeavor to inform you if the issue is beyond a factor that we can reasonably control. -- (ii) That result from any voluntary actions or inactions from you. -- (iii) That result from instance class CPU and memory resource limitations. -- (iv) That result from you not following the basic operational guidelines described in our [Docs](https://supabase.com/docs) (e.g., overloading a database instance to the point it is inoperable, creating an excessively large number of tables that significantly increase the recovery time, etc.). -- (vi) That result in a long recovery time due to insufficient IO capacity for your database workload. -- (vii) That result from your equipment, software, or other technology. -- (viii) Arising from our suspension and termination of your right to use Supabase in accordance with our Terms. - -#### Database SLA Exclusions - -- (i) Unofficially supported Postgres extensions are excluded from our SLA. -- (ii) Our SLA only applies to the 2 most recent major releases of Postgres that Supabase has officially supported. Older versions are excluded. - -#### Auth SLA Exclusions - -- (i) Inappropriately provisioned compute resources related to your project for the expected load on your database and Auth servers, especially in the case of verifying or storing password-based credentials in the sign-in, sign-up, password change, password reset, and other such flows and APIs. -- (ii) Any accidental or intentional modifications, additions, or deletions of database objects (tables, views, triggers, roles, functions, indexes, constraints, permissions, grants, and similar) in the `auth` schema or foreign key relationships in any schema to non-primary key columns within the `auth` schema that may cause total or partial outage, including outages caused in the future but related to such modifications, additions, or deletions in the past when arising from schema migrations initiated by Supabase. -- (iii) Total or partial outages caused by third-party services as configured by default or by choice, including and not limited to: OAuth, OpenID Connect, Security Assertion Markup Language servers and related APIs and protocols; Email (via SMTP or otherwise) and SMS sending servers and related APIs; CAPTCHA services and APIs; Password Strength Checking services such as HaveIBeenPwned.org; IP geo-location; and other such services. -- (iv) Outages caused by overly permissive rate-limiting configurations. -- (v) Outages caused by email sending issues when using the provisional (default) email sending configuration included with any Supabase project with the intention of getting started but not for production use. -- (vi) Outages or issues caused by retracted versions of official Supabase libraries, frameworks, software packages (CLI, Docker image, executable, Infrastructure-as-Code plugins, etc.) or APIs, including urgent retractions due to identified security vulnerabilities. -- (vii) Outages or issues caused by unofficial Supabase client libraries, frameworks, or API proxies, including security vulnerabilities, even when those libraries internally use official Supabase libraries. -- (viii) Outages or issues that could have been resolved by upgrading to a higher minor or patch version of an official Supabase client library, framework, or software package (CLI, Docker image, executable, Infrastructure-as-Code plugin, etc.). - -#### Storage SLA Exclusions - -- (i) Inappropriately provisioned compute resources related to your project for the expected load on your database. -- (ii) Low amounts of pgBouncer or Supervisor connection pool configuration, such as max_clients and pool_size, for high amounts of traffic. -- (iii) Any accidental or intentional modifications, additions, or deletions of database objects (tables, views, triggers, roles, functions, indexes, constraints, permissions, grants, and similar) in the `storage` schema, or foreign key relationships in any schema to non-primary key columns within the `storage` schema, that may cause total or partial outage, including outages caused in the future but related to such modifications, additions, or deletions in the past when arising from schema migrations initiated by Supabase. -- (iv) Outages or issues that could have been resolved by upgrading to a higher minor or patch version of an official Supabase client library, framework, or software package (CLI, Docker image, executable, Infrastructure-as-Code plugin, etc.). -- (v) Outages caused by accidental deletions of objects or buckets via the Storage API by the user. - -#### Management API SLA Exclusions - -- (i) Until the management API reaches General Availability (GA), we cannot provide an uptime commitment. -- (ii) Personal access tokens getting lost or leaked due to improper maintenance or improper use of confidential information. -- (iii) Violation of Supabase fair-use policy. - -#### Realtime SLA Exclusions - -- (i) Inappropriately provisioned compute resources related to your project for the expected load on your database. -- (ii) The Realtime service does not guarantee messaging deliverability. If you need at-least-once, exactly-once, or at-most-once guarantees, you will need to build this functionality on top of the realtime service. - -#### Edge Functions - -- (i) **Uptime Commitment Exclusion:** Until Edge Functions reach General Availability (GA), we cannot provide an uptime commitment. Customers acknowledge that the service may experience downtime, interruptions, or performance issues, and no specific availability percentage is promised at this time. - -## Support - -Supabase provides Support Service Level Agreements for our Team and Enterprise customers. - -### 1. Urgent +### Urgent **Critical Issue** -Defect resulting in full or partial system outage or a condition that makes Supabase unusable -or unavailable in production for all of Customer's Users. +Defect resulting in full or partial system outage or a condition that makes Supabase unusable or unavailable in production for all of Customer's Users. -### 2. High +### High **Significant Business Disruption** -Issue resulting in a situation meaning major functionality is impacted and -significant performance degradation is experienced. Issue impacts significant proportion of user base and / or major -Supabase functionality. +Issue resulting in a situation meaning major functionality is impacted and significant performance degradation is experienced. Issue impacts significant proportion of user base and / or major Supabase functionality. -### 3. Normal +### Normal **Minor Feature or Functional Issue / General Question** -Issue results in a component of Supabase not -performing as expected or documented. An inquiry by a Customer representative regarding a general technical issue -or general question. +Issue results in a component of Supabase not performing as expected or documented. An inquiry by a Customer representative regarding a general technical issue or general question. -### 4. Low +### Low **Minor Issue / Feature Request** An Information request about Supabase or feature request. -## Target initial response times +### Severity and Target Initial Response Times | Severity Level | Team | Enterprise Standard | Priority Plus | | -------------- | ------------------------------------ | ------------------------------------- | ------------------------ | @@ -149,7 +369,7 @@ An Information request about Supabase or feature request. | 3. Normal | 1 business day
Monday - Friday | 1 business day
Monday - Friday | 12 hours
24/7 x 365 | | 4. Low | 2 business days
Monday - Friday | 2 business days
Monday - Friday | 24 hours
24/7 x 365 | -Business hours are from 6am to 6pm (local time), except where otherwise stated. +Business hours are 6am to 6pm local time unless stated otherwise. From bd95e9a1b9a248fad0dfe0a3268319c988019ee5 Mon Sep 17 00:00:00 2001 From: Terry Sutton Date: Fri, 20 Jun 2025 13:12:47 -0230 Subject: [PATCH 07/84] Chore/link to usage (#36404) * Add link to usage page * Add usage button --- .../BillingSettings/Restriction.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index 02d6159a65d..eb2cb8e44be 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -7,11 +7,15 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import { CriticalIcon, WarningIcon } from 'ui' import { PricingMetric } from 'data/analytics/org-daily-stats-query' +import { usePathname } from 'next/navigation' export const Restriction = () => { const org = useSelectedOrganization() const { data: usage, isSuccess: isSuccessOrgUsage } = useOrgUsageQuery({ orgSlug: org?.slug }) + const pathname = usePathname() + const isUsagePage = pathname?.endsWith('/usage') + const hasExceededAnyLimits = Boolean( usage?.usages.find( (metric) => @@ -69,6 +73,11 @@ export const Restriction = () => { {org.plan.id === 'free' ? 'Upgrade plan' : 'Change spend cap'} + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + + {!isUsagePage && ( + + )} + {!isUsagePage && ( + + )}
-
+
From 7960046c96b3c3b2b3ecc5efeda836fdc150f421 Mon Sep 17 00:00:00 2001 From: Lakshan Perera Date: Sun, 22 Jun 2025 18:11:09 +1000 Subject: [PATCH 10/84] fix: attempt to stringify request body before parsing (#36586) --- .../Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx index d7f58f0d76e..11cd1dd9e04 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet.tsx @@ -159,7 +159,7 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester // Validate that the body is valid JSON try { - JSON.parse(values.body) + JSON.parse(JSON.stringify(values.body)) } catch (e) { form.setError('body', { message: 'Must be a valid JSON string' }) return From 4945ba186109f4bfb7e03368de59f0590f667cf9 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Sun, 22 Jun 2025 23:36:07 +0200 Subject: [PATCH 11/84] feat: add asymmetric JWT UI (#36519) * original files from previous PR * get rid of router in jwt-secrets to make it show up * rename jwt-secrets page to jwt-signing-keys * add jwt-signing-keys query * add legacy jwt signing keys query * wire-up JwtSecretKeysTable with queries * add jwt signing keys page to settings menu * deduplicate labels, descriptions, etc * add create, update jwt-signing-key mutations * update types * remove unused components, to be refactored later * make everything into a mostly working state * legacy migration added * put jwt keys page like api keys * fully migrate legacy jwt secret page * fix prettier * fix typecheck ts-expect-error * rm unneeded file * Fix compile errors. * Rename the files and move them to the same folder. * Merge the two constant files. * Fix the imports. * Fix a bug in the API keys page when opening it in a new tab. * Change the page to be at /signing-keys * Fix some minor types. * Break apart some of the components in the signing keys UI. * Use a feature banner for the initial action. * Make a create key dialog and move functionality there. * Fix some cosmetic issues. * Minor cosmetic fixes. * Remove extra keys in RQ cache. * Add a missing link * Add a banner when the feature flag is false. * Minor type fix. * more tiny type fix * fix error on create standby key * add alert to prevent revoking legacy jwt secret without disabling legacy api keys first --------- Co-authored-by: Ivan Vasilov --- .../APIKeys/ApiKeysIllustrations.tsx | 10 +- .../JwtSecrets/algorithm-details.ts | 97 ++ .../JwtSecrets/algorithm-hover-card.tsx | 24 + .../interfaces/JwtSecrets/illustrations.tsx | 384 ++++++++ .../jwt-secret-keys-table/action-panel.tsx | 37 + .../create-key-dialog.tsx | 90 ++ .../jwt-secret-keys-table/index.tsx | 861 ++++++++++++++++++ .../jwt-secret-keys-table/signing-key-row.tsx | 147 +++ .../jwt-settings.tsx} | 5 +- .../jwt.constants.ts} | 15 + .../JwtSecrets/signing-keys-coming-soon.tsx | 28 + .../JwtSecrets/start-using-keys-banner.tsx | 28 + .../NewPaymentMethodElement.tsx | 12 +- .../interfaces/Settings/API/ApiKeysMoved.tsx | 18 +- .../interfaces/Settings/API/ServiceList.tsx | 41 - .../layouts/JWTKeys/JWTKeysLayout.tsx | 36 + .../SettingsMenu.utils.tsx | 7 + apps/studio/components/ui/InfoPill.tsx | 61 ++ .../ProjectSettings/ToggleLegacyApiKeys.tsx | 10 +- .../jwt-signing-key-create-mutation.ts | 64 ++ .../jwt-signing-key-delete-mutation.ts | 58 ++ .../jwt-signing-key-update-mutation.ts | 62 ++ .../jwt-signing-keys-query.ts | 43 + apps/studio/data/jwt-signing-keys/keys.ts | 4 + .../legacy-jwt-signing-key-create-mutation.ts | 64 ++ .../legacy-jwt-signing-key-query.ts | 42 + .../organization-project-claim-mutation.ts | 1 - .../organization-project-claim-query.ts | 1 - .../project/[ref]/settings/jwt/index.tsx | 58 ++ .../[ref]/settings/jwt/signing-keys.tsx | 36 + packages/api-types/types/api.d.ts | 583 +++++++++++- packages/api-types/types/platform.d.ts | 10 +- 32 files changed, 2824 insertions(+), 113 deletions(-) create mode 100644 apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts create mode 100644 apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/illustrations.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx rename apps/studio/components/interfaces/{Settings/API/JWTSettings.tsx => JwtSecrets/jwt-settings.tsx} (99%) rename apps/studio/components/interfaces/{Settings/API/API.constants.ts => JwtSecrets/jwt.constants.ts} (66%) create mode 100644 apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx create mode 100644 apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx create mode 100644 apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx create mode 100644 apps/studio/components/ui/InfoPill.tsx create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts create mode 100644 apps/studio/data/jwt-signing-keys/keys.ts create mode 100644 apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts create mode 100644 apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts create mode 100644 apps/studio/pages/project/[ref]/settings/jwt/index.tsx create mode 100644 apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx diff --git a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx index 8d3e2e43aa6..c60ecf73031 100644 --- a/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx +++ b/apps/studio/components/interfaces/APIKeys/ApiKeysIllustrations.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Github, Mail } from 'lucide-react' +import { ExternalLink, Github } from 'lucide-react' import { LOCAL_STORAGE_KEYS } from 'common' import { FeatureBanner } from 'components/ui/FeatureBanner' @@ -119,7 +119,13 @@ export const ApiKeysComingSoonBanner = () => {

diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts new file mode 100644 index 00000000000..66b1f589697 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts @@ -0,0 +1,97 @@ +export interface AlgorithmDetail { + name: string + description: string + pros: string[] + cons: string[] + label: string + shortDescription: string + links: { url: string; label: string }[] +} + +export const algorithmDetails: Record = { + HS256: { + label: 'HS256 (Symmetric)', + name: 'HMAC with SHA-256', + description: 'Symmetric algorithm using a shared secret key', + pros: [ + 'Fast and simple to use', + 'Requires less computational power', + 'Suitable for server-to-server communication', + ], + cons: [ + 'Requires secure key exchange', + "Not suitable when the verifier shouldn't be able to sign tokens", + 'Key needs to be kept secret on both sides', + ], + shortDescription: 'HMAC with SHA-256: Fast, simple, requires secure key exchange', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.2', + label: 'RFC 7518 Specification', + }, + ], + }, + RS256: { + label: 'RSA 2048', + name: 'RSA with SHA-256', + description: 'Asymmetric algorithm using a public/private key pair', + pros: [ + 'Allows public key to be distributed freely', + 'Private key can be kept secret on the signing side', + "Suitable for scenarios where the verifier shouldn't be able to sign tokens", + ], + cons: [ + 'Slower than HS256', + 'Requires more computational power', + 'Keys are larger than ECDSA keys', + ], + shortDescription: 'RSA with SHA-256: Allows public key distribution, slower', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.3', + label: 'RFC 7518 Specification', + }, + ], + }, + ES256: { + label: 'ECC (P-256)', + name: 'ECDSA with SHA-256', + description: 'Asymmetric algorithm using elliptic curve cryptography', + pros: [ + 'Faster than RSA', + 'Smaller key and signature sizes compared to RSA', + 'Provides forward secrecy', + ], + cons: [ + 'Less widely supported than RSA', + 'More complex to implement correctly', + 'Requires careful implementation to avoid timing attacks', + ], + shortDescription: 'ECDSA with SHA-256: Compact keys, fast, modern alternative to RSA', + links: [ + { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' }, + { + url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.4', + label: 'RFC 7518 Specification', + }, + ], + }, +} + +export const algorithmLabels = Object.keys(algorithmDetails).reduce( + (a, i) => { + a[i] = algorithmDetails[i].label + return a + }, + {} as { [name: keyof typeof algorithmDetails]: string } +) + +export const algorithmDescriptions = Object.keys(algorithmDetails).reduce( + (a, i) => { + a[i] = algorithmDetails[i].shortDescription + return a + }, + {} as { [name: keyof typeof algorithmDetails]: string } +) diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx new file mode 100644 index 00000000000..79d9cf31777 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx @@ -0,0 +1,24 @@ +import { GlobeLock } from 'lucide-react' +import React from 'react' + +import { InfoPill } from '../../ui/InfoPill' +import { AlgorithmDetail, algorithmDetails } from './algorithm-details' + +interface AlgorithmHoverCardProps { + algorithm: keyof typeof algorithmDetails + legacy?: boolean +} + +export const AlgorithmHoverCard: React.FC = ({ algorithm, legacy }) => { + const details: AlgorithmDetail = algorithmDetails[algorithm] + + return ( + } + title={details.name} + description={details.description} + links={details.links} + /> + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx new file mode 100644 index 00000000000..f261c0bdc5f --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx @@ -0,0 +1,384 @@ +export const StandbyKeyIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + +) + +export const WhyRotateKeysIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) + +export const WhyUseStandbyKeysIllustration = () => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +) diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx new file mode 100644 index 00000000000..cfebd69aa98 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx @@ -0,0 +1,37 @@ +import { ComponentProps, forwardRef } from 'react' +import { Button, Card, CardDescription, CardHeader, CardTitle } from 'ui' + +interface ActionPanelProps extends Omit, 'onClick' | 'type'> { + title: string + description: string + buttonLabel: ComponentProps['children'] + onClick: ComponentProps['onClick'] + loading: ComponentProps['loading'] + icon?: ComponentProps['icon'] + type?: ComponentProps['type'] +} + +export const ActionPanel = forwardRef( + ({ title, description, buttonLabel, onClick, loading, icon, type, ...props }, ref) => { + return ( + + +
+ {title} + {description} +
+
+ +
+
+
+ ) + } +) +ActionPanel.displayName = 'ActionPanel' diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx new file mode 100644 index 00000000000..e26c5935c13 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx @@ -0,0 +1,90 @@ +import { useJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/jwt-signing-key-create-mutation' +import { JWTAlgorithm } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { useState } from 'react' +import { toast } from 'sonner' +import { + Button, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Label_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, +} from 'ui' +import { algorithmDescriptions } from '../algorithm-details' + +export const CreateKeyDialog = ({ + projectRef, + onClose, +}: { + projectRef: string + onClose: () => void +}) => { + const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256') + + const { mutate, isLoading: isLoadingMutation } = useJWTSigningKeyCreateMutation({ + onSuccess: () => { + onClose() + }, + onError: (error) => { + toast.error(`Failed to add new standby key: ${error.message}`) + }, + }) + + const handleAddNewStandbyKey = async () => { + mutate({ + projectRef: projectRef!, + algorithm: newKeyAlgorithm, + status: 'standby', + }) + } + + return ( + <> + + Create a new Standby Key + + + +
+ Choose the key type to use: + setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + RS256 (RSA) + + ES256 (ECC) + + + EdDSA (Ed25519) + + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+
+ + + + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx new file mode 100644 index 00000000000..222edf7b24a --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx @@ -0,0 +1,861 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { + ArrowRight, + CircleArrowUp, + Eye, + FileKey, + Key, + MoreVertical, + RotateCw, + ShieldOff, + Timer, + Trash2, +} from 'lucide-react' +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useJWTSigningKeyDeleteMutation } from 'data/jwt-signing-keys/jwt-signing-key-delete-mutation' +import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signing-key-update-mutation' +import { + JWTAlgorithm, + JWTSigningKey, + useJWTSigningKeysQuery, +} from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { useLegacyJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation' +import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query' +import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' +import { useFlag } from 'hooks/ui/useFlag' +import { + Badge, + Button, + Card, + CardContent, + cn, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from 'ui' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' +import { algorithmDescriptions, algorithmLabels } from '../algorithm-details' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' +import { SigningKeysComingSoonBanner } from '../signing-keys-coming-soon' +import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner' +import { ActionPanel } from './action-panel' +import { CreateKeyDialog } from './create-key-dialog' +import { SigningKeyRow } from './signing-key-row' + +const MotionTableRow = motion(TableRow) + +export default function JWTSecretKeysTable() { + const { ref: projectRef } = useParams() + const newJwtSecrets = useFlag('newJwtSecrets') + + const [selectedKey, setSelectedKey] = useState(null) + const [shownDialog, setShownDialog] = useState< + 'legacy' | 'create' | 'rotate' | 'confirm-rotate' | 'key-details' | 'revoke' | 'delete' | null + >(null) + + const resetDialog = () => { + setSelectedKey(null) + setShownDialog(null) + } + + const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256') + + const { data: signingKeys, isLoading: isLoadingSigningKeys } = useJWTSigningKeysQuery({ + projectRef, + }) + const { data: legacyKey, isLoading: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery({ + projectRef, + }) + const { data: legacyAPIKeysStatus, isLoading: isLoadingLegacyAPIKeysStatus } = + useLegacyAPIKeysStatusQuery({ projectRef }) + + const legacyMutation = useLegacyJWTSigningKeyCreateMutation() + + const updateMutation = useJWTSigningKeyUpdateMutation() + const deleteMutation = useJWTSigningKeyDeleteMutation() + + const isLoadingMutation = + updateMutation.isLoading || deleteMutation.isLoading || legacyMutation.isLoading + const isLoading = isLoadingSigningKeys || isLoadingLegacyKey || isLoadingLegacyAPIKeysStatus + + const sortedKeys = useMemo(() => { + if (!signingKeys || !Array.isArray(signingKeys.keys)) return [] + + return signingKeys.keys.sort((a: JWTSigningKey, b: JWTSigningKey) => { + const order: Record = { + standby: 0, + in_use: 1, + previously_used: 2, + revoked: 3, + } + return ( + order[a.status] - order[b.status] || + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + }) + }, [signingKeys]) + + const standbyKey = useMemo(() => sortedKeys.find((key) => key.status === 'standby'), [sortedKeys]) + const inUseKey = useMemo(() => sortedKeys.find((key) => key.status === 'in_use'), [sortedKeys]) + const previouslyUsedKeys = useMemo( + () => sortedKeys.filter((key) => key.status === 'previously_used'), + [sortedKeys] + ) + const revokedKeys = useMemo( + () => sortedKeys.filter((key) => key.status === 'revoked'), + [sortedKeys] + ) + + const handleLegacyMigration = async () => { + try { + await legacyMutation.mutateAsync({ + projectRef: projectRef!, + }) + } catch (error) { + console.error('Failed to migrate legacy JWT secret to new JWT signing keys', error) + } + } + + const handlePreviouslyUsedKey = async (keyId: string) => { + if (!projectRef) { + return + } + try { + await updateMutation.mutateAsync({ projectRef, keyId, status: 'previously_used' }) + resetDialog() + } catch (error) { + console.error('Failed to move key to previously used', error) + } + } + + const handleStandbyKey = async (keyId: string) => { + try { + await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'standby' }) + resetDialog() + } catch (error) { + console.error('Failed to move key to standby', error) + } + } + + const handleRevokeKey = async (keyId: string) => { + try { + await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'revoked' }) + resetDialog() + } catch (error) { + console.error('Failed to revoke key', error) + } + } + + const handleDeleteKey = async (keyId: string) => { + try { + await deleteMutation.mutateAsync({ projectRef: projectRef!, keyId }) + resetDialog() + } catch (error) { + console.error('Failed to delete key', error) + } + } + + const handleRotateKey = async () => { + try { + await updateMutation.mutateAsync({ + projectRef: projectRef!, + keyId: standbyKey!.id, + status: 'in_use', + }) + resetDialog() + } catch (error) { + console.error('Failed to rotate key', error) + } + } + + if (isLoading) { + return + } + + if (!newJwtSecrets) { + return + } + + return ( + <> +
+ {legacyKey ? ( + <> + {standbyKey && ( + setShownDialog('rotate')} + loading={isLoadingMutation} + icon={} + type="warning" + /> + )} + + {!standbyKey && ( + setShownDialog('create')} + loading={isLoadingMutation} + type="primary" + icon={} + /> + )} + + ) : ( + setShownDialog('legacy')} + isLoading={isLoadingMutation} + /> + )} +
+ + {sortedKeys.length > 0 && ( + <> +
+ + +
+ + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {standbyKey && ( + + )} + {inUseKey && ( + + )} + + +
+ + +
+ +
+
+

Previously used keys

+

+ These JWT signing keys are still used to verify JWTs already + issued. Revoke them once all JWTs have expired. +

+
+ + + {previouslyUsedKeys.length > 0 ? ( + + + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {previouslyUsedKeys.map((key) => ( + + +
+ + + {statusLabels[key.status]} + +
+
+ +
+ + {key.id} + + +
+
+ + + + + + +
+ ) : ( +
+ No previously used keys +
+ )} +
+
+
+ + )} + + {revokedKeys.length > 0 && ( +
+
+

Revoked keys

+

+ These keys are no longer used to verify or sign JWTs. +

+
+ + + + + + + Status + + + Key ID + + + Type + + + Actions + + + + + + {revokedKeys.map((key) => ( + + +
+ + + {statusLabels[key.status]} + +
+
+ +
+ + {key.id} + + +
+
+ + + + + + +
+
+
+
+ )} + + {/* TODO(hf): For launch
+

Resources

+ +
+ +
+
+ +
+
+

Why Rotate keys?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+ + +
+
+ +
+
+

Why use a Standby key?

+

+ Create Standby keys ahead of time which can then be promoted to 'In use' at any + time. +

+ +
+
+
+
+
*/} + + + + + Start using new JWT signing keys + + + +

+ Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to + use an asymmetric JWT signing key you first have to migrate it to the new system. This + change does not cause any downtime on your project. +

+
+ + + +
+
+ + + + + + + + + + + Rotate Key + + + + + {standbyKey ? ( + <> + The standby key ({algorithmLabels[standbyKey.algorithm]}) will be promoted to 'In + use'. This will: +
    +
  • Change the current standby key to 'In use'
  • +
  • Move the current 'In use' key to 'Previously used'
  • +
  • Move any 'Previously used' key to 'Revoked'
  • +
+ + ) : ( + <> + Since there is no standby key, you need to choose an algorithm for the new key: +
+ setNewKeyAlgorithm(value)} + > + + + + + HS256 (Symmetric) + ES256 (ECC) + RS256 (RSA) + EdDSA (Ed25519) + + +

+ {algorithmDescriptions[newKeyAlgorithm]} +

+
+ + )} +
+
+ + + +
+
+ + + + + Confirm key rotation + + Review the key rotation process below. Ensure your application's components have + already picked up and are trusting your standby key to avoid downtime. + + + + +
+ {standbyKey ? ( +
+ + + STANDBY KEY + + {algorithmLabels[standbyKey!.algorithm]} + + + + + + CURRENTLY USED + +
+ ) : ( +
+ + + New Key + + {algorithmLabels[newKeyAlgorithm]} + + + + + + CURRENTLY USED + +
+ )} + +
+ + + CURRENTLY USED + + {inUseKey?.algorithm && algorithmLabels[inUseKey.algorithm]} + + + + + + PREVIOUS KEY + +
+
+
+ + + + +
+
+ + + + + Key Details + + + + {selectedKey && ( + <> +
+
+ +

Public Key (PEM format)

+
+
+                    {typeof selectedKey.public_jwk === 'string'
+                      ? selectedKey.public_jwk
+                      : JSON.stringify(selectedKey.public_jwk ?? '', null, 2)}
+                  
+
+
+

JWKS URL

+
+                    {`${window.location.origin}/jwt/v1/jwks.json`}
+                  
+
+ + )} +
+
+
+ + {selectedKey && + selectedKey.status === 'previously_used' && + (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && ( + handleRevokeKey(selectedKey.id)} + onCancel={resetDialog} + title={`Revoke ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, revoke this signing key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will no longer be trusted!', + description: + 'By revoking a signing key, all applications trusting it will no longer do so. If there are JWTs (access tokens) that are valid at the time of revocation, they will no longer be trusted, causing users with such JWTs to be signed out.', + }} + /> + )} + + {selectedKey && + selectedKey.status === 'previously_used' && + legacyKey?.id === selectedKey.id && + (legacyAPIKeysStatus?.enabled ?? true) && ( + resetDialog()}> + + + Disable JWT-based legacy API keys first + + + It's not possible to revoke the legacy JWT secret unless you have already disabled + JWT-based legacy API keys. This is because revoking the JWT secret invalidates the + JWT-based legacy API keys. + + + OK + + + + )} + + {selectedKey && selectedKey.status === 'revoked' && ( + handleDeleteKey(selectedKey.id)} + onCancel={resetDialog} + title={`Permanently delete ${selectedKey.id}`} + confirmString={selectedKey.id} + confirmLabel="Yes, permanently delete this key" + confirmPlaceholder="Type the ID of the key to confirm" + variant="destructive" + alert={{ + title: 'This key will be permanently deleted.', + description: + 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.', + }} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx new file mode 100644 index 00000000000..e8395ed57b0 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx @@ -0,0 +1,147 @@ +import { motion } from 'framer-motion' +import { CircleArrowDown, Eye, Key, MoreVertical, ShieldOff, Timer } from 'lucide-react' + +import { components } from 'api-types' +import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query' +import { + Badge, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import { AlgorithmHoverCard } from '../algorithm-hover-card' +import { statusColors, statusLabels } from '../jwt.constants' + +interface SigningKeyRowProps { + signingKey: components['schemas']['SigningKeyResponse'] + setSelectedKey: (key: JWTSigningKey | null) => void + setShownDialog: ( + dialog: + | 'legacy' + | 'create' + | 'rotate' + | 'confirm-rotate' + | 'key-details' + | 'revoke' + | 'delete' + | null + ) => void + handlePreviouslyUsedKey: (keyId: string) => void + legacyKey?: components['schemas']['SigningKeyResponse'] | null +} + +const MotionTableRow = motion(TableRow) + +export const SigningKeyRow = ({ + signingKey, + setSelectedKey, + setShownDialog, + handlePreviouslyUsedKey, + legacyKey, +}: SigningKeyRowProps) => ( + + +
+ + {signingKey.status === 'standby' ? : } + {statusLabels[signingKey.status]} + +
+
+ +
+ + {signingKey.id} + + +
+
+ + + + + + + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx new file mode 100644 index 00000000000..d0460153ee9 --- /dev/null +++ b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx @@ -0,0 +1,28 @@ +import { FeatureBanner } from 'components/ui/FeatureBanner' +import { Import } from 'lucide-react' +import { Button } from 'ui' + +export const StartUsingJwtSigningKeysBanner = ({ + onClick, + isLoading, +}: { + onClick: () => void + isLoading: boolean +}) => { + return ( + +
+

Start using JWT signing keys

+

+ Right now your project is using the legacy JWT secret. To start taking advantage of the + new JWT signing keys, migrate your project's secret to the new set up. +

+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx index 5123b3bca2c..3ef69b313e2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx @@ -16,7 +16,11 @@ const NewPaymentMethodElement = forwardRef( pending_subscription_flow_enabled, email, readOnly, - }: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean }, + }: { + pending_subscription_flow_enabled: boolean + email?: string | null | undefined + readOnly: boolean + }, ref ) => { const stripe = useStripe() @@ -59,7 +63,11 @@ const NewPaymentMethodElement = forwardRef( createPaymentMethod, })) - return + return ( + + ) } ) diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx index 869b3a48efe..bf00d51029c 100644 --- a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx +++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx @@ -19,6 +19,7 @@ export const ApiKeysMoved = () => { { name: 'Infrastructure', key: 'infrastructure', url: '#' }, { name: 'Integrations', key: 'integrations', url: '#' }, { name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' }, + { name: 'JWT Keys', key: 'jwt-keys', url: '#', label: 'NEW' }, { name: 'Add Ons', key: 'addons', url: '#' }, { name: 'Vault', @@ -37,14 +38,19 @@ export const ApiKeysMoved = () => { return (
-
-

API keys have moved

+
+

API keys and JWT settings have moved

- They can now be found in the new API Keys page + They can now be found in their own respective pages.

- - - +
+ + + + + + +
{/* Menu illustration using the actual ProductMenu component */} diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 56b402af109..0b1ed99ab3a 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -1,4 +1,3 @@ -import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' import { useQueryClient } from '@tanstack/react-query' import { AlertCircle } from 'lucide-react' import { useEffect, useRef } from 'react' @@ -9,7 +8,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import DatabaseSelector from 'components/ui/DatabaseSelector' import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' import { configKeys } from 'data/config/keys' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' @@ -17,9 +15,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { PROJECT_STATUS } from 'lib/constants' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Badge, Input } from 'ui' -import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants' import { ApiKeysMoved } from './ApiKeysMoved' -import JWTSettings from './JWTSettings' import { PostgrestConfig } from './PostgrestConfig' const ServiceList = () => { @@ -32,15 +28,6 @@ const ServiceList = () => { const { data: databases, isError } = useReadReplicasQuery({ projectRef }) const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) - const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) - const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const jwtSecretUpdateError = data?.jwtSecretUpdateError - - const previousJwtSecretUpdateStatus = useRef() - const { Failed, Updated, Updating } = JwtSecretUpdateStatus - const jwtSecretUpdateErrorMessage = - JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] - // Get the API service const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) @@ -54,30 +41,6 @@ const ServiceList = () => { ? loadBalancers?.[0].endpoint ?? '' : selectedDatabase?.restUrl - useEffect(() => { - if (previousJwtSecretUpdateStatus.current === Updating) { - switch (jwtSecretUpdateStatus) { - case Updated: - client.invalidateQueries(configKeys.api(projectRef)) - client.invalidateQueries(configKeys.settings(projectRef)) - client.invalidateQueries(configKeys.postgrest(projectRef)) - toast.success('Successfully updated JWT secret') - break - case Failed: - toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) - break - } - } - - previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus - }, [jwtSecretUpdateStatus]) - - useEffect(() => { - if (source !== undefined) { - state.setSelectedDatabaseId('load-balancer') - } - }, [source]) - return (
{isLoading ? ( @@ -147,10 +110,6 @@ const ServiceList = () => { -
- -
-
diff --git a/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx b/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx new file mode 100644 index 00000000000..9b3825af558 --- /dev/null +++ b/apps/studio/components/layouts/JWTKeys/JWTKeysLayout.tsx @@ -0,0 +1,36 @@ +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer } from 'components/layouts/Scaffold' +import { PropsWithChildren } from 'react' + +import { useParams } from 'common' + +const JWTKeysLayout = ({ children }: PropsWithChildren) => { + const { ref: projectRef } = useParams() + + const navigationItems = [ + { + label: 'JWT Secret', + href: `/project/${projectRef}/settings/jwt`, + id: 'legacy-jwt-keys', + }, + { + label: 'JWT Signing Keys (Coming Soon)', + href: `/project/${projectRef}/settings/jwt/signing-keys`, + id: 'signing-keys', + }, + ] + + return ( + + + {children} + + + ) +} + +export default JWTKeysLayout diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 6f847292c24..f7f7138e956 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -64,6 +64,13 @@ export const generateSettingsMenu = ( items: [], label: 'NEW', }, + { + name: 'JWT Keys', + key: 'jwt', + url: `/project/${ref}/settings/jwt`, + items: [], + label: 'NEW', + }, ] : []), { diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx new file mode 100644 index 00000000000..be2b35973bc --- /dev/null +++ b/apps/studio/components/ui/InfoPill.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from 'ui/src/components/shadcn/ui/hover-card' +import { Badge } from 'ui/src/components/shadcn/ui/badge' +import { ExternalLink } from 'lucide-react' + +interface Link { + url: string + label: string +} + +interface InfoPillProps { + label: string + icon: React.ReactNode + title: string + description: string + links?: Link[] +} + +export const InfoPill: React.FC = ({ label, icon, title, description, links }) => { + return ( + + + + {icon} {label} + + + +
+ {React.cloneElement(icon as React.ReactElement, { + className: 'w-5 h-5', + strokeWidth: 1.5, + })} +
+
+

{title}

+

{description}

+ {links && links.length > 0 && ( +
+ {links.map((link, index) => ( + + + {link.label} + + ))} +
+ )} +
+
+
+ ) +} diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx index 0c17e4a676f..9a8e4f18563 100644 --- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx +++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { toast } from 'sonner' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation' import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query' @@ -11,11 +11,11 @@ import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIc import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' export const ToggleLegacyApiKeysPanel = () => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() const [isConfirmOpen, setIsConfirmOpen] = useState(false) const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } = - useLegacyAPIKeysStatusQuery({ projectRef: project!.ref }) + useLegacyAPIKeysStatusQuery({ projectRef }) const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } = useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*') @@ -77,7 +77,7 @@ const ToggleApiKeysModal = ({ onClose: () => void legacyAPIKeysStatusData: { enabled: boolean } }) => { - const { project } = useProjectContext() + const { ref: projectRef } = useParams() const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } = useToggleLegacyAPIKeysMutation() @@ -86,7 +86,7 @@ const ToggleApiKeysModal = ({ const enabled = !legacyAPIKeysStatusData?.enabled toggleLegacyAPIKey( - { projectRef: project!.ref, enabled }, + { projectRef, enabled }, { onSuccess: () => { toast.success( diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..91ad4e4d66e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { JWTAlgorithm } from './jwt-signing-keys-query' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyCreateVariables { + projectRef?: string + algorithm: JWTAlgorithm + status: 'in_use' | 'standby' +} + +export async function createJWTSigningKey(payload: JWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys', { + params: { + path: { ref: payload.projectRef }, + }, + body: { + algorithm: payload.algorithm, + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyCreateData = Awaited> + +export const useJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => createJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to create new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts new file mode 100644 index 00000000000..d39179436c9 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { del, handleError } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyDeleteVariables { + projectRef?: string + keyId: string +} + +export async function deleteJWTSigningKey(payload: JWTSigningKeyDeleteVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await del('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyDeleteData = Awaited> + +export const useJWTSigningKeyDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => deleteJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to delete JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts new file mode 100644 index 00000000000..a406194980e --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-key-update-mutation.ts @@ -0,0 +1,62 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, patch } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface JWTSigningKeyUpdateVariables { + projectRef?: string + keyId: string + status: 'in_use' | 'standby' | 'previously_used' | 'revoked' +} + +export async function updateJWTSigningKey(payload: JWTSigningKeyUpdateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await patch('/v1/projects/{ref}/config/auth/signing-keys/{id}', { + params: { + path: { ref: payload.projectRef, id: payload.keyId }, + }, + body: { + status: payload.status, + }, + }) + + if (error) handleError(error) + return data +} + +type JWTSigningKeyUpdateData = Awaited> + +export const useJWTSigningKeyUpdateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => updateJWTSigningKey(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to update new JWT signing key: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts new file mode 100644 index 00000000000..83cf2a5addc --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/jwt-signing-keys-query.ts @@ -0,0 +1,43 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { components } from 'api-types' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +export type JWTSigningKey = components['schemas']['SigningKeyResponse'] + +export type JWTAlgorithm = components['schemas']['SigningKeyResponse']['algorithm'] + +interface JWTSigningKeysVariables { + projectRef?: string +} + +async function getJWTSigningKeys({ projectRef }: JWTSigningKeysVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type JWTSigningKeysData = Awaited> + +export const useJWTSigningKeysQuery = ( + { projectRef }: JWTSigningKeysVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.list(projectRef), + ({ signal }) => getJWTSigningKeys({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/jwt-signing-keys/keys.ts b/apps/studio/data/jwt-signing-keys/keys.ts new file mode 100644 index 00000000000..4d2c9a01a63 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/keys.ts @@ -0,0 +1,4 @@ +export const jwtSigningKeysKeys = { + list: (projectRef?: string) => ['projects', projectRef, 'jwt-signing-keys'] as const, + legacy: (projectRef?: string) => ['projects', projectRef, 'legacy-jwt-signing-key'] as const, +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts new file mode 100644 index 00000000000..3ec3c15fc10 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts @@ -0,0 +1,64 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { handleError, post } from 'data/fetchers' +import type { ResponseError } from 'types' +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyCreateVariables { + projectRef?: string +} + +export async function createLegacyJWTSigningKey(payload: LegacyJWTSigningKeyCreateVariables) { + if (!payload.projectRef) throw new Error('projectRef is required') + + const { data, error } = await post('/v1/projects/{ref}/config/auth/signing-keys/legacy', { + params: { + path: { ref: payload.projectRef }, + }, + }) + + if (error) handleError(error) + return data +} + +type LegacyJWTSigningKeyCreateData = Awaited> + +export const useLegacyJWTSigningKeyCreateMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation< + LegacyJWTSigningKeyCreateData, + ResponseError, + LegacyJWTSigningKeyCreateVariables + >((vars) => createLegacyJWTSigningKey(vars), { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await queryClient.invalidateQueries(jwtSigningKeysKeys.legacy(projectRef)) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error( + `Failed to enable use of JWT signing keys with legacy JWT secret: ${data.message}` + ) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts new file mode 100644 index 00000000000..069b7504776 --- /dev/null +++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get, handleError } from 'data/fetchers' +import { ResponseError } from 'types' + +import { jwtSigningKeysKeys } from './keys' + +interface LegacyJWTSigningKeyVariables { + projectRef?: string +} + +async function getLegacyJWTSigningKey( + { projectRef }: LegacyJWTSigningKeyVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get(`/v1/projects/{ref}/config/auth/signing-keys/legacy`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) { + handleError(error) + } + + return data +} + +export type LegacyJWTSigningKeyData = Awaited> + +export const useLegacyJWTSigningKeyQuery = ( + { projectRef }: LegacyJWTSigningKeyVariables, + { enabled, ...options }: UseQueryOptions = {} +) => + useQuery( + jwtSigningKeysKeys.legacy(projectRef), + ({ signal }) => getLegacyJWTSigningKey({ projectRef }, signal), + { + enabled: enabled && !!projectRef, + ...options, + } + ) diff --git a/apps/studio/data/organizations/organization-project-claim-mutation.ts b/apps/studio/data/organizations/organization-project-claim-mutation.ts index 25b29c20ea4..16d42a31571 100644 --- a/apps/studio/data/organizations/organization-project-claim-mutation.ts +++ b/apps/studio/data/organizations/organization-project-claim-mutation.ts @@ -10,7 +10,6 @@ type OrganizationProjectClaimVariables = { } async function claimOrganizationProject({ slug, token }: OrganizationProjectClaimVariables) { - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await post('/v1/organizations/{slug}/project-claim/{token}', { params: { path: { slug, token } }, }) diff --git a/apps/studio/data/organizations/organization-project-claim-query.ts b/apps/studio/data/organizations/organization-project-claim-query.ts index f620074a041..169043d7fdc 100644 --- a/apps/studio/data/organizations/organization-project-claim-query.ts +++ b/apps/studio/data/organizations/organization-project-claim-query.ts @@ -51,7 +51,6 @@ async function getOrganizationProjectClaim( ) { if (!slug || !token) throw new Error('Slug and token are required') - // @ts-expect-error the endpoint is hidden in the spec for now const { data, error } = await get(`/v1/organizations/{slug}/project-claim/{token}`, { params: { path: { slug, token } }, signal, diff --git a/apps/studio/pages/project/[ref]/settings/jwt/index.tsx b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx new file mode 100644 index 00000000000..07f3565d857 --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/jwt/index.tsx @@ -0,0 +1,58 @@ +import JWTSettings from 'components/interfaces/JwtSecrets/jwt-settings' +import DefaultLayout from 'components/layouts/DefaultLayout' +import JWTKeysLayout from 'components/layouts/JWTKeys/JWTKeysLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import type { NextPageWithLayout } from 'types' + +import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'common' +import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from 'components/interfaces/JwtSecrets/jwt.constants' +import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' +import { configKeys } from 'data/config/keys' +import { useEffect, useRef } from 'react' +import { toast } from 'sonner' + +const JWTKeysLegacyPage: NextPageWithLayout = () => { + const { ref: projectRef, source } = useParams() + const client = useQueryClient() + + const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) + const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus + const jwtSecretUpdateError = data?.jwtSecretUpdateError + + const previousJwtSecretUpdateStatus = useRef() + const { Failed, Updated, Updating } = JwtSecretUpdateStatus + const jwtSecretUpdateErrorMessage = + JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] + + useEffect(() => { + if (previousJwtSecretUpdateStatus.current === Updating) { + switch (jwtSecretUpdateStatus) { + case Updated: + client.invalidateQueries(configKeys.api(projectRef)) + client.invalidateQueries(configKeys.settings(projectRef)) + client.invalidateQueries(configKeys.postgrest(projectRef)) + toast.success('Successfully updated JWT secret') + break + case Failed: + toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`) + break + } + } + + previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus + }, [jwtSecretUpdateStatus]) + + return +} + +JWTKeysLegacyPage.getLayout = (page) => ( + + + {page} + + +) + +export default JWTKeysLegacyPage diff --git a/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx new file mode 100644 index 00000000000..406f8d9ae01 --- /dev/null +++ b/apps/studio/pages/project/[ref]/settings/jwt/signing-keys.tsx @@ -0,0 +1,36 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import JWTSecretKeysTable from 'components/interfaces/JwtSecrets/jwt-secret-keys-table' +import DefaultLayout from 'components/layouts/DefaultLayout' +import JWTKeysLayout from 'components/layouts/JWTKeys/JWTKeysLayout' +import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const JWTSigningKeysPage: NextPageWithLayout = () => { + const isPermissionsLoaded = usePermissionsLoaded() + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'auth_signing_keys') + + return ( + <> + {!isPermissionsLoaded ? ( + + ) : !canReadAPIKeys ? ( + + ) : ( + + )} + + ) +} + +JWTSigningKeysPage.getLayout = (page) => ( + + + {page} + + +) + +export default JWTSigningKeysPage diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 88d51e24ff2..4af05d16429 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -129,6 +129,26 @@ export interface paths { patch?: never trace?: never } + '/v1/oauth/authorize/project-claim': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** + * Authorize user through oauth and claim a project + * @description Initiates the OAuth authorization flow for the specified provider. After successful authentication, the user can claim ownership of the specified project. + */ + get: operations['v1-oauth-authorize-project-claim'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/oauth/revoke': { parameters: { query?: never @@ -218,6 +238,24 @@ export interface paths { patch?: never trace?: never } + '/v1/organizations/{slug}/project-claim/{token}': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Gets project details for the specified organization and claim token */ + get: operations['v1-get-organization-project-claim'] + put?: never + /** Claims project for the specified organization */ + post: operations['v1-claim-project-for-organization'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects': { parameters: { query?: never @@ -269,7 +307,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getPerformanceAdvisors'] + get: operations['v1-get-performance-advisors'] put?: never post?: never delete?: never @@ -290,7 +328,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getSecurityAdvisors'] + get: operations['v1-get-security-advisors'] put?: never post?: never delete?: never @@ -315,7 +353,7 @@ export interface paths { * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. * */ - get: operations['getLogs'] + get: operations['v1-get-project-logs'] put?: never post?: never delete?: never @@ -332,7 +370,7 @@ export interface paths { cookie?: never } /** Gets project's usage api counts */ - get: operations['getApiCounts'] + get: operations['v1-get-project-usage-api-count'] put?: never post?: never delete?: never @@ -349,7 +387,7 @@ export interface paths { cookie?: never } /** Gets project's usage api requests count */ - get: operations['getApiRequestsCount'] + get: operations['v1-get-project-usage-request-count'] put?: never post?: never delete?: never @@ -369,7 +407,7 @@ export interface paths { get: operations['v1-get-project-api-keys'] put?: never /** Creates a new API key for the project */ - post: operations['createApiKey'] + post: operations['v1-create-project-api-key'] delete?: never options?: never head?: never @@ -384,15 +422,15 @@ export interface paths { cookie?: never } /** Get API key */ - get: operations['getApiKey'] + get: operations['v1-get-project-api-key'] put?: never post?: never /** Deletes an API key for the project */ - delete: operations['deleteApiKey'] + delete: operations['v1-delete-project-api-key'] options?: never head?: never /** Updates an API key for the project */ - patch: operations['updateApiKey'] + patch: operations['v1-update-project-api-key'] trace?: never } '/v1/projects/{ref}/api-keys/legacy': { @@ -403,9 +441,9 @@ export interface paths { cookie?: never } /** Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ - get: operations['checkLegacyApiKeys'] + get: operations['v1-get-project-legacy-api-keys'] /** Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */ - put: operations['updateLegacyApiKeys'] + put: operations['v1-update-project-legacy-api-keys'] post?: never delete?: never options?: never @@ -476,6 +514,25 @@ export interface paths { patch?: never trace?: never } + '/v1/projects/{ref}/claim-token': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Gets project claim token */ + get: operations['v1-get-project-claim-token'] + put?: never + /** Creates project claim token */ + post: operations['v1-create-project-claim-token'] + /** Revokes project claim token */ + delete: operations['v1-delete-project-claim-token'] + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects/{ref}/config/auth': { parameters: { query?: never @@ -501,11 +558,11 @@ export interface paths { path?: never cookie?: never } - /** [Alpha] List all signing keys for the project */ - get: operations['listSigningKeysForProject'] + /** List all signing keys for the project */ + get: operations['v1-get-project-signing-keys'] put?: never - /** [Alpha] Create a new signing key for the project in standby status */ - post: operations['createSigningKeyForProject'] + /** Create a new signing key for the project in standby status */ + post: operations['v1-create-project-signing-key'] delete?: never options?: never head?: never @@ -519,16 +576,34 @@ export interface paths { path?: never cookie?: never } - /** [Alpha] Get information about a signing key */ - get: operations['getSigningKeyForProject'] + /** Get information about a signing key */ + get: operations['v1-get-project-signing-key'] put?: never post?: never - /** [Alpha] Remove a signing key from a project, where the status is previously_used */ - delete: operations['deleteSigningKey'] + /** Remove a signing key from a project. Only possible if the key has been in revoked status for a while. */ + delete: operations['v1-remove-project-signing-key'] options?: never head?: never - /** [Alpha] Update a signing key, mainly its status */ - patch: operations['patchSigningKey'] + /** Update a signing key, mainly its status */ + patch: operations['v1-update-project-signing-key'] + trace?: never + } + '/v1/projects/{ref}/config/auth/signing-keys/legacy': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get the signing key information for the JWT secret imported as signing key for this project. This endpoint will be removed in the future, check for HTTP 404 Not Found. */ + get: operations['v1-get-legacy-signing-key'] + put?: never + /** Set up the project's existing JWT secret as an in_use JWT signing key. This endpoint will be removed in the future always check for HTTP 404 Not Found. */ + post: operations['v1-create-legacy-signing-key'] + delete?: never + options?: never + head?: never + patch?: never trace?: never } '/v1/projects/{ref}/config/auth/sso/providers': { @@ -576,10 +651,10 @@ export interface paths { cookie?: never } /** Lists all third-party auth integrations */ - get: operations['listTPAForProject'] + get: operations['v1-list-project-tpa-integrations'] put?: never /** Creates a new third-party auth integration */ - post: operations['createTPAForProject'] + post: operations['v1-create-project-tpa-integration'] delete?: never options?: never head?: never @@ -594,11 +669,11 @@ export interface paths { cookie?: never } /** Get a third-party integration */ - get: operations['getTPAForProject'] + get: operations['v1-get-project-tpa-integration'] put?: never post?: never /** Removes a third-party auth integration */ - delete: operations['deleteTPAForProject'] + delete: operations['v1-delete-project-tpa-integration'] options?: never head?: never patch?: never @@ -778,6 +853,41 @@ export interface paths { patch?: never trace?: never } + '/v1/projects/{ref}/database/backups/restore-point': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get restore points for project */ + get: operations['v1-get-restore-point'] + put?: never + /** Initiates a creation of a restore point for a database */ + post: operations['v1-create-restore-point'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } + '/v1/projects/{ref}/database/backups/undo': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + get?: never + put?: never + /** Initiates an undo to a given restore point */ + post: operations['v1-undo'] + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/v1/projects/{ref}/database/context': { parameters: { query?: never @@ -790,7 +900,7 @@ export interface paths { * @deprecated * @description This is an **experimental** endpoint. It is subject to change or removal in future versions. Use it with caution, as it may not remain supported or stable. */ - get: operations['getDatabaseMetadata'] + get: operations['v1-get-database-metadata'] put?: never post?: never delete?: never @@ -1849,6 +1959,14 @@ export interface components { CreateOrganizationV1: { name: string } + CreateProjectClaimTokenResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + token: string + token_alias: string + } CreateProviderBody: { attribute_mapping?: { keys: { @@ -2271,6 +2389,41 @@ export interface components { /** @enum {string} */ token_type: 'Bearer' } + OrganizationProjectClaimResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + preview: { + errors: { + key: string + message: string + }[] + info: { + key: string + message: string + }[] + members_exceeding_free_project_limit: { + limit: number + name: string + }[] + /** @enum {string} */ + source_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' + target_organization_eligible: boolean | null + target_organization_has_free_project_slots: boolean | null + /** @enum {string|null} */ + target_subscription_plan: 'free' | 'pro' | 'team' | 'enterprise' | null + valid: boolean + warnings: { + key: string + message: string + }[] + } + project: { + name: string + ref: string + } + } OrganizationResponseV1: { id: string name: string @@ -2312,6 +2465,13 @@ export interface components { jwt_secret?: string max_rows: number } + ProjectClaimTokenResponse: { + created_at: string + /** Format: uuid */ + created_by: string + expires_at: string + token_alias: string + } ProjectUpgradeEligibilityResponse: { current_app_version: string /** @enum {string} */ @@ -3153,6 +3313,14 @@ export interface components { /** Format: int64 */ recovery_time_target_unix: number } + V1RestorePointPostBody: { + name: string + } + V1RestorePointResponse: { + name: string + /** @enum {string} */ + status: 'AVAILABLE' | 'PENDING' | 'REMOVED' + } V1RunQueryBody: { query: string read_only?: boolean @@ -3185,6 +3353,9 @@ export interface components { public: boolean updated_at: string } + V1UndoBody: { + name: string + } V1UpdateFunctionBody: { body?: string name?: string @@ -3464,6 +3635,33 @@ export interface operations { } } } + 'v1-oauth-authorize-project-claim': { + parameters: { + query: { + client_id: string + code_challenge?: string + code_challenge_method?: 'plain' | 'sha256' | 'S256' + /** @description Project ref */ + project_ref: string + redirect_uri: string + response_mode?: string + response_type: 'code' | 'token' | 'id_token token' + state?: string + } + header?: never + path?: never + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-revoke-token': { parameters: { query?: never @@ -3620,6 +3818,62 @@ export interface operations { } } } + 'v1-get-organization-project-claim': { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + token: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['OrganizationProjectClaimResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-claim-project-for-organization': { + parameters: { + query?: never + header?: never + path: { + /** @description Organization slug */ + slug: string + token: string + } + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-list-all-projects': { parameters: { query?: never @@ -3725,7 +3979,7 @@ export interface operations { } } } - getPerformanceAdvisors: { + 'v1-get-performance-advisors': { parameters: { query?: never header?: never @@ -3753,9 +4007,11 @@ export interface operations { } } } - getSecurityAdvisors: { + 'v1-get-security-advisors': { parameters: { - query?: never + query?: { + lint_type?: 'sql' + } header?: never path: { /** @description Project ref */ @@ -3781,7 +4037,7 @@ export interface operations { } } } - getLogs: { + 'v1-get-project-logs': { parameters: { query?: { iso_timestamp_end?: string @@ -3813,7 +4069,7 @@ export interface operations { } } } - getApiCounts: { + 'v1-get-project-usage-api-count': { parameters: { query?: { interval?: '15min' | '30min' | '1hr' | '3hr' | '1day' | '3day' | '7day' @@ -3850,7 +4106,7 @@ export interface operations { } } } - getApiRequestsCount: { + 'v1-get-project-usage-request-count': { parameters: { query?: never header?: never @@ -3910,7 +4166,7 @@ export interface operations { } } } - createApiKey: { + 'v1-create-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -3945,7 +4201,7 @@ export interface operations { } } } - getApiKey: { + 'v1-get-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -3977,7 +4233,7 @@ export interface operations { } } } - deleteApiKey: { + 'v1-delete-project-api-key': { parameters: { query?: { reason?: string @@ -4012,7 +4268,7 @@ export interface operations { } } } - updateApiKey: { + 'v1-update-project-api-key': { parameters: { query?: { /** @description Boolean string, true or false */ @@ -4048,7 +4304,7 @@ export interface operations { } } } - checkLegacyApiKeys: { + 'v1-get-project-legacy-api-keys': { parameters: { query?: never header?: never @@ -4076,7 +4332,7 @@ export interface operations { } } } - updateLegacyApiKeys: { + 'v1-update-project-legacy-api-keys': { parameters: { query: { /** @description Boolean string, true or false */ @@ -4320,6 +4576,88 @@ export interface operations { } } } + 'v1-get-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['ProjectClaimTokenResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['CreateProjectClaimTokenResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-delete-project-claim-token': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 204: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-get-auth-service-config': { parameters: { query?: never @@ -4394,7 +4732,7 @@ export interface operations { } } } - listSigningKeysForProject: { + 'v1-get-project-signing-keys': { parameters: { query?: never header?: never @@ -4422,7 +4760,7 @@ export interface operations { } } } - createSigningKeyForProject: { + 'v1-create-project-signing-key': { parameters: { query?: never header?: never @@ -4454,7 +4792,7 @@ export interface operations { } } } - getSigningKeyForProject: { + 'v1-get-project-signing-key': { parameters: { query?: never header?: never @@ -4483,7 +4821,7 @@ export interface operations { } } } - deleteSigningKey: { + 'v1-remove-project-signing-key': { parameters: { query?: never header?: never @@ -4512,7 +4850,7 @@ export interface operations { } } } - patchSigningKey: { + 'v1-update-project-signing-key': { parameters: { query?: never header?: never @@ -4545,6 +4883,62 @@ export interface operations { } } } + 'v1-get-legacy-signing-key': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SigningKeyResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-legacy-signing-key': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['SigningKeyResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } 'v1-list-all-sso-provider': { parameters: { query?: never @@ -4731,7 +5125,7 @@ export interface operations { } } } - listTPAForProject: { + 'v1-list-project-tpa-integrations': { parameters: { query?: never header?: never @@ -4759,7 +5153,7 @@ export interface operations { } } } - createTPAForProject: { + 'v1-create-project-tpa-integration': { parameters: { query?: never header?: never @@ -4791,7 +5185,7 @@ export interface operations { } } } - getTPAForProject: { + 'v1-get-project-tpa-integration': { parameters: { query?: never header?: never @@ -4820,7 +5214,7 @@ export interface operations { } } } - deleteTPAForProject: { + 'v1-delete-project-tpa-integration': { parameters: { query?: never header?: never @@ -5340,7 +5734,100 @@ export interface operations { } } } - getDatabaseMetadata: { + 'v1-get-restore-point': { + parameters: { + query?: { + name?: string + } + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['V1RestorePointResponse'] + } + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + /** @description Failed to get requested restore points */ + 500: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-create-restore-point': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['V1RestorePointPostBody'] + } + } + responses: { + 201: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['V1RestorePointResponse'] + } + } + } + } + 'v1-undo': { + parameters: { + query?: never + header?: never + path: { + /** @description Project ref */ + ref: string + } + cookie?: never + } + requestBody: { + content: { + 'application/json': components['schemas']['V1UndoBody'] + } + } + responses: { + 201: { + headers: { + [name: string]: unknown + } + content?: never + } + 403: { + headers: { + [name: string]: unknown + } + content?: never + } + } + } + 'v1-get-database-metadata': { parameters: { query?: never header?: never diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 2dfaf2c706b..208765a7f5e 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -4299,7 +4299,7 @@ export interface components { token: string token_alias: string } - CreateBackendParams: { + CreateBackendParamsOpenapi: { config: | { hostname: string @@ -7713,7 +7713,7 @@ export interface components { | 'auth_mfa_web_authn_default' | 'log_drain_default' } - UpdateBackendParams: { + UpdateBackendParamsOpenapi: { config?: | { hostname: string @@ -7755,6 +7755,8 @@ export interface components { } description?: string name?: string + /** @enum {string} */ + type: 'postgres' | 'bigquery' | 'webhook' | 'datadog' | 'elastic' | 'loki' } UpdateCollectionBody: { name: string @@ -14637,7 +14639,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['CreateBackendParams'] + 'application/json': components['schemas']['CreateBackendParamsOpenapi'] } } responses: { @@ -14678,7 +14680,7 @@ export interface operations { } requestBody: { content: { - 'application/json': components['schemas']['UpdateBackendParams'] + 'application/json': components['schemas']['UpdateBackendParamsOpenapi'] } } responses: { From 327cab053a619002a3d8bcd2f0ee0501e6d87a2f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 23 Jun 2025 11:16:24 +0800 Subject: [PATCH 12/84] Clean up tabs interface feature flags (#36441) * Clean up tabs interface feature flags * Update SQL e2e test --- .../FeaturePreview.constants.tsx | 14 -- .../FeaturePreview/FeaturePreviewContext.tsx | 18 -- .../FeaturePreview/FeaturePreviewModal.tsx | 4 - .../FeaturePreview/InlineEditorPreview.tsx | 1 - .../App/FeaturePreview/SqlEditorTabs.tsx | 28 --- .../App/FeaturePreview/TableEditorTabs.tsx | 192 ------------------ .../interfaces/SQLEditor/RenameQueryModal.tsx | 10 +- .../interfaces/SQLEditor/SQLEditor.tsx | 8 +- apps/studio/components/interfaces/Sidebar.tsx | 10 +- .../interfaces/TableGridEditor/EmptyState.tsx | 66 ------ .../SidePanelEditor/SidePanelEditor.tsx | 6 +- .../TableGridEditor/TableGridEditor.tsx | 96 ++++----- .../interfaces/TableGridEditor/index.ts | 1 - .../NavigationBar/NavigationBar.utils.tsx | 10 +- .../layouts/ProjectLayout/ProjectLayout.tsx | 12 +- .../SQLEditorNavV2/SQLEditorNav.tsx | 55 +++-- .../SQLEditorNavV2/SQLEditorTreeViewItem.tsx | 7 +- .../TableEditorLayout/EntityListItem.tsx | 5 +- .../TableEditorLayout/TableEditorMenu.tsx | 8 +- .../studio/components/layouts/Tabs/NewTab.tsx | 2 - apps/studio/components/layouts/Tabs/Tabs.tsx | 73 ++++--- .../layouts/editors/EditorBaseLayout.tsx | 28 +-- .../pages/project/[ref]/editor/[id].tsx | 7 +- .../pages/project/[ref]/editor/index.tsx | 33 ++- apps/studio/pages/project/[ref]/sql/[id].tsx | 76 ++++--- apps/studio/pages/project/[ref]/sql/index.tsx | 32 ++- .../pages/project/[ref]/sql/quickstarts.tsx | 30 ++- .../pages/project/[ref]/sql/templates.tsx | 30 ++- e2e/studio/features/sql-editor.spec.ts | 4 +- 29 files changed, 213 insertions(+), 653 deletions(-) delete mode 100644 apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx delete mode 100644 apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx delete mode 100644 apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx index ceadbd396bf..c2b2c54f742 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreview.constants.tsx @@ -8,20 +8,6 @@ export const FEATURE_PREVIEWS = [ isNew: true, isPlatformOnly: false, }, - { - key: LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - name: 'Table Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: false, - }, - { - key: LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - name: 'SQL Editor Tabs', - discussionsUrl: 'https://github.com/orgs/supabase/discussions/35636', - isNew: true, - isPlatformOnly: true, - }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, name: 'Project API documentation', diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 4fa0a7dcf67..bef4199d7cc 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -2,8 +2,6 @@ import { noop } from 'lodash' import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react' import { FeatureFlagContext, LOCAL_STORAGE_KEYS } from 'common' -import { useFlag } from 'hooks/ui/useFlag' -import { IS_PLATFORM } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' @@ -21,15 +19,10 @@ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { const { hasLoaded } = useContext(FeatureFlagContext) - const enableTabsInterface = useFlag('tabsInterface') // [Joshen] Similar logic to feature flagging previews, we can use flags to default opt in previews const isDefaultOptIn = (feature: (typeof FEATURE_PREVIEWS)[number]) => { switch (feature.key) { - case LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS: - return enableTabsInterface - case LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS: - return enableTabsInterface default: return false } @@ -86,14 +79,3 @@ export const useIsInlineEditorEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR] } - -export const useIsTableEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - return flags[LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS] -} - -export const useIsSQLEditorTabsEnabled = () => { - const { flags } = useFeaturePreviewContext() - if (!IS_PLATFORM) return false - return flags[LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS] -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 94bc324361f..f7a047d003b 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -14,15 +14,11 @@ import { CLSPreview } from './CLSPreview' import { FEATURE_PREVIEWS } from './FeaturePreview.constants' import { useFeaturePreviewContext } from './FeaturePreviewContext' import { InlineEditorPreview } from './InlineEditorPreview' -import { SqlEditorTabsPreview } from './SqlEditorTabs' -import { TableEditorTabsPreview } from './TableEditorTabs' const FEATURE_PREVIEW_KEY_TO_CONTENT: { [key: string]: ReactNode } = { [LOCAL_STORAGE_KEYS.UI_PREVIEW_INLINE_EDITOR]: , - [LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS]: , - [LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: , [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: , } diff --git a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx index 08a82c3f74c..228da9d8b4d 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/InlineEditorPreview.tsx @@ -1,7 +1,6 @@ import Image from 'next/image' import { BASE_PATH } from 'lib/constants' -import { Admonition } from 'ui-patterns' export const InlineEditorPreview = () => { return ( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx deleted file mode 100644 index ee053af8657..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorTabs.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { BASE_PATH } from 'lib/constants' -import Image from 'next/image' - -export const SqlEditorTabsPreview = () => { - return ( -
- api-docs-side-panel-preview -

- The SQL Editor now features tabs for improved navigation and organization. Conveniently go - between the queries that you're focused on, and collapse the sidebar for a bigger real - estate while writing your queries. -

-
-

Enabling this preview will:

-
    -
  • Enable opening of queries in the SQL Editor as tabs
  • -
  • Support closing of the navigation sidebar for a larger code editor space
  • -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx b/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx deleted file mode 100644 index 1a83d1e70dd..00000000000 --- a/apps/studio/components/interfaces/App/FeaturePreview/TableEditorTabs.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { ExternalLink, X } from 'lucide-react' -import Image from 'next/image' - -import { LOCAL_STORAGE_KEYS } from 'common' -import { useEditorType } from 'components/layouts/editors/EditorsLayout.hooks' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InlineLinkClassName } from 'components/ui/InlineLink' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { BASE_PATH } from 'lib/constants' -import { useAppStateSnapshot } from 'state/app-state' -import { - Alert_Shadcn_, - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Badge, - Button, - cn, - HoverCard, - HoverCardContent_Shadcn_, - HoverCardTrigger_Shadcn_, -} from 'ui' -import { useIsSQLEditorTabsEnabled, useIsTableEditorTabsEnabled } from './FeaturePreviewContext' - -export const TableEditorTabsPreview = () => { - return ( -
- api-docs-side-panel-preview -

- The Table Editor now features tabs for improved navigation and organization. Have multiple - tables opened across schemas and conveniently go across them without having to switch - schemas. Collapse the sidebar for a bigger real estate while browsing your data. -

-
-

Enabling this preview will:

-
    -
  • Enable opening of tables in the Table Editor as tabs
  • -
  • Support closing of the navigation sidebar for a larger data grid space
  • -
-
-
- ) -} - -export const TabsUpdateCallout = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - - - NEW - - Tabs Interface for Editors - - - The Table and SQL Editors now feature tabs for improved navigation and organization! Check - out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. - - - - - } - className="absolute top-2 right-2 px-1" - onClick={() => setTabsInterfaceAcknowledge(true)} - tooltip={{ content: { side: 'bottom', text: 'Dismiss' } }} - /> - - ) -} - -export const TabsUpdateTooltip = () => { - const editor = useEditorType() - const appSnap = useAppStateSnapshot() - - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - const isTabsEnabled = isTableEditorTabsEnabled || isSQLEditorTabsEnabled - - const [tableEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS, - '' - ) - const [sqlEditorTabsPreviewState] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS, - '' - ) - const [tabsInterfaceAcknowledge, setTabsInterfaceAcknowledge] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.TABS_INTERFACE_ACKNOWLEDGED, - false - ) - - const isDefaultOptedInTabs = - isTabsEnabled && tableEditorTabsPreviewState === '' && sqlEditorTabsPreviewState === '' - - if (!isDefaultOptedInTabs || tabsInterfaceAcknowledge) return null - - return ( - - -
- - NEW - -
-
- -
-

Tabs Interface for Editors

-

- The Table and SQL Editors now feature tabs for improved navigation and organization! - Check out our{' '} - { - appSnap.setSelectedFeaturePreview( - editor === 'table' - ? LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS - : LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS - ) - appSnap.setShowFeaturePreviewModal(true) - }} - > - feature previews - {' '} - for more information. -

-
- -
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 2f062081911..35af13d8543 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { getContentById } from 'data/content/content-id-query' import { UpsertContentPayload, @@ -11,12 +12,10 @@ import { import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' -import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' export interface RenameQueryModalProps { @@ -42,7 +41,6 @@ const RenameQueryModal = ({ { enabled: visible } ) const isSQLSnippet = snippet.type === 'sql' - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref }) // Customers on HIPAA plans should not have access to Supabase AI @@ -112,10 +110,8 @@ const RenameQueryModal = ({ snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabsSnap.updateTab(tabId, { label: nameInput }) - } + const tabId = createTabId('sql', { id }) + tabsSnap.updateTab(tabId, { label: nameInput }) toast.success('Successfully renamed snippet!') if (onComplete) onComplete() diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 054174f05f3..fb4e0914772 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -51,7 +51,6 @@ import { TooltipTrigger, cn, } from 'ui' -import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' import { RunQueryWarningModal } from './RunQueryWarningModal' import { @@ -95,7 +94,6 @@ export const SQLEditor = () => { const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject() const [selectedSchemas] = useSchemasForAi(project?.ref!) const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const { sourceSqlDiff, @@ -212,10 +210,8 @@ export const SQLEditor = () => { try { const { title: name } = await generateSqlTitle({ sql }) snapV2.renameSnippet({ id, name }) - if (isSQLEditorTabsEnabled && ref) { - const tabId = createTabId('sql', { id }) - tabs.updateTab(tabId, { label: name }) - } + const tabId = createTabId('sql', { id }) + tabs.updateTab(tabId, { label: name }) } catch (error) { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user } diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 11ef4f28fa6..bb7625b410a 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -42,10 +42,7 @@ import { Sidebar as SidebarPrimitive, useSidebar, } from 'ui' -import { - useIsAPIDocsSidePanelEnabled, - useIsSQLEditorTabsEnabled, -} from './App/FeaturePreview/FeaturePreviewContext' +import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext' export const ICON_SIZE = 32 export const ICON_STROKE_WIDTH = 1.5 @@ -228,7 +225,6 @@ const ProjectLinks = () => { const showWarehouse = useFlag('warehouse') const showUnifiedLogs = useFlag('unifiedLogs') - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() const activeRoute = router.pathname.split('/')[3] @@ -244,9 +240,7 @@ const ProjectLinks = () => { 'realtime:all', ]) - const toolRoutes = generateToolRoutes(ref, project, { - sqlEditorTabs: isSqlEditorTabsEnabled, - }) + const toolRoutes = generateToolRoutes(ref, project) const productRoutes = generateProductRoutes(ref, project, { auth: authEnabled, edgeFunctions: edgeFunctionsEnabled, diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx deleted file mode 100644 index 0fbf052477c..00000000000 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useLocalStorage } from 'hooks/misc/useLocalStorage' -import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import { useTableEditorStateSnapshot } from 'state/table-editor' - -export interface EmptyStateProps {} - -const EmptyState = ({}: EmptyStateProps) => { - const snap = useTableEditorStateSnapshot() - const { selectedSchema } = useQuerySchemaState() - const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema) - const canCreateTables = - useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema - - const [sort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( - 'table-editor-sort', - 'alphabetical' - ) - - const { project } = useProjectContext() - const { data } = useEntityTypesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schemas: [selectedSchema], - sort, - }) - - const totalCount = data?.pages?.[0].data.count ?? 0 - - return ( -
- {totalCount === 0 ? ( - -

- There are no tables available in this schema. -

-
- ) : ( -
- -

- Select a table from the navigation panel on the left to view its data - {canCreateTables && ', or create a new one.'} -

-
-
- )} -
- ) -} - -export default EmptyState diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 2bfb205ed15..debbd89ef68 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -5,7 +5,6 @@ import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useDatabasePublicationCreateMutation } from 'data/database-publications/database-publications-create-mutation' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' @@ -21,6 +20,7 @@ import { tableRowKeys } from 'data/table-rows/keys' import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' import { tableKeys } from 'data/tables/keys' +import { RetrieveTableResult } from 'data/tables/table-retrieve-query' import { getTables } from 'data/tables/tables-query' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' @@ -50,7 +50,6 @@ import { import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' import TableEditor from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' -import { RetrieveTableResult } from 'data/tables/table-retrieve-query' export interface SidePanelEditorProps { editable?: boolean @@ -71,7 +70,6 @@ const SidePanelEditor = ({ const { ref } = useParams() const snap = useTableEditorStateSnapshot() const tabsSnap = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) const queryClient = useQueryClient() @@ -493,7 +491,7 @@ const SidePanelEditor = ({ `Table ${table.name} has been updated but there were some errors. Please check these errors separately.` ) } else { - if (isTableEditorTabsEnabled && ref && payload.name) { + if (ref && payload.name) { // [Joshen] Only table entities can be updated via the dashboard const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) tabsSnap.updateTab(tabId, { label: payload.name }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 08ec744a22f..a29a158fc05 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { isUndefined } from 'lodash' +import Link from 'next/link' import { useRouter } from 'next/router' import { useCallback } from 'react' @@ -18,13 +18,11 @@ import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import Link from 'next/link' import { useAppStateSnapshot } from 'state/app-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' -import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import DeleteConfirmationDialogs from './DeleteConfirmationDialogs' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' import TableDefinition from './TableDefinition' @@ -44,7 +42,6 @@ export const TableGridEditor = ({ const { ref: projectRef, id } = useParams() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const { selectedSchema } = useQuerySchemaState() useLoadTableEditorStateFromLocalStorageIntoUrl({ @@ -65,9 +62,9 @@ export const TableGridEditor = ({ connectionString: project?.connectionString, }) - const onClearDashboardHistory = () => { + const onClearDashboardHistory = useCallback(() => { if (projectRef) appSnap.setDashboardHistory(projectRef, 'editor', undefined) - } + }, [appSnap, projectRef]) const onTableCreated = useCallback( (table: { id: number }) => { @@ -78,19 +75,12 @@ export const TableGridEditor = ({ const onTableDeleted = useCallback(async () => { // For simplicity for now, we just open the first table within the same schema - if (isTableEditorTabsEnabled && selectedTable) { + if (selectedTable) { // Close tab const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) tabs.handleTabClose({ id: tabId, router, editor: 'table', onClearDashboardHistory }) - } else { - const tables = await getTables(selectedSchema) - if (tables.length > 0) { - router.push(`/project/${projectRef}/editor/${tables[0].id}`) - } else { - router.push(`/project/${projectRef}/editor`) - } } - }, [getTables, isTableEditorTabsEnabled, projectRef, router, selectedSchema]) + }, [onClearDashboardHistory, router, selectedTable, tabs]) // NOTE: DO NOT PUT HOOKS AFTER THIS LINE if (isLoadingSelectedTable || !projectRef) { @@ -105,7 +95,7 @@ export const TableGridEditor = ({ ) } - if (isUndefined(selectedTable)) { + if (!selectedTable) { return (
@@ -114,45 +104,41 @@ export const TableGridEditor = ({ title={`Unable to find your table with ID ${id}`} description="This table doesn't exist in your database" > - {isTableEditorTabsEnabled && ( - <> - {!!tabId ? ( - - ) : openTabs.length > 0 ? ( - - ) : ( - - )} - + {!!tabId ? ( + + ) : openTabs.length > 0 ? ( + + ) : ( + )}
diff --git a/apps/studio/components/interfaces/TableGridEditor/index.ts b/apps/studio/components/interfaces/TableGridEditor/index.ts index 50e1839b986..52936211e7e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/index.ts +++ b/apps/studio/components/interfaces/TableGridEditor/index.ts @@ -1,2 +1 @@ -export { default as EmptyState } from './EmptyState' export { default as SidePanelEditor } from './SidePanelEditor/SidePanelEditor' diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index ac33ba4ff1d..41a6434e197 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -19,12 +19,7 @@ import { } from 'icons' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -export const generateToolRoutes = ( - ref?: string, - project?: Project, - features?: { sqlEditorTabs: boolean } -): Route[] => { - const { sqlEditorTabs } = features ?? {} +export const generateToolRoutes = (ref?: string, project?: Project, features?: {}): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP const buildingUrl = `/project/${ref}` @@ -42,8 +37,7 @@ export const generateToolRoutes = ( icon: , link: !IS_PLATFORM ? `/project/${ref}/sql/1` - : ref && - (isProjectBuilding ? buildingUrl : `/project/${ref}/sql${!!sqlEditorTabs ? '' : '/new'}`), + : ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 3c887f9ac36..0d6d6f1fcd9 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -4,10 +4,6 @@ import { useRouter } from 'next/router' import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect, useState } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' import AISettingsModal from 'components/ui/AISettingsModal' @@ -101,14 +97,8 @@ const ProjectLayout = forwardRef { const snapV2 = useSqlEditorV2StateSnapshot() const tabs = useTabsStateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const [sectionVisibility, setSectionVisibility] = useLocalStorage( LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''), @@ -293,12 +292,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({ onSuccess: (data) => { - if (isSQLEditorTabsEnabled) { - // Update Tabs state - currently unknown how to differentiate between sql and non-sql content - // so we're just deleting all tabs for with matching IDs - const tabIds = data.map((id) => createTabId('sql', { id })) - tabs.removeTabs(tabIds) - } + // Update Tabs state - currently unknown how to differentiate between sql and non-sql content + // so we're just deleting all tabs for with matching IDs + const tabIds = data.map((id) => createTabId('sql', { id })) + tabs.removeTabs(tabIds) }, onError: (error, data) => { if (error.message.includes('Contents not found')) { @@ -541,10 +538,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (isSuccess && isSQLEditorTabsEnabled) { + if (isSuccess) { sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) } - }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup]) + }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup]) return ( <> @@ -572,15 +569,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={projectSnippetsTreeState} aria-label="project-level-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -656,15 +651,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { data={favoritesTreeState} aria-label="favorite-snippets" nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -746,15 +739,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }} expandedIds={expandedFolderIds} nodeRenderer={({ element, ...props }) => { - const isOpened = - isSQLEditorTabsEnabled && - Object.values(tabs.tabsMap).some( - (tab) => tab.metadata?.sqlId === element.metadata?.id - ) + const isOpened = Object.values(tabs.tabsMap).some( + (tab) => tab.metadata?.sqlId === element.metadata?.id + ) const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId + const isPreview = tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx index 895eaee2bb2..3633fdf5b74 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx @@ -1,7 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { IS_PLATFORM } from 'common' import { useParams } from 'common/hooks/useParams' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query' import { Snippet } from 'data/content/sql-folders-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -83,7 +82,6 @@ export const SQLEditorTreeViewItem = ({ const { profile } = useProfile() const { className, onClick } = getNodeProps() const snapV2 = useSqlEditorV2StateSnapshot() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const isOwner = profile?.id === element?.metadata.owner_id const isSharedSnippet = element.metadata.visibility === 'project' @@ -262,13 +260,12 @@ export const SQLEditorTreeViewItem = ({ {}} onFocusCapture={(e) => e.stopPropagation()} > Open in new tab diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index d1e8269143b..62cea948bb6 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -10,7 +10,6 @@ import { MAX_EXPORT_ROW_COUNT_MESSAGE, } from 'components/grid/components/header/Header' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { formatTableRowsToSQL, getEntityLintDetails, @@ -70,11 +69,9 @@ const EntityListItem: ItemRenderer = ({ const snap = useTableEditorStateSnapshot() const { selectedSchema } = useQuerySchemaState() - // For tabs preview flag logic - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const tabId = createTabId(entity.type, { id: entity.id }) const tabs = useTabsStateSnapshot() - const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false + const isPreview = tabs.previewTabId === tabId const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id) const isActive = Number(id) === entity.id diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index a306730b114..84bd7f4f689 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' import AlertError from 'components/ui/AlertError' @@ -45,11 +44,10 @@ import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' const TableEditorMenu = () => { - const { ref, id: _id } = useParams() + const { id: _id } = useParams() const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const isMobile = useBreakpoint() const [showModal, setShowModal] = useState(false) @@ -118,10 +116,10 @@ const TableEditorMenu = () => { const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session - if (isTableEditorTabsEnabled && entityTypes && !searchText) { + if (entityTypes && !searchText) { tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes }) } - }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp]) + }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp]) return ( <> diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index ba199aab480..8afcfe3bd4d 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -6,7 +6,6 @@ import { useRouter } from 'next/router' import { toast } from 'sonner' import { useParams } from 'common' -import { TabsUpdateCallout } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -114,7 +113,6 @@ export function NewTab() { return (
-
{actions.map((item, i) => ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index 66fb4b9b715..847a3c11d45 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -12,7 +12,6 @@ import { Plus, X } from 'lucide-react' import { useRouter } from 'next/router' import { useParams } from 'common' -import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { useAppStateSnapshot } from 'state/app-state' import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs' import { @@ -173,43 +172,41 @@ export const EditorTabs = () => { {/* Non-draggable new tab */} {hasNewTab && ( - <> - + +
+ New +
+ { + e.preventDefault() + e.stopPropagation() + }} + className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" + onMouseDown={(e) => { + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.preventDefault() + e.stopPropagation() + handleClose('new') + }} > - -
- New -
- { - e.preventDefault() - e.stopPropagation() - }} - className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer" - onMouseDown={(e) => { - e.preventDefault() - e.stopPropagation() - }} - onPointerDown={(e) => { - e.preventDefault() - e.stopPropagation() - handleClose('new') - }} - > - - {' '} -
- - + + {' '} +
+ )} @@ -235,8 +232,6 @@ export const EditorTabs = () => {
- - diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx index eafb111c3b0..9f0da404d7f 100644 --- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx +++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx @@ -2,10 +2,6 @@ import { usePathname } from 'next/navigation' import { ComponentProps, ReactNode } from 'react' import { useParams } from 'common' -import { - useIsSQLEditorTabsEnabled, - useIsTableEditorTabsEnabled, -} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useTabsStateSnapshot } from 'state/tabs' import { cn } from 'ui' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' @@ -26,12 +22,6 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore const editor = useEditorType() const tabs = useTabsStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() - const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() - - const tableEditorTabsEnabled = editor === 'table' && isTableEditorTabsEnabled - const sqlEditorTabsEnabled = editor === 'sql' && isSQLEditorTabsEnabled - const hasNoOpenTabs = editor === 'table' ? tabs.openTabs.filter((x) => !x.startsWith('sql')).length === 0 : false const hideTabs = @@ -40,16 +30,14 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore return (
- {tableEditorTabsEnabled || sqlEditorTabsEnabled ? ( -
- {hideTabs ? : } -
- ) : null} +
+ {hideTabs ? : } +
{children}
diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index 8e4bab3f158..7409c843e69 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react' import { useParams } from 'common' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' @@ -30,11 +29,9 @@ const TableEditorPage: NextPageWithLayout = () => { * - selectedTable changes (when a new table is loaded) * - id changes (when URL parameter changes) */ - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() useEffect(() => { - // tabs preview flag logic - if (isTableEditorTabsEnabled && selectedTable && projectRef) { + if (selectedTable && projectRef) { const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) if (!store.tabsMap[tabId]) { store.addTab({ @@ -52,7 +49,7 @@ const TableEditorPage: NextPageWithLayout = () => { store.makeTabActive(tabId) } } - }, [selectedTable, id, projectRef, isTableEditorTabsEnabled]) + }, [selectedTable, id, projectRef]) return } diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index b970f2e90a1..55797c6e53d 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -1,14 +1,13 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' + import { useParams } from 'common' -import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import EmptyState from 'components/interfaces/TableGridEditor/EmptyState' import SidePanelEditor from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu' import { NewTab } from 'components/layouts/Tabs/NewTab' -import { useRouter } from 'next/router' -import { useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' @@ -18,7 +17,6 @@ const TableEditorPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() const tabStore = useTabsStateSnapshot() const appSnap = useAppStateSnapshot() - const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const onTableCreated = (table: { id: number }) => { router.push(`/project/${projectRef}/editor/${table.id}`) @@ -26,25 +24,22 @@ const TableEditorPage: NextPageWithLayout = () => { useEffect(() => { const lastOpenedTab = appSnap.dashboardHistory.editor - if (isTableEditorTabsEnabled) { - const lastTabId = tabStore.openTabs.find((id) => - editorEntityTypes.table.includes(tabStore.tabsMap[id]?.type) - ) - if (lastOpenedTab !== undefined) { - router.push(`/project/${projectRef}/editor/${appSnap.dashboardHistory.editor}`) - } else if (lastTabId) { - // Handle redirect to last opened table tab, or last table tab - const lastTab = tabStore.tabsMap[lastTabId] - if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`) - } - } else if (lastOpenedTab) { + + const lastTabId = tabStore.openTabs.find((id) => + editorEntityTypes.table.includes(tabStore.tabsMap[id]?.type) + ) + if (lastOpenedTab !== undefined) { router.push(`/project/${projectRef}/editor/${appSnap.dashboardHistory.editor}`) + } else if (lastTabId) { + // Handle redirect to last opened table tab, or last table tab + const lastTab = tabStore.tabsMap[lastTabId] + if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`) } - }, [isTableEditorTabsEnabled]) + }, []) return ( <> - {isTableEditorTabsEnabled ? : } + ) diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 40d8b604b6a..36acdb1ef89 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -2,7 +2,6 @@ import { useRouter } from 'next/router' import { useEffect } from 'react' import { useParams } from 'common/hooks/useParams' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { SQLEditor } from 'components/interfaces/SQLEditor/SQLEditor' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' @@ -26,7 +25,6 @@ const SqlEditor: NextPageWithLayout = () => { const tabs = useTabsStateSnapshot() const appSnap = useAppStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() const allSnippets = useSnippets(ref!) const snippet = allSnippets.find((x) => x.id === id) @@ -72,23 +70,21 @@ const SqlEditor: NextPageWithLayout = () => { // Watch for route changes useEffect(() => { - if (isSqlEditorTabsEnabled) { - if (!router.isReady || !id || id === 'new') return + if (!router.isReady || !id || id === 'new') return - const tabId = createTabId('sql', { id }) - const snippet = allSnippets.find((x) => x.id === id) + const tabId = createTabId('sql', { id }) + const snippet = allSnippets.find((x) => x.id === id) - tabs.addTab({ - id: tabId, - type: 'sql', - label: snippet?.name || 'Untitled Query', - metadata: { - sqlId: id, - name: snippet?.name, - }, - }) - } - }, [router.isReady, id, isSqlEditorTabsEnabled]) + tabs.addTab({ + id: tabId, + type: 'sql', + label: snippet?.name || 'Untitled Query', + metadata: { + sqlId: id, + name: snippet?.name, + }, + }) + }, [router.isReady, id]) if (snippetMissing || invalidId) { return ( @@ -99,31 +95,27 @@ const SqlEditor: NextPageWithLayout = () => { title={`Unable to find snippet with ID ${id}`} description="This snippet doesn't exist in your project" > - {isSqlEditorTabsEnabled && ( - <> - {!!tabId ? ( - - ) : ( - - )} - + {!!tabId ? ( + + ) : ( + )}
diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index 633f87e8738..b76303b8010 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -1,12 +1,12 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' + import { useParams } from 'common' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' import { NewTab } from 'components/layouts/Tabs/NewTab' -import { useRouter } from 'next/router' -import { useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' import { useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' @@ -16,26 +16,20 @@ const TableEditorPage: NextPageWithLayout = () => { const { ref: projectRef } = useParams() const store = useTabsStateSnapshot() const appSnap = useAppStateSnapshot() - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() useEffect(() => { - if (!isSqlEditorTabsEnabled) { - // Redirect to /new if not using tabs - router.push(`/project/${projectRef}/sql/new`) - } else { - // Handle redirect to last opened snippet tab, or last snippet tab - const lastOpenedTab = appSnap.dashboardHistory.sql - const lastTabId = store.openTabs.find((id) => store.tabsMap[id]?.type === 'sql') - if (lastOpenedTab !== undefined) { - router.push(`/project/${projectRef}/sql/${appSnap.dashboardHistory.sql}`) - } else if (lastTabId) { - const lastTab = store.tabsMap[lastTabId] - if (lastTab) router.push(`/project/${projectRef}/sql/${lastTab.id.replace('sql-', '')}`) - } + // Handle redirect to last opened snippet tab, or last snippet tab + const lastOpenedTab = appSnap.dashboardHistory.sql + const lastTabId = store.openTabs.find((id) => store.tabsMap[id]?.type === 'sql') + if (lastOpenedTab !== undefined) { + router.push(`/project/${projectRef}/sql/${appSnap.dashboardHistory.sql}`) + } else if (lastTabId) { + const lastTab = store.tabsMap[lastTabId] + if (lastTab) router.push(`/project/${projectRef}/sql/${lastTab.id.replace('sql-', '')}`) } - }, [isSqlEditorTabsEnabled]) + }, []) - return isSqlEditorTabsEnabled ? : null + return } TableEditorPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx index bdeffe57588..2b69bbc2512 100644 --- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx +++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx @@ -2,7 +2,6 @@ import { useParams } from 'next/navigation' import { useRouter } from 'next/router' import { useEffect } from 'react' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import SQLQuickstarts from 'components/interfaces/SQLEditor/SQLTemplates/SQLQuickstarts' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' @@ -16,25 +15,20 @@ const SqlQuickstarts: NextPageWithLayout = () => { const { ref } = useParams<{ ref: string }>() const tabs = useTabsStateSnapshot() - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() - useEffect(() => { - if (isSqlEditorTabsEnabled) { - if (!router.isReady) return + if (!router.isReady) return - const tabId = createTabId('sql', { id: 'quickstarts' }) - - tabs.addTab({ - id: tabId, - type: 'sql', - label: 'Quickstarts', - metadata: { - sqlId: 'quickstarts', - name: 'quickstarts', - }, - }) - } - }, [router.isReady, isSqlEditorTabsEnabled, ref]) + const tabId = createTabId('sql', { id: 'quickstarts' }) + tabs.addTab({ + id: tabId, + type: 'sql', + label: 'Quickstarts', + metadata: { + sqlId: 'quickstarts', + name: 'quickstarts', + }, + }) + }, [router.isReady, ref]) return } diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx index 45920a90c96..2e219edaa52 100644 --- a/apps/studio/pages/project/[ref]/sql/templates.tsx +++ b/apps/studio/pages/project/[ref]/sql/templates.tsx @@ -2,7 +2,6 @@ import { useParams } from 'next/navigation' import { useRouter } from 'next/router' import { useEffect } from 'react' -import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import SQLTemplates from 'components/interfaces/SQLEditor/SQLTemplates/SQLTemplates' import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' @@ -16,25 +15,20 @@ const SqlTemplates: NextPageWithLayout = () => { const { ref } = useParams<{ ref: string }>() const tabs = useTabsStateSnapshot() - const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() - useEffect(() => { - if (isSqlEditorTabsEnabled) { - if (!router.isReady) return + if (!router.isReady) return - const tabId = createTabId('sql', { id: 'templates' }) - - tabs.addTab({ - id: tabId, - type: 'sql', - label: 'Templates', - metadata: { - sqlId: 'templates', - name: 'templates', - }, - }) - } - }, [router.isReady, isSqlEditorTabsEnabled, ref]) + const tabId = createTabId('sql', { id: 'templates' }) + tabs.addTab({ + id: tabId, + type: 'sql', + label: 'Templates', + metadata: { + sqlId: 'templates', + name: 'templates', + }, + }) + }, [router.isReady, ref]) return } diff --git a/e2e/studio/features/sql-editor.spec.ts b/e2e/studio/features/sql-editor.spec.ts index efb523b4183..b437c0a2f23 100644 --- a/e2e/studio/features/sql-editor.spec.ts +++ b/e2e/studio/features/sql-editor.spec.ts @@ -1,11 +1,11 @@ import { expect } from '@playwright/test' +import { env } from '../env.config' import { test } from '../utils/test' import { toUrl } from '../utils/to-url' -import { env } from '../env.config' test.describe('SQL Editor', () => { test('should check if SQL editor can run simple commands', async ({ page }) => { - await page.goto(toUrl(`/project/${env.PROJECT_REF}/sql`)) + await page.goto(toUrl(`/project/${env.PROJECT_REF}/sql/new?skip=true`)) const editor = page.getByRole('code').nth(0) From 3a23ebda6c2f7789bf4cfe368f5582ca18c2fd1f Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 23 Jun 2025 11:31:04 +0800 Subject: [PATCH 13/84] Fix edge functions headers span (#36525) --- .../EdgeFunctionSecrets.tsx | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index 98c2e51e065..4aba64bac4e 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -42,6 +42,18 @@ const EdgeFunctionSecrets = () => { [] : data ?? [] + const headers = [ + Name, + + Digest{' '} + + SHA256 + + , + Updated at, + , + ] + return ( <> {isLoading && } @@ -73,17 +85,7 @@ const EdgeFunctionSecrets = () => {
Name, - - Digest{' '} - - SHA256 - - , - Updated at, - , - ]} + head={headers} body={ secrets.length > 0 ? ( secrets.map((secret) => ( @@ -95,7 +97,7 @@ const EdgeFunctionSecrets = () => { )) ) : secrets.length === 0 && searchString.length > 0 ? ( - +

No results found

Your search for "{searchString}" did not return any results @@ -104,7 +106,7 @@ const EdgeFunctionSecrets = () => { ) : ( - +

No secrets created

There are no secrets associated with your project yet From 17632f75207f62415f85721afd1300625de7653c Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 23 Jun 2025 11:43:44 +0800 Subject: [PATCH 14/84] Attempt to fix chrome bookmark image (#36544) --- apps/studio/pages/_app.tsx | 1 + apps/studio/public/img/supabase-logo.png | Bin 0 -> 5819 bytes 2 files changed, 1 insertion(+) create mode 100644 apps/studio/public/img/supabase-logo.png diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index f8f95bfbb55..feb9b18b026 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -109,6 +109,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { Supabase + {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}