diff --git a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx index f246726847596..65be243d12739 100644 --- a/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx +++ b/apps/studio/components/grid/components/footer/operations/SaveQueueActionBar.tsx @@ -1,23 +1,21 @@ -import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions' -import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts' -import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' -import { AnimatePresence, motion } from 'framer-motion' import { Eye } from 'lucide-react' +import { AnimatePresence, motion } from 'framer-motion' import { createPortal } from 'react-dom' -import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button } from 'ui' -import { getModKeyLabel } from '@/lib/helpers' - -const modKey = getModKeyLabel() +import { + useOperationQueueShortcuts, + getModKey, +} from 'components/grid/hooks/useOperationQueueShortcuts' +import { useIsQueueOperationsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useTableEditorStateSnapshot } from 'state/table-editor' +import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions' export const SaveQueueActionBar = () => { const snap = useTableEditorStateSnapshot() const isQueueOperationsEnabled = useIsQueueOperationsEnabled() const { handleSave } = useOperationQueueActions() - useOperationQueueShortcuts() - const operationCount = snap.operationQueue.operations.length const isSaving = snap.operationQueue.status === 'saving' const isOperationQueuePanelOpen = snap.sidePanel?.type === 'operation-queue' @@ -25,6 +23,16 @@ export const SaveQueueActionBar = () => { const isVisible = isQueueOperationsEnabled && snap.hasPendingOperations && !isOperationQueuePanelOpen + useOperationQueueShortcuts({ + enabled: isQueueOperationsEnabled && snap.hasPendingOperations, + onSave: handleSave, + onTogglePanel: () => snap.onViewOperationQueue(), + isSaving, + hasOperations: operationCount > 0, + }) + + const modKey = getModKey() + const content = ( {isVisible && ( @@ -41,7 +49,7 @@ export const SaveQueueActionBar = () => {
diff --git a/apps/studio/components/interfaces/Home/ProjectUsage.tsx b/apps/studio/components/interfaces/Home/ProjectUsage.tsx index 1127da0644abd..29d1645696d75 100644 --- a/apps/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/apps/studio/components/interfaces/Home/ProjectUsage.tsx @@ -1,11 +1,3 @@ -import dayjs from 'dayjs' -import { Auth, Database, Realtime, Storage } from 'icons' -import sumBy from 'lodash/sumBy' -import { ChevronDown } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useState } from 'react' - import { useParams } from 'common' import BarChart from 'components/ui/Charts/BarChart' import { InlineLink } from 'components/ui/InlineLink' @@ -15,10 +7,17 @@ import { UsageApiCounts, useProjectLogStatsQuery, } from 'data/analytics/project-log-stats-query' +import dayjs from 'dayjs' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Auth, Database, Realtime, Storage } from 'icons' +import sumBy from 'lodash/sumBy' +import { ChevronDown } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' import type { ChartIntervals } from 'types' import { Button, @@ -88,20 +87,20 @@ const ProjectUsage = () => { selectedInterval.startUnit as dayjs.ManipulateType ) const endDateLocal = dayjs() - const { data: charts } = useFillTimeseriesSorted( - data?.result || [], - 'timestamp', - [ + const { data: charts } = useFillTimeseriesSorted({ + data: data?.result ?? [], + timestampKey: 'timestamp', + valueKey: [ 'total_auth_requests', 'total_rest_requests', 'total_storage_requests', 'total_realtime_requests', ], - 0, - startDateLocal.toISOString(), - endDateLocal.toISOString(), - 5 - ) + defaultValue: 0, + startDate: startDateLocal.toISOString(), + endDate: endDateLocal.toISOString(), + minPointsToFill: 5, + }) const datetimeFormat = selectedInterval.format || 'MMM D, ha' const handleBarClick = ( diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts index 6b5b8a680b23f..cb9e65c7833f0 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts @@ -8,6 +8,7 @@ import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' import { LogsTableName } from '../Settings/Logs/Logs.constants' import { genChartQuery } from '../Settings/Logs/Logs.utils' import { + type RawChartData, calculateAggregatedMetrics, calculateDateRange, calculateHealthMetrics, @@ -81,11 +82,11 @@ const fetchServiceHealthMetrics = async ( signal, }) - if (error || data?.error) { - throw error || data?.error + if (error ?? data?.error) { + throw error ?? data?.error } - return (data?.result || []) as ChartQueryResult[] + return (data?.result ?? []) as ChartQueryResult[] } /** @@ -119,18 +120,18 @@ const useServiceHealthQuery = ({ const normalizedData = useTimeseriesUnixToIso(queryResult.data ?? [], 'timestamp') // Fill gaps in timeseries - const { data: filledData } = useFillTimeseriesSorted( - normalizedData, - 'timestamp', - 'ok_count', - 0, + const { data: filledData } = useFillTimeseriesSorted({ + data: normalizedData, + timestampKey: 'timestamp', + valueKey: 'ok_count', + defaultValue: 0, startDate, - endDate - ) + endDate, + }) // Transform to LogsBarChartDatum format const eventChartData: LogsBarChartDatum[] = useMemo( - () => transformToBarChartData(filledData), + () => transformToBarChartData(filledData as RawChartData[]), [filledData] ) diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts index 9baab7b547651..cb3d7c8da0ba6 100644 --- a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts @@ -36,11 +36,12 @@ export const calculateDateRange = ( return { startDate: start, endDate: end } } -type RawChartData = { +export type RawChartData = { timestamp: string | number ok_count?: number | null warning_count?: number | null error_count?: number | null + [key: string]: string | number | null | undefined } /** @@ -49,9 +50,9 @@ type RawChartData = { export const transformToBarChartData = (data: RawChartData[]): LogsBarChartDatum[] => { return data.map((row) => ({ timestamp: typeof row.timestamp === 'string' ? row.timestamp : String(row.timestamp), - ok_count: row.ok_count || 0, - warning_count: row.warning_count || 0, - error_count: row.error_count || 0, + ok_count: row.ok_count ?? 0, + warning_count: row.warning_count ?? 0, + error_count: row.error_count ?? 0, })) } diff --git a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index 2c37fb750ba09..dbbcd0eb30537 100644 --- a/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/apps/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -1,47 +1,47 @@ -import sumBy from 'lodash/sumBy' -import { ChevronRight } from 'lucide-react' -import { Fragment, useRef, useState } from 'react' -import * as z from 'zod' - import { useParams } from 'common' +import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants' +import { + MAP_CHART_THEME, + buildCountsByIso2, + computeMarkerRadius, + extractIso2FromFeatureProps, + getFillColor, + getFillOpacity, + isKnownCountryCode, + isMicroCountry, + iso2ToCountryName, +} from 'components/interfaces/Reports/utils/geo' import { - jsonSyntaxHighlight, TextFormatter, + jsonSyntaxHighlight, } from 'components/interfaces/Settings/Logs/LogsFormatters' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import BarChart from 'components/ui/Charts/BarChart' +import { geoCentroid } from 'd3-geo' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' +import { BASE_PATH } from 'lib/constants' +import sumBy from 'lodash/sumBy' +import { ChevronRight } from 'lucide-react' +import { useTheme } from 'next-themes' +import { Fragment, useRef, useState } from 'react' +import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps' import type { ResponseError } from 'types' import { - Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, + Alert_Shadcn_, Button, Collapsible, - Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, WarningIcon, } from 'ui' -import { queryParamsToObject } from '../Reports.utils' +import * as z from 'zod' + import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget' -import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps' -import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants' -import { BASE_PATH } from 'lib/constants' -import { geoCentroid } from 'd3-geo' -import { - buildCountsByIso2, - getFillColor, - getFillOpacity, - isMicroCountry, - isKnownCountryCode, - computeMarkerRadius, - MAP_CHART_THEME, - extractIso2FromFeatureProps, - iso2ToCountryName, -} from 'components/interfaces/Reports/utils/geo' -import { useTheme } from 'next-themes' +import { queryParamsToObject } from '../Reports.utils' export const NetworkTrafficRenderer = ( props: ReportWidgetProps<{ @@ -50,14 +50,14 @@ export const NetworkTrafficRenderer = ( egress: number }> ) => { - const { data, error, isError } = useFillTimeseriesSorted( - props.data, - 'timestamp', - ['ingress_mb', 'egress_mb'], - 0, - props.params?.iso_timestamp_start, - props.params?.iso_timestamp_end - ) + const { data, error, isError } = useFillTimeseriesSorted({ + data: props.data, + timestampKey: 'timestamp', + valueKey: ['ingress_mb', 'egress_mb'], + defaultValue: 0, + startDate: props.params?.iso_timestamp_start, + endDate: props.params?.iso_timestamp_end, + }) const totalIngress = sumBy(props.data, 'ingress_mb') const totalEgress = sumBy(props.data, 'egress_mb') @@ -76,7 +76,7 @@ export const NetworkTrafficRenderer = ( Failed to retrieve network traffic - {error.message} + {error?.message ?? 'Unknown error'} ) } @@ -121,14 +121,14 @@ export const TotalRequestsChartRenderer = ( const total = props.data.reduce((acc, datum) => { return acc + datum.count }, 0) - const { data, error, isError } = useFillTimeseriesSorted( - props.data, - 'timestamp', - 'count', - 0, - props.params?.iso_timestamp_start, - props.params?.iso_timestamp_end - ) + const { data, error, isError } = useFillTimeseriesSorted({ + data: props.data, + timestampKey: 'timestamp', + valueKey: 'count', + defaultValue: 0, + startDate: props.params?.iso_timestamp_start, + endDate: props.params?.iso_timestamp_end, + }) if (!!props.error) { const error = ( @@ -140,7 +140,7 @@ export const TotalRequestsChartRenderer = ( Failed to retrieve total requests - {error.message} + {error?.message ?? 'Unknown error'} ) } @@ -253,14 +253,14 @@ export const ErrorCountsChartRenderer = ( return acc + datum.count }, 0) - const { data, error, isError } = useFillTimeseriesSorted( - props.data, - 'timestamp', - 'count', - 0, - props.params?.iso_timestamp_start, - props.params?.iso_timestamp_end - ) + const { data, error, isError } = useFillTimeseriesSorted({ + data: props.data, + timestampKey: 'timestamp', + valueKey: 'count', + defaultValue: 0, + startDate: props.params?.iso_timestamp_start, + endDate: props.params?.iso_timestamp_end, + }) if (!!props.error) { const error = ( @@ -272,7 +272,7 @@ export const ErrorCountsChartRenderer = ( Failed to retrieve request errors - {error.message} + {error?.message ?? 'Unknown error'} ) } @@ -302,14 +302,14 @@ export const ResponseSpeedChartRenderer = ( avg: datum.avg, })) - const { data, error, isError } = useFillTimeseriesSorted( - transformedData, - 'timestamp', - 'avg', - 0, - props.params?.iso_timestamp_start, - props.params?.iso_timestamp_end - ) + const { data, error, isError } = useFillTimeseriesSorted({ + data: transformedData, + timestampKey: 'timestamp', + valueKey: 'avg', + defaultValue: 0, + startDate: props.params?.iso_timestamp_start, + endDate: props.params?.iso_timestamp_end, + }) const lastAvg = props.data[props.data.length - 1]?.avg @@ -323,7 +323,7 @@ export const ResponseSpeedChartRenderer = ( Failed to retrieve response speeds - {error.message} + {error?.message ?? 'Unknown error'} ) } diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index ef21606cd2c0f..39633d1304796 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -1,16 +1,17 @@ import { useQuery } from '@tanstack/react-query' -import { Loader2 } from 'lucide-react' -import { useState } from 'react' - import type { ChartHighlightAction } from 'components/ui/Charts/ChartHighlightActions' import { ComposedChart } from 'components/ui/Charts/ComposedChart' +import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' import { useChartHighlight } from 'components/ui/Charts/useChartHighlight' import type { AnalyticsInterval } from 'data/analytics/constants' import type { ReportConfig } from 'data/reports/v2/reports.types' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Loader2 } from 'lucide-react' +import { useState } from 'react' import { Card, CardContent, cn } from 'ui' + import { ReportChartUpsell } from './ReportChartUpsell' export interface ReportChartV2Props { @@ -32,10 +33,13 @@ export interface ReportChartV2Props { // Compute total across entire period over unique attribute keys. // Excludes attributes that are disabled, reference lines, max values, or marked omitFromTotal. -export function computePeriodTotal(chartData: any[], dynamicAttributes: any[]): number { +export function computePeriodTotal( + chartData: Record[], + dynamicAttributes: MultiAttribute[] +): number { const attributeKeys = Array.from( new Set( - (dynamicAttributes as any[]) + dynamicAttributes .filter( (a) => a?.enabled !== false && @@ -43,11 +47,11 @@ export function computePeriodTotal(chartData: any[], dynamicAttributes: any[]): !a?.isMaxValue && !a?.omitFromTotal ) - .map((a: any) => a.attribute) + .map((a) => a.attribute) ) ) - return chartData.reduce((sum: number, row: any) => { + return chartData.reduce((sum: number, row: Record) => { const rowTotal = attributeKeys.reduce((acc: number, key: string) => { const value = row?.[key] return acc + (typeof value === 'number' ? value : 0) @@ -111,16 +115,16 @@ export const ReportChartV2 = ({ const firstItem = chartData[0] const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start' - const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted( - chartData, + const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted({ + data: chartData, timestampKey, - (dynamicAttributes as any[]).map((attr: any) => attr.attribute), - 0, + valueKey: dynamicAttributes.map((attr) => attr.attribute), + defaultValue: 0, startDate, endDate, - undefined, - interval - ) + minPointsToFill: undefined, + interval, + }) const [chartStyle, setChartStyle] = useState(report.defaultChartStyle) const chartHighlight = useChartHighlight() diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx index 4206f44a60745..eb87f92a313bd 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnType.tsx @@ -22,6 +22,7 @@ import { CommandInput_Shadcn_, CommandItem_Shadcn_, CommandList_Shadcn_, + CommandSeparator_Shadcn_, Command_Shadcn_, CriticalIcon, Input, @@ -191,13 +192,13 @@ const ColumnType = ({ placeholder="Search types..." // [Joshen] Addresses style issues when this component is being used in the old Form component // Specifically in WrapperDynamicColumns - can be cleaned up once we're no longer using that - className="!bg-transparent focus:!shadow-none focus:!ring-0" + className="!bg-transparent focus:!shadow-none focus:!ring-0 text-xs" /> Type not found. - + {POSTGRES_DATA_TYPE_OPTIONS.map((option: PostgresDataTypeOption) => ( ))} + {enumTypes.length > 0 && ( <> - Other types - + + {enumTypes.map((option) => ( {fullTableName}
{columnName} - + · where {whereClause}
- } onClick={handleDelete} - className="shrink-0 w-7" + className="shrink-0" aria-label="Remove operation" - tooltip={{ content: { side: 'bottom', text: 'Remove operation' } }} /> diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx index 9bfa1251e06c7..5a5754ad2cb1f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/OperationQueueSidePanel/OperationQueueSidePanel.tsx @@ -1,9 +1,9 @@ import { useOperationQueueActions } from 'components/grid/hooks/useOperationQueueActions' +import { useOperationQueueShortcuts } from 'components/grid/hooks/useOperationQueueShortcuts' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button, SidePanel } from 'ui' import { OperationList } from './OperationList' -import { getModKeyLabel } from '@/lib/helpers' import { QueuedOperation } from '@/state/table-editor-operation-queue.types' interface OperationQueueSidePanelProps { @@ -11,8 +11,6 @@ interface OperationQueueSidePanelProps { closePanel: () => void } -const modKey = getModKeyLabel() - export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueSidePanelProps) => { const snap = useTableEditorStateSnapshot() @@ -23,6 +21,14 @@ export const OperationQueueSidePanel = ({ visible, closePanel }: OperationQueueS onCancelSuccess: closePanel, }) + const { modKey } = useOperationQueueShortcuts({ + enabled: visible, + onSave: handleSave, + onTogglePanel: closePanel, + isSaving, + hasOperations: operations.length > 0, + }) + return (
- Pending changes + Pending Changes {operations.length} operation{operations.length !== 1 ? 's' : ''} diff --git a/apps/studio/data/analytics/project-log-stats-query.ts b/apps/studio/data/analytics/project-log-stats-query.ts index d50cb53fdf0d6..b0eaeea269f2b 100644 --- a/apps/studio/data/analytics/project-log-stats-query.ts +++ b/apps/studio/data/analytics/project-log-stats-query.ts @@ -1,9 +1,10 @@ import { QueryClient, useQuery } from '@tanstack/react-query' import { operations } from 'api-types' import { get, handleError } from 'data/fetchers' -import { analyticsKeys } from './keys' import { UseCustomQueryOptions } from 'types' +import { analyticsKeys } from './keys' + export type ProjectLogStatsVariables = { projectRef?: string interval?: NonNullable< @@ -20,6 +21,7 @@ export interface UsageApiCounts { total_rest_requests: number total_realtime_requests: number timestamp: string + [key: string]: string | number } export async function getProjectLogStats( diff --git a/apps/studio/hooks/analytics/useFillTimeseriesSorted.test.ts b/apps/studio/hooks/analytics/useFillTimeseriesSorted.test.ts new file mode 100644 index 0000000000000..0ea09e7d3dd14 --- /dev/null +++ b/apps/studio/hooks/analytics/useFillTimeseriesSorted.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' + +import { hasValidTimestamp, sortByTimestamp } from './useFillTimeseriesSorted' + +describe('hasValidTimestamp', () => { + it('should return true when data has valid timestamp key', () => { + const data = [{ timestamp: '2024-01-01T00:00:00Z', value: 1 }] + expect(hasValidTimestamp(data, 'timestamp')).toBe(true) + }) + + it('should return false when data is empty', () => { + expect(hasValidTimestamp([], 'timestamp')).toBe(false) + }) + + it('should return false when timestamp key does not exist', () => { + const data = [{ value: 1 }] + expect(hasValidTimestamp(data, 'timestamp')).toBe(false) + }) + + it('should work with custom timestamp key', () => { + const data = [{ period_start: '2024-01-01T00:00:00Z', value: 1 }] + expect(hasValidTimestamp(data, 'period_start')).toBe(true) + }) +}) + +describe('sortByTimestamp', () => { + it('should sort data in ascending order by timestamp', () => { + const data = [ + { timestamp: '2024-01-03T00:00:00Z', value: 3 }, + { timestamp: '2024-01-01T00:00:00Z', value: 1 }, + { timestamp: '2024-01-02T00:00:00Z', value: 2 }, + ] + + const sorted = sortByTimestamp(data, 'timestamp') + + expect(sorted[0].value).toBe(1) + expect(sorted[1].value).toBe(2) + expect(sorted[2].value).toBe(3) + }) + + it('should handle already sorted data', () => { + const data = [ + { timestamp: '2024-01-01T00:00:00Z', value: 1 }, + { timestamp: '2024-01-02T00:00:00Z', value: 2 }, + ] + + const sorted = sortByTimestamp(data, 'timestamp') + + expect(sorted[0].value).toBe(1) + expect(sorted[1].value).toBe(2) + }) + + it('should handle empty array', () => { + const sorted = sortByTimestamp([], 'timestamp') + expect(sorted).toEqual([]) + }) + + it('should handle single item', () => { + const data = [{ timestamp: '2024-01-01T00:00:00Z', value: 1 }] + const sorted = sortByTimestamp(data, 'timestamp') + expect(sorted).toEqual(data) + }) + + it('should work with custom timestamp key', () => { + const data = [ + { period_start: '2024-01-03T00:00:00Z', value: 3 }, + { period_start: '2024-01-01T00:00:00Z', value: 1 }, + { period_start: '2024-01-02T00:00:00Z', value: 2 }, + ] + + const sorted = sortByTimestamp(data, 'period_start') + + expect(sorted[0].value).toBe(1) + expect(sorted[1].value).toBe(2) + expect(sorted[2].value).toBe(3) + }) + + it('should handle timestamps with different time zones', () => { + const data = [ + { timestamp: '2024-01-01T12:00:00Z', value: 2 }, + { timestamp: '2024-01-01T00:00:00Z', value: 1 }, + { timestamp: '2024-01-01T18:00:00Z', value: 3 }, + ] + + const sorted = sortByTimestamp(data, 'timestamp') + + expect(sorted[0].value).toBe(1) + expect(sorted[1].value).toBe(2) + expect(sorted[2].value).toBe(3) + }) + + it('should maintain stable sort for equal timestamps', () => { + const data = [ + { timestamp: '2024-01-01T00:00:00Z', value: 1, id: 'a' }, + { timestamp: '2024-01-01T00:00:00Z', value: 2, id: 'b' }, + { timestamp: '2024-01-01T00:00:00Z', value: 3, id: 'c' }, + ] + + const sorted = sortByTimestamp(data, 'timestamp') + + // Should maintain original order for equal timestamps + expect(sorted[0].id).toBe('a') + expect(sorted[1].id).toBe('b') + expect(sorted[2].id).toBe('c') + }) +}) diff --git a/apps/studio/hooks/analytics/useFillTimeseriesSorted.ts b/apps/studio/hooks/analytics/useFillTimeseriesSorted.ts index af81240c7656f..70a6acaa8faa0 100644 --- a/apps/studio/hooks/analytics/useFillTimeseriesSorted.ts +++ b/apps/studio/hooks/analytics/useFillTimeseriesSorted.ts @@ -1,35 +1,126 @@ import { fillTimeseries } from 'components/interfaces/Settings/Logs/Logs.utils' +import type { Datum } from 'components/ui/Charts/Charts.types' import { useMemo } from 'react' +export type FillTimeseriesOptions = { + /** The timeseries data to fill gaps in */ + data: T[] + /** The key in each data object that contains the timestamp */ + timestampKey: string + /** The key(s) to fill with default values when gaps exist */ + valueKey: string | string[] + /** Default value to use for gaps */ + defaultValue: number + /** Start of the time range (ISO string) */ + startDate?: string + /** End of the time range (ISO string) */ + endDate?: string + /** Minimum number of points before filling is applied */ + minPointsToFill?: number + /** Optional interval specification (e.g., '5m', '1h') */ + interval?: string +} + +export type FillTimeseriesResult = { + data: T[] + error: Error | null + isError: boolean +} + +/** + * Sorts timeseries data by timestamp in ascending order + * Returns a new sorted array without mutating the input + */ +export function sortByTimestamp(data: T[], timestampKey: string): T[] { + return [...data].sort((a, b) => { + return ( + new Date(a[timestampKey] as string).getTime() - new Date(b[timestampKey] as string).getTime() + ) + }) +} + +/** + * Validates that the data has a valid timestamp key + */ +export function hasValidTimestamp(data: T[], timestampKey: string): boolean { + return Boolean(data[0]?.[timestampKey]) +} + /** * Convenience hook for memoized filling of timeseries data. + * + * Fills gaps in timeseries data and sorts results by timestamp. + * + * @example + * ```ts + * const { data, error, isError } = useFillTimeseriesSorted({ + * data: chartData, + * timestampKey: 'timestamp', + * valueKey: 'count', + * defaultValue: 0, + * startDate: startIso, + * endDate: endIso + * }) + * ``` */ -export const useFillTimeseriesSorted = (...args: Parameters) => { +export const useFillTimeseriesSorted = ( + options: FillTimeseriesOptions +): FillTimeseriesResult => { + const { + data, + timestampKey, + valueKey, + defaultValue, + startDate, + endDate, + minPointsToFill = 20, + interval, + } = options + return useMemo(() => { - const [data, timestampKey] = args - if (!data[0]?.[timestampKey]) + // Early return if no valid timestamp + if (!hasValidTimestamp(data, timestampKey)) { return { data, - error: undefined, + error: null, isError: false, } + } try { - const filled = fillTimeseries(...args) + const filled = fillTimeseries( + data, + timestampKey, + valueKey, + defaultValue, + startDate, + endDate, + minPointsToFill, + interval + ) as T[] + + const sorted = sortByTimestamp(filled, timestampKey) return { - data: filled.sort((a, b) => { - return (new Date(a[args[1]]) as any) - (new Date(b[args[1]]) as any) - }), - error: undefined, + data: sorted, + error: null, isError: false, } - } catch (error: any) { + } catch (error: unknown) { return { data: [], - error, + error: error instanceof Error ? error : new Error(String(error)), isError: true, } } - }, [JSON.stringify(args[0]), ...args]) + }, [ + JSON.stringify(data), + timestampKey, + JSON.stringify(valueKey), + defaultValue, + startDate, + endDate, + minPointsToFill, + interval, + ]) } diff --git a/apps/studio/hooks/analytics/useLogsPreview.tsx b/apps/studio/hooks/analytics/useLogsPreview.tsx index 5e04dbc70dc9f..84832205fbaa5 100644 --- a/apps/studio/hooks/analytics/useLogsPreview.tsx +++ b/apps/studio/hooks/analytics/useLogsPreview.tsx @@ -1,7 +1,4 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query' -import dayjs from 'dayjs' -import { useCallback, useMemo, useState } from 'react' - import { LogsTableName, PREVIEWER_DATEPICKER_HELPERS, @@ -22,6 +19,9 @@ import { genDefaultQuery, } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' +import dayjs from 'dayjs' +import { useCallback, useMemo, useState } from 'react' + import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' import { useLogsUrlState } from './useLogsUrlState' import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' @@ -248,14 +248,14 @@ function useLogsPreview({ 'timestamp' ) - const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted( - normalizedEventChartData, - 'timestamp', - 'count', - 0, - timestampStart, - timestampEnd || new Date().toISOString() - ) + const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted({ + data: normalizedEventChartData, + timestampKey: 'timestamp', + valueKey: 'count', + defaultValue: 0, + startDate: timestampStart, + endDate: timestampEnd ?? new Date().toISOString(), + }) return { newCount, diff --git a/apps/studio/hooks/analytics/useProjectUsageStats.tsx b/apps/studio/hooks/analytics/useProjectUsageStats.tsx index 78021bff7c6dc..ddbbe4cab141d 100644 --- a/apps/studio/hooks/analytics/useProjectUsageStats.tsx +++ b/apps/studio/hooks/analytics/useProjectUsageStats.tsx @@ -1,6 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useMemo } from 'react' - import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' import type { EventChart, @@ -10,6 +8,8 @@ import type { } from 'components/interfaces/Settings/Logs/Logs.types' import { genChartQuery } from 'components/interfaces/Settings/Logs/Logs.utils' import { get } from 'data/fetchers' +import { useMemo } from 'react' + import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' @@ -97,14 +97,14 @@ function useProjectUsageStats({ 'timestamp' ) - const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted( - normalizedEventChartData, - 'timestamp', - 'count', - 0, - timestampStart, - timestampEnd || new Date().toISOString() - ) + const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted({ + data: normalizedEventChartData, + timestampKey: 'timestamp', + valueKey: 'count', + defaultValue: 0, + startDate: timestampStart, + endDate: timestampEnd ?? new Date().toISOString(), + }) return { isLoading: !eventChartResponse, diff --git a/apps/studio/hooks/ui/useHotKey.ts b/apps/studio/hooks/ui/useHotKey.ts index 41e470ca11cc8..d8daff563c33b 100644 --- a/apps/studio/hooks/ui/useHotKey.ts +++ b/apps/studio/hooks/ui/useHotKey.ts @@ -1,49 +1,25 @@ import { useEffect } from 'react' -import { useLatest } from 'react-use' -// [Joshen] Refactor: Remove dependencies, and just make this into a single definition -function useHotKey( +export function useHotKey( callback: (e: KeyboardEvent) => void, key: string, - options?: { enabled?: boolean } -): void -/** - * @deprecated The `dependencies` parameter is deprecated. Use the overload without dependencies instead. - */ -function useHotKey( - callback: (e: KeyboardEvent) => void, - key: string, - dependencies: unknown[], - options?: { enabled?: boolean } -): void -function useHotKey( - callback: (e: KeyboardEvent) => void, - key: string, - dependenciesOrOptions?: unknown[] | { enabled?: boolean }, + dependencies: any[] = [], options?: { enabled?: boolean } ): void { - // Determine which overload was called - const isDepsArray = Array.isArray(dependenciesOrOptions) - const resolvedOptions = isDepsArray ? options : dependenciesOrOptions - const enabled = resolvedOptions?.enabled ?? true - - const enabledRef = useLatest(enabled) - const callbackRef = useLatest(callback) - const keyRef = useLatest(key) + const enabled = options?.enabled ?? true useEffect(() => { + if (!enabled) return + function handler(e: KeyboardEvent) { - if (!enabledRef.current) return - if ((e.metaKey || e.ctrlKey) && e.key === keyRef.current && !e.altKey && !e.shiftKey) { - callbackRef.current(e) + if ((e.metaKey || e.ctrlKey) && e.key === key && !e.altKey && !e.shiftKey) { + callback(e) } } - window.addEventListener('keydown', handler, true) + window.addEventListener('keydown', handler) return () => { - window.removeEventListener('keydown', handler, true) + window.removeEventListener('keydown', handler) } - }, [callbackRef, enabledRef, keyRef]) + }, [key, enabled, ...dependencies]) } - -export { useHotKey } diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 58ffb5c303d29..a82e0c00dc518 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -182,11 +182,6 @@ export const detectOS = () => { } } -export const getModKeyLabel = () => { - const os = detectOS() - return os === 'macos' ? '⌘' : 'Ctrl+' -} - /** * Convert a list of tables to SQL * @param t - The list of tables diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index dd26dee0ae5c1..429d27bb27cd8 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -1,12 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import dayjs, { Dayjs } from 'dayjs' -import maxBy from 'lodash/maxBy' -import meanBy from 'lodash/meanBy' -import sumBy from 'lodash/sumBy' -import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' - import { useFlag } from 'common' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import DefaultLayout from 'components/layouts/DefaultLayout' @@ -19,8 +12,14 @@ import { useFunctionsCombinedStatsQuery, } from 'data/analytics/functions-combined-stats-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' +import dayjs, { Dayjs } from 'dayjs' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import maxBy from 'lodash/maxBy' +import meanBy from 'lodash/meanBy' +import sumBy from 'lodash/sumBy' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' import type { ChartIntervals, NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, @@ -81,7 +80,9 @@ const PageLayout: NextPageWithLayout = () => { }) const combinedStatsData = useMemo(() => { - const result = combinedStatsResults.data?.result + const result = combinedStatsResults.data?.result as + | Record[] + | undefined return result || [] }, [combinedStatsResults.data]) @@ -98,10 +99,10 @@ const PageLayout: NextPageWithLayout = () => { data: combinedStatsChartData, error: combinedStatsError, isError: isErrorCombinedStats, - } = useFillTimeseriesSorted( - combinedStatsData, - 'timestamp', - [ + } = useFillTimeseriesSorted({ + data: combinedStatsData, + timestampKey: 'timestamp', + valueKey: [ 'requests_count', 'log_count', 'log_info_count', @@ -119,10 +120,10 @@ const PageLayout: NextPageWithLayout = () => { 'avg_external_memory_used', 'max_cpu_time_used', ], - 0, - startDate.toISOString(), - endDate.toISOString() - ) + defaultValue: 0, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }) const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions( PermissionAction.FUNCTIONS_READ, @@ -179,7 +180,7 @@ const PageLayout: NextPageWithLayout = () => { Failed to reterieve execution time - {combinedStatsError.message} + {combinedStatsError?.message ?? 'Unknown error'} ) : ( @@ -224,7 +225,7 @@ const PageLayout: NextPageWithLayout = () => { Failed to reterieve invocations - {combinedStatsError.message} + {combinedStatsError?.message ?? 'Unknown error'} ) @@ -326,7 +327,7 @@ const PageLayout: NextPageWithLayout = () => { Failed to retrieve CPU time - {combinedStatsError.message} + {combinedStatsError?.message ?? 'Unknown error'} ) : ( @@ -371,7 +372,7 @@ const PageLayout: NextPageWithLayout = () => { Failed to retrieve memory usage - {combinedStatsError.message} + {combinedStatsError?.message ?? 'Unknown error'} ) diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 0bd5be50c420a..a45f9672c4451 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -1,4 +1,7 @@ import type { PostgresColumn } from '@supabase/postgres-meta' +import { PropsWithChildren, createContext, useContext } from 'react' +import { proxy, useSnapshot } from 'valtio' + import { useConstant } from 'common' import type { SupaRow } from 'components/grid/types' import { @@ -8,16 +11,15 @@ import { import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types' import type { EditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' import type { TableField } from 'components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.types' -import { PropsWithChildren, createContext, useContext } from 'react' import type { Dictionary } from 'types' -import { proxy, useSnapshot } from 'valtio' import { NewQueuedOperation, + QueuedOperationType, + type EditCellContentPayload, type OperationQueueState, - type QueueStatus, type QueuedOperation, - QueuedOperationType, + type QueueStatus, } from './table-editor-operation-queue.types' export const TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE = 100 @@ -201,14 +203,10 @@ export const createTableEditorState = () => { sidePanel: { type: 'csv-import', file }, } }, - toggleViewOperationQueue: () => { - if (state.ui.open === 'side-panel' && state.ui.sidePanel.type === 'operation-queue') { - state.closeSidePanel() - } else { - state.ui = { - open: 'side-panel', - sidePanel: { type: 'operation-queue' }, - } + onViewOperationQueue: () => { + state.ui = { + open: 'side-panel', + sidePanel: { type: 'operation-queue' }, } },