Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Protect dashboard: Tell the Monitor card's failures apart, with a link to connect a WordPress.com account and a way to retry.
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import apiFetch from '@wordpress/api-fetch';
import { getSettings, gmdateI18n } from '@wordpress/date';
import { useEffect, useState } from '@wordpress/element';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
import { seen } from '@wordpress/icons';
import { Skeleton, Stack, Text, VisuallyHidden } from '@wordpress/ui';
import { Button, Link, Skeleton, Stack, Text, VisuallyHidden } from '@wordpress/ui';
import { CardRow, ProtectCard } from '../../components/card';
import TabLink from '../../components/tab-link';
import isModuleActive from '../../data/is-module-active';
import type { MonitorContext, Uptime, UptimeDay } from './types';
import type { CardStatus } from '../../components/card';
import './style.scss';

const UPTIME_PATH = '/jetpack/v4/protect-dashboard/uptime';
const CONNECT_URL = 'admin.php?page=my-jetpack#/connection';

/**
* Text for one day's bar.
*
Expand Down Expand Up @@ -46,13 +49,15 @@ function describeDay( day: UptimeDay ): string {
*
* @param available - Whether Monitor can run on this site.
* @param active - Whether Monitor is on.
* @param connected - Whether the user has a WordPress.com connection.
* @param uptime - The uptime response, once loaded.
* @param failed - Whether loading it failed.
* @return The badge, if any.
*/
function getStatus(
available: boolean,
active: boolean,
connected: boolean,
uptime: Uptime | null,
failed: boolean
): CardStatus | undefined {
Expand All @@ -62,6 +67,9 @@ function getStatus(
if ( ! active ) {
return { label: __( 'Off', 'jetpack-protect-pkg' ), intent: 'draft' };
}
if ( ! connected ) {
return { label: __( 'On', 'jetpack-protect-pkg' ), intent: 'none' };
}
if ( uptime?.isUp === true ) {
return { label: __( 'Operational', 'jetpack-protect-pkg' ), intent: 'stable' };
}
Expand Down Expand Up @@ -90,23 +98,36 @@ export default function MonitorCard( { state: monitor, settings, openTab }: Moni
available &&
! settings.isSaving( 'monitor' ) &&
isModuleActive( settings.settings, 'monitor', Boolean( monitor?.active ) );
const { refresh } = settings;
const [ uptime, setUptime ] = useState< Uptime | null >( null );
const [ failed, setFailed ] = useState( false );
const [ error, setError ] = useState< string | null >( null );
const [ retries, setRetries ] = useState( 0 );
const connected = Boolean( monitor?.userConnected ) && error !== 'not_connected';
const failed = connected && error !== null;
const canLoad = active && Boolean( monitor?.userConnected );
const retry = useCallback( () => setRetries( count => count + 1 ), [] );

useEffect( () => {
if ( ! active ) {
if ( ! canLoad ) {
return;
}
let current = true;
setUptime( null );
setFailed( false );
apiFetch< Uptime >( { path: '/jetpack/v4/protect-dashboard/uptime' } )
setError( null );
// A retry skips the failure the server remembers for a minute.
apiFetch< Uptime >( { path: retries ? `${ UPTIME_PATH }?retry=1` : UPTIME_PATH } )
.then( response => current && setUptime( response ) )
.catch( () => current && setFailed( true ) );
.catch( async ( e: { code?: string } ) => {
// Monitor was turned off elsewhere: re-read it so the card and its toggle show Off.
if ( e?.code === 'monitor_inactive' ) {
await refresh( [ 'monitor' ] );
}
return current && setError( e?.code ?? 'uptime_unavailable' );
} );
return () => {
current = false;
};
}, [ active ] );
}, [ canLoad, retries, refresh ] );

const days = uptime?.days;
const count = ( status: UptimeDay[ 'status' ] ) =>
Expand Down Expand Up @@ -144,26 +165,48 @@ export default function MonitorCard( { state: monitor, settings, openTab }: Moni
<ProtectCard
icon={ seen }
title={ __( 'Monitor', 'jetpack-protect-pkg' ) }
status={ getStatus( available, active, uptime, failed ) }
status={ getStatus( available, active, connected, uptime, failed ) }
>
<CardRow>
{ ! active && <Text variant="body-md">{ body }</Text> }
{ active && (
<Stack direction="column" gap="md">
<Text variant="body-md">
{ sprintf(
/* translators: %d is a number of days. */
__( 'Uptime, last %d days (UTC)', 'jetpack-protect-pkg' ),
days?.length ?? monitor?.uptimeDays ?? 0
) }
</Text>
{ failed && (
{ connected && (
<Text variant="body-md">
{ __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ) }
{ sprintf(
/* translators: %d is a number of days. */
__( 'Uptime, last %d days (UTC)', 'jetpack-protect-pkg' ),
days?.length ?? monitor?.uptimeDays ?? 0
) }
</Text>
) }
{ ! failed && ! days && <Skeleton className="jp-protect-uptime__skeleton" /> }
{ ! failed && days && (
{ ! connected && (
<>
<Text variant="body-md">
{ __(
'Your site is being monitored. Connect your WordPress.com account to see its uptime history.',
'jetpack-protect-pkg'
) }
</Text>
<Link href={ CONNECT_URL }>
{ __( 'Connect your account', 'jetpack-protect-pkg' ) }
</Link>
</>
) }
{ failed && (
<Stack direction="row" gap="sm" align="center">
<Text variant="body-md">
{ __( 'Uptime history is unavailable right now.', 'jetpack-protect-pkg' ) }
</Text>
<Button variant="minimal" size="compact" onClick={ retry }>
{ __( 'Try again', 'jetpack-protect-pkg' ) }
</Button>
</Stack>
) }
{ connected && ! failed && ! days && (
<Skeleton className="jp-protect-uptime__skeleton" />
) }
{ connected && ! failed && days && (
<>
<div className="jp-protect-uptime" aria-hidden="true">
{ days.map( day => (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import apiFetch from '@wordpress/api-fetch';
import { useCallback, useState } from '@wordpress/element';
import MonitorCard from '../monitor-card';
import type { ProtectSettingsData } from '../../../data/use-protect-settings';
import type { ProtectSettings, ProtectSettingsData } from '../../../data/use-protect-settings';
import type { MonitorState, UptimeDay } from '../types';

jest.mock( '@wordpress/api-fetch', () => ( { __esModule: true, default: jest.fn() } ) );
Expand All @@ -21,7 +23,14 @@ const renderCard = (
render(
<MonitorCard
state={ { available: true, active: true, uptimeDays: 40, userConnected: true, ...state } }
settings={ { settings: null, isSaving: () => false, ...settings } as ProtectSettingsData }
settings={
{
settings: null,
isSaving: () => false,
refresh: jest.fn(),
...settings,
} as ProtectSettingsData
}
openTab={ jest.fn() }
/>
);
Expand Down Expand Up @@ -61,15 +70,64 @@ describe( 'MonitorCard', () => {
expect( mockApiFetch ).not.toHaveBeenCalled();
} );

it( 'says the history is unavailable when it cannot be loaded', async () => {
mockApiFetch.mockRejectedValue( { code: 'not_connected' } );
it( 'offers a retry that skips the remembered failure when WordPress.com fails', async () => {
mockApiFetch.mockRejectedValueOnce( { code: 'uptime_unavailable' } );
mockApiFetch.mockResolvedValue( { days, isUp: true } );
renderCard();

await expect(
screen.findByText( 'Uptime history is unavailable right now.' )
).resolves.toBeInTheDocument();
expect( screen.getByText( 'Status unknown' ) ).toBeInTheDocument();
expect( screen.queryByRole( 'listitem' ) ).not.toBeInTheDocument();
await userEvent.click( screen.getByRole( 'button', { name: 'Try again' } ) );

await expect( screen.findByText( 'Operational' ) ).resolves.toBeInTheDocument();
expect( mockApiFetch ).toHaveBeenLastCalledWith( {
path: '/jetpack/v4/protect-dashboard/uptime?retry=1',
} );
} );

it.each( [
[ 'at page load', { userConnected: false }, 0 ],
[ 'since page load', {}, 1 ],
] )(
'points a user who is not connected %s to the connection page',
async ( _name, state, requests ) => {
mockApiFetch.mockRejectedValue( { code: 'not_connected' } );
renderCard( {}, state );

await expect(
screen.findByRole( 'link', { name: 'Connect your account' } )
).resolves.toHaveAttribute( 'href', 'admin.php?page=my-jetpack#/connection' );
expect( screen.getByText( 'On' ) ).toBeInTheDocument();
expect( screen.queryByText( /^Uptime, last/ ) ).not.toBeInTheDocument();
expect( screen.queryByRole( 'button', { name: 'Try again' } ) ).not.toBeInTheDocument();
expect( mockApiFetch ).toHaveBeenCalledTimes( requests );
}
);

it( 'shows Off when the server says Monitor was turned off elsewhere', async () => {
mockApiFetch.mockRejectedValue( { code: 'monitor_inactive' } );
const Card = () => {
const [ live, setLive ] = useState< ProtectSettings | null >( null );
const refresh = useCallback( async () => setLive( { monitor: false } ), [] );
return (
<MonitorCard
state={ { available: true, active: true, uptimeDays: 40, userConnected: true } }
settings={
{ settings: live, isSaving: () => false, refresh } as unknown as ProtectSettingsData
}
openTab={ jest.fn() }
/>
);
};
render( <Card /> );

await expect( screen.findByText( 'Off' ) ).resolves.toBeInTheDocument();
expect( screen.getByText( 'Turn on in Settings' ) ).toBeInTheDocument();
expect(
screen.queryByText( 'Uptime history is unavailable right now.' )
).not.toBeInTheDocument();
} );

it( 'waits for a Monitor save to finish before asking for uptime', () => {
Expand Down
13 changes: 11 additions & 2 deletions projects/packages/protect/src/sections/class-monitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Automattic\Jetpack\Protect\Dashboard_Section;
use Jetpack_Options;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;

if ( ! defined( 'ABSPATH' ) ) {
Expand Down Expand Up @@ -85,16 +86,24 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_uptime' ),
'permission_callback' => array( Dashboard::class, 'can_manage' ),
'args' => array(
'retry' => array(
'description' => __( 'Ask WordPress.com again, even if it failed in the last minute.', 'jetpack-protect-pkg' ),
'type' => 'boolean',
'default' => false,
),
),
)
);
}

/**
* Daily uptime for the last UPTIME_DAYS days (oldest first) and the current status, from WordPress.com.
*
* @param WP_REST_Request|null $request The request; its `retry` flag skips a remembered failure.
* @return array|WP_Error
*/
public function get_uptime() {
public function get_uptime( $request = null ) {
if ( ! Dashboard::get_module_state( 'monitor' )['active'] ) {
return new WP_Error( 'monitor_inactive', __( 'Downtime monitoring is off.', 'jetpack-protect-pkg' ), array( 'status' => 409 ) );
}
Expand All @@ -108,7 +117,7 @@ public function get_uptime() {
if ( is_array( $cached ) ) {
return $cached;
}
if ( 'failed' === $cached ) {
if ( 'failed' === $cached && ! ( $request && $request['retry'] ) ) {
return $this->unavailable_error();
}

Expand Down
12 changes: 12 additions & 0 deletions projects/packages/protect/tests/php/Monitor_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,18 @@ public function test_get_uptime_is_a_502_when_the_history_is_unusable_and_caches
$this->assertCount( $requests, $this->requests );
}

public function test_get_uptime_asks_wpcom_again_on_a_retry_despite_a_cached_failure() {
$this->uptime_response = self::json_response( array(), 500 );
$monitor = new Monitor();
$monitor->get_uptime();
$this->uptime_response = self::json_response( array( '2026-10-01' => array( 'status' => 'up' ) ) );
$request = new \WP_REST_Request( 'GET', '/jetpack/v4/protect-dashboard/uptime' );
$request->set_param( 'retry', true );

$this->assertInstanceOf( WP_Error::class, $monitor->get_uptime() );
$this->assertCount( 1, $monitor->get_uptime( $request )['days'] );
}

public function test_get_uptime_refuses_a_disconnected_user_even_with_a_warm_cache() {
$monitor = new Monitor();
$monitor->get_uptime();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: other

Protect: Tell the Monitor card's failures apart on the Protect dashboard, with a link to connect a WordPress.com account and a way to retry.
Loading