Automattic / Automattic/jetpack
Port Scan overview to Jetpack wp-admin as a native page
- Dominant language
- PHP
- Stars
- 1.8k
- Forks
- 898
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 774
Description
Replace the standalone Scan UI inside the Jetpack Protect plugin with the **MSD-aligned overview ported from Calypso's Dashboard**. Architecture follows `projects/packages/activity-log/` (per [#48244](https://github.com/Automattic/jetpack/pull/48244)) and `projects/packages/backup/` (per [#48236](https://github.com/Automattic/jetpack/pull/48236) / [#48297](https://github.com/Automattic/jetpack/issues/48297)) β a new bundled `projects/packages/scan/` package with a wp-admin shell, TanStack Query data layer, and `@wordpress/dataviews` UI.
## Status (2026-05-02)
Implementation lives on [#48458](https://github.com/Automattic/jetpack/pull/48458) (`try/jetpack-scan-new-ui`). Progress vs. the 8-phase plan:
- β
**Phases 0, 1, 2, 3, 4, 5, 6, 7** β fully shipped.
- π‘ **Phase 8** β bridge permission tests (9/9 phpunit) + `isFixComplete` (5/5 jest) shipped; broader bridge Γ happy/error coverage and Playwright e2e deferred.
**Build pipeline**: migrated from webpack + `@automattic/jetpack-webpack-config` to `@wordpress/build` (mirrors Newsletter [#48420](https://github.com/Automattic/jetpack/pull/48420) and Forms). Routes now live under `routes/index/` (`route.tsx`, `stage.tsx`, `route.scss`, `package.json`); the page chrome moved to `_inc/components/scan-page.{tsx,scss}`. Routing switched from `react-router`'s `useSearchParams` to `@wordpress/route`'s `useSearch` / `useNavigate`. PHP-side, `Jetpack_Scan` loads `build/build.php`, registers polyfills via `WP_Build_Polyfills`, and bridges the user-facing `?page=jetpack-scan` slug onto wp-build's auto-generated `jetpack-scan-wp-admin` enqueue.
## Source
- Calypso shell: `client/dashboard/sites/scan/` ([wp-calypso](https://github.com/Automattic/wp-calypso/tree/trunk/client/dashboard/sites/scan))
- Calypso DataViews tables: `client/dashboard/sites/scan-active/` and `client/dashboard/sites/scan-history/`
- Calypso REST surface: `wpcom/v2 /sites/:siteId/scan*` and `β¦/alerts/*` (see `packages/api-queries/src/site-scan.ts`)
- Existing component library: `projects/js-packages/scan/` (`ThreatsDataViews`, `ThreatSeverityBadge`, `Threat` types) β reused as building blocks
- Pin: Calypso source SHA at port start, recorded in the package's `AGENTS.md`
> Note: Calypso's Scan dashboard is **single-pane tabbed** (Active / History) with threat details in a Modal via DataViews `RenderModal` β not dual-pane like Backup. The port preserves this layout. See decision #1 below if we want to deliberately diverge.
## Architectural rules
- **Package location:** new `projects/packages/scan/` (sibling to `activity-log`, `backup`). Existing `projects/plugins/protect/` is left untouched in this PR; future deprecation tracked separately.
- **Package name:** `@automattic/jetpack-scan-page` (avoids clash with existing `@automattic/jetpack-scan` js-package).
- **Build:** `@wordpress/build` (`wp-build`) β Newsletter / Forms pattern. Drops webpack + `@automattic/jetpack-webpack-config` for this package; the plugin's webpack chain still consumes the wp-build `build/` output via the auto-generated `build/build.php`.
- **Shell pattern (post wp-build migration):**
- `src/class-jetpack-scan.php` β wp-admin submenu (`?page=jetpack-scan`), `WP_Build_Polyfills::register`, `require build/build.php`, `bridge_wp_build_enqueue`, `fix_boot_import_map_ordering` (work-around for `WordPress/gutenberg#76870`)
- `src/class-initial-state.php`, `src/class-rest-controller.php`
- `routes/index/{route.tsx,stage.tsx,route.scss,package.json}` β the wp-build entry. Stage mounts `QueryClientProvider` + `HeaderActionsProvider` and renders `` with `Tabs.Panel` children.
- `_inc/components/scan-page.{tsx,scss}` β page chrome (`@wordpress/admin-ui` `Page` + `Tabs.Root` + sticky tab row anchored to `--jetpack-scan-page-header-height`)
- **Data layer:** TanStack Query factories matching Calypso names (`siteScanQuery`, `siteScanHistoryQuery`, `siteScanCountsQuery`, `siteScanEnqueueMutation`, `fixThreatMutation`, `ignoreThreatMutation`, `unignoreThreatMutation`, `fixThreatsMutation`, `fixThreatsStatusQuery`).
- **REST bridges (admin-only) under `jetpack/v4/site/scan/*`:**
- `GET /site/scan` β `/sites/:siteId/scan` *(blog auth β matches Protect's `Threats::fetch_status()`)*
- `GET /site/scan/history` β `/sites/:siteId/scan/history` *(blog auth)*
- `GET /site/scan/counts` β `/sites/:siteId/scan/counts` *(blog auth)*
- `POST /site/scan/enqueue` β `/sites/:siteId/scan/enqueue` *(blog auth β matches Protect's `Threats::scan()`)*
- `POST /site/scan/threat/{id}/fix` (and `/ignore`, `/unignore`) β `/sites/:siteId/alerts/:threatId?...` *(user auth)*
- `POST /site/scan/threats/fix` β `/sites/:siteId/alerts/fix` *(user auth)*
- `GET /site/scan/threats/fix-status` β `/sites/:siteId/alerts/fix?threat_ids[]=β¦` *(user auth)*
Implementation: each route proxies to WPCOM via `Automattic\Jetpack\Connection\Client::wpcom_json_api_request_as_blog()` for site-level reads / scan enqueue and `β¦_as_user()` for alert mutations. Permission callback admin-only, validated by phpunit.
- **Routing:** `@wordpress/route` (`useNavigate` / `useSearch`). Single overview route at `/`; `?tab=active|history` switches panels, `?jps-mock=1` for mock mode. (Was originally planned as `createHashRouter` from `react-router@7`; switched as part of the wp-build migration to match Newsletter / Forms.)
- **Mock mode:** `?jps-mock=1` short-circuits all gates so design iteration works without a Scan plan or WPCOM connection. Mock fixtures live in `src/js/data/mock/`.
- **Components:** `@wordpress/dataviews@14`, `@wordpress/components`, `@wordpress/ui`, `Page` from `@wordpress/admin-ui`, WPDS tokens (`--wpds-*`).
- **i18n:** textdomain `jetpack-scan-page`. `__`, `_n`, `_x`, `sprintf` from `@wordpress/i18n`.
- **Suppress** `admin_notices` / `all_admin_notices` on the Scan page (Forms-style).
- **Drop Calypso-only concerns:** `HostingFeatureGatedWithCallout`, `JETPACK_CONTACT_SUPPORT` link, multi-site routing (`siteRoute.useParams`, `useRouter().navigate('/sites/...')`), `TimeMismatchNotice`, `isEnabled('dashboard/omnibar')`, `PerformanceTrackerStop`, `calypso_dashboard_*` event names.
- **Reuse** `@automattic/jetpack-scan` (existing js-package) for `ThreatSeverityBadge` + `Threat` types. Calypso's modals are richer than what that package ships β Scan's row-action modals (`fix-threat-modal.tsx`, `ignore-threat-modal.tsx`, `unignore-threat-modal.tsx`) are ported into the new package and wired via the upstream's `RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal` props (added in #48458).
## Plan
### Phase 0: Shell + data layer skeleton
- [x] Scaffold `projects/packages/scan/` (`package.json`, `composer.json`, `AGENTS.md`)
- [x] PHP shell: `class-jetpack-scan.php`, `class-initial-state.php` (`JPSCAN_INITIAL_STATE`), `class-rest-controller.php` (empty handlers)
- [x] JS shell: ~~`index.js`, `admin.tsx` (hash router), `shell.tsx` (`AdminPage` + `HeaderActionsProvider`), `providers.tsx` (`QueryClient` + `ThemeProvider`), `gates.tsx`, `routes.ts`~~ β replaced by wp-build entry: `routes/index/{route,stage,route.scss,package.json}.tsx` + `_inc/components/scan-page.{tsx,scss}` + `src/js/header-actions-context.tsx` + `gates.tsx` (now a pass-through)
- [x] Data skeleton: `data/{fetchers,query-options,types,use-site-data,use-track-event}.ts`
- [x] Mock mode: `?jps-mock=1` flag, `data/mock/fixtures.ts`, `mock-banner.tsx` (5 active + 4 history fixtures)
- [x] wp-admin menu entry registered, blank "Hello Scan" page renders
### Phase 1: Active threats
- [x] Port `client/dashboard/sites/scan-active/index.tsx` β `screens/overview/active-threats.tsx`
- [x] Port `dataviews/{fields,actions}.tsx` for active list (lives in upstream `@automattic/jetpack-scan`'s `ThreatsDataViews`)
- [x] Port `severity-badge.tsx`, `utils.ts` (`getThreatType`/`getThreatIcon`) β reused from `@automattic/jetpack-scan`
- [x] Port `use-scan-state.ts` state machine (`idle|enqueued|running|success|error`)
- [x] Wire `useSiteScanQuery` over `jetpack/v4/site/scan` REST bridge
- [x] Tabbed shell rendering Active tab; History tab wired
- [x] Header actions: `ScanNowButton` + conditional `Auto-fix N threats` button (modal wired in Phase 4)
- [x] `usePersistentView` for view persistence β implemented as `persistKey` prop on upstream `ThreatsDataViews`. Both panels pass stable namespaced keys (`jetpack-scan:active-threats:view`, `jetpack-scan:scan-history:view`); filters / sort / search / page / layout round-trip across reloads
### Phase 2: Scan history
- [x] Port `client/dashboard/sites/scan-history/index.tsx`
- [x] Port history `dataviews/{fields,actions}.tsx`
- [x] Wire `useSiteScanHistoryQuery` over `jetpack/v4/site/scan/history`
- [x] History tab rendering
### Phase 3: Single-threat modals
- [x] Port `fix-threat-modal.tsx`, `fix-threat-confirmation.tsx`
- [x] Port `ignore-threat-modal.tsx`, `unignore-threat-modal.tsx`
- [x] Wire `fixThreatMutation`, `ignoreThreatMutation`, `unignoreThreatMutation` over threat-id REST bridges
- [x] Port `useFixThreats` orchestration hook (2s polling for fixer status)
- [x] Wire row actions in `dataviews/actions.tsx` (via new `Render*Modal` props on upstream `ThreatsDataViews`)
- [x] Handle `useFixThreatsStatusQuery` `isError` state in fix / bulk-fix modals (P2 review)
- [x] Don't render `done` step on initial bulk-fix POST failure (P2 review)
### Phase 4: Bulk fix + view-details
- [x] Port `bulk-fix-threats-modal.tsx`
- [x] Port `view-details-modal.tsx` (lean version β title + severity + signature + description + file path / context block / extension version / first-detected / fixed-on metadata + fix-description summary)
- [x] Wire `view-details-modal` as DataViews `RenderModal` action β added `RenderViewModal` prop on upstream `ThreatsDataViews` (always-eligible, `large` modal size). Wired on both panels.
- [x] Wire `fixThreatsMutation` + `fixThreatsStatusQuery` polling
- [x] "Auto-fix N threats" header CTA opens bulk-fix modal
### Phase 5: Notices, scan-now, in-progress UI
- [x] Port `scan-notices.tsx`, `scan-now-button.tsx`, `scan-status.tsx`
- [x] Active tab toggles between `` (running) and ``
- [x] Wire `siteScanEnqueueMutation` over `jetpack/v4/site/scan/enqueue`
- [x] Snackbar notices via `@wordpress/notices`
- [ ] Copy in `scan-callout-illustration.svg`, `scan-scanning-illustration.svg` β blocked on decision #7 (asset clearance)
### Phase 6: Polish
- [x] Drop `HostingFeatureGated`, `TimeMismatchNotice`, `PerformanceTrackerStop`, `isEnabled('dashboard/omnibar')`
- [x] Replace `JETPACK_CONTACT_SUPPORT` with Jetpack-equivalent support URL
- [ ] Decide on Codeable upsell (see decision #4)
- [x] Suppress `admin_notices` / `all_admin_notices` on the page
- [x] WPDS tokens applied; full-width layout (BoundedLayout 1344px cap deferred)
- [x] DataViews empty body anchored to `calc(100vh - 320px)`; page chain anchored to viewport so the footer pins to the bottom even on tall screens
- [x] In-table active/historic status toggle hidden via new `showStatusFilter` prop on `ThreatsDataViews` (page-level tabs already cover that dimension)
- [ ] Mobile responsiveness verification
- [x] Accessibility pass on modals (DataViews-managed; uses native `Modal` from `@wordpress/components`)
- [x] Empty / loading / error state copy
### Phase 7: Analytics
- [x] `data/use-track-event.ts` wraps `@automattic/jetpack-analytics`'s `tracks.recordEvent` (same client Forms / Backup / Activity Log use)
- [x] Rename Calypso events: `calypso_dashboard_scan_*` β `jetpack_scan_*`
- [x] `jetpack_scan_scan_now`
- [x] `jetpack_scan_fix_threats_cta_click` (`{ threat_count }`)
- [x] `jetpack_scan_bulk_fix_threats_modal_open` / `_click` / `_success` / `_failed`
- [x] `jetpack_scan_{fix,ignore,unignore}_threat_modal_open` / `_click` / `_success` / `_failed` (Phase 3)
- [x] `jetpack_scan_view_details_modal_open` (Phase 4)
- [ ] `jetpack_scan_codeable_estimate_click` β pending decision #4
- [x] DataViews canonical: `_view_change`, `_filter_change`, `_search` (`{ has_query: boolean }`), `_page_change`, `_layout_changed` β added `onTrackEvent` callback prop on the upstream `ThreatsDataViews` (Protect can adopt the same hook with its own prefix). `_reset_view_click` not yet β DataViews doesn't expose a distinct hook for "reset view" beyond the diffs the canonical events already cover.
### Phase 8: Tests
- [x] phpunit `tests/php/Jetpack_Scan_Bridges_Test.php` β admin permission callback + route registration (9/9)
- [x] Jest unit tests: `isFixComplete` polling-terminator from `useFixThreatsStatusQuery` (5/5)
- [ ] Broader Jest coverage: bulk-fix orchestration, fix-status `isError` paths, modal render tests
- [ ] E2E (Playwright) happy paths: scan-now, single-threat fix, bulk-fix
- [ ] Bridge Γ happy/error path coverage in a follow-up PR
## Decisions to make
- [ ] **#1** Faithful tabbed-with-modal port vs deliberately add a Backup-style side-pane?
- [x] **#2** DataViews configs: thin wrapper around existing `ThreatsDataViews` from `js-packages/scan` (decided β added `RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal` / `showStatusFilter` / `empty` props upstream)
- [x] **#3** Surface as a separate wp-admin entry (`?page=jetpack-scan`) β decided.
- [ ] **#4** Keep or drop the Codeable freelancer upsell from `threat-description.tsx`?
- [ ] **#5** Deprecate `jetpack-protect/v1/*` REST surface or keep it side-by-side with `jetpack/v4/site/scan/*`?
- [x] **#6** Confirm package name `@automattic/jetpack-scan-page` β confirmed.
- [ ] **#7** Confirm asset clearance for the two Calypso SVG illustrations.
- [ ] **#8** What happens to WAF + Account Protection in `plugins/protect` post-port (status quo, also port, or deprecate the plugin)?
## Open follow-ups (from #48458 review)
- **Mirror repo**: empty `github.com/Automattic/jetpack-scan-page` needs to be created (org-admin task) so the **Mirror repo check** passes. Same gating Activity Log had.
- **Phase 8 broader tests**: Jest render tests for the four modals, bulk-fix orchestration, and fix-status `isError` paths; Playwright e2e for scan-now / single-fix / bulk-fix.
## Risks
- **API contract drift** between WPCOM `wpcom/v2 /sites/:siteId/scan*` and our bridges β pin the Calypso commit we ported from in `AGENTS.md`.
- **Scan plan gating** β Calypso uses `HostingFeatureGated`; wp-admin needs an equivalent via `Plan` from `protect-status`.
- **Modal stacking** β bulk-fix during in-progress fixer poll could collide with view-details; verify under wp-admin.
- **Polling collisions** β `useScanStatusQuery` polling vs user view changes; reset page=1 on dataset changes (Activity Log pattern).
- **Asset clearance** β confirm the SVG illustrations can ship under Jetpack's license/branding.
- **`@wordpress/dataviews` version skew** β confirm parity with Activity Log + Backup; investigate `@automattic/dataviews` ([#40612](https://github.com/Automattic/jetpack/pull/40612) closing comment) if versions diverge.
## References
- [#48458](https://github.com/Automattic/jetpack/pull/48458) β implementation PR (this issue)
- [#48420](https://github.com/Automattic/jetpack/pull/48420) β Newsletter unified page (canonical wp-build pattern)
- [#48244](https://github.com/Automattic/jetpack/pull/48244) β Activity Log wp-admin port
- [#48236](https://github.com/Automattic/jetpack/pull/48236) β Backup wp-admin port
- [#48297](https://github.com/Automattic/jetpack/issues/48297) β Backup port tracking issue
- [#48160](https://github.com/Automattic/jetpack/issues/48160) β Jetpack UI Modernization umbrella
- [#48156](https://github.com/Automattic/jetpack/pull/48156) β `ThreatSeverityBadge` already migrated to `@wordpress/ui`
- [#48154](https://github.com/Automattic/jetpack/pull/48154) β Shared `BoundedLayout` + sticky `JetpackFooter`
- Calypso source: [`client/dashboard/sites/scan/`](https://github.com/Automattic/wp-calypso/tree/trunk/client/dashboard/sites/scan), [`scan-active/`](https://github.com/Automattic/wp-calypso/tree/trunk/client/dashboard/sites/scan-active), [`scan-history/`](https://github.com/Automattic/wp-calypso/tree/trunk/client/dashboard/sites/scan-history)
- Companion plan docs: `protect-scan-port-plan.md`, `protect-dataviews-plan.md`
Contributor guide
Assessment
This issue has not been assessed yet.