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 ),
+ } );