Skip to content
Draft
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
46 changes: 1 addition & 45 deletions apps/admin/frontend/src/components/navigation_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@ import React, { useContext } from 'react';

import {
BatteryStatus,
Button,
DateTimeDisplay,
IconName,
Icons,
Toolbar,
LockMachineButton,
UsbEjectButton,
MainHeader,
MainContent,
Screen,
Expand All @@ -17,7 +16,6 @@ import {
Route,
Breadcrumbs,
} from '@votingworks/ui';
import type { UsbDriveStatus } from '@votingworks/usb-drive';
import {
BooleanEnvironmentVariableName,
isElectionManagerAuth,
Expand Down Expand Up @@ -166,48 +164,6 @@ function shouldShowToolbar(
}
}

const ToolbarButton = styled(Button)`
font-size: 0.8rem;
padding: 0.25rem 0.75rem;
`;

type ExtendedUsbDriveStatus = UsbDriveStatus['status'] | 'ejecting';
const USB_BUTTON_ICON_AND_TEXT: Record<
ExtendedUsbDriveStatus,
[IconName, string]
> = {
no_drive: ['Disabled', 'No USB'],
error: ['Disabled', 'No USB'],
mounted: ['Eject', 'Eject USB'],
ejecting: ['Eject', 'Ejecting...'],
ejected: ['Disabled', 'USB Ejected'],
};

function UsbEjectButton({
usbDriveStatus,
onEject,
isEjecting,
}: {
usbDriveStatus: UsbDriveStatus;
onEject: () => void;
isEjecting: boolean;
}): JSX.Element {
const extendedStatus: ExtendedUsbDriveStatus = isEjecting
? 'ejecting'
: usbDriveStatus.status;
const [icon, text] = USB_BUTTON_ICON_AND_TEXT[extendedStatus];
return (
<ToolbarButton
icon={icon}
onPress={onEject}
color="inverseNeutral"
disabled={extendedStatus !== 'mounted' || isEjecting}
>
{text}
</ToolbarButton>
);
}

export const Header = styled(MainHeader)`
display: flex;
align-items: center;
Expand Down
21 changes: 17 additions & 4 deletions apps/central-scan/frontend/src/components/network_section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,31 @@ import { NetworkSection } from './network_section.js';
const testCases: Array<{
connection: NetworkConnectionInfo;
expectedText: string;
expectedIcon: string;
}> = [
{
connection: { status: 'offline' },
expectedText: 'Offline',
expectedIcon: 'triangle-exclamation',
},
{
connection: { status: 'online-waiting-for-host' },
expectedText: 'Online — no VxAdmin detected on the network',
expectedIcon: 'triangle-exclamation',
},
{
connection: { status: 'online-multiple-hosts-detected' },
expectedText:
'Multiple VxAdmins detected on the network. Ensure only one VxAdmin is connected.',
expectedIcon: 'circle-exclamation',
},
{
connection: {
status: 'online-code-version-mismatch',
hostMachineId: '0002',
},
expectedText: 'VxAdmin (0002) is running a different software version',
expectedIcon: 'circle-exclamation',
},
{
connection: {
Expand All @@ -34,34 +39,42 @@ const testCases: Array<{
},
expectedText:
'VxAdmin (0002) detected on the network. Configure this machine with an election to connect.',
expectedIcon: 'triangle-exclamation',
},
{
connection: { status: 'online-host-unconfigured', hostMachineId: '0002' },
expectedText:
'VxAdmin (0002) detected on the network, but it is not configured with an election.',
expectedIcon: 'triangle-exclamation',
},
{
connection: {
status: 'online-ballot-hash-mismatch',
hostMachineId: '0002',
},
expectedText: 'VxAdmin (0002) is configured for a different election',
expectedIcon: 'triangle-exclamation',
},
{
connection: { status: 'online-host-detected', hostMachineId: '0002' },
expectedText: 'Online — VxAdmin (0002) detected on the network',
expectedIcon: 'square-check',
},
];

test.each(testCases)(
'renders $connection.status',
({ connection, expectedText }) => {
({ connection, expectedText, expectedIcon }) => {
const { unmount } = render(<NetworkSection connection={connection} />);
screen.getByText('Network');
const message = screen.getByText(
(_, element) => element?.textContent?.trim() === expectedText
);
expect(message).toBeInTheDocument();
// The icon severity matches the top-bar network status indicator's
// bucket for this status
expect(
screen.getByText(
(_, element) => element?.textContent?.trim() === expectedText
)
message.querySelector(`[data-icon='${expectedIcon}']`)
).toBeInTheDocument();
unmount();
}
Expand Down
18 changes: 10 additions & 8 deletions apps/central-scan/frontend/src/components/network_section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,43 @@ function ConnectionStatusMessage({
case 'offline':
return (
<P>
<Icons.Info /> Offline
<Icons.Warning color="warning" /> Offline
</P>
);
case 'online-waiting-for-host':
return (
<P>
<Icons.Info /> Online &mdash; no VxAdmin detected on the network
<Icons.Warning color="warning" /> Online &mdash; no VxAdmin detected
on the network
</P>
);
case 'online-multiple-hosts-detected':
return (
<P>
<Icons.Warning color="warning" /> Multiple VxAdmins detected on the
<Icons.Danger color="danger" /> Multiple VxAdmins detected on the
network. Ensure only one VxAdmin is connected.
</P>
);
case 'online-code-version-mismatch':
return (
<P>
<Icons.Warning color="warning" /> VxAdmin ({connection.hostMachineId})
<Icons.Danger color="danger" /> VxAdmin ({connection.hostMachineId})
is running a different software version
</P>
);
case 'online-machine-unconfigured':
return (
<P>
<Icons.Info /> VxAdmin ({connection.hostMachineId}) detected on the
network. Configure this machine with an election to connect.
<Icons.Warning color="warning" /> VxAdmin ({connection.hostMachineId})
detected on the network. Configure this machine with an election to
connect.
</P>
);
case 'online-host-unconfigured':
return (
<P>
<Icons.Info /> VxAdmin ({connection.hostMachineId}) detected on the
network, but it is not configured with an election.
<Icons.Warning color="warning" /> VxAdmin ({connection.hostMachineId})
detected on the network, but it is not configured with an election.
</P>
);
case 'online-ballot-hash-mismatch':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { expect, test } from 'vitest';
import { createMemoryHistory } from 'history';
import userEvent from '@testing-library/user-event';
import type { NetworkConnectionInfo } from '@votingworks/central-scan-backend';
import { renderInAppContext } from '../../test/render_in_app_context.js';
import { createApiMock } from '../../test/api.js';
import { screen, waitFor } from '../../test/react_testing_library.js';
import { NetworkStatusIndicator } from './network_status_indicator.js';

test('renders nothing when networking is disabled', async () => {
const apiMock = createApiMock();
const { container } = renderInAppContext(<NetworkStatusIndicator />, {
apiMock,
});
await waitFor(() =>
expect(apiMock.apiClient.getNetworkStatus).toHaveBeenCalled()
);
expect(container).toBeEmptyDOMElement();
});

const testCases: Array<{
connection: NetworkConnectionInfo;
expectedLabel: string;
expectedTreatment: 'connected' | 'warning' | 'error';
}> = [
{
connection: { status: 'offline' },
expectedLabel: 'No Network',
expectedTreatment: 'warning',
},
{
connection: { status: 'online-waiting-for-host' },
expectedLabel: 'No VxAdmin Connected',
expectedTreatment: 'warning',
},
{
connection: {
status: 'online-machine-unconfigured',
hostMachineId: '0002',
},
expectedLabel: 'No VxAdmin Connected',
expectedTreatment: 'warning',
},
{
connection: { status: 'online-host-unconfigured', hostMachineId: '0002' },
expectedLabel: 'No VxAdmin Connected',
expectedTreatment: 'warning',
},
{
connection: {
status: 'online-ballot-hash-mismatch',
hostMachineId: '0002',
},
expectedLabel: 'No VxAdmin Connected',
expectedTreatment: 'warning',
},
{
connection: { status: 'online-multiple-hosts-detected' },
expectedLabel: 'Network Error',
expectedTreatment: 'error',
},
{
connection: {
status: 'online-code-version-mismatch',
hostMachineId: '0002',
},
expectedLabel: 'Network Error',
expectedTreatment: 'error',
},
{
connection: { status: 'online-host-detected', hostMachineId: '0002' },
expectedLabel: 'Connected',
expectedTreatment: 'connected',
},
];

test.each(testCases)(
'renders $connection.status',
async ({ connection, expectedLabel, expectedTreatment }) => {
const apiMock = createApiMock();
apiMock.setNetworkStatus({ isEnabled: true, connection });
const { unmount } = renderInAppContext(<NetworkStatusIndicator />, {
apiMock,
});
const indicator = await screen.findByTestId('network-status');
expect(indicator).toHaveTextContent(expectedLabel);
switch (expectedTreatment) {
// Connected states show the plain network icon
case 'connected':
expect(
indicator.querySelector(`[data-icon='sitemap']`)
).toBeInTheDocument();
expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(1);
expect(
indicator.querySelector(`[data-testid='network-off-icon']`)
).not.toBeInTheDocument();
break;
// Warning states show the slashed network icon
case 'warning':
expect(
indicator.querySelector(`[data-testid='network-off-icon']`)
).toBeInTheDocument();
expect(indicator.querySelectorAll('[data-icon]')).toHaveLength(0);
break;
// Error states show the slashed network icon with a danger icon next
// to it
case 'error':
expect(
indicator.querySelector(`[data-testid='network-off-icon']`)
).toBeInTheDocument();
expect(
indicator.querySelector(`[data-icon='circle-exclamation']`)
).toBeInTheDocument();
break;
default:
throw new Error('unreachable');
}
unmount();
}
);

test('clicking the status navigates to the diagnostics page', async () => {
const apiMock = createApiMock();
apiMock.setNetworkStatus({
isEnabled: true,
connection: { status: 'online-host-detected', hostMachineId: '0002' },
});
const history = createMemoryHistory();
renderInAppContext(<NetworkStatusIndicator />, { apiMock, history });
userEvent.click(await screen.findByTestId('network-status'));
expect(history.location.pathname).toEqual('/hardware-diagnostics');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { throwIllegalValue } from '@votingworks/basics';
import type { NetworkConnectionInfo } from '@votingworks/central-scan-backend';
import {
NetworkIndicatorStatus,
NetworkStatusIndicator as NetworkStatusIndicatorView,
} from '@votingworks/ui';
import { useHistory } from 'react-router-dom';
import { getNetworkStatus } from '../api.js';

function indicatorStatus(
connection: NetworkConnectionInfo
): NetworkIndicatorStatus {
const { status } = connection;
switch (status) {
case 'online-host-detected':
return 'connected';
case 'offline':
return 'no-network';
case 'online-waiting-for-host':
case 'online-machine-unconfigured':
case 'online-host-unconfigured':
case 'online-ballot-hash-mismatch':
return 'no-host-connected';
case 'online-multiple-hosts-detected':
case 'online-code-version-mismatch':
return 'error';
// istanbul ignore next -- compile-time check
default:
return throwIllegalValue(status);
}
}

export function NetworkStatusIndicator(): JSX.Element | null {
const history = useHistory();
const networkStatusQuery = getNetworkStatus.useQuery();
if (!networkStatusQuery.isSuccess || !networkStatusQuery.data.isEnabled) {
return null;
}

return (
<NetworkStatusIndicatorView
status={indicatorStatus(networkStatusQuery.data.connection)}
onPress={() => history.push('/hardware-diagnostics')}
/>
);
}
Loading