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",