diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 70bffc48d3e..1041597198f 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,20 +1,16 @@ -import { partition } from 'lodash' -import { PropsWithChildren } from 'react' - -import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' +import { PropsWithChildren } from 'react' + import { OrganizationResourceBanner } from '../Organization/HeaderBanner' +import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const { data: allStatusPageEvents } = useIncidentStatusQuery() - const [maintenanceEvents, incidents] = partition( - allStatusPageEvents ?? [], - (event) => event.impact === 'maintenance' - ) + const { maintenanceEvents = [], incidents = [] } = allStatusPageEvents ?? {} const ongoingIncident = useFlag('ongoingIncident') || diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx index e7caa76a708..4561e4c30b7 100644 --- a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx +++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx @@ -1,9 +1,8 @@ -import { AnimatePresence, motion } from 'framer-motion' -import Link from 'next/link' - import { useIncidentStatusQuery } from 'data/platform/incident-status-query' import { processIncidentData } from 'data/platform/incident-status-utils' +import { AnimatePresence, motion } from 'framer-motion' import { ExternalLink } from 'lucide-react' +import Link from 'next/link' import { Button } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -54,8 +53,8 @@ const getStatusDescription = ( } export function IncidentAdmonition({ isActive }: IncidentAdmonitionProps) { - const { data: allStatusPageEvents = [], isLoading, isError } = useIncidentStatusQuery() - const incidents = allStatusPageEvents.filter((x) => x.impact !== 'maintenance') + const { data: allStatusPageEvents, isLoading, isError } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} // Don't render anything while loading, on error, or if no incidents if (isLoading || isError || !incidents || incidents.length === 0) { diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index 672103a7a81..60268f00035 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -1,20 +1,18 @@ import * as Sentry from '@sentry/nextjs' +import CopyButton from 'components/ui/CopyButton' +import { useIncidentStatusQuery } from 'data/platform/incident-status-query' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useStateTransition } from 'hooks/misc/useStateTransition' +import { BASE_PATH, DOCS_URL } from 'lib/constants' import { Loader2, Wrench } from 'lucide-react' import Link from 'next/link' import { type Dispatch, type PropsWithChildren, useCallback, useReducer } from 'react' import type { UseFormReturn } from 'react-hook-form' import SVG from 'react-inlinesvg' import { toast } from 'sonner' -// End of third-party imports - -import CopyButton from 'components/ui/CopyButton' -import { useIncidentStatusQuery } from 'data/platform/incident-status-query' -import { usePlatformStatusQuery } from 'data/platform/platform-status-query' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useStateTransition } from 'hooks/misc/useStateTransition' -import { BASE_PATH, DOCS_URL } from 'lib/constants' -import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger, cn } from 'ui' import { Admonition } from 'ui-patterns/admonition' + import { AIAssistantOption } from './AIAssistantOption' import { DiscordCTACard } from './DiscordCTACard' import { IncidentAdmonition } from './IncidentAdmonition' @@ -22,10 +20,10 @@ import { Success } from './Success' import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' import { - createInitialSupportFormState, type SupportFormActions, - supportFormReducer, type SupportFormState, + createInitialSupportFormState, + supportFormReducer, } from './SupportForm.state' import { NO_PROJECT_MARKER } from './SupportForm.utils' import { SupportFormV2 } from './SupportFormV2' @@ -67,10 +65,11 @@ function SupportFormPageContent() { const { form, initialError, projectRef, orgSlug } = useSupportForm(dispatch) const { - data: incidents, + data: allStatusPageEvents, isPending: isIncidentsPending, isError: isIncidentsError, } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} const hasActiveIncidents = !isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0 @@ -129,8 +128,10 @@ function SupportFormWrapper({ children }: PropsWithChildren) { } function SupportFormHeader() { - const { data, isPending: isLoading, isError } = usePlatformStatusQuery() - const isHealthy = data?.isHealthy + const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery() + const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} + const isMaintenance = maintenanceEvents.length > 0 + const isIncident = incidents.length > 0 return (
@@ -157,10 +158,10 @@ function SupportFormHeader() { icon={ isLoading ? ( - ) : isHealthy ? ( -
) : ( -
+
) } > @@ -169,9 +170,11 @@ function SupportFormHeader() { ? 'Checking status' : isError ? 'Failed to check status' - : isHealthy - ? 'All systems operational' - : 'Active incident ongoing'} + : isIncident + ? 'Active incident ongoing' + : isMaintenance + ? 'Scheduled maintenance' + : 'All systems operational'} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index c64257e678d..4c85b45f3ca 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -1,15 +1,16 @@ import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import dayjs from 'dayjs' -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' // End of third-party imports -import { API_URL } from 'lib/constants' +import { API_URL, BASE_PATH } from 'lib/constants' import { HttpResponse, http } from 'msw' import { createMockOrganization, createMockProject } from 'tests/helpers' import { customRender } from 'tests/lib/custom-render' import { addAPIMock, mswServer } from 'tests/lib/msw' import { createMockProfileContext } from 'tests/lib/profile-helpers' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' import { SupportFormPage } from '../SupportFormPage' @@ -173,6 +174,14 @@ vi.mock(import('lib/gotrue'), async (importOriginal) => { } }) +vi.mock(import('lib/constants'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + } +}) + const renderSupportFormPage = (options?: Parameters[1]) => customRender(, { profileContext: createMockProfileContext(), @@ -397,11 +406,9 @@ describe('SupportFormPage', () => { }, }) - addAPIMock({ - method: 'get', - path: '/platform/status', - response: { is_healthy: true } as any, - }) + mswServer.use( + http.get(`${BASE_PATH}/api/incident-status`, () => HttpResponse.json([], { status: 200 })) + ) addAPIMock({ method: 'get', @@ -459,11 +466,22 @@ describe('SupportFormPage', () => { }) test('shows system status: not healthy', async () => { - addAPIMock({ - method: 'get', - path: '/platform/status', - response: { is_healthy: false } as any, - }) + mswServer.use( + http.get(`${BASE_PATH}/api/incident-status`, () => + HttpResponse.json( + [ + { + id: 'z3qp8rln72pl', + active_since: '2026-01-26T10:30:00Z', + impact: 'critical', + status: 'in_progress', + name: 'Test incident', + }, + ], + { status: 200 } + ) + ) + ) renderSupportFormPage() @@ -474,7 +492,7 @@ describe('SupportFormPage', () => { test('shows system status: check failed', async () => { mswServer.use( - http.get(`${API_URL}/platform/status`, () => + http.get(`${BASE_PATH}/api/incident-status`, () => HttpResponse.json({ msg: 'Status service unavailable' }, { status: 500 }) ) ) diff --git a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx index 388f2ee16a5..4c5610b36b6 100644 --- a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx @@ -1,10 +1,25 @@ +import { LOCAL_STORAGE_KEYS } from 'common' import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' import { InlineLink } from 'components/ui/InlineLink' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + /** * Used to display ongoing maintenance */ export function MaintenanceBanner() { + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { maintenanceEvents = [] } = allStatusPageEvents ?? {} + const currentEventId = maintenanceEvents[0]?.id ?? '' + + const [dismissed, setDismissed] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(currentEventId), + false + ) + + if (dismissed) return null + return ( } + onDismiss={() => setDismissed(true)} /> ) } diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts index 6efb96c1a09..a6db924e096 100644 --- a/apps/studio/data/platform/incident-status-query.ts +++ b/apps/studio/data/platform/incident-status-query.ts @@ -1,11 +1,14 @@ import { useQuery } from '@tanstack/react-query' - import type { IncidentInfo } from 'lib/api/incident-status' import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { partition } from 'lodash' import { UseCustomQueryOptions } from 'types' + import { platformKeys } from './keys' -export async function getIncidentStatus(signal?: AbortSignal): Promise { +export async function getIncidentStatus( + signal?: AbortSignal +): Promise<{ maintenanceEvents: IncidentInfo[]; incidents: IncidentInfo[] }> { const response = await fetch(`${BASE_PATH}/api/incident-status`, { signal, method: 'GET', @@ -21,7 +24,11 @@ export async function getIncidentStatus(signal?: AbortSignal): Promise event.impact === 'maintenance' + ) + return { maintenanceEvents, incidents } } export type IncidentStatusData = Awaited> diff --git a/apps/studio/data/platform/keys.ts b/apps/studio/data/platform/keys.ts index 4b7877df0f0..3052d7b2fdf 100644 --- a/apps/studio/data/platform/keys.ts +++ b/apps/studio/data/platform/keys.ts @@ -1,4 +1,3 @@ export const platformKeys = { - status: () => ['platform', 'status'] as const, incidentStatus: () => ['platform', 'incident-status'] as const, } diff --git a/apps/studio/data/platform/platform-status-query.ts b/apps/studio/data/platform/platform-status-query.ts deleted file mode 100644 index 73f944673bf..00000000000 --- a/apps/studio/data/platform/platform-status-query.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import { get, handleError } from 'data/fetchers' -import { platformKeys } from './keys' -import { UseCustomQueryOptions } from 'types' - -export type PlatformStatusResponse = { - isHealthy: boolean -} - -export async function getPlatformStatus(signal?: AbortSignal) { - const { data, error } = await get('/platform/status', { signal }) - if (error) handleError(error) - return { isHealthy: (data as any).is_healthy } as PlatformStatusResponse -} - -export type PlatformStatusData = Awaited> -export type PlatformStatusError = unknown - -export const usePlatformStatusQuery = ( - options: UseCustomQueryOptions = {} -) => - useQuery({ - queryKey: platformKeys.status(), - queryFn: ({ signal }) => getPlatformStatus(signal), - ...options, - }) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 4553bcf8b50..86475d95ba7 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -9,6 +9,7 @@ export const LOCAL_STORAGE_KEYS = { PROJECTS_VIEW: 'projects-view', FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', + MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`, UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls',