diff --git a/client/dashboard/sites/backups/backup-notices.tsx b/client/dashboard/sites/backups/backup-notices.tsx index 680ba276c41a..f874742c93ea 100644 --- a/client/dashboard/sites/backups/backup-notices.tsx +++ b/client/dashboard/sites/backups/backup-notices.tsx @@ -1,12 +1,6 @@ -import { localizeUrl } from '@automattic/i18n-utils'; -import { JETPACK_CONTACT_SUPPORT } from '@automattic/urls'; -import { Button, ExternalLink } from '@wordpress/components'; -import { createInterpolateElement, useState, useEffect } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; -import { useFormattedTime } from '../../components/formatted-time'; -import InlineSupportLink from '../../components/inline-support-link'; -import { Notice } from '../../components/notice'; -import { isSelfHostedJetpackConnected } from '../../utils/site-types'; +import { BackupProgressNotices } from './backup-progress-notices'; +import { RestoreProgressNotices } from './restore-progress-notices'; +import { useRewindState } from './use-rewind-state'; import type { BackupState } from './use-backup-state'; import type { Site } from '@automattic/api-core'; @@ -18,7 +12,14 @@ interface BackupNoticesProps { } /** - * Renders a contextual Notice based on the site's backup status + * Orchestrates display of backup and restore operation notices with priority ordering. + * + * Priority order (highest to lowest): + * 1. Restore operations + * 2. Backup operations + * + * Blocking rules: + * - Restore blocks Backup (restore and backup cannot coexist) */ export function BackupNotices( { backupState, @@ -26,100 +27,26 @@ export function BackupNotices( { timezoneString, gmtOffset, }: BackupNoticesProps ) { - const { status, backup } = backupState; - const backupDate = useFormattedTime( - backup?.started ? backup.started.replace( ' ', 'T' ) + 'Z' : '', - { - timeStyle: 'short', - }, - timezoneString, - gmtOffset, - true // Use lowercase calendar label + const { hasActiveRestore } = useRewindState( site.ID ); + + return ( + <> + { /* Priority 1: Restore operations */ } + + + { /* Priority 2: Backup operations - blocked by active restore */ } + { ! hasActiveRestore && ( + + ) } + ); - const [ isDismissed, setIsDismissed ] = useState( false ); - - const handleDismiss = () => { - setIsDismissed( true ); - }; - - useEffect( () => { - // Reset dismissal when a new backup starts - if ( status === 'running' ) { - setIsDismissed( false ); - } - }, [ status ] ); - - if ( status === 'enqueued' ) { - return ( - - { __( 'We’re preparing to make a backup of your site.' ) } - - ); - } - - if ( status === 'running' ) { - return ( - - { sprintf( - /* translators: %s is a date, like "today at 10:00". */ - __( - 'We’re making a backup of your site from %s. Sit back and relax—we’ll take care of this in the background.' - ), - backupDate - ) } - - ); - } - - if ( status === 'success' && ! isDismissed ) { - return ( - - { __( 'You’ll be able to access your new backup in just a few minutes.' ) } - - ); - } - - if ( status === 'error' && ! isDismissed ) { - return ( - - { __( 'Contact support' ) } - - } - > - { createInterpolateElement( - sprintf( - /* translators: %s is a date, like "today at 10:00" */ - __( - 'We weren’t able to finish your backup from %s, but don’t worry—your existing data is safe. Check our help guide or contact support to get this resolved.' - ), - backupDate - ), - { - external: isSelfHostedJetpackConnected( site ) ? ( - - ) : ( - - ), - } - ) } - - ); - } - - return null; } diff --git a/client/dashboard/sites/backups/backup-progress-notices.tsx b/client/dashboard/sites/backups/backup-progress-notices.tsx new file mode 100644 index 000000000000..6deb45eae084 --- /dev/null +++ b/client/dashboard/sites/backups/backup-progress-notices.tsx @@ -0,0 +1,131 @@ +import { localizeUrl } from '@automattic/i18n-utils'; +import { JETPACK_CONTACT_SUPPORT } from '@automattic/urls'; +import { Button, ExternalLink } from '@wordpress/components'; +import { createInterpolateElement, useState, useEffect } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { useFormattedTime } from '../../components/formatted-time'; +import InlineSupportLink from '../../components/inline-support-link'; +import { Notice } from '../../components/notice'; +import { isSelfHostedJetpackConnected } from '../../utils/site-types'; +import type { BackupState } from './use-backup-state'; +import type { Site } from '@automattic/api-core'; + +interface BackupProgressNoticesProps { + backupState: BackupState; + site: Site; + timezoneString?: string; + gmtOffset?: number; +} + +/** + * Renders contextual notices based on the site's backup status + */ +export function BackupProgressNotices( { + backupState, + site, + timezoneString, + gmtOffset, +}: BackupProgressNoticesProps ) { + const { status, backup } = backupState; + const [ isDismissed, setIsDismissed ] = useState( false ); + + const backupDate = useFormattedTime( + backup?.started ? backup.started.replace( ' ', 'T' ) + 'Z' : '', + { + timeStyle: 'short', + }, + timezoneString, + gmtOffset, + true + ); + + useEffect( () => { + // Reset dismissal when a new backup starts + if ( status === 'running' ) { + setIsDismissed( false ); + } + }, [ status ] ); + + const notices = []; + + if ( status === 'enqueued' ) { + notices.push( + + { __( 'We’re preparing to make a backup of your site.' ) } + + ); + } + + if ( status === 'running' ) { + notices.push( + + { sprintf( + /* translators: %s is a date, like "today at 10:00". */ + __( + 'We’re making a backup of your site from %s. Sit back and relax—we’ll take care of this in the background.' + ), + backupDate + ) } + + ); + } + + if ( status === 'success' && ! isDismissed ) { + notices.push( + setIsDismissed( true ) } + > + { __( 'You’ll be able to access your new backup in just a few minutes.' ) } + + ); + } + + if ( status === 'error' && ! isDismissed ) { + notices.push( + setIsDismissed( true ) } + actions={ + + } + > + { createInterpolateElement( + sprintf( + /* translators: %s is a date, like "today at 10:00" */ + __( + 'We weren’t able to finish your backup from %s, but don’t worry—your existing data is safe. Check our help guide or contact support to get this resolved.' + ), + backupDate + ), + { + external: isSelfHostedJetpackConnected( site ) ? ( + + ) : ( + + ), + } + ) } + + ); + } + + return <>{ notices }; +} diff --git a/client/dashboard/sites/backups/restore-progress-notices.tsx b/client/dashboard/sites/backups/restore-progress-notices.tsx new file mode 100644 index 000000000000..f4dc753a6962 --- /dev/null +++ b/client/dashboard/sites/backups/restore-progress-notices.tsx @@ -0,0 +1,128 @@ +import { dismissSiteRestoreMutation } from '@automattic/api-queries'; +import { localizeUrl } from '@automattic/i18n-utils'; +import { JETPACK_CONTACT_SUPPORT } from '@automattic/urls'; +import { useMutation } from '@tanstack/react-query'; +import { Button, ExternalLink } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { useFormattedTime } from '../../components/formatted-time'; +import InlineSupportLink from '../../components/inline-support-link'; +import { Notice } from '../../components/notice'; +import { isSelfHostedJetpackConnected } from '../../utils/site-types'; +import { useRewindState } from './use-rewind-state'; +import type { Site } from '@automattic/api-core'; + +interface RestoreProgressNoticesProps { + site: Site; + timezoneString?: string; + gmtOffset?: number; +} + +/** + * Renders contextual notices based on the site's restore status + */ +export function RestoreProgressNotices( { + site, + timezoneString, + gmtOffset, +}: RestoreProgressNoticesProps ) { + const { hasActiveRestore, hasFinishedRestore, hasFailedRestore, restoreProgress, restoreId } = + useRewindState( site.ID ); + + const dismissRestore = useMutation( dismissSiteRestoreMutation( site.ID ) ); + + const restoreDate = useFormattedTime( + restoreProgress?.rewindId + ? new Date( parseInt( restoreProgress.rewindId ) * 1000 ).toISOString() + : '', + { + timeStyle: 'short', + }, + timezoneString, + gmtOffset, + true + ); + + const notices = []; + + if ( hasActiveRestore && restoreProgress ) { + notices.push( + + { sprintf( + /* translators: %s is a date, like "Monday, 20 October 2025 18:46". */ + __( 'We’re restoring your site back to %s. You’ll be notified once it’s complete.' ), + restoreDate + ) } + + ); + } + + if ( hasFinishedRestore && restoreProgress && restoreId ) { + notices.push( + dismissRestore.mutate( restoreId ) } + actions={ + + } + > + { sprintf( + /* translators: %s is a date, like "Monday, 20 October 2025 18:46" */ + __( 'We successfully restored your site back to %s!' ), + restoreDate + ) } + + ); + } + + if ( hasFailedRestore && restoreProgress && restoreId ) { + notices.push( + dismissRestore.mutate( restoreId ) } + actions={ + + } + > + { createInterpolateElement( + sprintf( + /* translators: %s is a date, like "Monday, 20 October 2025 18:46" */ + __( + 'We weren’t able to restore your site back to %1$s. Check our help guide or contact support to get this resolved.' + ), + restoreDate + ), + { + external: isSelfHostedJetpackConnected( site ) ? ( + + ) : ( + + ), + } + ) } + + ); + } + + return <>{ notices }; +} diff --git a/client/dashboard/sites/backups/use-restore-status.ts b/client/dashboard/sites/backups/use-restore-status.ts new file mode 100644 index 000000000000..0c51cf8f980a --- /dev/null +++ b/client/dashboard/sites/backups/use-restore-status.ts @@ -0,0 +1,14 @@ +import { siteRewindStateQuery } from '@automattic/api-queries'; +import { useQuery } from '@tanstack/react-query'; + +export function useRestoreStatus( siteId: number ) { + const { data: rewindState } = useQuery( siteRewindStateQuery( siteId ) ); + + const restore = rewindState?.rewind; + const isRestoreInProgress = restore?.status === 'queued' || restore?.status === 'running'; + + return { + restore, + isRestoreInProgress, + }; +} diff --git a/client/dashboard/sites/backups/use-rewind-state.ts b/client/dashboard/sites/backups/use-rewind-state.ts new file mode 100644 index 000000000000..c22882d2af1a --- /dev/null +++ b/client/dashboard/sites/backups/use-rewind-state.ts @@ -0,0 +1,49 @@ +import { siteRewindStateQuery, siteBackupRestoreProgressQuery } from '@automattic/api-queries'; +import { useQuery } from '@tanstack/react-query'; + +/** + * Hook to track rewind state including restore progress. + * Handles polling automatically when operations are active. + * @param siteId - The site ID to track + * @returns Object containing detection flags and full restore data + */ +export function useRewindState( siteId: number ) { + // Detection: Check if restore exists and poll when active + const { data: rewindState } = useQuery( { + ...siteRewindStateQuery( siteId ), + refetchInterval: ( query ) => { + const data = query.state.data; + const hasActiveOperation = + data?.rewind?.status === 'queued' || data?.rewind?.status === 'running'; + + // Poll every 3s if there's an active operation + return hasActiveOperation ? 3000 : false; + }, + } ); + + const restoreId = rewindState?.rewind?.restore_id; + + // Progress: Get detailed data if restore exists and poll while active + const { data: restoreProgress } = useQuery( { + ...siteBackupRestoreProgressQuery( siteId, restoreId ?? 0 ), + enabled: !! restoreId, + refetchInterval: ( query ) => { + const data = query.state.data; + // Stop polling when finished or failed + const isComplete = data?.status === 'finished' || data?.status === 'fail'; + return isComplete ? false : 1500; + }, + } ); + + return { + // Detection flags + hasActiveRestore: restoreProgress?.status === 'running' || restoreProgress?.status === 'queued', + hasFinishedRestore: restoreProgress?.status === 'finished', + hasFailedRestore: restoreProgress?.status === 'fail', + + // Full data + rewindState, + restoreProgress, + restoreId, + }; +} diff --git a/packages/api-core/src/index.ts b/packages/api-core/src/index.ts index f113b8115291..db6c51b21679 100644 --- a/packages/api-core/src/index.ts +++ b/packages/api-core/src/index.ts @@ -79,6 +79,7 @@ export * from './site-automated-transfers-eligibility'; export * from './site-backup-download'; export * from './site-backup-restore'; export * from './site-backups'; +export * from './site-rewind'; export * from './site-do-it-for-me'; export * from './site-domains'; export * from './site-flex-usage'; diff --git a/packages/api-core/src/site-backup-restore/mutators.ts b/packages/api-core/src/site-backup-restore/mutators.ts index 00306bb0ec19..812c7281c40d 100644 --- a/packages/api-core/src/site-backup-restore/mutators.ts +++ b/packages/api-core/src/site-backup-restore/mutators.ts @@ -1,5 +1,5 @@ import { wpcom } from '../wpcom-fetcher'; -import type { RestoreConfig, GranularRestoreConfig } from './types'; +import type { RestoreConfig, GranularRestoreConfig, DismissRestoreResponse } from './types'; /** * Initiate a restore operation for a site to a specific timestamp. @@ -56,3 +56,20 @@ export async function initiateSiteGranularRestore( return Number( data.restore_id ); } + +/** + * Dismiss a restore operation notice. + * @param siteId - The ID of the site. + * @param restoreId - The ID of the restore to dismiss. + * @returns A promise that resolves to the dismiss response. + */ +export async function dismissSiteRestore( + siteId: number, + restoreId: number +): Promise< DismissRestoreResponse > { + return wpcom.req.post( { + apiNamespace: 'wpcom/v2', + path: `/sites/${ siteId }/rewind/restores/${ restoreId }`, + body: { dismissed: true }, + } ); +} diff --git a/packages/api-core/src/site-backup-restore/types.ts b/packages/api-core/src/site-backup-restore/types.ts index ad36346d316c..3949cdffda24 100644 --- a/packages/api-core/src/site-backup-restore/types.ts +++ b/packages/api-core/src/site-backup-restore/types.ts @@ -53,3 +53,11 @@ export interface RestoreStatusResponse { failure_reason: string; }; } + +/** + * Dismiss restore response. + */ +export interface DismissRestoreResponse { + restore_id: number; + is_dismissed: boolean; +} diff --git a/packages/api-core/src/site-rewind/fetchers.ts b/packages/api-core/src/site-rewind/fetchers.ts new file mode 100644 index 000000000000..4f67945d3775 --- /dev/null +++ b/packages/api-core/src/site-rewind/fetchers.ts @@ -0,0 +1,15 @@ +import { wpcom } from '../wpcom-fetcher'; +import type { RewindState } from './types'; + +/** + * Fetch the rewind state for a site from the VaultPress platform. + * @param siteId - The ID of the site. + * @returns A promise that resolves to the rewind state. + */ +export async function fetchSiteRewindState( siteId: number ): Promise< RewindState > { + return wpcom.req.get( { + apiNamespace: 'wpcom/v2', + path: `/sites/${ siteId }/rewind`, + query: { force: 'wpcom' }, + } ); +} diff --git a/packages/api-core/src/site-rewind/index.ts b/packages/api-core/src/site-rewind/index.ts new file mode 100644 index 000000000000..e83b12f69b67 --- /dev/null +++ b/packages/api-core/src/site-rewind/index.ts @@ -0,0 +1,2 @@ +export * from './fetchers'; +export * from './types'; diff --git a/packages/api-core/src/site-rewind/types.ts b/packages/api-core/src/site-rewind/types.ts new file mode 100644 index 000000000000..34c8ca6d28e1 --- /dev/null +++ b/packages/api-core/src/site-rewind/types.ts @@ -0,0 +1,32 @@ +import type { RestoreStatus } from '../site-backup-restore/types'; + +export type RewindStateType = + | 'active' + | 'inactive' + | 'unavailable' + | 'awaiting_credentials' + | 'provisioning'; + +export interface RestoreInfo { + restore_id: number; + rewind_id: string; + status: RestoreStatus; + started_at: string; + site_id: number; + progress?: number; // Only present when status is 'running' + message?: string; // Only present when status is 'running' + current_entry?: string | null; // Only present when status is 'running' + reason?: string; // Only present when status is 'failed' + links?: { + dismiss?: { + apiVersion: string; + method: string; + path: string; + }; + }; +} + +export interface RewindState { + state: RewindStateType; + rewind?: RestoreInfo; +} diff --git a/packages/api-queries/src/index.ts b/packages/api-queries/src/index.ts index 7613956e4ecb..dad3159fae46 100644 --- a/packages/api-queries/src/index.ts +++ b/packages/api-queries/src/index.ts @@ -67,6 +67,7 @@ export * from './site-automated-transfers-eligibility'; export * from './site-backup-download'; export * from './site-backup-restore'; export * from './site-backups'; +export * from './site-rewind'; export * from './site-cache'; export * from './site-database'; export * from './site-defensive-mode'; diff --git a/packages/api-queries/src/site-backup-restore.ts b/packages/api-queries/src/site-backup-restore.ts index dcccecf86781..887c0b458700 100644 --- a/packages/api-queries/src/site-backup-restore.ts +++ b/packages/api-queries/src/site-backup-restore.ts @@ -2,12 +2,14 @@ import { fetchSiteBackupRestoreProgress, initiateSiteBackupRestore, initiateSiteGranularRestore, + dismissSiteRestore, type RestoreConfig, type GranularRestoreConfig, } from '@automattic/api-core'; import configApi from '@automattic/calypso-config'; import { mutationOptions, queryOptions } from '@tanstack/react-query'; import { queryClient } from './query-client'; +import { siteRewindStateQuery } from './site-rewind'; /** * Fetch the restore progress for a site. @@ -38,6 +40,8 @@ export const siteBackupRestoreInitiateMutation = ( siteId: number ) => onSuccess: ( restoreId ) => { // Start polling restore progress queryClient.prefetchQuery( siteBackupRestoreProgressQuery( siteId, restoreId ) ); + // Invalidate rewind state to pick up new restore + queryClient.invalidateQueries( siteRewindStateQuery( siteId ) ); }, } ); @@ -58,5 +62,21 @@ export const siteBackupGranularRestoreMutation = ( siteId: number ) => onSuccess: ( restoreId ) => { // Start polling restore progress queryClient.prefetchQuery( siteBackupRestoreProgressQuery( siteId, restoreId ) ); + // Invalidate rewind state to pick up new restore + queryClient.invalidateQueries( siteRewindStateQuery( siteId ) ); + }, + } ); + +/** + * Dismiss a restore operation notice. + * @param siteId - The ID of the site. + * @returns Mutation options for dismissing a restore. + */ +export const dismissSiteRestoreMutation = ( siteId: number ) => + mutationOptions( { + mutationFn: ( restoreId: number ) => dismissSiteRestore( siteId, restoreId ), + onSuccess: () => { + // Invalidate to refetch without the dismissed restore + queryClient.invalidateQueries( siteRewindStateQuery( siteId ) ); }, } ); diff --git a/packages/api-queries/src/site-rewind.ts b/packages/api-queries/src/site-rewind.ts new file mode 100644 index 000000000000..86c649148908 --- /dev/null +++ b/packages/api-queries/src/site-rewind.ts @@ -0,0 +1,8 @@ +import { fetchSiteRewindState } from '@automattic/api-core'; +import { queryOptions } from '@tanstack/react-query'; + +export const siteRewindStateQuery = ( siteId: number ) => + queryOptions( { + queryKey: [ 'site', siteId, 'rewind-state' ], + queryFn: () => fetchSiteRewindState( siteId ), + } );