- Online — no VxAdmin detected on the network
+ Online — no VxAdmin detected
+ on the network
);
case 'online-multiple-hosts-detected':
return (
- Multiple VxAdmins detected on the
+ Multiple VxAdmins detected on the
network. Ensure only one VxAdmin is connected.
);
case 'online-code-version-mismatch':
return (
- VxAdmin ({connection.hostMachineId})
+ VxAdmin ({connection.hostMachineId})
is running a different software version
);
case 'online-machine-unconfigured':
return (
- VxAdmin ({connection.hostMachineId}) detected on the
- network. Configure this machine with an election to connect.
+ VxAdmin ({connection.hostMachineId})
+ detected on the network. Configure this machine with an election to
+ connect.
);
case 'online-host-unconfigured':
return (
- VxAdmin ({connection.hostMachineId}) detected on the
- network, but it is not configured with an election.
+ VxAdmin ({connection.hostMachineId})
+ detected on the network, but it is not configured with an election.
);
case 'online-ballot-hash-mismatch':
diff --git a/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx b/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx
new file mode 100644
index 00000000000..286b0ddceef
--- /dev/null
+++ b/apps/central-scan/frontend/src/components/network_status_indicator.test.tsx
@@ -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(, {
+ 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(, {
+ 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(, { apiMock, history });
+ userEvent.click(await screen.findByTestId('network-status'));
+ expect(history.location.pathname).toEqual('/hardware-diagnostics');
+});
diff --git a/apps/central-scan/frontend/src/components/network_status_indicator.tsx b/apps/central-scan/frontend/src/components/network_status_indicator.tsx
new file mode 100644
index 00000000000..5c52c4d1fe6
--- /dev/null
+++ b/apps/central-scan/frontend/src/components/network_status_indicator.tsx
@@ -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 (
+ history.push('/hardware-diagnostics')}
+ />
+ );
+}
diff --git a/apps/central-scan/frontend/src/navigation_screen.tsx b/apps/central-scan/frontend/src/navigation_screen.tsx
index 2e3be0cfeb1..2b0c37d8891 100644
--- a/apps/central-scan/frontend/src/navigation_screen.tsx
+++ b/apps/central-scan/frontend/src/navigation_screen.tsx
@@ -1,9 +1,10 @@
import {
AppLogo,
- BatteryDisplay,
- Button,
+ BatteryStatus,
+ DateTimeDisplay,
H1,
LeftNav,
+ LockMachineButton,
Main,
MainContent,
MainHeader,
@@ -13,7 +14,8 @@ import {
Screen,
SessionTimeLimitTimer,
TestModeBanner,
- UsbControllerButton,
+ Toolbar,
+ UsbEjectButton,
VerticalElectionInfoBar,
} from '@votingworks/ui';
import styled from 'styled-components';
@@ -25,7 +27,8 @@ import {
import { DippedSmartCardAuth, ElectionDefinition } from '@votingworks/types';
import { Link, useRouteMatch } from 'react-router-dom';
import { AppContext } from './contexts/app_context.js';
-import { ejectUsbDrive, logOut } from './api.js';
+import { ejectUsbDrive, logOut, systemCallApi } from './api.js';
+import { NetworkStatusIndicator } from './components/network_status_indicator.js';
interface Props {
children: React.ReactNode;
@@ -39,13 +42,6 @@ export const Header = styled(MainHeader)`
gap: 0.5rem;
`;
-const HeaderActions = styled.div`
- display: flex;
- gap: 0.5rem;
- align-items: center;
- flex-shrink: 0;
-`;
-
// Because the VxCentralScan is such a long app name, we have to resize the app
// name and logo image to fit in the left nav
const CentralScanAppLogo = styled(AppLogo)`
@@ -98,8 +94,11 @@ export function NavigationScreen({ children, title }: Props): JSX.Element {
} = useContext(AppContext);
const logOutMutation = logOut.useMutation();
const ejectUsbDriveMutation = ejectUsbDrive.useMutation();
+ const batteryInfoQuery = systemCallApi.getBatteryInfo.useQuery();
const currentRoute = useRouteMatch();
const navItems = getNavItems(auth, electionDefinition);
+ const showToolbar =
+ isSystemAdministratorAuth(auth) || isElectionManagerAuth(auth);
function isActivePath(path: string): boolean {
return currentRoute.path.startsWith(path);
@@ -132,28 +131,27 @@ export function NavigationScreen({ children, title }: Props): JSX.Element {
+ {showToolbar && (
+
+
+ {batteryInfoQuery.isSuccess && batteryInfoQuery.data && (
+
+ )}
+
+ ejectUsbDriveMutation.mutate()}
+ isEjecting={ejectUsbDriveMutation.isLoading}
+ />
+ logOutMutation.mutate()} />
+
+ )}
{isTestMode && isElectionManagerAuth(auth) && electionDefinition && (
)}
{title}
-
- {(isSystemAdministratorAuth(auth) ||
- isElectionManagerAuth(auth)) && (
-
- ejectUsbDriveMutation.mutate()}
- usbDriveStatus={usbDriveStatus}
- usbDriveIsEjecting={ejectUsbDriveMutation.isLoading}
- />
-
-
-
- )}
- {children}
diff --git a/apps/central-scan/frontend/test/api.tsx b/apps/central-scan/frontend/test/api.tsx
index d11b90ab9d0..39ee141f2ce 100644
--- a/apps/central-scan/frontend/test/api.tsx
+++ b/apps/central-scan/frontend/test/api.tsx
@@ -32,12 +32,13 @@ import { screen } from './react_testing_library.js';
export type MockApiClient = Omit<
MockClient,
- 'getBatteryInfo' | 'getDiskSpaceSummary'
+ 'getBatteryInfo' | 'getDiskSpaceSummary' | 'getNetworkStatus'
> & {
// Because these are polled so frequently, we opt for a standard vitest mock instead of a
// libs/test-utils mock since the latter requires every call to be explicitly mocked
getBatteryInfo: Mock;
getDiskSpaceSummary: Mock;
+ getNetworkStatus: Mock;
};
export function createMockApiClient(): MockApiClient {
@@ -47,6 +48,12 @@ export function createMockApiClient(): MockApiClient {
(mockApiClient.getBatteryInfo as unknown as Mock) = vi.fn(() =>
Promise.resolve({ level: 1, discharging: false })
);
+ (mockApiClient.getNetworkStatus as unknown as Mock) = vi.fn(() =>
+ Promise.resolve({
+ isEnabled: false,
+ connection: { status: 'offline' },
+ })
+ );
(mockApiClient.getDiskSpaceSummary as unknown as Mock) = vi.fn(() =>
Promise.resolve({ total: 3, used: 2, available: 1 })
);
@@ -119,9 +126,7 @@ export function createApiMock(
connection: { status: 'offline' },
}
) {
- apiClient.getNetworkStatus
- .expectRepeatedCallsWith()
- .resolves(networkStatus);
+ apiClient.getNetworkStatus.mockResolvedValue(networkStatus);
},
expectGetElectionRecord(electionDefinition: ElectionDefinition | null) {
diff --git a/libs/types/src/ui_theme.ts b/libs/types/src/ui_theme.ts
index cb4b856f106..070396b99dd 100644
--- a/libs/types/src/ui_theme.ts
+++ b/libs/types/src/ui_theme.ts
@@ -78,6 +78,7 @@ export interface ColorTheme {
readonly inverseContainer: ColorString;
readonly inversePrimary: ColorString;
readonly inverseWarningAccent: ColorString;
+ readonly inverseDangerAccent: ColorString;
readonly dangerAccent: ColorString;
readonly warningAccent: ColorString;
diff --git a/libs/ui/src/icons.test.tsx b/libs/ui/src/icons.test.tsx
index 5c5f08710f2..ce94986e52e 100644
--- a/libs/ui/src/icons.test.tsx
+++ b/libs/ui/src/icons.test.tsx
@@ -28,6 +28,7 @@ test(`Icon renders with color`, () => {
inverse: theme.colors.onInverse,
inversePrimary: theme.colors.inversePrimary,
inverseWarning: theme.colors.inverseWarningAccent,
+ inverseDanger: theme.colors.inverseDangerAccent,
};
for (const [color, expectedColor] of Object.entries(expectedColors)) {
diff --git a/libs/ui/src/icons.tsx b/libs/ui/src/icons.tsx
index 348ec4fc310..90ea6296d2b 100644
--- a/libs/ui/src/icons.tsx
+++ b/libs/ui/src/icons.tsx
@@ -123,6 +123,7 @@ export const ICON_COLORS = [
'inverse',
'inversePrimary',
'inverseWarning',
+ 'inverseDanger',
] as const;
export type IconColor = (typeof ICON_COLORS)[number];
@@ -171,6 +172,7 @@ function iconColor(theme: UiTheme, color?: IconColor) {
inverse: colors.onInverse,
inversePrimary: colors.inversePrimary,
inverseWarning: colors.inverseWarningAccent,
+ inverseDanger: colors.inverseDangerAccent,
default: undefined,
}[color];
}
diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts
index 667d13eaa77..5e1f5d3e23f 100644
--- a/libs/ui/src/index.ts
+++ b/libs/ui/src/index.ts
@@ -128,6 +128,7 @@ export * from './fonts/roboto';
export * from './battery_display';
export * from './battery_low_alert';
export * from './toolbar';
+export * from './network_status_indicator';
export * from './fonts/font_awesome_styles';
export * from './save_readiness_report_button';
export * from './tabs';
diff --git a/libs/ui/src/network_status_indicator.test.tsx b/libs/ui/src/network_status_indicator.test.tsx
new file mode 100644
index 00000000000..bfcea2e418e
--- /dev/null
+++ b/libs/ui/src/network_status_indicator.test.tsx
@@ -0,0 +1,91 @@
+import { expect, test, vi } from 'vitest';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '../test/react_testing_library';
+
+import {
+ NetworkIndicatorStatus,
+ NetworkStatusIndicator,
+} from './network_status_indicator';
+
+const testCases: Array<{
+ status: NetworkIndicatorStatus;
+ expectedLabel: string;
+ expectedTreatment: 'connected' | 'warning' | 'error';
+}> = [
+ {
+ status: 'connected',
+ expectedLabel: 'Connected',
+ expectedTreatment: 'connected',
+ },
+ {
+ status: 'no-host-connected',
+ expectedLabel: 'No VxAdmin Connected',
+ expectedTreatment: 'warning',
+ },
+ {
+ status: 'no-network',
+ expectedLabel: 'No Network',
+ expectedTreatment: 'warning',
+ },
+ {
+ status: 'error',
+ expectedLabel: 'Network Error',
+ expectedTreatment: 'error',
+ },
+];
+
+test.each(testCases)(
+ 'renders $status',
+ ({ status, expectedLabel, expectedTreatment }) => {
+ const { unmount } = render();
+ const indicator = screen.getByTestId('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;
+ /* istanbul ignore next - compile-time check */
+ default:
+ throw new Error('unreachable');
+ }
+ unmount();
+ }
+);
+
+test('host machines label the connected state as network online', () => {
+ render();
+ const indicator = screen.getByTestId('network-status');
+ expect(indicator).toHaveTextContent('Network Online');
+ expect(indicator.querySelector(`[data-icon='sitemap']`)).toBeInTheDocument();
+});
+
+test('calls onPress when clicked', () => {
+ const onPress = vi.fn();
+ render();
+ userEvent.click(screen.getByTestId('network-status'));
+ expect(onPress).toHaveBeenCalledTimes(1);
+});
diff --git a/libs/ui/src/network_status_indicator.tsx b/libs/ui/src/network_status_indicator.tsx
new file mode 100644
index 00000000000..10b6ebfead2
--- /dev/null
+++ b/libs/ui/src/network_status_indicator.tsx
@@ -0,0 +1,154 @@
+import React, { useId } from 'react';
+import styled from 'styled-components';
+import { Icons } from './icons';
+
+/**
+ * The network statuses a machine's toolbar indicator can display, grouped by
+ * severity:
+ * - `connected` — neutral; on the network and connected to a host (or, for
+ * the host itself, online).
+ * - `no-host-connected` — warning; online but not connected to a compatible
+ * VxAdmin host (none detected, or one detected but not connectable, e.g.
+ * configured for a different election). Not applicable to the host itself.
+ * - `no-network` — warning; no network connection.
+ * - `error` — danger; a network conflict that blocks all connections (e.g.
+ * multiple hosts detected or an incompatible software version).
+ */
+export type NetworkIndicatorStatus =
+ | 'connected'
+ | 'no-host-connected'
+ | 'no-network'
+ | 'error';
+
+/**
+ * Network statuses applicable to the VxAdmin host machine itself, which is
+ * never waiting on a VxAdmin connection.
+ */
+export type HostNetworkIndicatorStatus = Exclude<
+ NetworkIndicatorStatus,
+ 'no-host-connected'
+>;
+
+const IndicatorButton = styled.button`
+ display: flex;
+ flex-direction: row;
+ gap: 0.4rem;
+ align-items: center;
+ white-space: nowrap;
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+`;
+
+/**
+ * A slashed variant of the network (sitemap) icon, used for warning and error
+ * states. Not part of FontAwesome, so it's inlined here. Sized and aligned to
+ * match the FontAwesome icons rendered by `Icons`.
+ */
+const NetworkOffSvg = styled.svg`
+ height: 1em;
+ width: 1em;
+ vertical-align: -0.125em;
+ color: ${(p) => p.theme.colors.onInverse};
+`;
+
+function NetworkOffIcon(): JSX.Element {
+ const maskId = useId();
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export type NetworkStatusIndicatorProps = {
+ onPress?: () => void;
+} & (
+ | { isHost: true; status: HostNetworkIndicatorStatus }
+ | { isHost?: false; status: NetworkIndicatorStatus }
+);
+
+/**
+ * A toolbar indicator showing a machine's network status. Rendered on inverse
+ * (dark) toolbar backgrounds.
+ */
+export function NetworkStatusIndicator(
+ props: NetworkStatusIndicatorProps
+): JSX.Element {
+ const { onPress, isHost, status } = props;
+
+ const contents: Record<
+ NetworkIndicatorStatus,
+ { icon: JSX.Element; label: string }
+ > = {
+ connected: {
+ icon: ,
+ label: isHost ? 'Network Online' : 'Connected',
+ },
+ 'no-host-connected': {
+ icon: ,
+ label: 'No VxAdmin Connected',
+ },
+ 'no-network': {
+ icon: ,
+ label: 'No Network',
+ },
+ error: {
+ icon: (
+
+
+
+
+ ),
+ label: 'Network Error',
+ },
+ };
+ const { icon, label } = contents[status];
+
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/libs/ui/src/themes/color_theme.stories.tsx b/libs/ui/src/themes/color_theme.stories.tsx
index f821238fd8f..5705fb17ae0 100644
--- a/libs/ui/src/themes/color_theme.stories.tsx
+++ b/libs/ui/src/themes/color_theme.stories.tsx
@@ -487,6 +487,20 @@ export function ColorThemes(): JSX.Element {
+
+
+
+
+ Inverse Danger Accent
+
+
+
+ = {
inversePrimary: DesktopPalette.Purple30,
inverseContainer: DesktopPalette.Gray80,
inverseWarningAccent: DesktopPalette.Orange30,
+ inverseDangerAccent: DesktopPalette.Red40,
successAccent: DesktopPalette.Green60,
warningAccent: DesktopPalette.Orange50,
diff --git a/libs/ui/src/toolbar.test.tsx b/libs/ui/src/toolbar.test.tsx
index 3e1ba510230..d424cb6e9a2 100644
--- a/libs/ui/src/toolbar.test.tsx
+++ b/libs/ui/src/toolbar.test.tsx
@@ -2,11 +2,13 @@ import { expect, test, vi } from 'vitest';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../test/react_testing_library';
+import { mockUsbDriveStatus } from './test-utils/mock_usb_drive';
import {
BatteryStatus,
DateTimeDisplay,
LockMachineButton,
Toolbar,
+ UsbEjectButton,
} from './toolbar';
vi.useFakeTimers({
@@ -71,3 +73,51 @@ test('Toolbar renders children', () => {
);
screen.getByText('test content');
});
+
+test('UsbEjectButton ejects a mounted drive', () => {
+ const onEject = vi.fn();
+ render(
+
+ );
+ const button = screen.getByRole('button', { name: /Eject USB/ });
+ expect(button).toBeEnabled();
+ userEvent.click(button);
+ expect(onEject).toHaveBeenCalledTimes(1);
+});
+
+test('UsbEjectButton is disabled while ejecting', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /Ejecting/ })).toBeDisabled();
+});
+
+test('UsbEjectButton is disabled without a drive', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /No USB/ })).toBeDisabled();
+});
+
+test('UsbEjectButton shows ejected state', () => {
+ render(
+
+ );
+ expect(screen.getByRole('button', { name: /USB Ejected/ })).toBeDisabled();
+});
diff --git a/libs/ui/src/toolbar.tsx b/libs/ui/src/toolbar.tsx
index 6eff511b649..f81a67e378e 100644
--- a/libs/ui/src/toolbar.tsx
+++ b/libs/ui/src/toolbar.tsx
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import styled from 'styled-components';
import type { BatteryInfo } from '@votingworks/backend';
+import type { UsbDriveStatus } from '@votingworks/usb-drive';
import { format } from '@votingworks/utils';
import { Button } from './button';
import { getBatteryIcon } from './battery_display';
-import { Icons } from './icons';
+import { IconName, Icons } from './icons';
export const Toolbar = styled.div`
display: flex;
@@ -70,6 +71,43 @@ export function DateTimeDisplay(): JSX.Element {
return {format.clockDateAndTime(currentDate)};
}
+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'],
+};
+
+export 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 (
+
+ {text}
+
+ );
+}
+
export function LockMachineButton({
onLock,
}: {