diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 9944f8d6590aa..626b8f0c725f5 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -1,42 +1,41 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useFlag, useParams } from 'common' +import { useParams } from 'common' import { Plus } from 'lucide-react' import { useRouter } from 'next/router' import { parseAsBoolean, useQueryState } from 'nuqs' -import { useMemo, useState } from 'react' +import { useState } from 'react' import { toast } from 'sonner' import { Menu } from 'ui' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' import { InnerSideBarEmptyPanel } from 'ui-patterns/InnerSideMenu' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { generateObservabilityMenuItems } from './ObservabilityMenu.utils' +import { + useGenerateCustomReportsMenu, + useGenerateObservabilityMenu, +} from './ObservabilityMenu.utils' import { ObservabilityMenuItem } from './ObservabilityMenuItem' -import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' import { CreateReportModal } from '@/components/interfaces/Reports/CreateReportModal' import { UpdateCustomReportModal } from '@/components/interfaces/Reports/UpdateModal' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { ProductMenu } from '@/components/ui/ProductMenu' import { ProductMenuShortcuts } from '@/components/ui/ProductMenu/ProductMenuShortcuts' import { useContentDeleteMutation } from '@/data/content/content-delete-mutation' -import { Content, ContentBase, useContentQuery } from '@/data/content/content-query' +import { Content } from '@/data/content/content-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { IS_PLATFORM } from '@/lib/constants' import { useProfile } from '@/lib/profile' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -import type { Dashboards } from '@/types' export const ObservabilityMenu = () => { const router = useRouter() const { profile } = useProfile() const { ref, id } = useParams() const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string - const showOverview = useFlag('observabilityOverview') - const { isSupamonitorEnabled } = useSupamonitorStatus() - const storageSupported = useIsFeatureEnabled('project_storage:all') + const menuItems = useGenerateObservabilityMenu() + const { data: customReportItems, isLoading } = useGenerateCustomReportsMenu() const { can: canCreateCustomReport } = useAsyncCheckPermissions( PermissionAction.CREATE, @@ -47,24 +46,6 @@ export const ObservabilityMenu = () => { } ) - // Preserve date range query parameters when navigating - const preservedQueryParams = useMemo(() => { - const { its, ite, isHelper, helperText } = router.query - const params = new URLSearchParams() - - if (its && typeof its === 'string') params.set('its', its) - if (ite && typeof ite === 'string') params.set('ite', ite) - if (isHelper && typeof isHelper === 'string') params.set('isHelper', isHelper) - if (helperText && typeof helperText === 'string') params.set('helperText', helperText) - - const queryString = params.toString() - return queryString ? `?${queryString}` : '' - }, [router.query]) - - const { data: content, isPending: isLoading } = useContentQuery({ - projectRef: ref, - type: 'report', - }) const { mutateAsync: deleteReport } = useContentDeleteMutation({ // Toasts are driven by toast.promise in onConfirmDeleteReport. This no-op keeps the hook // from showing its own default error toast, while its optimistic rollback still runs. @@ -104,52 +85,6 @@ export const ObservabilityMenu = () => { }) } - function isReportContent(c: Content): c is ContentBase & { - type: 'report' - content: Dashboards.Content - } { - return c.type === 'report' - } - - function getReportMenuItems() { - if (!content) return [] - - const reports = content?.content.filter(isReportContent) - - const sortedReports = reports?.sort((a, b) => { - if (a.name < b.name) { - return -1 - } - if (a.name > b.name) { - return 1 - } - return 0 - }) - - const reportMenuItems = sortedReports.map((r, idx) => ({ - id: r.id, - name: r.name, - description: r.description || '', - key: r.id || idx + '-report', - url: `/project/${ref}/observability/${r.id}${preservedQueryParams}`, - hasDropdownActions: true, - report: r, - })) - - return reportMenuItems - } - - const reportMenuItems = getReportMenuItems() - - const menuItems = generateObservabilityMenuItems({ - ref, - preservedQueryParams, - showOverview, - isSupamonitorEnabled, - storageSupported, - isPlatform: IS_PLATFORM, - }) - useShortcut( SHORTCUT_IDS.OBSERVABILITY_NEW_REPORT, () => { @@ -186,7 +121,7 @@ export const ObservabilityMenu = () => { title={ Custom Reports - {reportMenuItems.length > 0 && ( + {customReportItems.length > 0 && ( { } /> - {reportMenuItems.length > 0 && - reportMenuItems.map((item) => ( + {customReportItems.length > 0 && + customReportItems.map((item) => ( { /> ))} - {reportMenuItems.length === 0 ? ( + {customReportItems.length === 0 ? (
({ + REF: 'project-ref', + mockIsPlatform: { value: true }, +})) + +vi.mock('@/lib/constants', async () => { + const actual = await vi.importActual>('@/lib/constants') + return { + ...actual, + get IS_PLATFORM() { + return mockIsPlatform.value + }, + } +}) -const REF = 'test-project-ref' -const QUERY_PARAMS = '' +vi.mock('common', () => ({ + useFlag: vi.fn().mockReturnValue(false), + useParams: vi.fn().mockReturnValue({ ref: REF }), +})) + +vi.mock('@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus', () => ({ + useSupamonitorStatus: vi.fn(), +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: vi.fn(), +})) + +vi.mock('@/data/content/content-query', () => ({ + useContentQuery: vi.fn(), +})) + +describe('useGenerateObservabilityMenu', () => { + beforeEach(() => { + mockIsPlatform.value = true + routerMock.setCurrentUrl(`/project/${REF}/observability`) + vi.mocked(useFlag).mockReturnValue(false) + vi.mocked(useParams).mockReturnValue({ ref: REF }) + vi.mocked(useSupamonitorStatus).mockReturnValue({ + isSupamonitorEnabled: false, + isLoading: false, + }) + vi.mocked(useIsFeatureEnabled).mockReturnValue(true) + }) -const sectionTitles = (sections: ObservabilityMenuSection[]) => sections.map((s) => s.title) + it('always includes the GENERAL section', () => { + const { result } = renderHook(() => useGenerateObservabilityMenu()) -const itemKeys = (section: ObservabilityMenuSection | undefined) => - section?.items.map((i) => i.key) ?? [] + expect(result.current.some((section) => section.title === 'GENERAL')).toBe(true) + }) -const findSection = (sections: ObservabilityMenuSection[], title: string) => - sections.find((s) => s.title === title) + it('includes Overview when the observabilityOverview flag is enabled', () => { + vi.mocked(useFlag).mockReturnValue(true) -describe('generateObservabilityMenuItems - PRODUCT section', () => { - it('includes PRODUCT section on platform', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: true, - }) + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') - expect(sectionTitles(menu)).toContain('PRODUCT') - const productSection = findSection(menu, 'PRODUCT') - expect(itemKeys(productSection)).toEqual([ - 'database', - 'postgrest', - 'auth', - 'edge-functions', - 'storage', - 'realtime', - ]) + expect(general?.items.some((item) => item.key === 'observability')).toBe(true) }) - it('excludes PRODUCT section in self-hosted mode', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('excludes Overview when the observabilityOverview flag is disabled', () => { + vi.mocked(useFlag).mockReturnValue(false) - expect(sectionTitles(menu)).not.toContain('PRODUCT') - expect(menu.length).toBe(1) // Only GENERAL section + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') + + expect(general?.items.some((item) => item.key === 'observability')).toBe(false) }) - it('excludes Storage from PRODUCT when storageSupported is false', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, + it('shows Query Performance when supamonitor is disabled', () => { + vi.mocked(useSupamonitorStatus).mockReturnValue({ isSupamonitorEnabled: false, - storageSupported: false, - isPlatform: true, + isLoading: false, }) - const productSection = findSection(menu, 'PRODUCT') - expect(itemKeys(productSection)).not.toContain('storage') - expect(itemKeys(productSection)).toEqual([ - 'database', - 'postgrest', - 'auth', - 'edge-functions', - 'realtime', - ]) + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') + + expect(general?.items.some((item) => item.key === 'query-performance')).toBe(true) + expect(general?.items.some((item) => item.key === 'query-insights')).toBe(false) }) - it('includes Storage in PRODUCT when storageSupported is true', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: true, + it('shows Query Insights when supamonitor is enabled', () => { + vi.mocked(useSupamonitorStatus).mockReturnValue({ + isSupamonitorEnabled: true, + isLoading: false, }) - const productSection = findSection(menu, 'PRODUCT') - expect(itemKeys(productSection)).toContain('storage') + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') + + expect(general?.items.some((item) => item.key === 'query-insights')).toBe(true) + expect(general?.items.some((item) => item.key === 'query-performance')).toBe(false) }) -}) -describe('generateObservabilityMenuItems - GENERAL section', () => { - it('always includes GENERAL section', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('includes API Gateway and the PRODUCT section on platform', () => { + mockIsPlatform.value = true - expect(sectionTitles(menu)).toContain('GENERAL') + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') + + expect(general?.items.some((item) => item.key === 'api-overview')).toBe(true) + expect(result.current.some((section) => section.title === 'PRODUCT')).toBe(true) }) - it('includes Query Performance when supamonitor is disabled', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('excludes API Gateway and the PRODUCT section in self-hosted mode', () => { + mockIsPlatform.value = false + + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const general = result.current.find((section) => section.title === 'GENERAL') - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).toContain('query-performance') - expect(itemKeys(generalSection)).not.toContain('query-insights') + expect(general?.items.some((item) => item.key === 'api-overview')).toBe(false) + expect(result.current.some((section) => section.title === 'PRODUCT')).toBe(false) + expect(result.current.length).toBe(1) }) - it('includes Query Insights when supamonitor is enabled', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: true, - storageSupported: true, - isPlatform: false, - }) + it('includes Storage in the PRODUCT section when project_storage:all is enabled', () => { + vi.mocked(useIsFeatureEnabled).mockReturnValue(true) - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).toContain('query-insights') - expect(itemKeys(generalSection)).not.toContain('query-performance') + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') + + expect(product?.items.some((item) => item.key === 'storage')).toBe(true) + expect(useIsFeatureEnabled).toHaveBeenCalledWith('project_storage:all') }) - it('includes Overview when showOverview is true', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: true, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('excludes Storage from the PRODUCT section when project_storage:all is disabled', () => { + vi.mocked(useIsFeatureEnabled).mockReturnValue(false) + + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).toContain('observability') + expect(product?.items.some((item) => item.key === 'storage')).toBe(false) }) - it('excludes Overview when showOverview is false', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('always includes Database, Data API, Auth, Edge Functions, and Realtime in the PRODUCT section', () => { + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).not.toContain('observability') + expect(product?.items.map((item) => item.key)).toEqual( + expect.arrayContaining(['database', 'postgrest', 'auth', 'edge-functions', 'realtime']) + ) }) - it('includes API Gateway on platform', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: true, - }) + it('builds menu item URLs using the project ref', () => { + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') + const database = product?.items.find((item) => item.key === 'database') - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).toContain('api-overview') + expect(database?.url).toBe(`/project/${REF}/observability/database`) }) - it('excludes API Gateway in self-hosted mode', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) + it('handles an undefined project ref', () => { + vi.mocked(useParams).mockReturnValue({ ref: undefined }) - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).not.toContain('api-overview') + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') + const database = product?.items.find((item) => item.key === 'database') + + expect(database?.url).toBe('/project/undefined/observability/database') }) -}) -describe('generateObservabilityMenuItems - URL construction', () => { - it('constructs correct URLs with preserved query params', () => { - const params = '?its=2024-01-01&ite=2024-01-31' - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: params, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: true, - }) + it('preserves date range and helper query params across menu item URLs', () => { + routerMock.setCurrentUrl( + `/project/${REF}/observability?its=2024-01-01&ite=2024-01-31&isHelper=true&helperText=hello` + ) - const generalSection = findSection(menu, 'GENERAL') - const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance') - expect(queryPerfItem?.url).toBe(`/project/${REF}/observability/query-performance${params}`) + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') + const database = product?.items.find((item) => item.key === 'database') - const productSection = findSection(menu, 'PRODUCT') - const databaseItem = productSection?.items.find((i) => i.key === 'database') - expect(databaseItem?.url).toBe(`/project/${REF}/observability/database${params}`) + expect(database?.url).toBe( + `/project/${REF}/observability/database?its=2024-01-01&ite=2024-01-31&isHelper=true&helperText=hello` + ) }) - it('handles undefined ref', () => { - const menu = generateObservabilityMenuItems({ - ref: undefined, - preservedQueryParams: QUERY_PARAMS, - showOverview: false, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: true, - }) + it('ignores query param values that are not plain strings (e.g. duplicated keys)', () => { + routerMock.setCurrentUrl(`/project/${REF}/observability?its=2024-01-01&its=2024-02-01`) + + const { result } = renderHook(() => useGenerateObservabilityMenu()) + const product = result.current.find((section) => section.title === 'PRODUCT') + const database = product?.items.find((item) => item.key === 'database') - const generalSection = findSection(menu, 'GENERAL') - const queryPerfItem = generalSection?.items.find((i) => i.key === 'query-performance') - expect(queryPerfItem?.url).toBe('/project/undefined/observability/query-performance') + expect(database?.url).toBe(`/project/${REF}/observability/database`) }) }) -describe('generateObservabilityMenuItems - complete structure', () => { - it('returns only GENERAL in self-hosted with all standard items', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: true, - isSupamonitorEnabled: false, - storageSupported: true, - isPlatform: false, - }) +describe('useGenerateCustomReportsMenu', () => { + const buildReport = (overrides: Record = {}) => ({ + id: 'report-1', + name: 'Report', + description: '', + type: 'report', + content: {}, + ...overrides, + }) - expect(menu.length).toBe(1) - expect(menu[0].title).toBe('GENERAL') - expect(itemKeys(menu[0])).toEqual([ - 'observability', // Overview - 'query-performance', - // API Gateway excluded - ]) + beforeEach(() => { + routerMock.setCurrentUrl(`/project/${REF}/observability`) }) - it('returns GENERAL and PRODUCT on platform with all items', () => { - const menu = generateObservabilityMenuItems({ - ref: REF, - preservedQueryParams: QUERY_PARAMS, - showOverview: true, - isSupamonitorEnabled: true, - storageSupported: true, - isPlatform: true, - }) + it('returns an empty array and reports loading while content is pending', () => { + vi.mocked(useContentQuery).mockReturnValue({ data: undefined, isPending: true } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.isLoading).toBe(true) + expect(result.current.data).toEqual([]) + }) + + it('returns an empty array when there is no content', () => { + vi.mocked(useContentQuery).mockReturnValue({ data: undefined, isPending: false } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.isLoading).toBe(false) + expect(result.current.data).toEqual([]) + }) + + it('filters out non-report content', () => { + vi.mocked(useContentQuery).mockReturnValue({ + data: { + cursor: null, + content: [ + buildReport({ id: 'r1', name: 'A Report' }), + { id: 'sql-1', name: 'A snippet', description: '', type: 'sql', content: {} }, + ], + }, + isPending: false, + } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data[0].id).toBe('r1') + }) + + it('sorts reports alphabetically by name', () => { + vi.mocked(useContentQuery).mockReturnValue({ + data: { + cursor: null, + content: [ + buildReport({ id: 'r2', name: 'Zebra' }), + buildReport({ id: 'r1', name: 'Apple' }), + ], + }, + isPending: false, + } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.data.map((item) => item.name)).toEqual(['Apple', 'Zebra']) + }) + + it('builds report URLs preserving query params and defaults a missing description to an empty string', () => { + routerMock.setCurrentUrl(`/project/${REF}/observability?its=2024-01-01`) + vi.mocked(useContentQuery).mockReturnValue({ + data: { + cursor: null, + content: [buildReport({ id: 'r1', name: 'Report', description: undefined })], + }, + isPending: false, + } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.data[0].url).toBe(`/project/${REF}/observability/r1?its=2024-01-01`) + expect(result.current.data[0].description).toBe('') + }) + + it('falls back to an index-based key when the report id is missing', () => { + vi.mocked(useContentQuery).mockReturnValue({ + data: { + cursor: null, + content: [buildReport({ id: undefined, name: 'Report' })], + }, + isPending: false, + } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) + + expect(result.current.data[0].key).toBe('0-report') + }) + + it('marks every report item as having dropdown actions and carries the raw report', () => { + const report = buildReport({ id: 'r1', name: 'Report' }) + vi.mocked(useContentQuery).mockReturnValue({ + data: { cursor: null, content: [report] }, + isPending: false, + } as any) + + const { result } = renderHook(() => useGenerateCustomReportsMenu()) - expect(menu.length).toBe(2) - expect(sectionTitles(menu)).toEqual(['GENERAL', 'PRODUCT']) - - const generalSection = findSection(menu, 'GENERAL') - expect(itemKeys(generalSection)).toEqual([ - 'observability', // Overview - 'query-insights', // Supamonitor enabled - 'api-overview', // Platform only - ]) - - const productSection = findSection(menu, 'PRODUCT') - expect(itemKeys(productSection)).toEqual([ - 'database', - 'postgrest', - 'auth', - 'edge-functions', - 'storage', - 'realtime', - ]) + expect(result.current.data[0].hasDropdownActions).toBe(true) + expect(result.current.data[0].report).toEqual(report) }) }) diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx index 59b90083bcb26..ad7bd6d88169d 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.utils.tsx @@ -1,5 +1,13 @@ +import { useFlag, useParams } from 'common' +import { useRouter } from 'next/router' +import { useMemo } from 'react' + +import { useSupamonitorStatus } from '@/components/interfaces/QueryPerformance/hooks/useSupamonitorStatus' +import { useContentQuery, type Content, type ContentBase } from '@/data/content/content-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { IS_PLATFORM } from '@/lib/constants' import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry' +import { type Dashboards } from '@/types' interface ObservabilityMenuItem { name: string @@ -14,26 +22,36 @@ export interface ObservabilityMenuSection { items: ObservabilityMenuItem[] } -interface GenerateObservabilityMenuOptions { - ref: string | undefined - preservedQueryParams: string - showOverview: boolean - isSupamonitorEnabled: boolean - storageSupported: boolean - isPlatform?: boolean +const usePreservedQueryParams = () => { + const router = useRouter() + + // Preserve date range query parameters when navigating + const preservedQueryParams = useMemo(() => { + const { its, ite, isHelper, helperText } = router.query + const params = new URLSearchParams() + + if (its && typeof its === 'string') params.set('its', its) + if (ite && typeof ite === 'string') params.set('ite', ite) + if (isHelper && typeof isHelper === 'string') params.set('isHelper', isHelper) + if (helperText && typeof helperText === 'string') params.set('helperText', helperText) + + const queryString = params.toString() + return queryString ? `?${queryString}` : '' + }, [router.query]) + + return preservedQueryParams } -export function generateObservabilityMenuItems( - options: GenerateObservabilityMenuOptions -): ObservabilityMenuSection[] { - const { - ref, - preservedQueryParams, - showOverview, - isSupamonitorEnabled, - storageSupported, - isPlatform = IS_PLATFORM, - } = options +export const useGenerateObservabilityMenu = () => { + const { ref } = useParams() + const preservedQueryParams = usePreservedQueryParams() + + const showOverview = useFlag('observabilityOverview') + const topForPostgres = useFlag('topForPostgres') + const { isSupamonitorEnabled } = useSupamonitorStatus() + const storageSupported = useIsFeatureEnabled('project_storage:all') + + const baseUrl = `/project/${ref}/observability` const generalItems: ObservabilityMenuItem[] = [ ...(showOverview @@ -41,7 +59,7 @@ export function generateObservabilityMenuItems( { name: 'Overview', key: 'observability', - url: `/project/${ref}/observability${preservedQueryParams}`, + url: `${baseUrl}${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_OVERVIEW, }, ] @@ -51,7 +69,7 @@ export function generateObservabilityMenuItems( { name: 'Query Insights', key: 'query-insights', - url: `/project/${ref}/observability/query-insights${preservedQueryParams}`, + url: `${baseUrl}/query-insights${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_QUERY_PERFORMANCE, }, ] @@ -59,16 +77,26 @@ export function generateObservabilityMenuItems( { name: 'Query Performance', key: 'query-performance', - url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, + url: `${baseUrl}/query-performance${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_QUERY_PERFORMANCE, }, ]), - ...(isPlatform + ...(IS_PLATFORM ? [ { name: 'API Gateway', key: 'api-overview', - url: `/project/${ref}/observability/api-overview${preservedQueryParams}`, + url: `${baseUrl}/api-overview${preservedQueryParams}`, + shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_API_GATEWAY, + }, + ] + : []), + ...(topForPostgres + ? [ + { + name: 'Database Connections', + key: 'database-connections', + url: `${baseUrl}/connections`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_API_GATEWAY, }, ] @@ -79,25 +107,25 @@ export function generateObservabilityMenuItems( { name: 'Database', key: 'database', - url: `/project/${ref}/observability/database${preservedQueryParams}`, + url: `${baseUrl}/database${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_DATABASE, }, { name: 'Data API', key: 'postgrest', - url: `/project/${ref}/observability/postgrest${preservedQueryParams}`, + url: `${baseUrl}/postgrest${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_DATA_API, }, { name: 'Auth', key: 'auth', - url: `/project/${ref}/observability/auth${preservedQueryParams}`, + url: `${baseUrl}/auth${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_AUTH, }, { name: 'Edge Functions', key: 'edge-functions', - url: `/project/${ref}/observability/edge-functions${preservedQueryParams}`, + url: `${baseUrl}/edge-functions${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_FUNCTIONS, }, ...(storageSupported @@ -105,7 +133,7 @@ export function generateObservabilityMenuItems( { name: 'Storage', key: 'storage', - url: `/project/${ref}/observability/storage${preservedQueryParams}`, + url: `${baseUrl}/storage${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_STORAGE, }, ] @@ -113,7 +141,7 @@ export function generateObservabilityMenuItems( { name: 'Realtime', key: 'realtime', - url: `/project/${ref}/observability/realtime${preservedQueryParams}`, + url: `${baseUrl}/realtime${preservedQueryParams}`, shortcutId: SHORTCUT_IDS.NAV_OBSERVABILITY_REALTIME, }, ] @@ -126,7 +154,7 @@ export function generateObservabilityMenuItems( }, ] - if (isPlatform) { + if (IS_PLATFORM) { sections.push({ title: 'PRODUCT', key: 'product-section', @@ -136,3 +164,51 @@ export function generateObservabilityMenuItems( return sections } + +function isReportContent(c: Content): c is ContentBase & { + type: 'report' + content: Dashboards.Content +} { + return c.type === 'report' +} + +export const useGenerateCustomReportsMenu = () => { + const { ref } = useParams() + const preservedQueryParams = usePreservedQueryParams() + + const { data: content, isPending: isLoading } = useContentQuery({ + projectRef: ref, + type: 'report', + }) + + function getReportMenuItems() { + if (!content) return [] + + const reports = content?.content.filter(isReportContent) + + const sortedReports = reports?.sort((a, b) => { + if (a.name < b.name) { + return -1 + } + if (a.name > b.name) { + return 1 + } + return 0 + }) + + const reportMenuItems = sortedReports.map((r, idx) => ({ + id: r.id, + name: r.name, + description: r.description || '', + key: r.id || idx + '-report', + url: `/project/${ref}/observability/${r.id}${preservedQueryParams}`, + hasDropdownActions: true, + report: r, + })) + + return reportMenuItems + } + + const data = getReportMenuItems() + return { data, isLoading } +} diff --git a/apps/studio/state/shortcuts/registry/observability-nav.ts b/apps/studio/state/shortcuts/registry/observability-nav.ts index 648e86b254a58..349a85f7c2f1e 100644 --- a/apps/studio/state/shortcuts/registry/observability-nav.ts +++ b/apps/studio/state/shortcuts/registry/observability-nav.ts @@ -13,6 +13,7 @@ export const OBSERVABILITY_NAV_SHORTCUT_IDS = { NAV_OBSERVABILITY_OVERVIEW: 'nav.observability-overview', NAV_OBSERVABILITY_QUERY_PERFORMANCE: 'nav.observability-query-performance', NAV_OBSERVABILITY_API_GATEWAY: 'nav.observability-api-gateway', + NAV_OBSERVABILITY_CONNECTIONS: 'nav.observability-connections', NAV_OBSERVABILITY_DATABASE: 'nav.observability-database', NAV_OBSERVABILITY_DATA_API: 'nav.observability-data-api', NAV_OBSERVABILITY_AUTH: 'nav.observability-auth', @@ -46,6 +47,13 @@ export const observabilityNavRegistry: RegistryDefinations { if (command === 'build') { // Next's types declare NODE_ENV as read-only, so cast to assign it. ;(process.env as Record).NODE_ENV = 'production' + } else if (process.env.NODE_ENV === 'test') { + // `pnpm dev:studio-local` runs with a shell NODE_ENV=test (the Next + // path needs it to load `.env.test`), and Vite's + // define plugin inlines `process.env.NODE_ENV || mode` into the client — + // which would bake 'test' in and trip the vitest-only API_URL path. + // `next dev` always runs the bundle at 'development' regardless of the + // shell NODE_ENV; mirror that. Env-file selection is unaffected — the + // vite dev path selects `.env.test` via MODE=test (see envMode below), + // not NODE_ENV. + ;(process.env as Record).NODE_ENV = 'development' } + // `pnpm dev:studio-local` needs the `.env.test` cascade (self-hosted mode + // plus the supabase-cli keys that generateLocalEnv.js writes) — the Next + // path selects it via NODE_ENV=test, and the tanstack build via + // `--mode test` (e2e:setup:selfhosted). But `vite dev --mode test` is not + // an option: TanStack Start's dev-server plugin treats mode 'test' as + // "running under vitest" and skips installing its SSR middleware entirely, + // so every route 404s (see the `isTest` guard in devServerPlugin, + // @tanstack/start-plugin-core). So dev keeps mode 'development' and + // emulates the env cascade of the mode named by MODE instead: load it for + // the NEXT_PUBLIC_* defines below, and seed process.env for the SSR + // runtime. The seeding must not clobber shell-provided values (matching + // serve.js), and survives TanStack's own load-env plugin: that plugin + // Object.assigns loadEnv(mode) at configResolved — after this runs — and + // loadEnv gives existing process.env values priority over env-file values. + const envMode = command === 'serve' && process.env.MODE ? process.env.MODE : mode + // Inline NEXT_PUBLIC_* env vars at build time so `process.env.NEXT_PUBLIC_*` // works in the browser bundle (mirrors Next.js behaviour). - const env = loadEnv(mode, rootDir, '') + const env = loadEnv(envMode, rootDir, '') + + if (envMode !== mode) { + const processEnv = process.env as Record + for (const [key, value] of Object.entries(env)) { + processEnv[key] ??= value + } + } const publicEnvDefines = Object.fromEntries( Object.entries(env) .filter(([key]) => key.startsWith('NEXT_PUBLIC_')) diff --git a/apps/www/app/(home)/_components/LogosGrid.tsx b/apps/www/app/(home)/_components/LogosGrid.tsx index ae94535c54ced..5075be20b95bf 100644 --- a/apps/www/app/(home)/_components/LogosGrid.tsx +++ b/apps/www/app/(home)/_components/LogosGrid.tsx @@ -1,79 +1,37 @@ -'use client' - -import { AnimatePresence, motion } from 'framer-motion' -import { ComponentProps, useEffect, useState } from 'react' +import { ComponentProps } from 'react' import { BetasharesLogo, BoltLogo, - ChatbaseLogo, FigmaLogo, GithubLogo, - GoodtapeLogo, - GopuffLogo, - GumloopLogo, - HappyteamsLogo, - HumataLogo, LangchainLogo, - LoopsLogo, LovableLogo, - MarkpromptLogo, - MdnLogo, MobbinLogo, MozillaLogo, - PikaLogo, + OnePasswordLogo, PwcLogo, ResendLogo, - SoshiLogo, - SubmagicLogo, - TempoLogo, V0Logo, } from './logos/PublicityLogos' import SectionContainer from '@/components/Layouts/SectionContainer' const gridLogos: { name: string; Logo: (props: ComponentProps<'svg'>) => React.JSX.Element }[] = [ - { name: 'betashares', Logo: BetasharesLogo }, - { name: 'bolt', Logo: BoltLogo }, - { name: 'chatbase', Logo: ChatbaseLogo }, - { name: 'figma', Logo: FigmaLogo }, - { name: 'github', Logo: GithubLogo }, - { name: 'goodtape', Logo: GoodtapeLogo }, - { name: 'gopuff', Logo: GopuffLogo }, - { name: 'gumloop', Logo: GumloopLogo }, - { name: 'happyteams', Logo: HappyteamsLogo }, - { name: 'humata', Logo: HumataLogo }, - { name: 'langchain', Logo: LangchainLogo }, - { name: 'loops', Logo: LoopsLogo }, { name: 'lovable', Logo: LovableLogo }, - { name: 'markprompt', Logo: MarkpromptLogo }, - { name: 'mdn', Logo: MdnLogo }, - { name: 'mobbin', Logo: MobbinLogo }, { name: 'mozilla', Logo: MozillaLogo }, - { name: 'pika', Logo: PikaLogo }, { name: 'pwc', Logo: PwcLogo }, - { name: 'resend', Logo: ResendLogo }, - { name: 'soshi', Logo: SoshiLogo }, - { name: 'submagic', Logo: SubmagicLogo }, - { name: 'tempo', Logo: TempoLogo }, + { name: 'figma', Logo: FigmaLogo }, { name: 'v0', Logo: V0Logo }, + { name: 'bolt', Logo: BoltLogo }, + { name: 'github', Logo: GithubLogo }, + { name: 'betashares', Logo: BetasharesLogo }, + { name: 'mobbin', Logo: MobbinLogo }, + { name: 'resend', Logo: ResendLogo }, + { name: 'langchain', Logo: LangchainLogo }, + { name: '1password', Logo: OnePasswordLogo }, ] -const LOGOS_PER_PAGE = 12 - export function LogosGrid() { - const [page, setPage] = useState(0) - const totalPages = Math.ceil(gridLogos.length / LOGOS_PER_PAGE) - - useEffect(() => { - const interval = setInterval(() => { - setPage((p) => (p + 1) % totalPages) - }, 10000) - return () => clearInterval(interval) - }, [totalPages]) - - const start = page * LOGOS_PER_PAGE - const currentLogos = gridLogos.slice(start, start + LOGOS_PER_PAGE) - return (
@@ -84,34 +42,27 @@ export function LogosGrid() {
- - - {currentLogos.map(({ name, Logo }, i) => ( - - - - ))} - - +
    + {gridLogos.map(({ name, Logo }) => ( +
  • + +
  • + ))} +
diff --git a/package.json b/package.json index 4b5c8e45eae81..6993678bcd04c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "clean": "turbo run clean --parallel && rimraf -G node_modules/{*,.bin,.modules.yaml} .turbo/cache", "dev": "turbo run dev --parallel", "dev:studio": "turbo run dev --filter=studio --parallel", - "dev:studio-local": "pnpm setup:cli && NODE_ENV=test pnpm --prefix ./apps/studio dev", + "dev:studio-local": "pnpm setup:cli && NODE_ENV=test MODE=test pnpm --prefix ./apps/studio dev", "dev:docs": "turbo run dev --filter=docs --parallel", "dev:www": "turbo run dev --filter=www --parallel", "dev:design-system": "turbo run dev --filter=design-system --parallel",