diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index e5f3358609f28..85d09e05af316 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.34.3 + version: 2.39.2 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -70,6 +70,10 @@ flags: - id: yaml name: yaml type: '[ env | pretty | json | toml | yaml ]' + - id: profile + name: --profile + description: use a specific profile for connecting to Supabase API + default_value: supabase - id: workdir name: --workdir description: path to a Supabase project directory diff --git a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx index 248f01eed91a0..9d238b3acdfca 100644 --- a/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx +++ b/apps/studio/components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails.tsx @@ -8,6 +8,7 @@ export interface AuthorizeRequesterDetailsProps { name: string domain: string scopes: OAuthScope[] + showOnlyScopes?: boolean } export const ScopeSection = ({ @@ -55,31 +56,43 @@ export const AuthorizeRequesterDetails = ({ name, domain, scopes, + showOnlyScopes = false, }: AuthorizeRequesterDetailsProps) => { return (
-
-
-
- {!icon &&

{name[0]}

} + {!showOnlyScopes && ( +
+
+
+ {!icon &&

{name[0]}

} +
+

+ {name} ({domain}) is requesting API access to an organization. +

-

- {name} ({domain}) is requesting API access to an organization. -

-
+ )}
-

Permissions

-

- The following scopes will apply for the{' '} - selected organization and all of its projects. -

+ {!showOnlyScopes && ( + <> +

Permissions

+

+ The following scopes will apply for the{' '} + selected organization and all of its projects. +

+ + )}
+ {scopes.length === 0 && ( +

+ No permissions requested, {name} will not have access to your organization or projects +

+ )} ) => { return ( <> -
+
Supabase
-
-
{children}
+
+
{children}
) diff --git a/apps/studio/components/ui/Charts/AreaChart.tsx b/apps/studio/components/ui/Charts/AreaChart.tsx index 035582d4a2262..4328b6ba5803f 100644 --- a/apps/studio/components/ui/Charts/AreaChart.tsx +++ b/apps/studio/components/ui/Charts/AreaChart.tsx @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { useState } from 'react' import { Area, AreaChart as RechartAreaChart, Tooltip, XAxis } from 'recharts' -import { useChartSync } from './useChartSync' +import { useChartHoverState } from './useChartHoverState' import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants' import ChartHeader from './ChartHeader' @@ -35,11 +35,9 @@ const AreaChart = ({ syncId, }: AreaChartProps) => { const { Container } = useChartSize(size) - const { - state: syncState, - updateState: updateSyncState, - clearState: clearSyncState, - } = useChartSync(syncId) + const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( + syncId || 'default' + ) const [focusDataIndex, setFocusDataIndex] = useState(null) const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) @@ -102,21 +100,12 @@ const AreaChart = ({ setFocusDataIndex(e.activeTooltipIndex) } - if (syncId) { - updateSyncState({ - activeIndex: e.activeTooltipIndex, - activePayload: e.activePayload, - activeLabel: e.activeLabel, - isHovering: true, - }) - } + setHover(e.activeTooltipIndex) }} onMouseLeave={() => { setFocusDataIndex(null) - if (syncId) { - clearSyncState() - } + clearHover() }} > @@ -137,16 +126,13 @@ const AreaChart = ({ /> - syncId && syncState.isHovering && syncState.activeIndex !== null ? ( + syncId && syncTooltip && hoveredIndex !== null ? (
- {dayjs(data[syncState.activeIndex]?.[xAxisKey]).format(customDateFormat)} + {dayjs(data[hoveredIndex]?.[xAxisKey]).format(customDateFormat)}
- {numberFormatter( - Number(data[syncState.activeIndex]?.[yAxisKey]) || 0, - valuePrecision - )} + {numberFormatter(Number(data[hoveredIndex]?.[yAxisKey]) || 0, valuePrecision)} {format}
diff --git a/apps/studio/components/ui/Charts/BarChart.tsx b/apps/studio/components/ui/Charts/BarChart.tsx index c406134d3ee27..5977709174e4c 100644 --- a/apps/studio/components/ui/Charts/BarChart.tsx +++ b/apps/studio/components/ui/Charts/BarChart.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import { ComponentProps, useState, useMemo } from 'react' -import { useChartSync } from './useChartSync' +import { useChartHoverState } from './useChartHoverState' import { Bar, CartesianGrid, @@ -58,11 +58,9 @@ const BarChart = ({ syncId, }: BarChartProps) => { const { Container } = useChartSize(size) - const { - state: syncState, - updateState: updateSyncState, - clearState: clearSyncState, - } = useChartSync(syncId) + const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( + syncId || 'default' + ) const [focusDataIndex, setFocusDataIndex] = useState(null) // Transform data to ensure yAxisKey values are numbers @@ -151,21 +149,12 @@ const BarChart = ({ setFocusDataIndex(e.activeTooltipIndex) } - if (syncId) { - updateSyncState({ - activeIndex: e.activeTooltipIndex, - activePayload: e.activePayload, - activeLabel: e.activeLabel, - isHovering: true, - }) - } + setHover(e.activeTooltipIndex) }} onMouseLeave={() => { setFocusDataIndex(null) - if (syncId) { - clearSyncState() - } + clearHover() }} onClick={(tooltipData) => { const datum = tooltipData?.activePayload?.[0]?.payload @@ -188,16 +177,13 @@ const BarChart = ({ /> - syncId && syncState.isHovering && syncState.activeIndex !== null ? ( + syncId && syncTooltip && hoveredIndex !== null ? (
- {dayjs(data[syncState.activeIndex]?.[xAxisKey]).format(customDateFormat)} + {dayjs(data[hoveredIndex]?.[xAxisKey]).format(customDateFormat)}
- {numberFormatter( - Number(data[syncState.activeIndex]?.[yAxisKey]) || 0, - valuePrecision - )} + {numberFormatter(Number(data[hoveredIndex]?.[yAxisKey]) || 0, valuePrecision)} {typeof format === 'string' ? format : ''}
diff --git a/apps/studio/components/ui/Charts/ChartHandler.tsx b/apps/studio/components/ui/Charts/ChartHandler.tsx index fdce29bb07510..90193372084c4 100644 --- a/apps/studio/components/ui/Charts/ChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ChartHandler.tsx @@ -33,6 +33,7 @@ interface ChartHandlerProps { isLoading?: boolean format?: string highlightedValue?: string | number + syncId?: string } /** @@ -59,6 +60,7 @@ const ChartHandler = ({ isLoading, format, highlightedValue, + syncId, ...otherProps }: PropsWithChildren) => { const router = useRouter() @@ -176,6 +178,7 @@ const ChartHandler = ({ highlightedValue={_highlightedValue} title={label} customDateFormat={customDateFormat} + syncId={syncId} {...otherProps} /> ) : ( @@ -187,6 +190,7 @@ const ChartHandler = ({ highlightedValue={_highlightedValue} title={label} customDateFormat={customDateFormat} + syncId={syncId} {...otherProps} /> )} diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 28ca8a92a98c0..92840ba3a0e9f 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' -import { useChartSync } from './useChartSync' +import { useChartHoverState } from './useChartHoverState' import { numberFormatter } from './Charts.utils' export interface ChartHeaderProps { @@ -58,7 +58,7 @@ const ChartHeader = ({ isNetworkChart = false, attributes, }: ChartHeaderProps) => { - const { state: syncState } = useChartSync(syncId) + const { hoveredIndex, syncHover } = useChartHoverState(syncId || 'default') const [localHighlightedValue, setLocalHighlightedValue] = useState(highlightedValue) const [localHighlightedLabel, setLocalHighlightedLabel] = useState(highlightedLabel) @@ -76,8 +76,8 @@ const ChartHeader = ({ } useEffect(() => { - if (syncId && syncState.activeIndex !== null && data && xAxisKey && yAxisKey) { - const activeDataPoint = data[syncState.activeIndex] + if (syncId && hoveredIndex !== null && syncHover && data && xAxisKey && yAxisKey) { + const activeDataPoint = data[hoveredIndex] if (activeDataPoint) { // For stacked charts, we need to calculate the total of all attributes // that should be included in the total (excluding reference lines, max values, etc.) @@ -127,7 +127,8 @@ const ChartHeader = ({ setLocalHighlightedLabel(highlightedLabel) } }, [ - syncState, + hoveredIndex, + syncHover, syncId, data, xAxisKey, diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 7dc8b70ac6770..619e7ddf7c269 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' -import { useChartSync } from './useChartSync' +import { useChartHoverState } from './useChartHoverState' import { Area, Bar, @@ -102,11 +102,9 @@ export default function ComposedChart({ docsUrl, }: ComposedChartProps) { const { resolvedTheme } = useTheme() - const { - state: syncState, - updateState: updateSyncState, - clearState: clearSyncState, - } = useChartSync(syncId) + const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( + syncId || 'default' + ) const [_activePayload, setActivePayload] = useState(null) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = useState(null) @@ -339,14 +337,7 @@ export default function ComposedChart({ setActivePayload(e.activePayload) } - if (syncId) { - updateSyncState({ - activeIndex: e.activeTooltipIndex, - activePayload: e.activePayload, - activeLabel: e.activeLabel, - isHovering: true, - }) - } + setHover(e.activeTooltipIndex) const activeTimestamp = data[e.activeTooltipIndex]?.timestamp chartHighlight?.handleMouseMove({ @@ -367,9 +358,7 @@ export default function ComposedChart({ setFocusDataIndex(null) setActivePayload(null) - if (syncId) { - clearSyncState() - } + clearHover() }} onClick={(tooltipData) => { const datum = tooltipData?.activePayload?.[0]?.payload @@ -404,7 +393,9 @@ export default function ComposedChart({ attributes={attributes} valuePrecision={valuePrecision} showTotal={showTotal} - isActiveHoveredChart={isActiveHoveredChart || (!!syncId && syncState.isHovering)} + isActiveHoveredChart={ + isActiveHoveredChart || (!!syncId && syncTooltip && hoveredIndex !== null) + } /> ) : null } diff --git a/apps/studio/components/ui/Charts/ReportSettings.tsx b/apps/studio/components/ui/Charts/ReportSettings.tsx new file mode 100644 index 0000000000000..df6511047b3f5 --- /dev/null +++ b/apps/studio/components/ui/Charts/ReportSettings.tsx @@ -0,0 +1,59 @@ +import { Settings } from 'lucide-react' +import { useState } from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label_Shadcn_, Switch } from 'ui' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useChartHoverState } from './useChartHoverState' +import { cn } from 'ui' + +interface ReportSettingsProps { + chartId: string +} + +export const ReportSettings = ({ chartId }: ReportSettingsProps) => { + const [isOpen, setIsOpen] = useState(false) + const { syncHover, syncTooltip, setSyncHover, setSyncTooltip } = useChartHoverState(chartId) + + return ( + + + } + className="w-7" + tooltip={{ content: { side: 'bottom', text: 'Report settings' } }} + /> + + +
+ +
+ Sync chart headers + +
+

+ When enabled, hovering over any chart will update headers across all charts +

+
+ + +
+ Sync tooltips + +
+

+ When enabled, also shows tooltips on all charts.{' '} + + Requires header sync. + +

+
+
+
+
+ ) +} diff --git a/apps/studio/components/ui/Charts/StackedBarChart.tsx b/apps/studio/components/ui/Charts/StackedBarChart.tsx index 9d25d99b64471..0ef800be4b3fd 100644 --- a/apps/studio/components/ui/Charts/StackedBarChart.tsx +++ b/apps/studio/components/ui/Charts/StackedBarChart.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts' import dayjs from 'dayjs' import utc from 'dayjs/plugin/utc' -import { useChartSync } from './useChartSync' +import { useChartHoverState } from './useChartHoverState' import ChartHeader from './ChartHeader' import { CHART_COLORS, @@ -58,11 +58,9 @@ const StackedBarChart: React.FC = ({ syncId, }) => { const { Container } = useChartSize(size) - const { - updateState: updateSyncState, - clearState: clearSyncState, - state: syncState, - } = useChartSync(syncId) + const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( + syncId || 'default' + ) const { dataKeys, stackedData, percentagesStackedData } = useStacked({ data, xAxisKey, @@ -125,21 +123,12 @@ const StackedBarChart: React.FC = ({ setFocusDataIndex(e.activeTooltipIndex) } - if (syncId) { - updateSyncState({ - activeIndex: e.activeTooltipIndex, - activePayload: e.activePayload, - activeLabel: e.activeLabel, - isHovering: true, - }) - } + setHover(e.activeTooltipIndex) }} onMouseLeave={() => { setFocusDataIndex(null) - if (syncId) { - clearSyncState() - } + clearHover() }} > {!hideLegend && ( @@ -205,7 +194,7 @@ const StackedBarChart: React.FC = ({ fontSize: '12px', }} wrapperClassName="bg-gray-600 rounded min-w-md" - active={!!syncId && syncState.isHovering} + active={!!syncId && syncTooltip && hoveredIndex !== null} /> diff --git a/apps/studio/components/ui/Charts/useChartHoverState.test.tsx b/apps/studio/components/ui/Charts/useChartHoverState.test.tsx new file mode 100644 index 0000000000000..aac7c8c8b2142 --- /dev/null +++ b/apps/studio/components/ui/Charts/useChartHoverState.test.tsx @@ -0,0 +1,352 @@ +import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {} + + return { + getItem: vi.fn((key: string) => store[key] || null), + setItem: vi.fn((key: string, value: string) => { + store[key] = value + }), + clear: vi.fn(() => { + store = {} + }), + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + +describe('useChartHoverState', () => { + let useChartHoverState: any + + beforeEach(async () => { + localStorageMock.clear() + consoleWarnSpy.mockClear() + + vi.resetModules() + const { useChartHoverState: importedHook } = await import('./useChartHoverState') + useChartHoverState = importedHook + }) + + afterEach(() => { + localStorageMock.clear() + }) + + describe('initialization', () => { + it('should initialize with default state', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + expect(result.current.hoveredIndex).toBe(null) + expect(result.current.syncHover).toBe(false) + expect(result.current.syncTooltip).toBe(false) + expect(result.current.hoveredChart).toBe(null) + expect(result.current.isHovered).toBe(false) + expect(result.current.isCurrentChart).toBe(false) + }) + + it('should load sync settings from localStorage on initialization', async () => { + localStorageMock.setItem('supabase-chart-hover-sync-enabled', 'true') + localStorageMock.setItem('supabase-chart-tooltip-sync-enabled', 'true') + + vi.resetModules() + const { useChartHoverState: useChartHoverStateWithStorage } = await import( + './useChartHoverState' + ) + + const { result } = renderHook(() => useChartHoverStateWithStorage('chart1')) + + expect(result.current.syncHover).toBe(true) + expect(result.current.syncTooltip).toBe(true) + }) + + it('should handle corrupted localStorage data gracefully', async () => { + localStorageMock.setItem('supabase-chart-hover-sync-enabled', 'invalid-json') + localStorageMock.setItem('supabase-chart-tooltip-sync-enabled', 'invalid-json') + + vi.resetModules() + const { useChartHoverState: useChartHoverStateWithCorrupted } = await import( + './useChartHoverState' + ) + + const { result } = renderHook(() => useChartHoverStateWithCorrupted('chart1')) + + expect(result.current.syncHover).toBe(false) + expect(result.current.syncTooltip).toBe(false) + expect(consoleWarnSpy).toHaveBeenCalled() + }) + }) + + describe('hover functionality', () => { + it('should set hover state locally when sync is disabled', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setHover(5) + }) + + expect(result.current.hoveredIndex).toBe(5) + expect(result.current.hoveredChart).toBe('chart1') + expect(result.current.isHovered).toBe(true) + expect(result.current.isCurrentChart).toBe(true) + }) + + it('should clear hover state locally when sync is disabled', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setHover(5) + }) + + act(() => { + result.current.clearHover() + }) + + expect(result.current.hoveredIndex).toBe(null) + expect(result.current.hoveredChart).toBe(null) + expect(result.current.isHovered).toBe(false) + expect(result.current.isCurrentChart).toBe(false) + }) + + it('should sync hover state globally when sync is enabled', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + + act(() => { + result1.current.setSyncHover(true) + }) + act(() => { + result1.current.setHover(3) + }) + + expect(result1.current.hoveredIndex).toBe(3) + expect(result1.current.hoveredChart).toBe('chart1') + expect(result1.current.isCurrentChart).toBe(true) + + expect(result2.current.hoveredIndex).toBe(3) + expect(result2.current.hoveredChart).toBe('chart1') + expect(result2.current.isCurrentChart).toBe(false) + }) + + it('should clear synced hover state globally', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + + act(() => { + result1.current.setSyncHover(true) + result1.current.setHover(3) + }) + act(() => { + result2.current.clearHover() + }) + + expect(result1.current.hoveredIndex).toBe(null) + expect(result1.current.hoveredChart).toBe(null) + expect(result2.current.hoveredIndex).toBe(null) + expect(result2.current.hoveredChart).toBe(null) + }) + }) + + describe('sync settings', () => { + it('should enable hover sync and save to localStorage', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setSyncHover(true) + }) + + expect(result.current.syncHover).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'supabase-chart-hover-sync-enabled', + 'true' + ) + }) + + it('should disable hover sync and automatically disable tooltip sync', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setSyncTooltip(true) + }) + + expect(result.current.syncHover).toBe(true) + expect(result.current.syncTooltip).toBe(true) + act(() => { + result.current.setSyncHover(false) + }) + + expect(result.current.syncHover).toBe(false) + expect(result.current.syncTooltip).toBe(false) + }) + + it('should enable tooltip sync and automatically enable hover sync', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setSyncTooltip(true) + }) + + expect(result.current.syncHover).toBe(true) + expect(result.current.syncTooltip).toBe(true) + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'supabase-chart-hover-sync-enabled', + 'true' + ) + expect(localStorageMock.setItem).toHaveBeenCalledWith( + 'supabase-chart-tooltip-sync-enabled', + 'true' + ) + }) + + it('should handle localStorage errors gracefully when saving sync settings', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + localStorageMock.setItem.mockImplementationOnce(() => { + throw new Error('Storage quota exceeded') + }) + + act(() => { + result.current.setSyncHover(true) + }) + + expect(result.current.syncHover).toBe(true) + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'Failed to save chart hover sync setting to localStorage:', + expect.any(Error) + ) + }) + }) + + describe('state synchronization between multiple hooks', () => { + it('should synchronize state changes between multiple hook instances', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + const { result: result3 } = renderHook(() => useChartHoverState('chart3')) + + act(() => { + result1.current.setSyncHover(true) + }) + + expect(result1.current.syncHover).toBe(true) + expect(result2.current.syncHover).toBe(true) + expect(result3.current.syncHover).toBe(true) + + act(() => { + result2.current.setHover(7) + }) + expect(result1.current.hoveredIndex).toBe(7) + expect(result1.current.hoveredChart).toBe('chart2') + expect(result2.current.hoveredIndex).toBe(7) + expect(result2.current.hoveredChart).toBe('chart2') + expect(result3.current.hoveredIndex).toBe(7) + expect(result3.current.hoveredChart).toBe('chart2') + }) + + it('should correctly identify current chart vs synced charts', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + + act(() => { + result1.current.setSyncHover(true) + result1.current.setHover(5) + }) + + expect(result1.current.isCurrentChart).toBe(true) + expect(result2.current.isCurrentChart).toBe(false) + expect(result1.current.isHovered).toBe(true) + expect(result2.current.isHovered).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle setting hover to null', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setHover(5) + }) + + act(() => { + result.current.setHover(null) + }) + + expect(result.current.hoveredIndex).toBe(null) + expect(result.current.hoveredChart).toBe(null) + }) + + it('should handle rapid state changes', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + act(() => { + result.current.setSyncHover(true) + result.current.setHover(1) + result.current.setHover(2) + result.current.setHover(3) + result.current.clearHover() + result.current.setHover(4) + }) + + expect(result.current.hoveredIndex).toBe(4) + expect(result.current.hoveredChart).toBe('chart1') + }) + + it('should not trigger unnecessary state updates when state is the same', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + const initialSyncHover = result.current.syncHover + + act(() => { + result.current.setSyncHover(false) + }) + + expect(result.current.syncHover).toBe(initialSyncHover) + }) + }) + + describe('helper functions', () => { + it('should correctly calculate isHovered for current chart', () => { + const { result } = renderHook(() => useChartHoverState('chart1')) + + expect(result.current.hoveredIndex).toBe(null) + expect(result.current.isHovered).toBe(false) + + act(() => { + result.current.setHover(5) + }) + + expect(result.current.hoveredIndex).toBe(5) + expect(result.current.isHovered).toBe(true) + }) + + it('should correctly calculate isHovered for synced charts', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + + act(() => { + result1.current.setSyncHover(true) + result1.current.setHover(5) + }) + + expect(result1.current.isHovered).toBe(true) // Current chart + expect(result2.current.isHovered).toBe(true) // Synced chart + }) + + it('should correctly identify current chart', () => { + const { result: result1 } = renderHook(() => useChartHoverState('chart1')) + const { result: result2 } = renderHook(() => useChartHoverState('chart2')) + + act(() => { + result1.current.setSyncHover(true) + result1.current.setHover(5) + }) + + expect(result1.current.isCurrentChart).toBe(true) + expect(result2.current.isCurrentChart).toBe(false) + }) + }) +}) diff --git a/apps/studio/components/ui/Charts/useChartHoverState.tsx b/apps/studio/components/ui/Charts/useChartHoverState.tsx new file mode 100644 index 0000000000000..0bbb2b6160f2e --- /dev/null +++ b/apps/studio/components/ui/Charts/useChartHoverState.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useState } from 'react' + +interface ChartHoverState { + hoveredIndex: number | null + hoveredChart: string | null + syncHover: boolean + syncTooltip: boolean +} + +const CHART_HOVER_SYNC_STORAGE_KEY = 'supabase-chart-hover-sync-enabled' +const CHART_TOOLTIP_SYNC_STORAGE_KEY = 'supabase-chart-tooltip-sync-enabled' + +// Global state shared across all hook instances +let globalState: ChartHoverState = { + hoveredIndex: null, + hoveredChart: null, + syncHover: false, + syncTooltip: false, +} + +// Subscribers for state changes +const subscribers = new Set<(state: ChartHoverState) => void>() + +// Load initial sync settings from localStorage +try { + const hoverSyncStored = localStorage.getItem(CHART_HOVER_SYNC_STORAGE_KEY) + const tooltipSyncStored = localStorage.getItem(CHART_TOOLTIP_SYNC_STORAGE_KEY) + + if (hoverSyncStored !== null) { + globalState.syncHover = JSON.parse(hoverSyncStored) + } + if (tooltipSyncStored !== null) { + globalState.syncTooltip = JSON.parse(tooltipSyncStored) + } +} catch (error) { + console.warn('Failed to load chart sync settings from localStorage:', error) +} + +function notifySubscribers() { + subscribers.forEach((callback) => callback(globalState)) +} + +function updateGlobalState(updates: Partial) { + const prevState = globalState + globalState = { ...globalState, ...updates } + + // Save sync settings to localStorage when they change + if (updates.syncHover !== undefined) { + try { + localStorage.setItem(CHART_HOVER_SYNC_STORAGE_KEY, JSON.stringify(globalState.syncHover)) + } catch (error) { + console.warn('Failed to save chart hover sync setting to localStorage:', error) + } + } + if (updates.syncTooltip !== undefined) { + try { + localStorage.setItem(CHART_TOOLTIP_SYNC_STORAGE_KEY, JSON.stringify(globalState.syncTooltip)) + } catch (error) { + console.warn('Failed to save chart tooltip sync setting to localStorage:', error) + } + } + + // Only notify if state actually changed + if (JSON.stringify(prevState) !== JSON.stringify(globalState)) { + notifySubscribers() + } +} + +export function useChartHoverState(chartId: string) { + const [state, setState] = useState(globalState) + + // Subscribe to global state changes + useEffect(() => { + const callback = (newState: ChartHoverState) => { + setState(newState) + } + + subscribers.add(callback) + + return () => { + subscribers.delete(callback) + } + }, []) + + // Set hover state for this chart + const setHover = useCallback( + (index: number | null) => { + if (globalState.syncHover) { + // If sync is enabled, update global state + updateGlobalState({ + hoveredIndex: index, + hoveredChart: index !== null ? chartId : null, + }) + } else { + // If sync is disabled, only update local state + setState((prev) => ({ + ...prev, + hoveredIndex: index, + hoveredChart: index !== null ? chartId : null, + })) + } + }, + [chartId] + ) + + // Clear hover state + const clearHover = useCallback(() => { + if (globalState.syncHover) { + updateGlobalState({ + hoveredIndex: null, + hoveredChart: null, + }) + } else { + setState((prev) => ({ + ...prev, + hoveredIndex: null, + hoveredChart: null, + })) + } + }, []) + + // Set sync settings (for settings component) + const setSyncHover = useCallback((enabled: boolean) => { + updateGlobalState({ + syncHover: enabled, + // If turning off hover sync, also turn off tooltip sync + ...(enabled === false && { syncTooltip: false }), + }) + }, []) + + const setSyncTooltip = useCallback((enabled: boolean) => { + updateGlobalState({ + syncTooltip: enabled, + // If turning on tooltip sync, also turn on hover sync + ...(enabled === true && { syncHover: true }), + }) + }, []) + + // Determine if this chart should show synced state + const isCurrentChart = state.hoveredChart === chartId + const shouldShowSyncedState = state.syncHover && state.hoveredChart !== null && !isCurrentChart + + return { + // Current state + hoveredIndex: shouldShowSyncedState + ? state.hoveredIndex + : isCurrentChart + ? state.hoveredIndex + : null, + syncHover: state.syncHover, + syncTooltip: state.syncTooltip, + hoveredChart: state.hoveredChart, + + // Actions + setHover, + clearHover, + setSyncHover, + setSyncTooltip, + + // Helpers + isHovered: state.hoveredIndex !== null && (isCurrentChart || shouldShowSyncedState), + isCurrentChart, + } +} diff --git a/apps/studio/components/ui/Charts/useChartSync.test.tsx b/apps/studio/components/ui/Charts/useChartSync.test.tsx deleted file mode 100644 index 9f12b06e882bc..0000000000000 --- a/apps/studio/components/ui/Charts/useChartSync.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { renderHook, act } from '@testing-library/react' -import { describe, it, expect, beforeEach } from 'vitest' -import { useChartSync, cleanupChartSync } from './useChartSync' - -describe('useChartSync', () => { - beforeEach(() => { - cleanupChartSync('test-sync-1') - cleanupChartSync('test-sync-2') - }) - - it('should initialize with default state', () => { - const { result } = renderHook(() => useChartSync('test-sync-1')) - - expect(result.current.state).toEqual({ - activeIndex: null, - activePayload: null, - activeLabel: null, - isHovering: false, - }) - }) - - it('should update state correctly', () => { - const { result } = renderHook(() => useChartSync('test-sync-1')) - - act(() => { - result.current.updateState({ - activeIndex: 5, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - expect(result.current.state).toEqual({ - activeIndex: 5, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - it('should clear state correctly', () => { - const { result } = renderHook(() => useChartSync('test-sync-1')) - - // First update the state - act(() => { - result.current.updateState({ - activeIndex: 5, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - // Then clear it - act(() => { - result.current.clearState() - }) - - expect(result.current.state).toEqual({ - activeIndex: null, - activePayload: null, - activeLabel: null, - isHovering: false, - }) - }) - - it('should sync state between multiple hooks with same syncId', () => { - const { result: result1 } = renderHook(() => useChartSync('test-sync-1')) - const { result: result2 } = renderHook(() => useChartSync('test-sync-1')) - - act(() => { - result1.current.updateState({ - activeIndex: 10, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - // Both hooks should have the same state - expect(result1.current.state.activeIndex).toBe(10) - expect(result2.current.state.activeIndex).toBe(10) - }) - - it('should not sync state between hooks with different syncId', () => { - const { result: result1 } = renderHook(() => useChartSync('test-sync-1')) - const { result: result2 } = renderHook(() => useChartSync('test-sync-2')) - - act(() => { - result1.current.updateState({ - activeIndex: 10, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - // Only the first hook should have updated state - expect(result1.current.state.activeIndex).toBe(10) - expect(result2.current.state.activeIndex).toBe(null) - }) - - it('should handle partial state updates', () => { - const { result } = renderHook(() => useChartSync('test-sync-1')) - - // Set initial state - act(() => { - result.current.updateState({ - activeIndex: 5, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - // Update only some fields - act(() => { - result.current.updateState({ - activeIndex: 10, - isHovering: false, - }) - }) - - expect(result.current.state).toEqual({ - activeIndex: 10, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: false, - }) - }) - - it('should handle undefined syncId', () => { - const { result } = renderHook(() => useChartSync(undefined)) - - act(() => { - result.current.updateState({ - activeIndex: 5, - activePayload: { test: 'data' }, - activeLabel: 'test-label', - isHovering: true, - }) - }) - - // State should remain unchanged when syncId is undefined - expect(result.current.state).toEqual({ - activeIndex: null, - activePayload: null, - activeLabel: null, - isHovering: false, - }) - }) -}) diff --git a/apps/studio/components/ui/Charts/useChartSync.tsx b/apps/studio/components/ui/Charts/useChartSync.tsx deleted file mode 100644 index bfc5400cbb64f..0000000000000 --- a/apps/studio/components/ui/Charts/useChartSync.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -export interface ChartSyncState { - activeIndex: number | null - activePayload: any - activeLabel: string | null - isHovering: boolean -} - -export interface ChartSyncHook { - syncId: string - state: ChartSyncState - updateState: (newState: Partial) => void - clearState: () => void - subscribe: (callback: (state: ChartSyncState) => void) => () => void -} - -const syncStateMap = new Map() -const subscribersMap = new Map void>>() - -const getInitialState = (): ChartSyncState => ({ - activeIndex: null, - activePayload: null, - activeLabel: null, - isHovering: false, -}) - -export function useChartSync(syncId?: string): ChartSyncHook { - const [state, setState] = useState(getInitialState()) - const isInitialized = useRef(false) - - useEffect(() => { - if (!syncId) return - - if (!syncStateMap.has(syncId)) { - syncStateMap.set(syncId, getInitialState()) - } - - if (!subscribersMap.has(syncId)) { - subscribersMap.set(syncId, new Set()) - } - - setState(syncStateMap.get(syncId) || getInitialState()) - isInitialized.current = true - }, [syncId]) - - useEffect(() => { - if (!syncId || !isInitialized.current) return - - const subscribers = subscribersMap.get(syncId)! - const callback = (newState: ChartSyncState) => { - setState(newState) - } - - subscribers.add(callback) - - return () => { - subscribers.delete(callback) - } - }, [syncId]) - - const updateState = useCallback( - (newState: Partial) => { - if (!syncId) return - - const currentState = syncStateMap.get(syncId) || getInitialState() - const updatedState = { ...currentState, ...newState } - - syncStateMap.set(syncId, updatedState) - - const subscribers = subscribersMap.get(syncId) - if (subscribers) { - subscribers.forEach((callback) => callback(updatedState)) - } - }, - [syncId] - ) - - const clearState = useCallback(() => { - if (!syncId) return - - const clearedState = getInitialState() - syncStateMap.set(syncId, clearedState) - - const subscribers = subscribersMap.get(syncId) - if (subscribers) { - subscribers.forEach((callback) => callback(clearedState)) - } - }, [syncId]) - - const subscribe = useCallback( - (callback: (state: ChartSyncState) => void) => { - if (!syncId) return () => {} - - const subscribers = subscribersMap.get(syncId)! - subscribers.add(callback) - - return () => { - subscribers.delete(callback) - } - }, - [syncId] - ) - - return { - syncId: syncId || '', - state, - updateState, - clearState, - subscribe, - } -} - -export function cleanupChartSync(syncId: string) { - syncStateMap.delete(syncId) - subscribersMap.delete(syncId) -} diff --git a/apps/studio/csp.js b/apps/studio/csp.js index c184c789d86d8..3175bd5096b13 100644 --- a/apps/studio/csp.js +++ b/apps/studio/csp.js @@ -31,8 +31,8 @@ const isDevOrStaging = process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' -const NIMBUS_STAGING_PROJECTS_URL = 'https://*.supabase-nimbus-projects.com' -const NIMBUS_STAGING_PROJECTS_URL_WS = 'wss://*.supabase-nimbus-projects.com' +const NIMBUS_STAGING_PROJECTS_URL = 'https://*.nmb-proj.com' +const NIMBUS_STAGING_PROJECTS_URL_WS = 'wss://*.nmb-proj.com' const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red' const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red' const SUPABASE_COM_URL = 'https://supabase.com' diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 8e56592aadbf3..9b04c2ea456d7 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -1,5 +1,4 @@ import dayjs from 'dayjs' -import { AlertCircle } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' @@ -8,7 +7,6 @@ import { toast } from 'sonner' import { useParams } from 'common' import { AuthorizeRequesterDetails } from 'components/interfaces/Organization/OAuthApps/AuthorizeRequesterDetails' import APIAuthorizationLayout from 'components/layouts/APIAuthorizationLayout' -import { FormPanel } from 'components/ui/Forms/FormPanel' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { useApiAuthorizationApproveMutation } from 'data/api-authorization/api-authorization-approve-mutation' import { useApiAuthorizationDeclineMutation } from 'data/api-authorization/api-authorization-decline-mutation' @@ -17,13 +15,23 @@ import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { withAuth } from 'hooks/misc/withAuth' import type { NextPageWithLayout } from 'types' import { - Alert, Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, - Listbox, + Card, + CardContent, + CardFooter, + CardHeader, + CheckIcon, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + WarningIcon, } from 'ui' +import { FormLayout } from 'ui-patterns/form/Layout/FormLayout' // Need to handle if no organizations in account // Need to handle if not logged in yet state @@ -44,6 +52,17 @@ const APIAuthorizationPage: NextPageWithLayout = () => { const isApproved = (requester?.approved_at ?? null) !== null const isExpired = dayjs().isAfter(dayjs(requester?.expires_at)) + const searchParams = + typeof window !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams() + const basePath = process.env.NEXT_PUBLIC_BASE_PATH + const pathname = + typeof window !== 'undefined' + ? basePath + ? location.pathname.replace(basePath, '') + : location.pathname + : '' + searchParams.set('returnTo', pathname) + const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ onSuccess: (res) => { window.location.href = res.url @@ -56,17 +75,6 @@ const APIAuthorizationPage: NextPageWithLayout = () => { }, }) - useEffect(() => { - if (isSuccessOrganizations && organizations.length > 0) { - if (organization_slug) { - setSelectedOrgSlug(organizations.find(({ slug }) => slug === organization_slug)?.slug) - } else { - setSelectedOrgSlug(organizations[0].slug) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSuccessOrganizations]) - const onApproveRequest = async () => { if (!auth_id) { return toast.error('Unable to approve request: auth_id is missing ') @@ -91,44 +99,58 @@ const APIAuthorizationPage: NextPageWithLayout = () => { declineRequest({ id: auth_id, slug: selectedOrgSlug }, { onError: () => setIsDeclining(false) }) } + useEffect(() => { + if (isSuccessOrganizations && organizations.length > 0) { + if (organization_slug) { + setSelectedOrgSlug(organizations.find(({ slug }) => slug === organization_slug)?.slug) + } else { + setSelectedOrgSlug(organizations[0].slug) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessOrganizations]) + if (isLoading) { return ( - Authorize API access

}> -
- - - -
-
- ) - } + + Authorize API access + +
+ + +
- if (auth_id === undefined) { - return ( - Authorization for API access

}> -
- - Please provide a valid authorization ID in the URL - -
-
+
+ + +
+ +
+ + +
+
+
) } if (isError) { return ( - Authorize API access

}> -
- -

Please retry your authorization request from the requesting app

- {error !== undefined &&

Error: {error?.message}

} -
-
-
+ + Authorize API access + + + + + Failed to fetch details for API authorization request + + +

Please retry your authorization request from the requesting app

+ {error !== undefined &&

Error: {error?.message}

} +
+
+
+
) } @@ -138,70 +160,39 @@ const APIAuthorizationPage: NextPageWithLayout = () => { ) return ( - Authorize API access for {requester?.name}

}> -
- -

- {requester.name} has read and write access to the organization " - {approvedOrganization?.name ?? 'Unknown'}" and all of its projects -

-

- Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')} -

-
-
-
+ + Authorize API access for {requester?.name} + + + + This authorization request has been approved + +

+ {requester.name} has been approved access to the organization " + {approvedOrganization?.name ?? 'Unknown'}" and all of its projects for the following + scopes: +

+ +

+ Approved on: {dayjs(requester.approved_at).format('DD MMM YYYY HH:mm:ss (ZZ)')} +

+
+
+
+
) } - const searchParams = new URLSearchParams(location.search) - let pathname = location.pathname - const basePath = process.env.NEXT_PUBLIC_BASE_PATH - if (basePath) { - pathname = pathname.replace(basePath, '') - } - - searchParams.set('returnTo', pathname) - return ( - Authorize API access for {requester?.name}

} - footer={ -
-
- - {isLoadingOrganizations ? ( - - ) : isSuccessOrganizations && organizations.length === 0 ? ( - - - - ) : ( - - )} -
-
- } - > -
- {/* API Authorization requester details */} + + Authorize API access for {requester?.name} + { scopes={requester.scopes} /> - {/* Expiry warning */} {isExpired && ( - - Please retry your authorization request from the requesting app - + + + This authorization request is expired + + Please retry your authorization request from the requesting app + + )} - {/* Organization selection */} {isLoadingOrganizations ? (
@@ -224,11 +217,11 @@ const APIAuthorizationPage: NextPageWithLayout = () => {
) : organizations?.length === 0 ? ( - + Organization is needed for installing an integration - + Your account isn't associated with any organizations. To use this integration, it must be installed within an organization. You'll be redirected to create an organization first. @@ -236,40 +229,75 @@ const APIAuthorizationPage: NextPageWithLayout = () => { ) : organization_slug && !selectedOrgSlug ? ( - + Organization is needed for installing an integration - + Your account is not a member of the pre-selected organization. To use this integration, it must be installed within an organization your account is associated with. ) : ( - - {(organizations ?? []).map((organization) => ( - - {organization.name} - - ))} - + + + + {organizations?.find((x) => x.slug === selectedOrgSlug)?.name} + + + + {(organizations ?? []).map((organization) => ( + + {organization.name} + + ))} + + + + )} +
+ + + {isLoadingOrganizations ? ( + + ) : isSuccessOrganizations && organizations.length === 0 ? ( + + + + ) : ( + )} -
-
+ + ) } diff --git a/apps/studio/pages/claim-project.tsx b/apps/studio/pages/claim-project.tsx index a4851d99da764..f534495011e5f 100644 --- a/apps/studio/pages/claim-project.tsx +++ b/apps/studio/pages/claim-project.tsx @@ -1,3 +1,6 @@ +import Head from 'next/head' +import { useMemo, useState } from 'react' + import { useParams } from 'common' import { ProjectClaimBenefits } from 'components/interfaces/Organization/ProjectClaim/benefits' import { ProjectClaimChooseOrg } from 'components/interfaces/Organization/ProjectClaim/choose-org' @@ -8,10 +11,8 @@ import { useApiAuthorizationQuery } from 'data/api-authorization/api-authorizati import { useOrganizationProjectClaimQuery } from 'data/organizations/organization-project-claim-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { withAuth } from 'hooks/misc/withAuth' -import Head from 'next/head' -import { useMemo, useState } from 'react' import type { NextPageWithLayout } from 'types' -import { Alert } from 'ui' +import { Admonition } from 'ui-patterns' const ClaimProjectPage: NextPageWithLayout = () => { const { auth_id, token: claimToken } = useParams() @@ -48,8 +49,8 @@ const ClaimProjectPage: NextPageWithLayout = () => { if ((selectedOrgSlug && claimToken && isLoadingProjectClaim) || isLoadingRequester) { return ( - -
+ +
@@ -60,23 +61,16 @@ const ClaimProjectPage: NextPageWithLayout = () => { if ((selectedOrgSlug && claimToken && isErrorProjectClaim) || isErrorRequester) { return ( - -
- -

Please retry your claim request from the requesting app

- {errorProjectClaim != undefined && ( -

Error: {errorProjectClaim?.message}

- )} - {errorRequester != undefined && ( -

Error: {errorRequester?.message}

- )} -

Please go back to the requesting app and try again.

-
-
+ + +

Please retry your claim request from the requesting app

+ {!!errorProjectClaim &&

Error: {errorProjectClaim?.message}

} + {!!errorRequester &&

Error: {errorRequester?.message}

} +
) } @@ -112,6 +106,7 @@ const ClaimProjectPage: NextPageWithLayout = () => { /> ) } + return null } @@ -120,7 +115,7 @@ ClaimProjectPage.getLayout = (page) => ( Claim project | Supabase -
{page}
+
{page}
) diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 6a3fd20c33435..ace9855c9a8fa 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -23,6 +23,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ChartHandler from 'components/ui/Charts/ChartHandler' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { ReportSettings } from 'components/ui/Charts/ReportSettings' import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner' import Panel from 'components/ui/Panel' import { analyticsKeys } from 'data/analytics/keys' @@ -217,7 +218,7 @@ const DatabaseUsage = () => { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 200) } - }, [db, chart]) + }, [db, chart, state]) return ( <> @@ -234,6 +235,7 @@ const DatabaseUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> +
{ endDate={selectedDateRange?.period_end?.date} updateDateRange={updateDateRange} defaultChartStyle={chart.defaultChartStyle as 'line' | 'bar' | 'stackedAreaLine'} + syncId="database-charts" showMaxValue={ chart.id === 'client-connections' || chart.id === 'pgbouncer-connections' ? true @@ -297,12 +300,12 @@ const DatabaseUsage = () => { defaultChartStyle={ chart.defaultChartStyle as 'line' | 'bar' | 'stackedAreaLine' } + syncId="database-charts" showMaxValue={ chart.id === 'client-connections' || chart.id === 'pgbouncer-connections' ? true : chart.showMaxValue } - syncId={chart.syncId} /> ) : ( { label="Replication lag" interval={selectedDateRange.interval} provider="infra-monitoring" + syncId="database-charts" />