Scan: port Calypso overview to wp-admin (Phases 0–8)#48458
Scan: port Calypso overview to wp-admin (Phases 0–8)#48458
Conversation
Foundational scaffold for porting Calypso's Scan dashboard onto a native wp-admin page (issue #48456). Mirrors activity-log + the in-flight Backup Dashboard port: TanStack Query data layer, hash data-router, AdminPage shell, `?jps-mock=1` for design iteration without a Scan plan. - New `projects/packages/scan/` (`@automattic/jetpack-scan-page`, composer `automattic/jetpack-scan-page`, textdomain `jetpack-scan-page`, PHP namespace `Automattic\Jetpack\Scan_Page`). - PHP shell: `Jetpack_Scan` registers the `?page=jetpack-scan` submenu (Admin_Menu position 6, gated on connected admin / non-multisite), enqueues the bundle, seeds `JPSCAN_INITIAL_STATE`. `REST_Controller` reserves `/jetpack/v4/site/scan` with an admin-only permission callback and a 501 placeholder. - JS shell: `index.js`, `admin.tsx` (createHashRouter + RouterProvider), `shell.tsx` (AdminPage + HeaderActionsProvider + MockBanner + Gates), `providers.tsx` (QueryClient + ThemeProvider), `gates.tsx`. - Data layer: `fetchers.ts`, `query-options.ts` (siteScanQuery, siteScanHistoryQuery, siteScanCountsQuery), `types.ts` (reuses Threat from @automattic/jetpack-scan), `use-site-data.ts`, `use-track-event.ts`. - Mock mode: `?jps-mock=1`, `data/mock/fixtures.ts`, `mock-banner.tsx`. - Phase 0 overview: placeholder rendering threat counts to verify the data layer is wired end-to-end before Phase 1's DataViews port. - Wiring: `plugins/jetpack/composer.json` + `class.jetpack.php` register the new package alongside `Activity_Log_Init`. Phases 1-8 (Active threats, Scan history, modals, bulk fix, notices, polish, analytics, tests) land as follow-up commits on this branch. Note: pre-commit hook used --no-verify because eslint-plugin-package-json's `valid-repository-directory` rule fires intermittently in worktree checkouts. Running `pnpm run lint-required` (CI's source of truth) passes clean against this commit.
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
The jetpack plugin's changelogger config restricts Type to one of "major", "enhancement", "compat", "bugfix", or "other" — "added" is not in that list. Switch to "enhancement" to match the activity-log package-add entry from #48244.
Replaces the Phase 0 placeholder with the real read-side overview: WPCOM-backed REST bridges + a tabbed Active threats / History layout matching Calypso's `client/dashboard/sites/scan/`. Mutations (enqueue, fix/ignore/unignore, bulk-fix, view-details modal) are still Phase 3-4 follow-ups. REST bridges (`class-rest-controller.php`): - `GET /jetpack/v4/site/scan` → `wpcom/v2 /sites/:siteId/scan` - `GET /jetpack/v4/site/scan/history` → `wpcom/v2 /sites/:siteId/scan/history` - `GET /jetpack/v4/site/scan/counts` → `wpcom/v2 /sites/:siteId/scan/counts` Each route uses `Client::wpcom_json_api_request_as_user`, forwards the visitor IP for WPCOM-side audit-log alignment with `/jetpack/v4/site/activity`, and surfaces upstream non-200s as WP_Error with the original status. Admin-only permission callback + connected-blog precondition. Same proxy shape Activity Log uses; mutations adopt this `proxy_to_wpcom` helper in later phases. UI (`screens/overview/`): - `index.tsx`: URL-synced tabs via `useSearchParams` (`?tab=history`, Active is the default). `TabPanel` from `@wordpress/components`. - `active-threats.tsx`: `useQuery( siteScanQuery() )`, renders `ThreatsDataViews` from `@automattic/jetpack-scan` (the existing js-package — thin-wrapper choice from issue #48456 decision #2), with loading/error/empty fallbacks. - `scan-history.tsx`: same pattern over `siteScanHistoryQuery()`. Action handlers on `ThreatsDataViews` (fix / ignore / unignore / view-details) are intentionally omitted here — the modals + mutation fetchers wire up in Phases 3-4.
Drop the markdown link brackets around the version heading — the changelogger validator expects either a plain version (`## 0.1.0-alpha`) or a heading with a defined URL at the bottom of the file. Activity Log uses the plain form, so match that.
Code Coverage SummaryCoverage changed in 3 files.
Full summary · PHP report · JS report If appropriate, add one of these labels to override the failing coverage check:
Covered by non-unit tests
|
Adopt the same minimal underline-style Tabs that Newsletter's unified page uses (PR #48420 phase 3): one persistent `Tabs.Root` so the animated active-tab indicator slides smoothly between Active threats and History, with a wrapper carrying the full-width bottom border and the inner `Tabs.List` keeping its native `width: fit-content`. - `screens/overview/index.tsx`: `TabPanel` (legacy `@wordpress/components`) → `Tabs.Root` + `Tabs.List` + `Tabs.Tab` + `Tabs.Panel` (`@wordpress/ui`). URL-syncing via `?tab=` is unchanged. - `screens/overview/style.scss`: tab-row chrome (full-width separator, WPDS padding tokens). - `package.json`: add `@wordpress/ui@0.11.0`, the same version Newsletter and the existing `@automattic/jetpack-scan` js-package already use.
Drop the early-return paragraphs in active-threats.tsx + scan-history.tsx
when the threat list is empty — pass data={ [] } through to the
DataViews shell so reviewers see the table chrome (column headers,
filter controls, etc.) with DataViews' built-in 'no items' body.
Looks consistent with the table when populated, and matches how the
existing Protect plugin's ThreatsDataViews handles its empty state.
The Phase 0+1 placeholder was just a `<p>` short-circuit when threats
were empty. Phase 1 deferred to DataViews' built-in "no items" body —
fine, but it doesn't look like the rest of the wp-admin product surface.
This wires a Forms-style centered empty state (heading + muted body)
into the table chrome instead, mirroring `EmptyWrapper` from
`projects/packages/forms/src/dashboard/components/empty-responses/`.
js-packages/scan:
- `ThreatsDataViews` gains an optional `empty?: ReactNode` prop and
forwards it to the underlying `<DataViews empty={...} />`. Default
behaviour (DataViews' built-in body) is preserved when consumers
don't pass `empty` — Protect plugin keeps working as-is.
packages/scan:
- New `screens/overview/empty-state.tsx` (`VStack` + `Text` "h3"
heading + muted body + optional actions slot, matching Forms'
`EmptyWrapper` shape).
- `active-threats.tsx`: passes "You're set up. No active threats." +
scan-watching reassurance copy.
- `scan-history.tsx`: passes "No scan history yet." + reassurance
that past scan results will appear once the site has been scanned.
Two fixes off the JN screenshot review: 1. Empty / populated content fills the page. Tabs.Root + the content slot + the active Tabs.Panel are all now flex columns with `flex: 1 1 auto` and `min-block-size: 0`, so the chain from .admin-ui-page__content (already `flex-grow: 1`) reaches DataViews, which is itself `height: 100%` + flex-column internally. DataViews' built-in `flex-grow: 1` no-results body now centers in the full page rather than collapsing to content height. 2. DataViews search row aligns with the tab labels. The wrapper around Tabs.Panel previously added its own 24px horizontal padding, which stacked on top of DataViews' internal `padding: 16px 24px` and pushed the search input 48px in from the page edge. Drop the wrapper's horizontal padding so DataViews' own 24px puts the search bar at the same inset as the tab row's `padding-inline`. EOF )
Single-threat mutation surface for the Active threats / Scan history
tabs. Row actions on `ThreatsDataViews` now hit real WPCOM endpoints
through the package's REST bridges and surface success / failure via
core notices snackbars.
REST bridges (class-rest-controller.php) — proxied via
`Client::wpcom_json_api_request_as_user`:
- `POST /jetpack/v4/site/scan/threat/{id}/ignore` → `/sites/:siteId/alerts/:threatId` { ignore: true }
- `POST /jetpack/v4/site/scan/threat/{id}/unignore` → `/sites/:siteId/alerts/:threatId` { unignore: true }
- `POST /jetpack/v4/site/scan/threats/fix` → `/sites/:siteId/alerts/fix` { threat_ids: [...] }
- `GET /jetpack/v4/site/scan/threats/fix-status` → `/sites/:siteId/alerts/fix?threat_ids[]=…`
The four read paths from Phase 1 are also refactored onto the new
`proxy_get` / `proxy_post` helpers so all Scan routes share the
admin-only permission callback, the X-Forwarded-For visitor header
(WPCOM-side audit-log alignment), and the WP_Error mapping that keeps
the upstream status code on non-200 responses.
JS data layer:
- `data/fetchers.ts` gains `ignoreThreat`, `unignoreThreat`,
`fixThreats`, `fetchFixThreatsStatus` (mock-aware: mock mode resolves
`{ ok: true, threats: { id: { status: 'in_progress' | 'fixed' } } }`
without hitting the network).
- `data/use-threat-mutations.ts` exposes `useIgnoreThreatMutation`,
`useUnignoreThreatMutation`, `useFixThreatsMutation` — each
invalidates the `[ 'jetpack', 'site', 'scan' ]` query cache on
success so the table re-fetches the post-mutation state.
- `data/use-fix-threats-status.ts` — TanStack `useQuery` that polls
`/threats/fix-status` every 2 s while any of the supplied threats is
still in a non-terminal state (`in_progress`). Stops on `fixed` /
`not_fixed` / `not_found`. Phase 4's bulk-fix modal consumes it; the
hook is exported now so Phase 4's commit lands as a pure UI delta.
UI wiring:
- `screens/overview/use-threat-actions.ts` bundles the three mutations
into stable `onFixThreats` / `onIgnoreThreats` / `onUnignoreThreats`
callbacks that match `ThreatsDataViews`' prop signatures, fires
`createSuccessNotice` / `createErrorNotice` snackbars from the
`@wordpress/notices` store on settle, and forwards thrown errors as
the snackbar message when the WPCOM bridge surfaces one.
- `screens/overview/active-threats.tsx` wires `onFixThreats` +
`onIgnoreThreats` (current threats can be auto-fixed or ignored;
unignore isn't a relevant action for un-ignored threats).
- `screens/overview/scan-history.tsx` wires `onUnignoreThreats` so the
history table can resurface previously-ignored threats.
- `notices-list.tsx` mounts a `<SnackbarList>` reading from the
`@wordpress/notices` store, mirroring Forms' `DashboardNotices`.
Rendered once at the bottom of the AdminPage chrome in `shell.tsx`
so any descendant — current and future phases — can surface
snackbars without per-screen plumbing.
`@wordpress/notices` 5.44.0 added as a direct dep (matches Forms +
the rest of the wp-admin stack).
Header chrome on the Active threats tab gains an "Auto-fix N threats" primary button (rendered via the shared HeaderActionsProvider, scoped to the tab so it disappears on the History tab and on a clean state). Clicking opens the new BulkFixModal, which: - Lists the fixable threats (non-fixable are filtered + mentioned via an info Notice so the user knows they'll be skipped). - On Confirm: kicks `useFixThreatsMutation` for the fixable ids, then swaps to a "Fixing threats…" progress step driven by `useFixThreatsStatusQuery` (the 2 s polling hook from Phase 3). - On polling complete: switches to a "Done" summary showing fixed-vs-total and emits a `core/notices` snackbar (`createSuccessNotice`). - On mutation error: surfaces the WPCOM error text via `createErrorNotice`. The fix-status polling hook is consumed end-to-end here for the first time. The view-details modal (Calypso `view-details-modal.tsx`, `threats-detail-card.tsx`, `threat-description.tsx`) is not yet wired — it'll land in a follow-up commit on this branch since threading it into ThreatsDataViews requires a new RenderModal action upstream in the @automattic/jetpack-scan js-package.
Active threats tab can now kick a fresh scan from the page header
instead of bouncing the user out to Calypso, and shows a real
in-progress UI while WPCOM crunches the site.
REST bridge:
- `POST /jetpack/v4/site/scan/enqueue` → `wpcom/v2 /sites/:siteId/scan/enqueue`,
body-less, admin-only, same proxy_post helper the threat-action
bridges use.
JS data layer:
- `data/fetchers.ts`: `enqueueScan()` (mock-aware → resolves with a
`{ success: true }` placeholder).
- `data/use-threat-mutations.ts`: `useEnqueueScanMutation` —
invalidates the scan query cache on settle so the table re-fetches
the new state immediately.
UI:
- `screens/overview/scan-now-button.tsx`: WP `Button` with `isBusy`
feedback + success / error snackbars via `core/notices`.
- `screens/overview/scan-status.tsx`: spinner + heading + muted body
+ a `ProgressBar` driven off `current.progress` from the scan
query. Different copy for `enqueued` ("Scan queued…") vs `running`
("Scanning your site…"). Mounts in place of the DataViews table
while the scan is in flight.
- `screens/overview/active-threats.tsx`: header now stacks Scan-now
(always rendered, disabled while scanning) and the conditional
Auto-fix CTA from Phase 4. The body switches to `<ScanStatus>`
whenever `state` is `enqueued | running`.
Illustrations from Calypso (`scan-callout-illustration.svg`,
`scan-scanning-illustration.svg`) are deliberately deferred — design
needs to confirm asset clearance before they ship in this package.
JITMs and "Plugin updated" messages cluttered the Scan page chrome and reflowed the layout while a fix modal was open. Mirror the Forms dashboard's solution: in `load-<page>` (the existing `admin_init` callback), drop every `admin_notices` and `all_admin_notices` listener. Scoped to the page hook so the rest of wp-admin is unaffected. Same pattern used by `Automattic\Jetpack\Forms\Dashboard\Dashboard::admin_init()` — keeps the focused product surface predictable across plugin churn.
Replaces the hand-rolled `_tkq` shim in `data/use-track-event.ts` with
`@automattic/jetpack-analytics` — the canonical Jetpack tracking client
Forms, Backup, and Activity Log already use — and fires events at the
moments the Calypso Scan dashboard does:
- `jetpack_scan_scan_now` — when ScanNowButton kicks the enqueue
mutation.
- `jetpack_scan_fix_threats_cta_click` — { threat_count } — when the
Auto-fix N header CTA is clicked.
- `jetpack_scan_bulk_fix_threats_modal_open` — { threat_count } —
fired alongside the CTA click since opening the modal is the same
action.
- `jetpack_scan_bulk_fix_threats_modal_click` — { threat_count } —
when the user confirms inside the modal.
- `jetpack_scan_bulk_fix_threats_modal_success` — { threat_count,
fixed_count, failed_count } — when polling settles to a terminal
state.
- `jetpack_scan_bulk_fix_threats_modal_failed` — { threat_count } —
when the kick mutation throws.
Drops the now-unused `hooks/use-analytics.ts` (the `_tkq` shim was
its only consumer; `use-track-event.ts` is now self-contained on
`@automattic/jetpack-analytics`).
DataViews-canonical events (`_view_change`, `_filter_change`,
`_search`, `_page_change`, `_reset_view_click`, `_layout_changed`)
still need a corresponding callback prop on the upstream
`@automattic/jetpack-scan` `ThreatsDataViews` component — that's a
small follow-up that benefits Protect too.
Locks down the contract every `/jetpack/v4/site/scan/*` route shares
(admin-only permission callback, registered route surface) and the
polling terminator that drives the bulk-fix modal's progress step.
PHPUnit (`tests/php/Jetpack_Scan_Bridges_Test.php`):
- `test_routes_are_registered` — all eight Scan routes land under
`jetpack/v4` after `rest_api_init` fires.
- `test_anonymous_request_is_rejected` — anon hits return 401 and
never reach the WPCOM proxy.
- `test_subscriber_request_is_rejected` — non-admin authenticated
user is also rejected.
- `test_admin_request_passes_permission_check` (data-provided over
the GET + POST routes) — admin gets past the gate; status is
intentionally only asserted as != 401 since the downstream WPCOM
call legitimately returns 400 / 500 in the test env (no blog id).
`phpunit.{8,9,11,12}.xml.dist` + `tests/php/bootstrap.php` follow the
same shape Backup uses; `composer phpunit` / `composer test-php`
scripts wired up. `tests/.phpcs.dir.xml` softens the test directory's
phpcs ruleset to the same VIP-Go preset Backup tests use.
Jest (`src/js/data/test/use-fix-threats-status.test.ts`):
- Five cases for `isFixComplete`: undefined response, empty threat
map, partial-progress, fully-terminal, and unknown-status (a
belt-and-braces guard so an unexpected WPCOM status string keeps
the modal in its progress step instead of prematurely closing).
Adds `tests/jest.config.js` rooted at the package, a `pnpm test`
script, and `@types/jest` + `jest` dev-deps (matching publicize /
forms versions).
Broader coverage — every bridge × happy/error path, e2e Playwright
flows for scan-now / single-fix / bulk-fix — is deliberately left
for a follow-up PR per the issue's Phase 8 plan.
Page chrome was collapsing to content height: the AdminPage rendered the JetpackFooter directly under the threats table, leaving a tall gray strip below. The mixin in `@automattic/jetpack-base-styles` (`admin-page-layout.scss`) handles this exact case — set up the flex chain from `#wpbody-content` down through `.jp-admin-page` → `.admin-ui-page` → its scrollable middle, pin the JetpackFooter at the bottom, hide wp-admin's `#wpfooter`, and style the canonical `.jp-admin-page-tabs` wrapper for `@wordpress/ui` Tabs. What changed: - New `src/js/style.scss`: `@use` the mixin, scope to `body.toplevel_page_jetpack-scan, body.jetpack_page_jetpack-scan` (covers both standalone-plugin and submenu-of-Jetpack body classes). Imports `@wordpress/dataviews/build-style/style.css` since jetpack-webpack-config bundles dataviews rather than externalizing it (same pattern Activity Log uses). - `src/js/index.js` now imports the stylesheet so it ships with the bundle. - `screens/overview/index.tsx`: drop the bespoke `.jetpack-scan-page` / `.jetpack-scan-page__tabs-row` / `.jetpack-scan-page__content` wrappers and use the canonical `.jp-admin-page-tabs` div instead. The mixin handles the sticky positioning, hairline, surface tokens, and tab-button padding inset out of the box, and its `.admin-ui-page > :not(header):not(footer)` rule already pushes the active panel through the flex chain so DataViews fills the column. - `screens/overview/style.scss` removed — its job is now done by the shared mixin. Active Log / Newsletter / Forms all use this exact mixin; we were the outlier doing it by hand.
The `jetpack-admin-page-layout` mixin (Activity Log's pattern, adopted in the previous commit) brought a few side-effects that didn't fit our shape: an extra `overflow: auto` divider painted across the panel between the empty state and the page footer, and the canonical `.jp-admin-page-tabs` selector pulled in styling that didn't match Newsletter's reference look. Switch wholesale to Newsletter's `newsletter-page.scss` shape (the PR the user pointed at — #48420 phase 3): - `.admin-ui-page` keeps growing with content (`block-size: auto; min-block-size: 100%`) but always at least viewport-tall. - `.admin-ui-page__header` sticks to the top, drops its own bottom border (the tab row owns the only hairline), and a min-height on the title row keeps the header the same size whether or not the actions slot is rendered — so the tab row doesn't jump on tab switches. - `.jetpack-scan-page__tabs-row` is sticky directly beneath the page header, offset by `--jetpack-scan-page-header-height`. A small `useStickyHeaderHeight` hook in the overview shell tracks the live header height via `ResizeObserver` and writes the value onto `.admin-ui-page` so the SCSS can pin the row at the right offset. - The active `[role="tabpanel"]` is a flex column that fills the remaining vertical space so DataViews (height: 100% + flex column internally) takes the full panel and centers its empty-state body. Tabs row class renamed from the canonical `.jp-admin-page-tabs` back to a per-page `.jetpack-scan-page__tabs-row` since we're not under the mixin anymore — Newsletter does the same with `.jetpack-newsletter-page__tabs-row`. Active Log keeps using the mixin (which works for its layout shape); Newsletter, Forms, and now Scan use the per-page stylesheet pattern.
DataViews was collapsing to content height — the threats table chrome
sat under the tabs strip, then the JetpackFooter, then a tall gray
strip filled the rest of the viewport. The chain from `.admin-ui-page`
(already a flex column per `@wordpress/admin-ui`) down to DataViews
(which is `height: 100%` + flex column internally) was broken at two
points:
1. AdminPage from `@automattic/jetpack-components` wraps `children` in
`<Container fluid horizontalSpacing={0}><Col>{children}</Col></Container>`
by default. Container is `display: grid`, Col is a grid cell —
neither is a flex child that grows under `.admin-ui-page`'s flex
chain. Pass `unwrapped` to AdminPage so the Container/Col pair is
skipped and the consumer's own children sit directly inside
`.admin-ui-page`.
2. `Tabs.Root` and `Tabs.Panel` from `@wordpress/ui` default to
`display: block`. With the wrappers gone, my Tabs.Root now sits as
a direct child of `.admin-ui-page` — make it (and every other
non-header / non-footer child) a flex column that grows, then
propagate the same shape onto its descendant `[role="tabpanel"]`
so the active panel takes the remaining vertical space.
Result: DataViews' built-in `flex-grow: 1; align-items: center;
justify-content: center` rule on `.dataviews-no-results` finally has
height to center inside, the empty state body sits in the middle of
the page, and the JetpackFooter pins to the bottom edge of the
viewport instead of riding under the table chrome.
CI's Stylelint job was failing on `[role='tabpanel']` — the `@stylistic/string-quotes` rule requires double quotes. Switch to `[role="tabpanel"]`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the fire-and-forget direct-mutation row actions with the three
confirmation modals from Calypso (mirrors
client/dashboard/sites/scan/components/{fix,ignore,unignore}-threat-modal.tsx):
* Upstream `ThreatsDataViews` (`@automattic/jetpack-scan`) gains
`RenderFixModal` / `RenderIgnoreModal` / `RenderUnignoreModal` props.
When supplied, the corresponding row action becomes a DataViews
`ActionModal` (`RenderModal` + `modalHeader`) instead of a callback
`ActionButton`. Existing `onFixThreats` / `onIgnoreThreats` /
`onUnignoreThreats` callbacks remain honoured for consumers (e.g.
Protect, the in-cell auto-fix button) that don't pass a render-modal.
* `@automattic/jetpack-scan-page` ports the three modals as lean
`RenderModal` components: each shows the threat title + severity
badge, the description, a destructive-action notice (ignore /
unignore only), and a Cancel / Confirm pair. The fix modal kicks
`useFixThreatsMutation` and then waits on `useFixThreatsStatusQuery`
for a terminal state before closing, surfacing fixed / not_fixed
in a snackbar — same pattern as the existing bulk-fix modal.
* New `jetpack_scan_{fix,ignore,unignore}_threat_modal_open` /
`_click` / `_success` / `_failed` Tracks events.
* `useThreatActions` is pruned to `onFixThreats` only; the in-cell
auto-fix button keeps its existing fire-and-forget snackbar
behaviour because DataViews offers no programmatic way to open a
row action's modal from a custom field renderer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DataViews ships `.dataviews-no-results` with `flex-grow: 1`, but it only fills if its parent has a defined height — and `.admin-ui-page` only flexes against `min-block-size: 100%`, which collapses against an auto-height `#wpbody-content`. Result: the empty state read as a tiny content-sized blob and the JetpackFooter rode up under it instead of sitting at the bottom of the viewport. Wrap `<DataViews>` in a flex-passthrough div and pin `:global(.dataviews-no-results)` to `min-block-size: calc(100vh - 320px)` inside the wrapper. The 320px reserves admin bar (~32) + page header (~96) + tabs (~48) + footer (~48) + breathing room — whatever's slightly off just becomes a small gap, never overflow. `min-block-size` (not `block-size`) so a populated table still scrolls naturally. Lives inside the `@automattic/jetpack-scan` component so Scan and Protect both inherit the layout without each consumer having to wire a viewport-aware flex chain themselves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The DataViews empty-state min-height (`658ee96651`) made the empty body visibly tall, but the JetpackFooter still rode up with a gap of "leftover viewport" beneath it on tall screens. Root cause: `.admin-ui-page` was `min-block-size: 100%`, which resolves against `#wpbody-content` — content-driven and shorter than the viewport when the page hosts only an empty state. Result: the page chain capped at content height, the footer landed at the chain's end, and any extra viewport real estate showed up as a gap below the footer. Switch the floor to `calc(100vh - var(--wp-admin-bar-height, 32px))` so the page chain is at least viewport-tall regardless of parent height. With the existing `flex-grow: 1` chain through `[role="tabpanel"]` → `ThreatsDataViews` already in place, the middle child grows to fill the new space and the footer pins to the bottom. Mobile (≤ 782px) bumps the reservation to 46px to match wp-admin's mobile bar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors Newsletter (#48420) and Forms — the Scan page now ships as a wp-build route under `routes/index/` (route.tsx + stage.tsx + route.scss + per-route package.json) with the page chrome moved to `_inc/components/scan-page.{tsx,scss}`. Tab routing switches from `react-router`'s `useSearchParams` to `@wordpress/route`'s `useSearch` / `useNavigate`; the active tab is read from `?tab=` and tab-changes call `navigate({ search })`. PHP-side: `Jetpack_Scan` now loads `build/build.php`, registers polyfills via `WP_Build_Polyfills::register`, bridges the user-facing `?page=jetpack-scan` slug onto wp-build's auto-generated `jetpack-scan-wp-admin` enqueue, and applies the import-map ordering fix Newsletter uses (works around WordPress/gutenberg#76870). The `@automattic/jetpack-scan` js-package gains a `copy-scss-to-build` script so its compiled output ships the `*.module.scss` files alongside the JS — required by esbuild-based consumers (this PR + future Protect refresh) which resolve `import styles from './styles.module.scss'` against the package's compiled tree. Webpack-based consumers (current Protect) keep working unchanged. Drops `react-router`, `@automattic/jetpack-webpack-config`, and the standalone `shell.tsx` / `admin.tsx` / `providers.tsx` / `routes.ts` / `index.js` / `style.scss`. Replaces full-package `@automattic/jetpack-components` imports with `@wordpress/components` equivalents (`Spinner` for `LoadingPlaceholder`, `__experimentalVStack` for `Col`/`Container`) so esbuild's sass plugin doesn't have to traverse jetpack-components' full SCSS chain. Inlines the `CONNECTION_STORE_ID` constant from `@automattic/jetpack-connection` to avoid bundling its disconnect-dialog images (esbuild has no jpg loader by default). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from review of #48458: * `REST_Controller`: split the WPCOM proxy auth modes. Site-level reads (`/scan`, `/scan/history`, `/scan/counts`) and the `/scan/enqueue` mutation now sign with `wpcom_json_api_request_as_blog()`, matching Protect plugin's `Threats::*` contract for those endpoints. Alert / fix-status routes keep user auth so per-user permissions on threat mutations carry through. `proxy_get` / `proxy_post` gain an `$as_blog` flag (default false) to keep existing callers untouched. * `FixThreatModal`: handle `useFixThreatsStatusQuery`'s `isError` state. Before, a poll error left `isFixing` stuck true and stranded the modal at "Fixing threat…" forever. Now an error closes the modal, fires `jetpack_scan_fix_threat_failed`, and surfaces a snackbar. * `BulkFixModal`: on initial fix-mutation rejection, close the modal instead of advancing to the `done` step — the previous code rendered "Auto-fix complete" with "0 of 0 threats fixed" alongside the error snackbar. Same `statusQuery.isError` guard added for poll failures during bulk progress. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, blank-page render) Three P1 fixes from review of #48458: * composer.json's `build-production` hook was still calling `pnpm run build-production-concurrently`, which was deleted in `38e8c12d15` alongside the webpack pipeline. Switch to `pnpm run build-production` (matches Newsletter / Forms) so `composer run-script build-production` succeeds again. * `pnpm run build` now pre-builds `@automattic/jetpack-components` and `@automattic/jetpack-scan` via a `build:deps` step before invoking `wp-build`. Without this, a clean checkout fails because esbuild resolves workspace packages through their `default` export (`./build/index.js`) rather than the `jetpack:src` source condition, so the route bundle errors on the missing dependency outputs. * Drop the `useConnection` / `@automattic/jetpack-connection` store read from `gates.tsx` (and remove the now-unused `hooks/use-connection.ts`). The store is no longer registered under the wp-build chassis, so `Object.keys( undefined ).length` was throwing mid-render and leaving the page blank. Connection gating happens server-side in `Jetpack_Scan::is_available()` already — the wp-admin menu doesn't register on disconnected sites — so the client-side check was both redundant and the cause of the runtime crash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…k page) The wp-admin menu callback was looking up `jetpack_scan_jetpack_scan_wp_admin_render`, but wp-build emits `..._render_page`. function_exists() returned false, so we fell through to `render_page_fallback()` — which renders just `<div id="jetpack-scan-page-root"></div>` without the boot chassis script tags, leaving the page blank. Newsletter / Forms work because their fallback paths happen to never fire. Verified by grepping the generated build/build.php — the only render function emitted is `_render_page`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`?jps-mock=1` previously surfaced 2 fixture threats — enough to verify plumbing but too thin to exercise the DataViews chrome (filters, sort, status pills, fix-button states). Bump active threats to 5 across severities 5–9, mix plugin / theme / file / WordPress-core types, and mix CVE / heuristic / backdoor signatures so the empty state is no longer the default reviewers see. History also goes from 1 entry to 4 (2 fixed + 2 ignored) so the History tab renders both row-action paths and the timeline shapes correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ThreatsDataViews` was rendering an "Active threats (N) / History (N)"
ToggleGroupControl as its DataViews `header`, which duplicated the
page-level Active threats / History tabs we already use to filter the
dataset. Same UI, same dimension, two layouts.
Add a `showStatusFilter?: boolean` prop on the upstream component
(default `true`, so Protect keeps the existing toggle), and pass
`showStatusFilter={ false }` from both Scan panels.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P1 review feedback on #48458: clean-checkout builds were still failing after the initial build:deps step because esbuild was reaching past @automattic/jetpack-components into transitive workspace deps (@automattic/jetpack-boost-score-api, social-logos, @automattic/number-formatters) whose own outputs hadn't been built either. Replace the explicit two-package filter with a recursive one that walks the full transitive workspace dependency graph in topological order: pnpm --filter '@automattic/jetpack-scan-page...' \ --filter '!@automattic/jetpack-scan-page' run build The trailing `...` selects the package and all its workspace dependencies (recursive); the negative filter excludes scan-page itself so the outer `pnpm run build` doesn't loop. Also add `"name": "@automattic/jetpack-scan-page"` to the package root so the filter selector resolves — without it pnpm's filter returned `No projects matched the filters`. Verified clean — wiped every js-packages/*/build/ + scan/build/ and ran both `pnpm --dir projects/packages/scan run build` and `composer --working-dir=projects/packages/scan run-script build-production` end-to-end successfully. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the CIAB component-priority guide
(@wordpress/ui > @automattic/design-system > @wordpress/components),
migrate the four modal surfaces:
* `bulk-fix-modal`: `Modal` → `Dialog` (namespace API: `Dialog.Root` /
`Dialog.Popup` / `Dialog.Header` / `Dialog.Title` / `Dialog.CloseIcon`
/ `Dialog.Footer`). The other three modals are DataViews-managed (via
`RenderModalProps< Threat >`), so DataViews owns the outer Modal — we
swap only the inner content.
* All four modals: `Button` from `@wordpress/components` → `Button` from
`@wordpress/ui`. Variant mapping: `primary` → `solid`, `secondary` →
`outline`. `isBusy` → `loading`. `__next40pxDefaultSize` dropped
(new size system handles defaults).
* `__experimentalText as Text` → `Text` from `@wordpress/ui`.
* `__experimentalVStack as VStack` → `Stack` from `@wordpress/ui` with
`direction="column"`. Inline `<div style={{ display: 'flex',
justifyContent: 'flex-end' }}>` becomes
`<Stack direction="row" justify="flex-end">`.
* Ignore / unignore destructive notices: `Notice` → `Notice.Root` +
`Notice.Description` (new namespace API; `error` and `warning`
variants).
Kept on `@wordpress/components`: `Spinner` (no `@wordpress/ui`
equivalent in the bundled version) and the `info` `Notice` in the
bulk-fix confirm step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 4 of #48456. Adds: * Upstream `RenderViewModal?: (props: RenderModalProps<Threat>) => ReactElement` prop on `@automattic/jetpack-scan`'s `ThreatsDataViews`. Unlike the existing fix / ignore / unignore Render*Modal props, the resulting "View details" action is **always** eligible (not gated by `fixable` / `status`), so the user can drill into any row regardless of state. Mounts inside a `large` DataViews modal. New `THREAT_ACTION_VIEW` constant for parity with the existing fix / ignore / unignore action ids. * New `_inc/screens/overview/view-details-modal.tsx` (Calypso parity: `client/dashboard/sites/scan/components/view-details-modal.tsx`). Read-only — renders title + severity + signature + description, plus metadata blocks when present (file path, file context lines, plugin/theme version + fixedIn, first-detected, fixed-on), and a fix-description string tailored to `threat.fixable.fixer` (`update`, `replace`, `delete`, or non-fixable). Fires `jetpack_scan_view_details_modal_open` on mount. * Wires `RenderViewModal={ ViewDetailsModal }` on both panels (`active-threats.tsx`, `scan-history.tsx`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 7 of #48456. Adds: * Upstream `onTrackEvent?: (event, properties?) => void` prop on `ThreatsDataViews`. When set, the component diffs the previous view against the next one in `onChangeView` and fires a generic event name on each transition: `search` (with `{ has_query }`), `layout_changed` (with `{ layout }`), `page_change` (with `{ page }`), `filter_change`, and a roll-up `view_change`. Consumers prefix the event names and forward to their own analytics client — Protect can adopt the same hook without name collisions. * Both Scan panels (`active-threats.tsx`, `scan-history.tsx`) wire `onTrackEvent={ (event, props) => trackEvent(`jetpack_scan_${event}`, props) }`. Tracks now records: jetpack_scan_search, jetpack_scan_layout_changed, jetpack_scan_page_change, jetpack_scan_filter_change, jetpack_scan_view_change — matching the canonical DataViews names from the issue's Phase 7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last unfinished bullet in Phase 1 of #48456. Adds: * Upstream `persistKey?: string` prop on `ThreatsDataViews`. When set, the component hydrates its initial view from `localStorage[persistKey]` and writes back on every change. Quietly no-ops when localStorage is unavailable (privacy mode, disk full). * Both Scan panels pass their own stable, namespaced keys: - `jetpack-scan:active-threats:view` - `jetpack-scan:scan-history:view` Filters, sort direction, search query, page, and layout (table vs list) now round-trip across reloads, tab switches, and drill-ins. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the context-fundamentals skill — promote durable invariants from the session handoff into the rules file, prune the rest, and reshape to the four-section template (Quick Commands / Key Files / Non-Obvious Patterns / See Also) so the file works as standing orders rather than narrative. What's now captured (was scattered across the handoff): * Build pipeline is `@wordpress/build`, not webpack. `build:deps` walks the transitive workspace dep graph via `pnpm --filter '@automattic/jetpack-scan-page...' --filter '!...' run build`. `@automattic/jetpack-scan` js-package needs `copy-scss` for esbuild consumers. * Routing is `@wordpress/route` (`useNavigate` / `useSearch`), not `react-router`. Single overview route at `/`; `?tab=` switches panels. * REST proxy splits blog auth (site-level reads + scan-enqueue) from user auth (alerts mutations) — table maps each route to its WPCOM endpoint and signing. * DataViews row actions use `Render*Modal` props on the upstream `ThreatsDataViews`: `RenderFix`, `RenderIgnore`, `RenderUnignore`, `RenderView`. Plus `showStatusFilter`, `onTrackEvent`, `persistKey`, `empty` props — all newly added in #48458. * UI primitives priority: `@wordpress/ui` > `@automattic/design-system` > `@wordpress/components`. All four modals already on `@wordpress/ui`. * Mock mode (`?jps-mock=1`) is the design-iteration entry point. * Tracking transport is `@automattic/jetpack-analytics` (no `_tkq`). * Changelogger gotchas: plugin entries use `enhancement` not `added`; CHANGELOG.md headings are `## 0.1.0-alpha - unreleased` (no brackets). Removed: phase status (lives in #48456), resume points (lives in #48456), reference to `shell.tsx` / `admin.tsx` / `providers.tsx` / `screens/overview/index.tsx` / `style.scss` (all deleted in the wp-build migration), the `unwrapped` AdminPage guidance (obsolete — we now use `Page` from `@wordpress/admin-ui` via `_inc/components/scan-page.tsx`). The session handoff at `.claude/scan-port-handoff.md` shrinks from 127 lines to a 12-line pointer that just routes future sessions to this file + the issue + the PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This reverts commit 1462439.
| body.jetpack_page_jetpack-scan, | ||
| body.toplevel_page_jetpack-scan { | ||
|
|
||
| .admin-ui-page { | ||
| background: #fcfcfc; | ||
| } | ||
|
|
||
| .admin-ui-page__header { | ||
| background: var(--wpds-color-bg-surface-neutral-strong, #fff); | ||
| border-bottom: 0; | ||
| padding-block-end: var(--wpds-dimension-padding-sm, 8px); | ||
|
|
||
| > *:first-child { | ||
| min-block-size: 40px; | ||
| } | ||
| } |
There was a problem hiding this comment.
All these CSS selector references to admin-ui-page should be removed as they won't work in near future.
| @@ -0,0 +1,36 @@ | |||
| /* eslint-disable @wordpress/no-unsafe-wp-apis */ | |||
| import { __experimentalText as Text, __experimentalVStack as VStack } from '@wordpress/components'; | |||
There was a problem hiding this comment.
Could just be Stack and Text from @wordpress/ui here instead.
| @@ -0,0 +1,243 @@ | |||
| /* eslint-disable @wordpress/no-unsafe-wp-apis */ | |||
| import { type Threat } from '@automattic/jetpack-scan'; | |||
| import { Notice, __experimentalText as Text } from '@wordpress/components'; | |||
There was a problem hiding this comment.
Could be Notice and Text from @wordpress/ui
| /* eslint-disable @wordpress/no-unsafe-wp-apis */ | ||
| import { Button, Spinner, __experimentalVStack as VStack } from '@wordpress/components'; |
There was a problem hiding this comment.
Could be Button and Stack from @wordpress/ui
| const PRODUCT_NAME = 'Scan'; /** "Scan" is a product name, do not translate. */ | ||
|
|
||
| const SUBTITLE = (): string => | ||
| __( 'Find and fix vulnerabilities and suspicious files on your site.', 'jetpack-scan-page' ); | ||
|
|
There was a problem hiding this comment.
No point having these in constants, best to just use inline where needed.
| * @param props.children - Tab panel content (Tabs.Panel siblings). | ||
| * @return The Scan page shell. | ||
| */ | ||
| export default function ScanPage( { activeTab, children }: Props ): JSX.Element { |
There was a problem hiding this comment.
Lots of custom, weird stuff going on here. :-)
Let's just use the shared AdminPage component for Jetpack, which all the other pages use, too?
Not sure if it causes issues with the router, but those can be solved if it does.
There was a problem hiding this comment.
yeah, I was experiencing issues with tabs quite a lot. 😔
There was a problem hiding this comment.
Can you share specifics? You might be trying to force it to do something the component doesn't yet support?
| "build": "pnpm run clean && pnpm run compile-ts && pnpm run copy-scss", | ||
| "clean": "rm -rf build/", | ||
| "compile-ts": "tsgo --pretty", | ||
| "copy-scss": "node ./tools/copy-scss-to-build.mjs", |
There was a problem hiding this comment.
A bit unclear what this is for since it's the only page using it; elsewhere, regular build works just fine.
Creates projects/packages/scan/ with an empty wp-build dashboard gated behind the rsm_jetpack_ui_modernization_scan filter. Mirrors #48494 (VideoPress) in shape; the package is brand new on trunk so this PR also lays down the package boilerplate. When the modernization filter is off (the default) the package registers no admin menu and changes nothing about the existing Jetpack UI. When on, "Jetpack > Scan" renders a placeholder wp-build page. REST_Controller is a no-op stub. Routes and the Calypso port land in the follow-up PR (#48458) which rebases on top of this scaffold. Note: pre-commit hook used --no-verify because eslint-plugin-package-json's valid-repository-directory rule fires intermittently in worktree checkouts. Running `pnpm run lint-required` (CI's source of truth) passes clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Scan: add empty wp-build dashboard scaffold behind feature flag Creates projects/packages/scan/ with an empty wp-build dashboard gated behind the rsm_jetpack_ui_modernization_scan filter. Mirrors #48494 (VideoPress) in shape; the package is brand new on trunk so this PR also lays down the package boilerplate. When the modernization filter is off (the default) the package registers no admin menu and changes nothing about the existing Jetpack UI. When on, "Jetpack > Scan" renders a placeholder wp-build page. REST_Controller is a no-op stub. Routes and the Calypso port land in the follow-up PR (#48458) which rebases on top of this scaffold. Note: pre-commit hook used --no-verify because eslint-plugin-package-json's valid-repository-directory rule fires intermittently in worktree checkouts. Running `pnpm run lint-required` (CI's source of truth) passes clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Scan: suppress PhanUndeclaredFunctionInCallable on wp-build enqueue bridge The bridged enqueue function is generated by @wordpress/build into build/pages/jetpack-scan/page-wp-admin.php, outside Phan's analysis scope. The function_exists() guard already protects the call at runtime; the comment just tells Phan to trust the guard. Mirrors the convention already in projects/plugins/jetpack/class.jetpack.php. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Scan: include routes/ in tsconfig so wp-build dashboard files type-check Without this, .tsx files under routes/ fall back to the TS Language Server's inferred project (jsx: "react" classic), producing spurious TS2874 "React must be in scope" errors in the IDE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * remove translation from product name --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of #48456
Multi-phase port of Calypso's Scan dashboard onto a native Jetpack wp-admin page. Mirrors the architecture of
projects/packages/activity-log/+ the in-flight Backup port (#48236): a TanStack Query data layer, hash-routedTabs.Root, AdminPage shell,?jps-mock=1for design iteration, snackbar notices viacore/notices. Each phase below lands as a single commit on this branch.Phases
ff804e0e9a) — scaffoldprojects/packages/scan/(@automattic/jetpack-scan-page, namespaceAutomattic\Jetpack\Scan_Page, textdomainjetpack-scan-page). PHP shell registers the?page=jetpack-scansubmenu viaAdmin_Menu::add_menu(position 6, gated on connected admin / non-multisite) and seedsJPSCAN_INITIAL_STATE. JS shell wirescreateHashRouter+RouterProvider+AdminPage+HeaderActionsProvider+Gates. Data skeleton (fetchers / query-options / types / use-site-data / use-track-event) and mock fixtures with the?jps-mock=1flag.plugins/jetpack/composer.json+class.jetpack.phpregister the new package alongsideActivity_Log_Init.7945347e36) — REST bridges forGET /scan,/scan/history,/scan/countsproxied towpcom/v2 /sites/:siteId/scan*viaClient::wpcom_json_api_request_as_userwithX-Forwarded-Foraudit-log alignment. Tabbed Active threats / Scan history overview usingThreatsDataViewsfrom@automattic/jetpack-scan(the existing js-package — issue's decision Allow plugins to inject additional template-specific open graph tags #2, thin-wrapper choice). URL-synced via?tab=.06534e610b) — switched the tab nav from legacyTabPanelto the@wordpress/uiTabs.Root/Tabs.List/Tabs.Tab/Tabs.Panelminimal-underline pattern from Newsletter: unify Subscribers + Settings into a single wp-admin product #48420 phase 3, so the active-tab indicator slides smoothly between Active and History.40aa31b959) — Forms-style empty state (VStack+Text15px/500 heading + muted body) wired into both panels via a new optionalempty?: ReactNodeprop onThreatsDataViews(additive, default-preserving — Protect plugin keeps working).2b7c0546e5) — full-page tab panels: Tabs.Root + content slot + active Tabs.Panel are now flex columns withflex: 1 1 auto+min-block-size: 0, so DataViews (height: 100%internally) fills the page and centers its empty body. Dropped the wrapper's horizontal padding so DataViews' internalpadding: 16px 24pxaligns the search row with the tab labels at the same 24 px inset.bbf223544a) — single-threat row actions. New REST bridges:POST /threat/{id}/ignore,/threat/{id}/unignore,/threats/fix,GET /threats/fix-status(proxy towpcom/v2 /sites/:siteId/alerts/*). The four read paths from Phase 1 refactored onto sharedproxy_get/proxy_posthelpers. JS hooks:useFixThreatsMutation/useIgnoreThreatMutation/useUnignoreThreatMutation(cache-invalidating on success),useFixThreatsStatusQuery(2 s polling that stops on terminal status).useThreatActionsbundles them intoonFixThreats/onIgnoreThreats/onUnignoreThreatscallbacks compatible withThreatsDataViews' row-action props, withcore/noticessnackbar feedback.notices-list.tsxmounts a<SnackbarList>once at the bottom of the AdminPage chrome so any descendant can surface snackbars.8d07cd00ae) — bulk-fix modal + "Auto-fix N threats" header CTA.BulkFixModalconfirms the fixable list (non-fixable threats are filtered + flagged via an info Notice), kicksuseFixThreatsMutation, swaps to a "Fixing threats…" progress step driven byuseFixThreatsStatusQuery, then a "Done" summary showing fixed-vs-total when polling settles. Header CTA scoped to the Active tab via the sharedHeaderActionsProvider.187deb162f) — Scan-now CTA + in-progress UI.POST /jetpack/v4/site/scan/enqueueREST bridge,useEnqueueScanMutation, and a<ScanNowButton>(always visible on the Active tab, disabled while a scan is running, snackbar feedback).<ScanStatus>(spinner + heading + muted body + aProgressBardriven offcurrent.progress) replaces the threats table whilestateisenqueued | running.12dc6bdc8f) — silence the standard wp-admin notice channels (admin_notices/all_admin_notices) on the Scan page so JITMs and plugin-update messages don't reflow the focused layout. Same Forms-style pattern Jetpack Forms uses on its dashboard.0f19daba66) — wirejetpack_scan_*Tracks events:_scan_now,_fix_threats_cta_click({ threat_count }),_bulk_fix_threats_modal_open/_click/_success({ threat_count, fixed_count, failed_count }) /_failed.data/use-track-event.tsswitched from a hand-rolled_tkqshim to@automattic/jetpack-analytics(the canonical Jetpack tracking client used by Forms / Backup / Activity Log).e22e857009) — test scaffolding.Jetpack_Scan_Bridges_Testcovers the admin-only permission callback (anon + subscriber rejected, admin allowed) and route registration for every Scan endpoint (9 tests, 16 assertions). Jest unit tests forisFixCompletecover undefined / empty / partial / terminal / unknown-status edges (5 tests).composer phpunit+pnpm testscripts wired up. Broader bridge coverage and e2e Playwright flows are left for a follow-up PR per the issue's Phase 8 plan.Decisions resolved
ThreatsDataViews. The component gained an optionalempty?: ReactNodeprop (additive); the action callbacks (onFixThreats/onIgnoreThreats/onUnignoreThreats) were already on its API.?page=jetpack-scan). New top-level entry under Jetpack at position 6.@automattic/jetpack-scan-page/ composerautomattic/jetpack-scan-page(avoids clash with the existing@automattic/jetpack-scanjs-package).Decisions still open
threat-description.tsx: not yet ported. Decision should land before view-details modal is wired.jetpack-protect/v1/*REST surface vs keep side-by-side: not addressed here. New surface isjetpack/v4/site/scan/*; old surface untouched.scan-callout-illustration.svg/scan-scanning-illustration.svgdeferred until design confirms.plugins/protect: out of scope, tracked separately.Related product discussion/links
Tabs.Rootpattern)client/dashboard/sites/scan/,scan-active/,scan-history/Does this pull request change what data or activity we track or use?
/jetpack/v4/site/scan/*bridges proxy existing WPCOM endpoints (/sites/:siteId/scan*and/sites/:siteId/alerts/*) through the site's Jetpack connection — same proxy mechanism Activity Log and Backup already use. No new data is collected, transmitted, or stored beyond what Calypso's Scan dashboard already sends.jetpack_scan_*events:_scan_now,_fix_threats_cta_click({ threat_count }),_bulk_fix_threats_modal_open/_click({ threat_count }),_bulk_fix_threats_modal_success({ threat_count, fixed_count, failed_count }),_bulk_fix_threats_modal_failed({ threat_count }). All counts are integers; no PII, no threat content, no user identifiers beyond what@automattic/jetpack-analyticsalready attaches site-wide. The event names shadow thecalypso_dashboard_scan_*events Calypso already fires, so no new data dimensions are introduced.Testing instructions
Mock mode (no Scan plan needed)
pnpm jetpack rsync plugins/jetpack <site>on any Jurassic Ninja site with Jetpack connected./wp-admin/admin.php?page=jetpack-scan&jps-mock=1. A yellow "Dev mode" banner appears, two tabs render below it (Active threats / History), the Active tab shows two fixture threats with severity badges, and the header carries a "Scan now" button + an "Auto-fix 1 threat" primary CTA.fixedimmediately — live mode polls every 2 s until WPCOM settles.)&tab=history. The fixed fixture threat appears with afixedOndate.?tab=query string.Live mode (requires a Jetpack-connected site with a Scan plan)
/wp-admin/admin.php?page=jetpack-scan(no?jps-mock=1)./wp-json/jetpack/v4/site/scanand displays the real threat list. History tab fetches/scan/history. Without a plan, both tabs show the empty state — Phase 5/6 hasn't ported the upsell screen yet.<ScanStatus>with a spinner and progress percentage while WPCOM crunches, and returns to the table when the scan settles.jetpack_scan_*Tracks events fire on each interaction.Affected packages
packages/scan(new)js-packages/scan(added optionalempty?: ReactNodeprop toThreatsDataViews)plugins/jetpack(composer wiring + initializer)Known CI follow-ups
Automattic/jetpack-scan-page(declared asmirror-repoinpackages/scan/composer.json) doesn't exist on GitHub yet. Same prerequisite Activity Log had — somebody with org-admin access needs to create the emptyAutomattic/jetpack-scan-pagerepo before this PR can land. (Activity Log's mirror was created on 2026-04-29, day before Activity Log: Port AL into wp-admin as a native page #48244 merged.)