diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eea6bc73a14..37dcee2749d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2054,9 +2054,15 @@ importers: '@wordpress/icons': specifier: 12.2.0 version: 12.2.0(react@18.3.1) + '@wordpress/route': + specifier: 0.10.0 + version: 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/ui': specifier: 0.11.0 version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 moment: specifier: 2.30.1 version: 2.30.1 @@ -2108,7 +2114,7 @@ importers: version: 6.44.0 '@wordpress/build': specifier: 0.13.0 - version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) + version: 0.13.0(@babel/core@7.29.0)(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2) browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -24787,7 +24793,7 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': + '@wordpress/build@0.13.0(@babel/core@7.29.0)(@wordpress/route@0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)': dependencies: '@emotion/babel-plugin': 11.13.5 autoprefixer: 10.5.0(postcss@8.5.14) @@ -24804,6 +24810,8 @@ snapshots: postcss-modules: 6.0.1(postcss@8.5.14) rtlcss: 4.3.0 sass-embedded: 1.97.3 + optionalDependencies: + '@wordpress/route': 0.10.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) transitivePeerDependencies: - '@babel/core' - browserslist diff --git a/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..5172587e5431 --- /dev/null +++ b/projects/packages/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. diff --git a/projects/packages/backup/changelog/wire-backup-modernization-data-layer b/projects/packages/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..6f44e75925a4 --- /dev/null +++ b/projects/packages/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/packages/backup/package.json b/projects/packages/backup/package.json index a05f0edefd32..66bb0e4ff057 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -47,7 +47,9 @@ "@wordpress/element": "6.44.0", "@wordpress/i18n": "6.17.0", "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0", "moment": "2.30.1", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index f89149985c3f..001b0c10dd25 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -3,10 +3,16 @@ "version": "1.0.0", "private": true, "dependencies": { + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.27.0", "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0" + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/ui": "0.11.0" }, "route": { "path": "/", diff --git a/projects/packages/backup/routes/dashboard/stage.tsx b/projects/packages/backup/routes/dashboard/stage.tsx index e29fe85c77d5..1eb92fdafa4b 100644 --- a/projects/packages/backup/routes/dashboard/stage.tsx +++ b/projects/packages/backup/routes/dashboard/stage.tsx @@ -1,18 +1,6 @@ -import { Page } from '@wordpress/admin-ui'; -import { __ } from '@wordpress/i18n'; +import OverviewScreen from '../../src/dashboard/screens/overview'; +import './style.scss'; -const Stage = () => { - // "VaultPress Backup" is a product name, do not translate. - return ( - - ); -}; +const Stage = () => ; export { Stage as stage }; diff --git a/projects/packages/backup/routes/dashboard/style.scss b/projects/packages/backup/routes/dashboard/style.scss new file mode 100644 index 000000000000..34b24961b99d --- /dev/null +++ b/projects/packages/backup/routes/dashboard/style.scss @@ -0,0 +1,22 @@ +// Route-level styles for the modernized Backup dashboard. +// Per-component styles live alongside each component under +// src/dashboard/components/*. This file is intentionally minimal for +// Task 1; later tasks add layout rules for the two-pane Overview here. + +.jpb-overview { + display: grid; + grid-template-columns: minmax(360px, 1fr) minmax(400px, 1.5fr); + gap: 16px; + width: 100%; + min-height: 600px; + + &__detail--empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + background-color: var(--wp-components-color-background-secondary, #f7f7f7); + border-radius: 8px; + padding: 24px; + } +} diff --git a/projects/packages/backup/routes/download/package.json b/projects/packages/backup/routes/download/package.json new file mode 100644 index 000000000000..a96873ae17e3 --- /dev/null +++ b/projects/packages/backup/routes/download/package.json @@ -0,0 +1,21 @@ +{ + "name": "_@jetpack-backup/download-route", + "version": "1.0.0", + "private": true, + "dependencies": { + "@tanstack/react-query": "5.90.8", + "@types/react": "18.3.28", + "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.27.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/ui": "0.11.0" + }, + "route": { + "path": "/download/$rewindId", + "page": "jetpack-backup-dashboard" + } +} diff --git a/projects/packages/backup/routes/download/route.tsx b/projects/packages/backup/routes/download/route.tsx new file mode 100644 index 000000000000..91499ada4b32 --- /dev/null +++ b/projects/packages/backup/routes/download/route.tsx @@ -0,0 +1 @@ +export const route = {}; diff --git a/projects/packages/backup/routes/download/stage.tsx b/projects/packages/backup/routes/download/stage.tsx new file mode 100644 index 000000000000..d44b7fd35af6 --- /dev/null +++ b/projects/packages/backup/routes/download/stage.tsx @@ -0,0 +1,6 @@ +import DownloadScreen from '../../src/dashboard/screens/download'; +import './style.scss'; + +const Stage = () => ; + +export { Stage as stage }; diff --git a/projects/packages/backup/routes/download/style.scss b/projects/packages/backup/routes/download/style.scss new file mode 100644 index 000000000000..220258cbceb6 --- /dev/null +++ b/projects/packages/backup/routes/download/style.scss @@ -0,0 +1,27 @@ +.jpb-download { + max-width: 700px; + margin-inline: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + + &__back { + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + width: max-content; + } + + &__card { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__link { + font-weight: 600; + } +} diff --git a/projects/packages/backup/routes/restore/package.json b/projects/packages/backup/routes/restore/package.json new file mode 100644 index 000000000000..4c9c78e92cd0 --- /dev/null +++ b/projects/packages/backup/routes/restore/package.json @@ -0,0 +1,21 @@ +{ + "name": "_@jetpack-backup/restore-route", + "version": "1.0.0", + "private": true, + "dependencies": { + "@tanstack/react-query": "5.90.8", + "@types/react": "18.3.28", + "@wordpress/admin-ui": "2.0.0", + "@wordpress/components": "32.6.0", + "@wordpress/date": "5.27.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "@wordpress/icons": "12.2.0", + "@wordpress/route": "0.10.0", + "@wordpress/ui": "0.11.0" + }, + "route": { + "path": "/restore/$rewindId", + "page": "jetpack-backup-dashboard" + } +} diff --git a/projects/packages/backup/routes/restore/route.tsx b/projects/packages/backup/routes/restore/route.tsx new file mode 100644 index 000000000000..91499ada4b32 --- /dev/null +++ b/projects/packages/backup/routes/restore/route.tsx @@ -0,0 +1 @@ +export const route = {}; diff --git a/projects/packages/backup/routes/restore/stage.tsx b/projects/packages/backup/routes/restore/stage.tsx new file mode 100644 index 000000000000..154b4660e014 --- /dev/null +++ b/projects/packages/backup/routes/restore/stage.tsx @@ -0,0 +1,6 @@ +import RestoreScreen from '../../src/dashboard/screens/restore'; +import './style.scss'; + +const Stage = () => ; + +export { Stage as stage }; diff --git a/projects/packages/backup/routes/restore/style.scss b/projects/packages/backup/routes/restore/style.scss new file mode 100644 index 000000000000..7235afa8f323 --- /dev/null +++ b/projects/packages/backup/routes/restore/style.scss @@ -0,0 +1,23 @@ +.jpb-restore { + max-width: 700px; + margin-inline: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + + &__back { + display: inline-flex; + align-items: center; + gap: 6px; + text-decoration: none; + width: max-content; + } + + &__card { + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + } +} diff --git a/projects/packages/backup/src/class-jetpack-backup.php b/projects/packages/backup/src/class-jetpack-backup.php index 3196bebcd2e5..e68097869ec7 100644 --- a/projects/packages/backup/src/class-jetpack-backup.php +++ b/projects/packages/backup/src/class-jetpack-backup.php @@ -122,6 +122,7 @@ public static function initialize() { Connection_Rest_Authentication::init(); add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); + add_action( 'rest_api_init', array( \Automattic\Jetpack\Backup\V0005\REST\Rest_Controller::class, 'register_routes' ) ); add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 1 ); // Akismet uses 4, so we need to use 1 to ensure both menus are added when only they exist. @@ -876,6 +877,27 @@ public static function maybe_load_wp_build() { self::load_wp_build(); add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); + add_action( 'admin_print_scripts', array( __CLASS__, 'render_connection_initial_state' ), 1 ); + } + + /** + * Emit `window.JP_CONNECTION_INITIAL_STATE` inline on the modernized + * Backup admin page. + * + * The modernized enqueue path short-circuits before the legacy + * `Connection_Initial_State::render_script()` call, so without this + * the React `` component never sees the connection state and + * sits on its loading skeleton forever. We emit the same JS payload + * the legacy path emits, just outside of a registered script handle + * (wp-build's handles aren't reliable here, and the global is + * page-scoped — any tag setting it works). + * + * @return void + */ + public static function render_connection_initial_state() { + echo ''; } /** @@ -931,7 +953,7 @@ public static function alias_screen_id_for_wp_build( $screen ) { * * @return bool */ - private static function is_modernized() { + public static function is_modernized() { return (bool) apply_filters( self::MODERNIZATION_FILTER, false ); } diff --git a/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx new file mode 100644 index 000000000000..5c8007571500 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-detail/index.tsx @@ -0,0 +1,34 @@ +import { dateI18n } from '@wordpress/date'; +import { Card, Stack, Text } from '@wordpress/ui'; +import type { NonBackupActivityItem } from '../../types/activity'; + +type Props = { + item: NonBackupActivityItem; +}; + +/** + * Right-pane detail card for non-backup activity rows (post publish, + * upload, plugin update, theme update). Lighter than `` — + * no Download/Restore actions, no file browser slot. + * + * @param props - Component props. + * @param props.item - The selected non-backup activity item. + * @return The rendered activity-detail card. + */ +export default function ActivityDetail( { item }: Props ) { + return ( + + + }> + { item.title } + + + { dateI18n( 'M j, Y, g:i A', item.publishedAt, undefined ) } + { ' ' } + { item.actor.name } + + { item.summary && { item.summary } } + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx new file mode 100644 index 000000000000..d04dfa590a84 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -0,0 +1,113 @@ +import { SearchControl, Spinner } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, settings as settingsIcon } from '@wordpress/icons'; +import { Button, Card, Stack } from '@wordpress/ui'; +import { useActivityLog } from '../../hooks/use-activity-log'; +import ActivityRow from '../activity-row'; +import './style.scss'; +import type { ActivityItem } from '../../types/activity'; + +const PAGE_SIZE = 10; + +type Props = { + selectedId: string | null; + onSelect: ( id: string ) => void; +}; + +/** + * Left pane of the modernized Overview: a paginated, searchable activity list. + * + * Owns its own search and pagination state and reads items from the mock hook. + * Selection is owned by the parent so it can be reflected in the URL and used + * by the right-hand detail pane. + * + * @param props - Component props. + * @param props.selectedId - Currently selected row id, or null when nothing is selected. + * @param props.onSelect - Callback invoked with the new selection id when a row is activated. + * @return The rendered activity list card. + */ +export default function ActivityList( { selectedId, onSelect }: Props ) { + const [ search, setSearch ] = useState( '' ); + const [ page, setPage ] = useState( 1 ); + const { items, totalPages, isLoading } = useActivityLog( { + page, + pageSize: PAGE_SIZE, + search, + } ); + + const handleSearchChange = useCallback( ( next: string ) => { + setSearch( next ); + setPage( 1 ); + }, [] ); + + const handlePrevPage = useCallback( () => { + setPage( p => Math.max( 1, p - 1 ) ); + }, [] ); + + const handleNextPage = useCallback( () => { + setPage( p => Math.min( totalPages, p + 1 ) ); + }, [ totalPages ] ); + + return ( + + + + + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/activity-list/style.scss b/projects/packages/backup/src/dashboard/components/activity-list/style.scss new file mode 100644 index 000000000000..3fdaf57a300c --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-list/style.scss @@ -0,0 +1,34 @@ +.jpb-activity-list { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + + &__header, + &__footer { + padding: 12px 16px; + } + + &__header { + border-bottom: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } + + &__footer { + border-top: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + font-size: 13px; + } + + &__rows { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; + padding: 8px; + } + + &__loading { + display: flex; + justify-content: center; + padding: 32px 0; + } +} diff --git a/projects/packages/backup/src/dashboard/components/activity-row/index.tsx b/projects/packages/backup/src/dashboard/components/activity-row/index.tsx new file mode 100644 index 000000000000..b23506d087c1 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-row/index.tsx @@ -0,0 +1,68 @@ +import { dateI18n } from '@wordpress/date'; +import { useCallback } from '@wordpress/element'; +import { Icon, cloud, image, post, plugins as pluginsIcon, color } from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import { isBackupItem } from '../../types/activity'; +import './style.scss'; +import type { ActivityItem, ActivityKind } from '../../types/activity'; + +const ICON_BY_KIND: Record< ActivityKind, typeof cloud > = { + backup: cloud, + upload: image, + post, + 'plugin-update': pluginsIcon, + 'theme-update': color, +}; + +type Props = { + item: ActivityItem; + isSelected: boolean; + onSelect: ( id: string ) => void; +}; + +/** + * One row in the Overview activity list. + * + * Renders as a ` + ); +} diff --git a/projects/packages/backup/src/dashboard/components/activity-row/style.scss b/projects/packages/backup/src/dashboard/components/activity-row/style.scss new file mode 100644 index 000000000000..6b159e5c9c2f --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/activity-row/style.scss @@ -0,0 +1,34 @@ +.jpb-activity-row { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 12px 16px; + background: transparent; + border: 0; + border-radius: 4px; + text-align: left; + cursor: pointer; + + &:hover { + background-color: var(--wp-admin-theme-color-background, #f0f6fc); + } + + &.is-selected { + background-color: var(--wp-admin-theme-color-background, #e0ecf7); + } + + &__icon { + flex: 0 0 auto; + margin-top: 2px; + } + + &__body { + flex: 1 1 auto; + min-width: 0; + } + + &__summary { + color: var(--wp-components-color-foreground-muted, #757575); + } +} diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx new file mode 100644 index 000000000000..0e3cae5bf7a8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/backup-detail/index.tsx @@ -0,0 +1,65 @@ +import { dateI18n } from '@wordpress/date'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, cloud, download as downloadIcon, backup as backupIcon } from '@wordpress/icons'; +import { Link } from '@wordpress/route'; +import { Card, Stack, Text } from '@wordpress/ui'; +import FileBrowser from '../file-browser'; +import './style.scss'; +import type { BackupActivityItem } from '../../types/activity'; + +type Props = { + item: BackupActivityItem; +}; + +/** + * Right-pane detail card for a selected backup activity item. + * + * Shows the status header with Download / Restore actions linking to the + * matching sibling routes, the backup's summary line, a timestamp by-line, + * and a `__files` slot reserved for the file browser (Task 4). + * + * @param props - Component props. + * @param props.item - The selected backup activity item. + * @return The rendered detail card. + */ +export default function BackupDetail( { item }: Props ) { + return ( + + + + + }> + { __( 'Backup and scan complete', 'jetpack-backup-pkg' ) } + + + + + + { __( 'Download backup', 'jetpack-backup-pkg' ) } + + + + { __( 'Restore to this point', 'jetpack-backup-pkg' ) } + + + + { item.stats } + + { sprintf( + /* translators: %1$s formatted date+time, %2$s actor name */ + __( '%1$s by %2$s', 'jetpack-backup-pkg' ), + dateI18n( 'M j, Y, g:i A', item.publishedAt, undefined ), + item.actor.name + ) } + +
+ +
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/backup-detail/style.scss b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss new file mode 100644 index 000000000000..01a754d00554 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/backup-detail/style.scss @@ -0,0 +1,59 @@ +.jpb-backup-detail { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + + &__header { + padding-bottom: 12px; + border-bottom: 1px solid var(--wp-components-color-gray-200, #e0e0e0); + } + + &__download { + display: inline-flex; + gap: 6px; + align-items: center; + text-decoration: none; + } + + // Styled-link "primary button" for the Restore action. The router's + // `` renders an ``, so we avoid nesting it inside a `` HTML). + &__restore { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 6px 12px; + border-radius: 2px; + background-color: var(--wp-admin-theme-color, #007cba); + font-size: 13px; + line-height: 1.4; + text-decoration: none; + + // wp-admin's global `a { color: #2271b1 }` is unlayered and wins + // over `@layer wp-ui-components`; force white explicitly. + &, + &:hover, + &:focus, + &:visited { + color: #fff; + } + + &:hover { + background-color: var(--wp-admin-theme-color-darker-10, #005a87); + } + + &:focus { + outline: 2px solid transparent; + box-shadow: 0 0 0 2px var(--wp-admin-theme-color, #007cba); + } + } + + &__stats { + font-weight: 600; + } + + &__files { + margin-top: 8px; + } +} diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx new file mode 100644 index 000000000000..bde9fdd0f480 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -0,0 +1,43 @@ +import { Page } from '@wordpress/admin-ui'; +import { __ } from '@wordpress/i18n'; +import QueryClientProvider from '../../providers/query-client-provider'; +import Gates from '../gates'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; + actions?: ReactNode; +}; + +/** + * Shared shell for every screen of the modernized Backup dashboard. + * + * Wraps a `` from `@wordpress/admin-ui` (which provides the standard + * wp-admin chrome) with the dashboard's QueryClient + Gates and a centered, + * max-width body container that every screen renders into. + * + * @param props - Component props. + * @param props.children - Screen contents to render inside the page body. + * @param props.actions - Optional nodes rendered in the page header's top-right action slot. + * @return The rendered dashboard shell. + */ +export default function DashboardLayout( { children, actions }: Props ) { + return ( + + +
+ { children } +
+
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss b/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss new file mode 100644 index 000000000000..5d6b83fb1dbc --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/style.scss @@ -0,0 +1,7 @@ +.jpb-dashboard-body { + box-sizing: border-box; + width: 100%; + max-width: 1344px; + margin-inline: auto; + padding: 24px; +} diff --git a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx new file mode 100644 index 000000000000..4e3bfce83ab9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -0,0 +1,195 @@ +import { CheckboxControl, Spinner } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { + Icon, + chevronRight, + chevronDown, + file as fileIcon, + category as folderIcon, +} from '@wordpress/icons'; +import { Stack, Text } from '@wordpress/ui'; +import { useFileTree } from '../../hooks/use-file-tree'; +import { isFolder } from '../../types/file-tree'; +import FileInfoCard from '../file-info-card'; +import './style.scss'; +import type { FileNode, FileNodeFile } from '../../types/file-tree'; + +type Props = { + rewindId: string; +}; + +// File selections store the resolved `FileNodeFile` rather than just a +// path: the tree fetches children lazily per folder, so a path-only +// lookup walked across roots can't find files inside collapsed-then- +// re-expanded folders. Holding the resolved node directly side-steps +// that and keeps `` decoupled from where the click came from. + +/** + * Lazy file-tree browser for the selected backup. Folders fetch their + * children on first expand via `useFileTree`; selecting a file opens + * `` to the right of the tree with a text preview when the + * mime type is text-shaped. + * + * @param props - Component props. + * @param props.rewindId - The selected backup's rewindId. Threaded into the + * file-tree and file-contents bridge calls so each + * request is scoped to the chosen backup point. + * @return The rendered tree. + */ +export default function FileBrowser( { rewindId }: Props ) { + const [ selected, setSelected ] = useState< Set< string > >( () => new Set() ); + const [ openFile, setOpenFile ] = useState< FileNodeFile | null >( null ); + const { children: roots } = useFileTree( rewindId, null ); + + const toggleSelected = useCallback( ( path: string ) => { + setSelected( prev => { + const next = new Set( prev ); + if ( next.has( path ) ) { + next.delete( path ); + } else { + next.add( path ); + } + return next; + } ); + }, [] ); + + const clearSelected = useCallback( () => setSelected( new Set() ), [] ); + const closeInfoCard = useCallback( () => setOpenFile( null ), [] ); + + return ( +
+ + { __( 'FILES', 'jetpack-backup-pkg' ) } + + + 0 } + label={ sprintf( + /* translators: %d count of selected files */ + __( '%d files selected', 'jetpack-backup-pkg' ), + selected.size + ) } + onChange={ clearSelected } + __nextHasNoMarginBottom + /> + +
+
+ { ( roots ?? [] ).map( node => ( + + ) ) } +
+ { openFile && ( + + ) } +
+
+ ); +} + +type NodeRowProps = { + node: FileNode; + depth: number; + rewindId: string; + selected: Set< string >; + onToggleSelected: ( path: string ) => void; + onOpenFile: ( file: FileNodeFile ) => void; +}; + +/** + * Recursive row inside the file-browser tree. Folders own their own + * expand state; while a folder is open, `useFileTree` keeps its + * children resolved (re-collapsing and re-opening re-issues the fetch). + * + * @param props - Component props. + * @param props.node - The node to render. + * @param props.depth - Indent depth (root = 0). + * @param props.rewindId - Backup rewind id, threaded into the fetcher. + * @param props.selected - Set of selected paths shared across rows. + * @param props.onToggleSelected - Toggle selection for a path. + * @param props.onOpenFile - Open the info-card for a file path. + * @return The rendered row. + */ +function NodeRow( { + node, + depth, + rewindId, + selected, + onToggleSelected, + onOpenFile, +}: NodeRowProps ) { + const [ open, setOpen ] = useState( false ); + const nodeIsFolder = isFolder( node ); + const { children, isLoading } = useFileTree( rewindId, open && nodeIsFolder ? node.path : null ); + + const handleToggleSelected = useCallback( + () => onToggleSelected( node.path ), + [ onToggleSelected, node.path ] + ); + const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); + const handleOpenFile = useCallback( () => { + if ( ! isFolder( node ) ) { + onOpenFile( node ); + } + }, [ onOpenFile, node ] ); + + return ( +
+
+ + { nodeIsFolder ? ( + + ) : ( + + ) } +
+ { open && nodeIsFolder && ( +
+ { isLoading && ( +
+ +
+ ) } + { ! isLoading && ( children ?? [] ).length === 0 && ( +
+ { __( 'Empty', 'jetpack-backup-pkg' ) } +
+ ) } + { ! isLoading && + ( children ?? [] ).map( child => ( + + ) ) } +
+ ) } +
+ ); +} diff --git a/projects/packages/backup/src/dashboard/components/file-browser/style.scss b/projects/packages/backup/src/dashboard/components/file-browser/style.scss new file mode 100644 index 000000000000..45f093775bc9 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-browser/style.scss @@ -0,0 +1,46 @@ +.jpb-file-browser { + display: flex; + flex-direction: column; + gap: 8px; + + &__header { + padding: 4px 0; + } + + &__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 280px); + gap: 16px; + } + + &__row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + } + + &__toggle, + &__file { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: 0; + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + color: inherit; + } + + &__empty { + font-style: italic; + color: var(--wp-components-color-foreground-muted, #757575); + padding: 4px 0; + } + + &__loading { + padding: 4px 0; + } +} diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx new file mode 100644 index 000000000000..7a8060695887 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -0,0 +1,99 @@ +import { __ } from '@wordpress/i18n'; +import { Icon, closeSmall } from '@wordpress/icons'; +import { Button, Card, Stack, Text } from '@wordpress/ui'; +import { useFileContents } from '../../hooks/use-file-contents'; +import { usePathInfo } from '../../hooks/use-path-info'; +import './style.scss'; +import type { FileNodeFile } from '../../types/file-tree'; + +type Props = { + rewindId: string; + file: FileNodeFile; + onClose: () => void; +}; + +/** + * Returns true when the given mime type is renderable as plain text. + * + * @param mime - Mime type string. + * @return Whether the type is textual. + */ +function isTextual( mime: string ): boolean { + return ( + mime.startsWith( 'text/' ) || + mime === 'application/x-php' || + mime === 'application/sql' || + mime === 'application/json' + ); +} + +/** + * Formats a byte count as a short human-readable string. + * + * @param bytes - Size in bytes. + * @return Formatted size (e.g. `4.7 KB`). + */ +function humanSize( bytes: number ): string { + if ( bytes < 1024 ) { + return `${ bytes } B`; + } + if ( bytes < 1024 * 1024 ) { + return `${ ( bytes / 1024 ).toFixed( 1 ) } KB`; + } + return `${ ( bytes / 1024 / 1024 ).toFixed( 1 ) } MB`; +} + +/** + * Side panel showing details for the currently-open file: name + path + + * size + mime, plus a monospace preview for recognized text mime types. + * + * @param props - Component props. + * @param props.rewindId - Backup rewind id, threaded into the file-contents fetcher. + * @param props.file - The file to render. + * @param props.onClose - Callback to close the card. + * @return The rendered info card. + */ +export default function FileInfoCard( { rewindId, file, onClose }: Props ) { + // The tree response only carries names + types — `mime_type` and `size` + // come from path-info, which we fetch once per opened file. + const { data: pathInfo, isLoading: pathInfoLoading } = usePathInfo( rewindId, file.path ); + const mimeType = pathInfo?.mime_type ?? file.mimeType; + const sizeBytes = pathInfo?.size ?? file.sizeBytes; + const canPreview = ! pathInfoLoading && isTextual( mimeType ); + const { content: contents } = useFileContents( rewindId, file.path, canPreview ); + + return ( + + + }> + { file.name } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx new file mode 100644 index 000000000000..805f65fc5bff --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx @@ -0,0 +1,36 @@ +import { Button, Card } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const JETPACK_CONNECT_USER_URL = 'admin.php?page=jetpack#/connection'; + +/** + * Fallback shown when the current user is an admin but isn't personally + * linked to a WordPress.com account on this site. + * + * Links out to the Jetpack settings connection screen rather than + * embedding `` (see `not-connected.tsx` for the + * wp-build/SCSS rationale). + * + * @return The rendered fallback. + */ +export default function SecondaryAdminScreen() { + return ( + + + }> + { __( 'Link your account to view backups', 'jetpack-backup-pkg' ) } + + + { __( + "This site's Jetpack connection is already set up, but your account isn't linked to a WordPress.com user yet.", + 'jetpack-backup-pkg' + ) } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/style.scss b/projects/packages/backup/src/dashboard/components/gates/style.scss new file mode 100644 index 000000000000..72f820b22ef8 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/style.scss @@ -0,0 +1,30 @@ +.jpb-gates { + &__skeleton { + display: flex; + justify-content: center; + align-items: center; + min-height: 320px; + } + + &__card { + max-width: 560px; + margin-inline: auto; + margin-block: 48px; + padding: 32px; + } + + &__cta { + display: inline-block; + padding: 8px 16px; + background-color: var(--wp-admin-theme-color, #007cba); + border-radius: 2px; + text-decoration: none; + + &, + &:hover, + &:focus, + &:visited { + color: #fff; + } + } +} diff --git a/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx b/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx new file mode 100644 index 000000000000..99bf5bba8a63 --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/restore-items-checklist/index.tsx @@ -0,0 +1,109 @@ +import { CheckboxControl } from '@wordpress/components'; +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import './style.scss'; +import type { RestoreItems } from '../../types/restore'; + +type Props = { + value: RestoreItems; + onChange: ( next: RestoreItems ) => void; +}; + +type ItemKey = keyof RestoreItems; + +type ItemDef = { + key: ItemKey; + label: string; + description?: string; +}; + +const ITEMS: ItemDef[] = [ + { key: 'themes', label: __( 'WordPress themes', 'jetpack-backup-pkg' ) }, + { key: 'plugins', label: __( 'WordPress plugins', 'jetpack-backup-pkg' ) }, + { + key: 'roots', + label: __( 'WordPress root', 'jetpack-backup-pkg' ), + description: __( 'Includes wp-config.php and any non WordPress files.', 'jetpack-backup-pkg' ), + }, + { + key: 'contents', + label: __( 'WP-content directory', 'jetpack-backup-pkg' ), + description: __( 'Excludes themes, plugins, and uploads.', 'jetpack-backup-pkg' ), + }, + { + key: 'sqls', + label: __( 'Site database', 'jetpack-backup-pkg' ), + description: __( 'Includes pages, and posts.', 'jetpack-backup-pkg' ), + }, + { + key: 'uploads', + label: __( 'Media uploads', 'jetpack-backup-pkg' ), + description: __( + 'You must also select Site database for restored media uploads to appear.', + 'jetpack-backup-pkg' + ), + }, +]; + +type RowProps = { + item: ItemDef; + value: RestoreItems; + onChange: ( next: RestoreItems ) => void; +}; + +/** + * Single row of the restore checklist: a labeled checkbox plus an optional + * muted description below it. Lives in its own component so the per-item + * `onChange` handler can be memoized via `useCallback` and satisfy + * `react/jsx-no-bind`. + * + * @param props - Component props. + * @param props.item - The item definition (key, label, optional description). + * @param props.value - Current state of every toggle in the parent checklist. + * @param props.onChange - Called with the next state when this row's toggle flips. + * @return The rendered row. + */ +function ChecklistRow( { item, value, onChange }: RowProps ) { + const handleChange = useCallback( + ( next: boolean ) => onChange( { ...value, [ item.key ]: next } ), + [ onChange, value, item.key ] + ); + + return ( + + + { item.description && ( + + { item.description } + + ) } + + ); +} + +/** + * Six-checkbox toggle list shared by the Restore and Download screens. + * + * The keys map to `RestoreItems` (themes/plugins/roots/contents/sqls/uploads); + * descriptions render as small muted text directly beneath their checkbox. + * + * @param props - Component props. + * @param props.value - Current state of each toggle. + * @param props.onChange - Called with the next state when any toggle flips. + * @return The rendered checklist. + */ +export default function RestoreItemsChecklist( { value, onChange }: Props ) { + return ( + + { ITEMS.map( item => ( + + ) ) } + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss b/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss new file mode 100644 index 000000000000..82fa9ac3281f --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/restore-items-checklist/style.scss @@ -0,0 +1,6 @@ +.jpb-restore-checklist { + + &__desc { + padding-left: 28px; + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/_helpers.ts b/projects/packages/backup/src/dashboard/data/api/_helpers.ts new file mode 100644 index 000000000000..458dabbbdb24 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/_helpers.ts @@ -0,0 +1,79 @@ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +const ROOT = '/jetpack/v4'; + +/** + * Prepend `/jetpack/v4` to a path and optionally append query args. + * + * @param path - Path under `/jetpack/v4`, e.g. `/site/capabilities`. + * @param args - Optional query args record. + * @return The fully-qualified API path. + */ +export function apiPath( + path: string, + args?: Record< string, string | number | boolean | undefined > +): string { + const base = `${ ROOT }${ path }`; + if ( ! args ) { + return base; + } + const filtered: Record< string, string | number | boolean > = {}; + for ( const key of Object.keys( args ) ) { + const value = args[ key ]; + if ( value !== undefined && value !== '' ) { + filtered[ key ] = value; + } + } + return Object.keys( filtered ).length ? addQueryArgs( base, filtered ) : base; +} + +/** + * Strip the decimal suffix WPCOM ships on rewind ids (e.g. + * `'1777035492.615'` → `'1777035492'`). The `/rewind/backup/$rewindId` + * URLs reject the suffixed form with a 404. + * + * @param rewindId - Raw rewind id, possibly with a decimal suffix. + * @return The integer-seconds portion only. + */ +export function toIntRewindId( rewindId: string ): string { + const idx = rewindId.indexOf( '.' ); + return idx === -1 ? rewindId : rewindId.slice( 0, idx ); +} + +/** + * Error thrown by the data-layer fetchers when WPCOM (via the bridge) + * responds with a known error code. Consumers branch on `code` to pick + * the right user-facing message. + */ +export class ApiError extends Error { + public readonly code: string; + public readonly data?: unknown; + + constructor( code: string, message: string, data?: unknown ) { + super( message ); + this.name = 'ApiError'; + this.code = code; + this.data = data; + } +} + +/** + * Thin wrapper around `@wordpress/api-fetch` that re-throws bridge errors + * as `ApiError` so React Query's onError handlers can branch on `code`. + * + * @param options - apiFetch options. + * @return The decoded JSON response. + */ +export async function apiCall< T >( options: Parameters< typeof apiFetch >[ 0 ] ): Promise< T > { + try { + return await apiFetch< T >( options ); + } catch ( raw ) { + const err = raw as { code?: string; message?: string; data?: unknown }; + throw new ApiError( + typeof err.code === 'string' ? err.code : 'unknown', + typeof err.message === 'string' ? err.message : 'Request failed', + err.data + ); + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts new file mode 100644 index 000000000000..7e04059bb033 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -0,0 +1,47 @@ +import { apiCall, apiPath } from './_helpers'; + +export type WpcomActivityEntry = { + activity_id: string; + name: string; + gridicon: string; + rewind_id?: string; + published: string; + summary: string; + actor?: { type: string; name: string }; + content?: { text?: string }; + object?: { backup_stats?: string }; + is_rewindable?: boolean; +}; + +export type WpcomActivityLogResponse = { + current?: { + orderedItems?: WpcomActivityEntry[]; + }; + total_items?: number; +}; + +type FetchArgs = { + number?: number; + aggregate?: boolean; + after?: string; + before?: string; +}; + +/** + * Fetch the rewindable activity log entries from the bridge. + * + * @param args - Optional pagination and filter args. + * @return The raw WPCOM-shaped response. + */ +export async function fetchActivityLog( + args: FetchArgs = {} +): Promise< WpcomActivityLogResponse > { + return apiCall< WpcomActivityLogResponse >( { + path: apiPath( '/activity-log', { + number: args.number, + aggregate: args.aggregate, + after: args.after, + before: args.before, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/capabilities.ts b/projects/packages/backup/src/dashboard/data/api/capabilities.ts new file mode 100644 index 000000000000..42587393f986 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/capabilities.ts @@ -0,0 +1,20 @@ +import { apiCall, apiPath } from './_helpers'; + +export type Capabilities = { + hasBackupPlan: boolean; + hasScan: boolean; + planSlug: string | null; +}; + +/** + * Fetch the site's backup capabilities. + * + * Wrapped in `apiCall` (not raw `apiFetch`) so bridge errors come back as + * `ApiError`s with a `code` field, matching the rest of the data-layer + * fetchers. + * + * @return The capabilities payload. + */ +export async function fetchCapabilities(): Promise< Capabilities > { + return apiCall< Capabilities >( { path: apiPath( '/site/capabilities' ) } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/download.ts b/projects/packages/backup/src/dashboard/data/api/download.ts new file mode 100644 index 000000000000..e494b4d30a30 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -0,0 +1,43 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type InitiateDownloadResponse = { + id: number; +}; + +export type DownloadStatusResponse = { + url?: string; + valid_until?: string; + progress?: number; + status: 'in-progress' | 'completed' | 'failed' | string; +}; + +/** + * Initiate a backup download. + * + * @param rewindId - The backup's rewind id. + * @return The download id. + */ +export async function initiateDownload( rewindId: string ): Promise< InitiateDownloadResponse > { + return apiCall< InitiateDownloadResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * Poll status for an in-flight download. + * + * @param rewindId - The backup's rewind id. + * @param downloadId - The download id returned by `initiateDownload`. + * @return The current download state. + */ +export async function fetchDownloadStatus( + rewindId: string, + downloadId: number +): Promise< DownloadStatusResponse > { + return apiCall< DownloadStatusResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }/status`, { + download_id: downloadId, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/file-contents.ts b/projects/packages/backup/src/dashboard/data/api/file-contents.ts new file mode 100644 index 000000000000..36789d4904f0 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-contents.ts @@ -0,0 +1,24 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type FileContentsResponse = { + content: string; +}; + +/** + * Fetch a text file's contents via the proxy (WPCOM signed URL + 64KB cap). + * + * @param rewindId - The backup's rewind id. + * @param encodedManifestPath - Base64-encoded manifest path. + * @return The decoded file content. + */ +export async function fetchFileContents( + rewindId: string, + encodedManifestPath: string +): Promise< FileContentsResponse > { + return apiCall< FileContentsResponse >( { + path: apiPath( '/rewind/backup/file-content', { + rewind_id: toIntRewindId( rewindId ), + encoded_manifest_path: encodedManifestPath, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/file-tree.ts b/projects/packages/backup/src/dashboard/data/api/file-tree.ts new file mode 100644 index 000000000000..14ae35bbbe15 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-tree.ts @@ -0,0 +1,59 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type WpcomFileNode = { + name: string; + type: 'd' | 'f' | 'archive'; + manifest_path?: string; +}; + +export type WpcomLsResponse = { + contents?: WpcomFileNode[]; +}; + +/** + * List the children of a folder inside a backup. + * + * @param rewindId - The backup's rewind id (decimal suffix stripped here). + * @param folderPath - Folder to list, relative to backup root. + * @return The decoded WPCOM ls response. + */ +export async function fetchFileTree( + rewindId: string, + folderPath: string +): Promise< WpcomLsResponse > { + return apiCall< WpcomLsResponse >( { + path: apiPath( '/rewind/backup/ls' ), + method: 'POST', + data: { + rewind_id: toIntRewindId( rewindId ), + path: folderPath, + }, + } ); +} + +export type WpcomPathInfo = { + size?: number; + last_modified?: string; + mime_type?: string; +}; + +/** + * Fetch metadata for a single file inside a backup. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path. + * @return The decoded WPCOM path-info response. + */ +export async function fetchPathInfo( + rewindId: string, + manifestPath: string +): Promise< WpcomPathInfo > { + return apiCall< WpcomPathInfo >( { + path: apiPath( '/rewind/backup/path-info' ), + method: 'POST', + data: { + rewind_id: toIntRewindId( rewindId ), + manifest_path: manifestPath, + }, + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/restore.ts b/projects/packages/backup/src/dashboard/data/api/restore.ts new file mode 100644 index 000000000000..1b29f13e01ff --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -0,0 +1,39 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +export type InitiateRestoreResponse = { + id: number; +}; + +export type RestoreStatusResponse = { + id: number; + status: 'queued' | 'in-progress' | 'finished' | 'failed' | string; + progress: number; + rewind_id: string; + error_code: string; + message: string; +}; + +/** + * Start a restore for the given backup point. + * + * @param rewindId - The backup's rewind id. + * @return The restore id. + */ +export async function initiateRestore( rewindId: string ): Promise< InitiateRestoreResponse > { + return apiCall< InitiateRestoreResponse >( { + path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * Poll restore status. + * + * @param restoreId - The restore id returned by `initiateRestore`. + * @return The current restore state. + */ +export async function fetchRestoreStatus( restoreId: number ): Promise< RestoreStatusResponse > { + return apiCall< RestoreStatusResponse >( { + path: apiPath( `/rewind/restore/${ restoreId }/status` ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts new file mode 100644 index 000000000000..bc71c2ef7ecd --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts @@ -0,0 +1,15 @@ +import { toIntRewindId } from '../_helpers'; + +describe( 'toIntRewindId', () => { + test( 'returns the input unchanged when there is no decimal suffix', () => { + expect( toIntRewindId( '1777035492' ) ).toBe( '1777035492' ); + } ); + + test( 'strips a single decimal suffix', () => { + expect( toIntRewindId( '1777035492.615' ) ).toBe( '1777035492' ); + } ); + + test( 'strips everything from the first dot onwards', () => { + expect( toIntRewindId( '1777035492.615.123' ) ).toBe( '1777035492' ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts new file mode 100644 index 000000000000..c035229732c5 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts @@ -0,0 +1,67 @@ +import type { ActivityItem, ActivityKind } from '../../types/activity'; +import type { WpcomActivityEntry } from '../api/activity-log'; + +const GRIDICON_TO_KIND: Record< string, ActivityKind | null > = { + cloud: 'backup', + image: 'upload', + post: 'post', + plugins: 'plugin-update', + color: 'theme-update', +}; + +/** + * Convert a single WPCOM rewindable-activity entry into the UI's + * `ActivityItem` shape. Returns null when the entry's gridicon doesn't + * map to a kind we render. + * + * @param entry - WPCOM entry. + * @return The mapped item, or null when the entry is filtered out. + */ +export function normalizeEntry( entry: WpcomActivityEntry ): ActivityItem | null { + const kind = GRIDICON_TO_KIND[ entry.gridicon ]; + if ( ! kind ) { + return null; + } + + const actor = entry.actor ?? { type: 'Application', name: 'Jetpack' }; + const base = { + id: entry.activity_id, + title: entry.summary, + publishedAt: entry.published, + actor: { + type: actor.type === 'Person' ? ( 'Person' as const ) : ( 'Application' as const ), + name: actor.name, + }, + summary: entry.content?.text ?? undefined, + }; + + if ( kind === 'backup' ) { + return { + ...base, + kind: 'backup', + rewindId: entry.rewind_id ?? entry.activity_id, + stats: entry.object?.backup_stats ?? entry.content?.text ?? '', + isComplete: + entry.name === 'rewind__backup_complete_full' || entry.name === 'rewind__backup_complete', + }; + } + + return { + ...base, + kind, + }; +} + +/** + * Convert an array of WPCOM entries into UI `ActivityItem`s, filtering + * out entries whose `gridicon` doesn't map to a known kind. + * + * @param entries - WPCOM entries (possibly empty). + * @return Normalized items. + */ +export function normalizeActivityLog( entries: WpcomActivityEntry[] | undefined ): ActivityItem[] { + if ( ! entries ) { + return []; + } + return entries.map( normalizeEntry ).filter( ( item ): item is ActivityItem => item !== null ); +} diff --git a/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts new file mode 100644 index 000000000000..df4430fb8808 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts @@ -0,0 +1,73 @@ +import { normalizeActivityLog, normalizeEntry } from '../activity-log'; +import type { WpcomActivityEntry } from '../../api/activity-log'; + +const backupEntry: WpcomActivityEntry = { + activity_id: 'a-1', + name: 'rewind__backup_complete_full', + gridicon: 'cloud', + rewind_id: '1777035492', + published: '2026-05-15T12:26:00.000Z', + summary: 'Backup and scan complete by Jetpack', + actor: { type: 'Application', name: 'Jetpack' }, + content: { text: '4 plugins, 1 theme' }, + object: { backup_stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page' }, +}; + +const postEntry: WpcomActivityEntry = { + activity_id: 'a-2', + name: 'post__published', + gridicon: 'post', + published: '2026-05-14T01:23:00.000Z', + summary: 'Post published by Totoro', + actor: { type: 'Person', name: 'Totoro' }, + content: { text: 'The Perks of Having a Cat' }, +}; + +const unknownEntry: WpcomActivityEntry = { + activity_id: 'a-3', + name: 'mystery__event', + gridicon: 'something-new', + published: '2026-05-13T01:23:00.000Z', + summary: 'A mystery event', +}; + +describe( 'normalizeEntry', () => { + test( 'maps a backup entry to a backup ActivityItem', () => { + const item = normalizeEntry( backupEntry ); + expect( item ).not.toBeNull(); + expect( item ).toMatchObject( { + id: 'a-1', + kind: 'backup', + rewindId: '1777035492', + stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', + isComplete: true, + } ); + } ); + + test( 'maps a post entry to a post ActivityItem', () => { + const item = normalizeEntry( postEntry ); + expect( item ).toMatchObject( { id: 'a-2', kind: 'post', actor: { type: 'Person' } } ); + } ); + + test( 'returns null for unknown gridicons', () => { + expect( normalizeEntry( unknownEntry ) ).toBeNull(); + } ); + + test( 'falls back to activity_id when rewind_id is missing', () => { + const fallback = { ...backupEntry, rewind_id: undefined }; + const item = normalizeEntry( fallback ); + expect( item ).toMatchObject( { kind: 'backup', rewindId: 'a-1' } ); + } ); +} ); + +describe( 'normalizeActivityLog', () => { + test( 'returns an empty array for undefined input', () => { + expect( normalizeActivityLog( undefined ) ).toEqual( [] ); + } ); + + test( 'filters out unmapped gridicons', () => { + const items = normalizeActivityLog( [ backupEntry, postEntry, unknownEntry ] ); + expect( items ).toHaveLength( 2 ); + expect( items.map( i => i.kind ) ).toEqual( [ 'backup', 'post' ] ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts new file mode 100644 index 000000000000..72aad9119dba --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -0,0 +1,41 @@ +import { QueryClient } from '@tanstack/react-query'; + +const STALE_TIME_DEFAULT_MS = 30_000; +const GC_TIME_DEFAULT_MS = 5 * 60_000; + +/** + * Module-scope QueryClient shared across the modernized Backup dashboard. + * + * Each wp-build route mounts its own `` (via + * ``), but the client itself is created once so the + * cache survives the per-route bundle re-mount on navigation. + */ +export const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: STALE_TIME_DEFAULT_MS, + gcTime: GC_TIME_DEFAULT_MS, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +} ); + +/** + * Stable cache keys for each query the dashboard issues. + * + * Centralizing keys here means any consumer can invalidate or read + * cached data without re-deriving the tuple shape ad hoc. + */ +export const keys = { + capabilities: () => [ 'backup', 'capabilities' ] as const, + activityLog: ( args: { page: number; pageSize: number; search: string } ) => + [ 'backup', 'activity-log', args ] as const, + fileTree: ( rewindId: string, folderPath: string | null ) => + [ 'backup', 'file-tree', rewindId, folderPath ] as const, + fileContents: ( rewindId: string, path: string ) => + [ 'backup', 'file-contents', rewindId, path ] as const, + downloadStatus: ( rewindId: string, downloadId: number ) => + [ 'backup', 'download-status', rewindId, downloadId ] as const, + restoreStatus: ( restoreId: number ) => [ 'backup', 'restore-status', restoreId ] as const, +}; diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts new file mode 100644 index 000000000000..0854dd0c6f25 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -0,0 +1,89 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchActivityLog } from '../data/api/activity-log'; +import { normalizeActivityLog } from '../data/normalize/activity-log'; +import { keys, queryClient } from '../data/query-client'; +import type { ActivityItem } from '../types/activity'; + +type Args = { + page: number; + pageSize: number; + search: string; +}; + +type Result = { + items: ActivityItem[]; + totalPages: number; + isLoading: boolean; + error: Error | null; +}; + +const MAX_FETCH_ITEMS = 100; + +/** + * Case-insensitive predicate: returns true when the activity item's title + * or summary contains the search query. An empty query matches every item. + * + * @param item - Activity item to test. + * @param q - Search query (raw, untrimmed). + * @return True when the item matches the query. + */ +function matchesSearch( item: ActivityItem, q: string ): boolean { + if ( ! q ) { + return true; + } + const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); + return haystack.includes( q.toLowerCase() ); +} + +/** + * React Query hook returning a paginated, search-filtered slice of the + * site's rewindable activity log. + * + * @param args - Query args. + * @param args.page - 1-indexed page number. + * @param args.pageSize - Items per page. + * @param args.search - Search query (title + summary, case-insensitive). + * @return Items, total pages, loading, error. + */ +export function useActivityLog( { page, pageSize, search }: Args ): Result { + const query = useQuery( { + queryKey: keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ), + queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), + } ); + + const allItems = useMemo( + () => normalizeActivityLog( query.data?.current?.orderedItems ), + [ query.data ] + ); + + const { items, totalPages } = useMemo( () => { + const filtered = allItems.filter( item => matchesSearch( item, search ) ); + const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); + const start = ( page - 1 ) * pageSize; + return { items: filtered.slice( start, start + pageSize ), totalPages: total }; + }, [ allItems, page, pageSize, search ] ); + + return { + items, + totalPages, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} + +/** + * Look up a single activity item from the React Query cache. + * + * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. + * @return The matching item, or null when the cache hasn't been populated yet or the id doesn't match anything in the cached page. + */ +export function getCachedActivityById( id: string ): ActivityItem | null { + const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( + keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) + ); + const items = normalizeActivityLog( data?.current?.orderedItems ); + return ( + items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? null + ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts new file mode 100644 index 000000000000..0bd6766f7767 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchCapabilities, type Capabilities } from '../data/api/capabilities'; +import { keys } from '../data/query-client'; + +const CAPABILITIES_STALE_MS = 5 * 60_000; + +type Result = { + data: Capabilities | undefined; + isLoading: boolean; + error: Error | null; +}; + +/** + * React Query hook returning the site's backup capabilities. + * + * @return Capabilities query state. + */ +export function useCapabilities(): Result { + const query = useQuery( { + queryKey: keys.capabilities(), + queryFn: fetchCapabilities, + staleTime: CAPABILITIES_STALE_MS, + } ); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts new file mode 100644 index 000000000000..80157a6e5bf6 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -0,0 +1,53 @@ +import { useMemo } from '@wordpress/element'; + +type Result = { + isLoaded: boolean; + isFullyConnected: boolean; + isSecondaryAdminNotConnected: boolean; + hasConnectionError: boolean; +}; + +type ConnectionStatus = { + isRegistered?: boolean; + isUserConnected?: boolean; + hasConnectedOwner?: boolean; +}; + +type ConnectionInitialState = { + connectionStatus?: ConnectionStatus; +}; + +declare global { + interface Window { + JP_CONNECTION_INITIAL_STATE?: ConnectionInitialState; + } +} + +/** + * Read Jetpack's connection state from the `JP_CONNECTION_INITIAL_STATE` + * global emitted by `Connection_Initial_State::render_script()` in PHP. + * + * We deliberately avoid `@automattic/jetpack-connection`'s store / hooks + * because the package's barrel pulls in SCSS that wp-build's bundler + * can't resolve, and the connection state is page-load static anyway — + * a single `window` read at hook-mount time covers every consumer. + * + * @return Connection state. + */ +export function useConnection(): Result { + return useMemo( () => { + const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; + const status: ConnectionStatus = state?.connectionStatus ?? {}; + + const isLoaded = Object.keys( status ).length > 0; + const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); + const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); + + return { + isLoaded, + isFullyConnected, + isSecondaryAdminNotConnected, + hasConnectionError: false, + }; + }, [] ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts new file mode 100644 index 000000000000..a959fe95e57f --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -0,0 +1,87 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchDownloadStatus, initiateDownload } from '../data/api/download'; +import { keys } from '../data/query-client'; + +type DownloadState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success'; downloadUrl: string } + | { phase: 'error'; message: string }; + +type Result = { + state: DownloadState; + submit: () => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useDownload` driving the modernized Download screen via the + * `/jetpack/v4/backups/download/$rewindId` bridge. + * + * Submit kicks off the WPCOM `prepare-download` and stores the download + * id; a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useDownload( rewindId: string ): Result { + const [ downloadId, setDownloadId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + const { + mutate: mutateInitiate, + reset: resetMutation, + isPending: isInitiating, + } = useMutation( { + mutationFn: () => initiateDownload( rewindId ), + onSuccess: result => { + setDownloadId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + const effectiveDownloadId = downloadId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.downloadStatus( rewindId, effectiveDownloadId ), + queryFn: () => fetchDownloadStatus( rewindId, effectiveDownloadId ), + enabled: downloadId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' ? POLL_INTERVAL_MS : false, + } ); + + const submit = useCallback( () => mutateInitiate(), [ mutateInitiate ] ); + const reset = useCallback( () => { + setDownloadId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: DownloadState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isInitiating ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'completed' && statusQuery.data.url ) { + state = { phase: 'success', downloadUrl: statusQuery.data.url }; + } else if ( statusQuery.data?.status === 'failed' ) { + state = { phase: 'error', message: __( 'Download failed.', 'jetpack-backup-pkg' ) }; + } else if ( downloadId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( ( statusQuery.data.progress ?? 0 ) * 100 ), + }; + } else if ( downloadId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts new file mode 100644 index 000000000000..a5cf363facd2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -0,0 +1,50 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchFileContents } from '../data/api/file-contents'; +import { keys } from '../data/query-client'; + +type Result = { + content: string | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Base64-encode a manifest path the way WPCOM's file-content endpoint + * expects. Uses `window.btoa` in the browser and falls back to Node's + * `Buffer` for tests/SSR. + * + * @param manifestPath - The raw manifest path. + * @return Base64-encoded path. + */ +function encodeManifestPath( manifestPath: string ): string { + if ( typeof window !== 'undefined' && typeof window.btoa === 'function' ) { + return window.btoa( manifestPath ); + } + return Buffer.from( manifestPath, 'utf-8' ).toString( 'base64' ); +} + +/** + * Hook fetching a text file's contents for the preview pane. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path (NOT base64-encoded — encoded here). + * @param enabled - When false, the query is skipped (e.g. binary mime types). + * @return Content + loading state. + */ +export function useFileContents( + rewindId: string, + manifestPath: string | null, + enabled: boolean +): Result { + const query = useQuery( { + queryKey: keys.fileContents( rewindId, manifestPath ?? '' ), + queryFn: () => fetchFileContents( rewindId, encodeManifestPath( manifestPath ?? '' ) ), + enabled: enabled && Boolean( rewindId ) && Boolean( manifestPath ), + } ); + + return { + content: query.data?.content ?? null, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts new file mode 100644 index 000000000000..10d4454c4bde --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchFileTree, type WpcomFileNode } from '../data/api/file-tree'; +import { keys } from '../data/query-client'; +import type { FileNode } from '../types/file-tree'; + +const BASE_FOLDER_PATH = '/'; + +type Result = { + children: FileNode[] | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Convert a raw WPCOM ls node into the dashboard's `FileNode` shape, joining + * the child's name onto the parent path so callers see absolute paths. + * + * @param raw - The WPCOM ls entry. + * @param parentPath - Path of the folder the entry lives in. + * @return The mapped `FileNode`. + */ +function toFileNode( raw: WpcomFileNode, parentPath: string ): FileNode { + const fullPath = + parentPath === BASE_FOLDER_PATH ? `/${ raw.name }` : `${ parentPath }/${ raw.name }`; + + if ( raw.type === 'd' || raw.type === 'archive' ) { + return { + type: 'folder', + name: raw.name, + path: fullPath, + }; + } + return { + type: 'file', + name: raw.name, + path: fullPath, + sizeBytes: 0, + mimeType: 'application/octet-stream', + }; +} + +/** + * Hook returning the children of a folder inside a backup. + * + * Passing `null` for `folderPath` is the "ask for the root tree" idiom; + * the hook treats it as a query for `/`. + * + * @param rewindId - The backup's rewind id (decimal-suffix-safe). + * @param folderPath - Folder to load, or null for the root. + * @return Children list, loading flag, error. + */ +export function useFileTree( rewindId: string, folderPath: string | null ): Result { + const path = folderPath ?? BASE_FOLDER_PATH; + const query = useQuery( { + queryKey: keys.fileTree( rewindId, path ), + queryFn: () => fetchFileTree( rewindId, path ), + enabled: Boolean( rewindId ), + } ); + + const children = useMemo< FileNode[] | null >( () => { + if ( ! query.data ) { + return null; + } + return ( query.data.contents ?? [] ).map( child => toFileNode( child, path ) ); + }, [ query.data, path ] ); + + return { + children, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-path-info.ts b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts new file mode 100644 index 000000000000..1311b21ab3a4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-path-info.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchPathInfo, type WpcomPathInfo } from '../data/api/file-tree'; + +type Result = { + data: WpcomPathInfo | undefined; + isLoading: boolean; + error: Error | null; +}; + +/** + * Hook fetching path-info metadata (size, mime type, last modified) for + * a single file inside a backup. + * + * `useFileTree` doesn't return mime + size on its tree entries — WPCOM's + * `/rewind/backup/ls` only ships `name` + `type`. Path-info is a + * per-file follow-up call that the FileInfoCard issues on open. + * + * @param rewindId - The backup's rewind id. + * @param manifestPath - The file's manifest path, or null when no file is open. + * @return Path-info query state. + */ +export function usePathInfo( rewindId: string, manifestPath: string | null ): Result { + const query = useQuery( { + queryKey: [ 'backup', 'path-info', rewindId, manifestPath ] as const, + queryFn: () => fetchPathInfo( rewindId, manifestPath as string ), + enabled: Boolean( rewindId ) && Boolean( manifestPath ), + } ); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts new file mode 100644 index 000000000000..526477241e17 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchRestoreStatus, initiateRestore } from '../data/api/restore'; +import { keys } from '../data/query-client'; +import type { RestoreState } from '../types/restore'; + +type Result = { + state: RestoreState; + submit: () => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useRestore` driving the modernized Restore screen via the + * `/jetpack/v4/rewind/to/$rewindId` bridge. + * + * Submit kicks off the WPCOM rewind initiate and stores the restore id; + * a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useRestore( rewindId: string ): Result { + const [ restoreId, setRestoreId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + // Destructure to keep stable references for useCallback deps — + // passing the whole `mutation` object trips + // @tanstack/query/no-unstable-deps. + const { + mutate: kickOff, + reset: resetMutation, + isPending, + } = useMutation( { + mutationFn: () => initiateRestore( rewindId ), + onSuccess: result => { + setRestoreId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + // Always-defined query key so @tanstack/query/exhaustive-deps stays + // happy; the `enabled` flag keeps the placeholder query from firing. + const effectiveRestoreId = restoreId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.restoreStatus( effectiveRestoreId ), + queryFn: () => fetchRestoreStatus( effectiveRestoreId ), + enabled: restoreId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' || query.state.data?.status === 'queued' + ? POLL_INTERVAL_MS + : false, + } ); + + const submit = useCallback( () => kickOff(), [ kickOff ] ); + const reset = useCallback( () => { + setRestoreId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: RestoreState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isPending ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'finished' ) { + state = { phase: 'success' }; + } else if ( statusQuery.data?.status === 'failed' ) { + state = { + phase: 'error', + message: + statusQuery.data.message || + statusQuery.data.error_code || + __( 'Restore failed.', 'jetpack-backup-pkg' ), + }; + } else if ( restoreId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( statusQuery.data.progress ?? 0 ), + }; + } else if ( restoreId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx new file mode 100644 index 000000000000..5ba39f6a66d4 --- /dev/null +++ b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx @@ -0,0 +1,19 @@ +import { QueryClientProvider as TanStackProvider } from '@tanstack/react-query'; +import { queryClient } from '../data/query-client'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +/** + * Wraps children in TanStack's `` with the + * dashboard's shared singleton client. + * + * @param props - Component props. + * @param props.children - Children to wrap. + * @return The provider tree. + */ +export default function QueryClientProvider( { children }: Props ) { + return { children }; +} diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx new file mode 100644 index 000000000000..80894980ed65 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -0,0 +1,105 @@ +import { Notice, ProgressBar, Spinner } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, cloud, download as downloadIcon, arrowLeft } from '@wordpress/icons'; +import { Link, useParams } from '@wordpress/route'; +import { Button, Card, Stack, Text } from '@wordpress/ui'; +import DashboardLayout from '../components/dashboard-layout'; +import RestoreItemsChecklist from '../components/restore-items-checklist'; +import { toIntRewindId } from '../data/api/_helpers'; +import { useDownload } from '../hooks/use-download'; +import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; + +/** + * Download screen — same narrow layout as the Restore screen minus the + * warning notice. Submission initiates a WPCOM-side download prep; the + * success branch surfaces the signed download URL as a link. + * + * @return The rendered Download screen. + */ +export default function DownloadScreen() { + const { rewindId } = useParams( { from: '/download/$rewindId' } ); + const downloadPoint = ( () => { + const seconds = parseInt( toIntRewindId( rewindId ), 10 ); + return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; + } )(); + const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); + const { state, submit, reset } = useDownload( rewindId ); + + return ( + +
+ + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + + + + + }> + { __( 'Download backup', 'jetpack-backup-pkg' ) } + + { downloadPoint && ( + + { __( 'Download point:', 'jetpack-backup-pkg' ) }{ ' ' } + { dateI18n( 'M j, Y, g:i A', downloadPoint, undefined ) } + + ) } + + + { ( state.phase === 'idle' || state.phase === 'submitting' ) && ( + <> + + { __( + 'Choose the items you wish to include in the download:', + 'jetpack-backup-pkg' + ) } + + + + + ) } + { state.phase === 'progress' && ( + + { __( 'Preparing download…', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'success' && ( + + + { __( 'Your download is ready.', 'jetpack-backup-pkg' ) } + + + { __( 'Download the file', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'error' && ( + + + { state.message } + + + + ) } + +
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx new file mode 100644 index 000000000000..f3559a02a313 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -0,0 +1,95 @@ +import { useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { useNavigate, useSearch } from '@wordpress/route'; +import { Button, Stack, Text } from '@wordpress/ui'; +import ActivityDetail from '../components/activity-detail'; +import ActivityList from '../components/activity-list'; +import BackupDetail from '../components/backup-detail'; +import DashboardLayout from '../components/dashboard-layout'; +import { getCachedActivityById } from '../hooks/use-activity-log'; +import { isBackupItem } from '../types/activity'; + +type OverviewSearch = Record< string, unknown > & { selected?: string }; + +/** + * Overview screen for the modernized Backup dashboard. + * + * Renders the shared `` chrome around a two-pane body: the + * left pane is the searchable, paginated activity list; the right pane is an + * empty-state placeholder until the backup-detail commit lands. Selection is + * persisted in the URL via `?selected=` so a refresh preserves it. + * + * @return The rendered Overview screen. + */ +export default function OverviewScreen() { + const search = useSearch( { + from: '/' as unknown as never, + strict: false, + } ) as OverviewSearch; + const navigate = useNavigate(); + const selectedId = typeof search.selected === 'string' ? search.selected : null; + + const setSelected = useCallback( + ( id: string ) => { + // Merge into existing search so future params (filters, range, etc.) aren't dropped. + navigate( { + search: { ...search, selected: id }, + } as unknown as Parameters< typeof navigate >[ 0 ] ); + }, + [ navigate, search ] + ); + + return ( + + + + + } + > +
+ + +
+
+ ); +} + +/** + * Right-pane router for the Overview screen. + * + * Resolves the URL-driven `selectedId` to an activity item and renders the + * matching detail card: `` for backup rows and `` + * for everything else. Falls back to an empty/not-found state when the + * selection is missing or doesn't resolve. + * + * @param props - Component props. + * @param props.selectedId - Currently selected row id, or null when nothing is selected. + * @return The rendered detail card or an empty-state placeholder. + */ +function RightPane( { selectedId }: { selectedId: string | null } ) { + if ( ! selectedId ) { + return ( +
+ { __( 'Select an item from the list to see details.', 'jetpack-backup-pkg' ) } +
+ ); + } + const item = getCachedActivityById( selectedId ); + if ( ! item ) { + return ( +
+ { __( 'Item not found.', 'jetpack-backup-pkg' ) } +
+ ); + } + if ( isBackupItem( item ) ) { + return ; + } + return ; +} diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx new file mode 100644 index 000000000000..1dbe525bad74 --- /dev/null +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -0,0 +1,104 @@ +import { Notice, ProgressBar, Spinner } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, backup as backupIcon, arrowLeft } from '@wordpress/icons'; +import { Link, useParams } from '@wordpress/route'; +import { Button, Card, Stack, Text } from '@wordpress/ui'; +import DashboardLayout from '../components/dashboard-layout'; +import RestoreItemsChecklist from '../components/restore-items-checklist'; +import { toIntRewindId } from '../data/api/_helpers'; +import { useRestore } from '../hooks/use-restore'; +import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; + +/** + * Restore screen — narrow centered layout with the warning notice, the + * shared item checklist, and a Confirm button. Submit kicks off a + * WPCOM rewind and polls until it lands on success, failure, or error. + * + * @return The rendered Restore screen. + */ +export default function RestoreScreen() { + const { rewindId } = useParams( { from: '/restore/$rewindId' } ); + const restorePoint = ( () => { + const seconds = parseInt( toIntRewindId( rewindId ), 10 ); + return Number.isFinite( seconds ) ? new Date( seconds * 1000 ).toISOString() : null; + } )(); + const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); + const { state, submit, reset } = useRestore( rewindId ); + + return ( + +
+ + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + + + + + }> + { __( 'Restore backup', 'jetpack-backup-pkg' ) } + + { restorePoint && ( + + { __( 'Restore point:', 'jetpack-backup-pkg' ) }{ ' ' } + { dateI18n( 'M j, Y, g:i A', restorePoint, undefined ) } + + ) } + + + { ( state.phase === 'idle' || state.phase === 'submitting' ) && ( + <> + + { __( + 'Restoring will overwrite the matching parts of your live site with the contents of this backup. This cannot be undone.', + 'jetpack-backup-pkg' + ) } + + { __( 'Choose the items you wish to restore:', 'jetpack-backup-pkg' ) } + + + + ) } + { state.phase === 'progress' && ( + + { __( 'Restoring…', 'jetpack-backup-pkg' ) } + + + ) } + { state.phase === 'success' && ( + + + { __( 'Restore complete.', 'jetpack-backup-pkg' ) } + + { __( 'Back to overview', 'jetpack-backup-pkg' ) } + + ) } + { state.phase === 'error' && ( + + + { state.message } + + + + ) } + +
+
+ ); +} diff --git a/projects/packages/backup/src/dashboard/types/activity.ts b/projects/packages/backup/src/dashboard/types/activity.ts new file mode 100644 index 000000000000..c5accd084b04 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/activity.ts @@ -0,0 +1,30 @@ +export type ActivityKind = 'backup' | 'post' | 'upload' | 'plugin-update' | 'theme-update'; + +export type ActivityActor = { + type: 'Application' | 'Person'; + name: string; +}; + +export type ActivityItemBase = { + id: string; + title: string; + publishedAt: string; + actor: ActivityActor; + summary?: string; +}; + +export type BackupActivityItem = ActivityItemBase & { + kind: 'backup'; + rewindId: string; + stats: string; + isComplete: boolean; +}; + +export type NonBackupActivityItem = ActivityItemBase & { + kind: Exclude< ActivityKind, 'backup' >; +}; + +export type ActivityItem = BackupActivityItem | NonBackupActivityItem; + +export const isBackupItem = ( item: ActivityItem ): item is BackupActivityItem => + item.kind === 'backup'; diff --git a/projects/packages/backup/src/dashboard/types/file-tree.ts b/projects/packages/backup/src/dashboard/types/file-tree.ts new file mode 100644 index 000000000000..2a8a3164d507 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/file-tree.ts @@ -0,0 +1,19 @@ +export type FileNodeBase = { + name: string; + path: string; +}; + +export type FolderNode = FileNodeBase & { + type: 'folder'; + children?: FileNode[]; +}; + +export type FileNodeFile = FileNodeBase & { + type: 'file'; + sizeBytes: number; + mimeType: string; +}; + +export type FileNode = FolderNode | FileNodeFile; + +export const isFolder = ( node: FileNode ): node is FolderNode => node.type === 'folder'; diff --git a/projects/packages/backup/src/dashboard/types/restore.ts b/projects/packages/backup/src/dashboard/types/restore.ts new file mode 100644 index 000000000000..78b211c96337 --- /dev/null +++ b/projects/packages/backup/src/dashboard/types/restore.ts @@ -0,0 +1,24 @@ +export type RestoreItems = { + themes: boolean; + plugins: boolean; + roots: boolean; + contents: boolean; + sqls: boolean; + uploads: boolean; +}; + +export const DEFAULT_RESTORE_ITEMS: RestoreItems = { + themes: true, + plugins: true, + roots: true, + contents: true, + sqls: true, + uploads: true, +}; + +export type RestoreState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success' } + | { phase: 'error'; message: string }; diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php new file mode 100644 index 000000000000..d7044ad77ed5 --- /dev/null +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -0,0 +1,99 @@ + WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_activity_log' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'number' => array( 'type' => 'integer' ), + 'aggregate' => array( 'type' => 'boolean' ), + 'after' => array( 'type' => 'string' ), + 'before' => array( 'type' => 'string' ), + ), + ) + ); + } + + /** + * Proxy the WPCOM rewindable activity log. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_activity_log( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $query = array_filter( + array( + 'number' => $request->get_param( 'number' ), + 'aggregate' => $request->get_param( 'aggregate' ) ? 'true' : null, + 'after' => $request->get_param( 'after' ), + 'before' => $request->get_param( 'before' ), + ), + static function ( $value ) { + return null !== $value && '' !== $value; + } + ); + + $endpoint = sprintf( '/sites/%d/activity/rewindable', $blog_id ); + if ( ! empty( $query ) ) { + $endpoint .= '?' . http_build_query( $query ); + } + + $response = Client::wpcom_json_api_request_as_user( + $endpoint, + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'activity_log_fetch_failed', + __( 'Could not fetch the site activity log.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php new file mode 100644 index 000000000000..13e346e77897 --- /dev/null +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -0,0 +1,96 @@ +` decision tree. + */ +class Capabilities_Bridge { + + /** + * Register the GET /jetpack/v4/site/capabilities route. + * + * @return void + */ + public static function register_routes() { + register_rest_route( + 'jetpack/v4', + '/site/capabilities', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_capabilities' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + ) + ); + } + + /** + * Proxy `/sites/{id}/rewind` (v2, as_user) and project the response + * into the shape the React layer expects. + * + * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. + */ + public static function get_capabilities() { + $blog_id = Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( + 'not_connected', + __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), + array( 'status' => 412 ) + ); + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind?force=wpcom', $blog_id ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'capabilities_fetch_failed', + __( 'Could not fetch site capabilities.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) ) { + $body = array(); + } + + $capabilities = isset( $body['capabilities'] ) && is_array( $body['capabilities'] ) + ? $body['capabilities'] + : array(); + + return rest_ensure_response( + array( + 'hasBackupPlan' => in_array( 'backup', $capabilities, true ), + 'hasScan' => in_array( 'scan', $capabilities, true ), + 'planSlug' => isset( $body['plan_slug'] ) ? (string) $body['plan_slug'] : null, + ) + ); + } +} diff --git a/projects/packages/backup/src/rest/class-download-bridge.php b/projects/packages/backup/src/rest/class-download-bridge.php new file mode 100644 index 000000000000..d2730e87e54e --- /dev/null +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -0,0 +1,168 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_download' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/backups/download/(?P[A-Za-z0-9.\-]+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_download_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'download_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a backup download. + * + * Proxies POST wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/prepare-download. + * Returns `{ id }` for the React layer (the WPCOM key is `downloadId`). + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_download( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/prepare-download', + $blog_id, + rawurlencode( $rewind_id ) + ), + 'v2', + array( 'method' => 'POST' ), + array( + 'types' => $request->get_param( 'types' ) ? $request->get_param( 'types' ) : (object) array(), + ), + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Could not start the backup download.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $download_id = is_array( $body ) && isset( $body['downloadId'] ) ? (int) $body['downloadId'] : 0; + if ( ! $download_id ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Download response missing download id.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( array( 'id' => $download_id ) ); + } + + /** + * Poll download status. + * + * Proxies GET wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/downloads/{downloadId}. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_download_status( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $download_id = (int) $request->get_param( 'download_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/downloads/%d', + $blog_id, + rawurlencode( $rewind_id ), + $download_id + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_status_fetch_failed', + __( 'Could not fetch download status.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php new file mode 100644 index 000000000000..6159cf2aecd8 --- /dev/null +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -0,0 +1,253 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'list_directory' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/backup/path-info', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'get_path_info' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'manifest_path' => array( + 'type' => 'string', + 'required' => true, + ), + 'extension_type' => array( + 'type' => 'string', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/backup/file-content', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_file_content' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'encoded_manifest_path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + } + + /** + * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function list_directory( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/backup/ls', $blog_id ), + 'v2', + array( 'method' => 'POST' ), + array( + 'backup_id' => $request->get_param( 'rewind_id' ), + 'path' => $request->get_param( 'path' ), + ), + 'wpcom' + ); + + return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) ); + } + + /** + * Get metadata for a single file. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/path-info. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_path_info( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/backup/path-info', $blog_id ), + 'v2', + array( 'method' => 'POST' ), + array( + 'backup_id' => $request->get_param( 'rewind_id' ), + 'manifest_path' => $request->get_param( 'manifest_path' ), + 'extension_type' => $request->get_param( 'extension_type' ), + ), + 'wpcom' + ); + + return self::forward_response( $response, 'backup_path_info_fetch_failed', __( 'Could not fetch file metadata.', 'jetpack-backup-pkg' ) ); + } + + /** + * Fetch a text file's content for the preview pane. + * + * Resolves the one-time signed URL from WPCOM, then fetches the + * stream server-side and caps the body at PREVIEW_MAX_BYTES. + * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so + * the browser can't fetch it directly. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_file_content( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' ); + + // Step 1: resolve the signed stream URL. + $url_response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backup/%s/file/%s/url', + $blog_id, + rawurlencode( $rewind_id ), + rawurlencode( $encoded_manifest_path ) + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $url_response ) ) { + return $url_response; + } + + $url_status = wp_remote_retrieve_response_code( $url_response ); + if ( 200 !== $url_status ) { + return new WP_Error( + 'backup_file_content_url_failed', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $url_status ) && $url_status > 0 ? $url_status : 500 ) + ); + } + + $url_body = json_decode( wp_remote_retrieve_body( $url_response ), true ); + $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null; + if ( ! $signed_url ) { + return new WP_Error( + 'backup_file_content_url_missing', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => 502 ) + ); + } + + // Step 2: fetch the stream body server-side. + $stream_response = wp_remote_get( $signed_url, array( 'timeout' => 15 ) ); + + if ( is_wp_error( $stream_response ) ) { + return $stream_response; + } + + $stream_status = wp_remote_retrieve_response_code( $stream_response ); + if ( 200 !== $stream_status ) { + return new WP_Error( + 'backup_file_content_stream_failed', + __( 'Could not fetch file content.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $stream_status ) && $stream_status > 0 ? $stream_status : 500 ) + ); + } + + $content = wp_remote_retrieve_body( $stream_response ); + + if ( strlen( $content ) > self::PREVIEW_MAX_BYTES ) { + $content = substr( $content, 0, self::PREVIEW_MAX_BYTES ); + } + + return rest_ensure_response( array( 'content' => $content ) ); + } + + /** + * Shared response forwarder for the bridges that just pass through + * WPCOM JSON. Wraps WP_Error / non-200 responses with bridge-level + * error codes the front-end branches on. + * + * @param array|\WP_Error $response The wp_remote_* response. + * @param string $code Error code for non-200. + * @param string $message Translated error message. + * @return \WP_REST_Response|WP_Error + */ + private static function forward_response( $response, $code, $message ) { + if ( is_wp_error( $response ) ) { + return $response; + } + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + $code, + $message, + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php new file mode 100644 index 000000000000..a117d1cecb3e --- /dev/null +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -0,0 +1,52 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_restore' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/restore/(?P\d+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_restore_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'restore_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a restore. + * + * Proxies POST rest/v1 /activity-log/{site}/rewind/to/{rewindId}. + * + * Sign as the user: rewind is a destructive admin action and + * WPCOM's audit log identifies the actor by their wpcom user. + * (We tried as_blog v1.1 to side-step user-permission + * requirements; WPCOM rejects it with `That API call is not + * allowed for this account.` — the rewind endpoint refuses + * blog-token auth entirely.) + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_restore( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $types = $request->get_param( 'types' ); + + $body = array( + 'force_rewind' => true, + 'types' => is_array( $types ) || is_object( $types ) ? $types : (object) array(), + ); + + // `base_api_path` defaults to 'wpcom' on `as_user`, but the + // activity-log endpoints live under `rest/v1/...` + // (https://public-api.wordpress.com/rest/v1/activity-log/...). + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/to/%s', $blog_id, rawurlencode( $rewind_id ) ), + '1', + array( 'method' => 'POST' ), + $body, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + $upstream = json_decode( wp_remote_retrieve_body( $response ), true ); + return new WP_Error( + 'restore_initiate_failed', + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), + array( + 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500, + 'upstream' => is_array( $upstream ) ? $upstream : null, + ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $restore_id_in = is_array( $body ) && isset( $body['restore_id'] ) ? (int) $body['restore_id'] : 0; + if ( ! $restore_id_in ) { + return new WP_Error( + 'restore_initiate_failed', + __( 'Restore response missing restore id.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + return rest_ensure_response( array( 'id' => $restore_id_in ) ); + } + + /** + * Poll restore status. + * + * Proxies GET rest/v1 /activity-log/{site}/rewind/{restore_id}/restore-status. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_restore_status( WP_REST_Request $request ) { + $blog_id = Jetpack_Options::get_option( 'id' ); + $restore_id = (int) $request->get_param( 'restore_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/%d/restore-status', $blog_id, $restore_id ), + '1', + array(), + null, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'restore_status_fetch_failed', + __( 'Could not fetch restore progress.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $status = is_array( $body ) && isset( $body['restore_status'] ) && is_array( $body['restore_status'] ) + ? $body['restore_status'] + : ( is_array( $body ) ? $body : array() ); + + return rest_ensure_response( + array( + 'id' => isset( $status['restore_id'] ) ? (int) $status['restore_id'] : $restore_id, + 'status' => isset( $status['status'] ) ? (string) $status['status'] : 'queued', + 'progress' => isset( $status['percent'] ) ? (float) $status['percent'] : 0, + 'rewind_id' => isset( $status['rewind_id'] ) ? (string) $status['rewind_id'] : '', + 'error_code' => isset( $status['error_code'] ) ? (string) $status['error_code'] : '', + 'message' => isset( $status['message'] ) ? (string) $status['message'] : '', + ) + ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php new file mode 100644 index 000000000000..468e1de3b0fa --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -0,0 +1,91 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/activity-log', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/activity-log' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php new file mode 100644 index 000000000000..93e4c328c0a7 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php @@ -0,0 +1,91 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/site/capabilities', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/capabilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php new file mode 100644 index 000000000000..bef5a0892364 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php @@ -0,0 +1,103 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/backups/download/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/backups/download/123/status' ); + $status_request->set_param( 'download_id', 1 ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php new file mode 100644 index 000000000000..72f059852e79 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php @@ -0,0 +1,109 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The ls route registers when the modernization filter is on. + */ + public function test_ls_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/ls', $routes ); + } + + /** + * The path-info route registers when the modernization filter is on. + */ + public function test_path_info_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/path-info', $routes ); + } + + /** + * The file-content route registers when the modernization filter is on. + */ + public function test_file_content_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/file-content', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/backup/file-content' ); + $request->set_param( 'rewind_id', '123' ); + $request->set_param( 'encoded_manifest_path', 'abc' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php new file mode 100644 index 000000000000..ef68697686d7 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php @@ -0,0 +1,102 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/to/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/restore/(?P\d+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/rewind/to/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/restore/1/status' ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} diff --git a/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..5172587e5431 --- /dev/null +++ b/projects/plugins/backup/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. diff --git a/projects/plugins/backup/changelog/wire-backup-modernization-data-layer b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..6f44e75925a4 --- /dev/null +++ b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: minor +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui new file mode 100644 index 000000000000..27171917b368 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-backup-modernization-dashboard-ui @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Backup: flesh out the modernized dashboard behind `rsm_jetpack_ui_modernization_backup` — overview list, two-pane backup detail with file browser + preview, and narrow Restore + Download forms. UI only with mocked data; no-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer new file mode 100644 index 000000000000..f1160b77c714 --- /dev/null +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability checks, drop the mock-mode dev banner. No-op when the modernization filter is off.