feat(usb-drive, admin): multi-USB drive API for VxAdmin#8057
feat(usb-drive, admin): multi-USB drive API for VxAdmin#8057eventualbuddha wants to merge 33 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a multi-USB-drive API to libs/usb-drive and migrates VxAdmin to use it, as groundwork for supporting multiple simultaneous USB drives. The new MultiUsbDrive interface tracks all connected drives, auto-mounts FAT32 partitions, and handles per-drive eject/format with action locking. A UsbDriveAdapter bridges back to the single-drive UsbDrive interface for existing consumers in other apps.
Changes:
libs/usb-drive: Newmulti_usb_drive.tswithMultiUsbDriveinterface anddetectMultiUsbDrive()factory;usb_drive_adapter.tsbridges to legacyUsbDrive;block_devices.tsgainsgetAllUsbDrives()andcreateBlockDeviceChangeWatcher(); mocks added;mount.shupdated to derive mount point from device name.apps/admin/backend:buildApp()now acceptsMultiUsbDrive; newgetUsbDrives()/ejectUsbDrive({ driveDevPath })/formatUsbDrive({ driveDevPath })API endpoints.apps/admin/frontend:AppContextnow carriesusbDrives: UsbDriveInfo[]; agetUsbDriveStatus()helper converts to the legacyUsbDriveStatusshape for components not yet multi-drive aware.
Reviewed changes
Copilot reviewed 49 out of 49 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
libs/usb-drive/src/multi_usb_drive.ts |
Core new multi-drive implementation with auto-mount, per-drive eject/format, and action locking |
libs/usb-drive/src/usb_drive_adapter.ts |
Adapter from MultiUsbDrive to UsbDrive for backward compatibility |
libs/usb-drive/src/usb_drive.ts |
Refactored to delegate to detectMultiUsbDrive + createUsbDriveAdapter |
libs/usb-drive/src/block_devices.ts |
Adds getAllUsbDrives() (disk→partition hierarchy) and createBlockDeviceChangeWatcher() |
libs/usb-drive/src/mocks/memory_multi_usb_drive.ts |
New mock for MultiUsbDrive for tests |
libs/usb-drive/src/mocks/file_usb_drive.ts |
New createMockFileMultiUsbDrive() backed by state file |
libs/usb-drive/src/index.ts |
Exports new public types and factories |
libs/usb-drive/scripts/mount.sh |
Mount point now derived from device name for multi-drive support |
apps/admin/backend/src/app.ts |
Migrated to MultiUsbDrive; new API routes for multi-drive operations |
apps/admin/backend/src/server.ts |
Updated startup to use MultiUsbDrive |
apps/admin/frontend/src/contexts/app_context.ts |
Context updated to use usbDrives: UsbDriveInfo[] |
apps/admin/frontend/src/get_usb_drive_status.ts |
New helper to convert UsbDriveInfo[] to legacy UsbDriveStatus |
apps/admin/frontend/src/api.ts |
New getUsbDrives, updated ejectUsbDrive/formatUsbDrive |
| Various frontend screens/components | Updated to use usbDrives from context + getUsbDriveStatus helper |
| Various backend test files | Updated to use MockMultiUsbDrive and new API shapes |
7ab5b39 to
744e595
Compare
474827d to
e102d74
Compare
apps/admin/frontend/src/api.ts
Outdated
| // Refetch when the first drive's mount state changes (inserted/ejected/removed) | ||
| queryKey(usbDrives: UsbDriveInfo[]): QueryKey { | ||
| const mountType = usbDrives[0]?.partitions[0]?.mount.type ?? 'no_drive'; | ||
| return ['listPotentialElectionPackagesOnUsbDrive', mountType]; |
There was a problem hiding this comment.
Ironically, Copilot suggested that I be less inclusive than when it was just usbDrives[0]. I'm inclined to go back to just doing the full object. @copilot, please just change this to:
return ['listPotentialElectionPackagesOnUsbDrive', usbDrives[0]];| driveAction.set(driveDevPath, 'ejecting'); | ||
| await logger.logAsCurrentRole(LogEventId.UsbDriveEjectInit); | ||
| try { | ||
| const disk = cachedDrives.find((d) => d.devPath === driveDevPath); | ||
| assert(disk, `Drive not found: ${driveDevPath}`); | ||
|
|
|
@eventualbuddha I've opened a new pull request, #8122, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@eventualbuddha I've opened a new pull request, #8123, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@eventualbuddha I've opened a new pull request, #8124, to work on those changes. Once the pull request is ready, I'll request review from you. |
|
@eventualbuddha I've opened a new pull request, #8125, to work on those changes. Once the pull request is ready, I'll request review from you. |
Look for more kinds of drives than simply those that show up as /dev/sd*. Also changes the mount point to include the partition name so multiple partitions may be mounted simultaneously.
- Add `MultiUsbDrive` interface with `getDrives()`, `mountDrive()`, `ejectDrive()`, `formatDrive()`, and `sync()` methods - Add `UsbDriveInfo` and `UsbPartitionInfo` types for the per-drive state - Add `detectMultiUsbDrive(logger)` factory that polls block devices and auto-mounts/unmounts partitions on insertion/ejection - Add `createMockMultiUsbDrive()` for testing; uses `makeTemporaryDirectory` from `@votingworks/fixtures` to back inserted drives with a real directory - Export `VX_USB_LABEL_REGEXP` from this module (moved from usb_drive.ts) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `createMockFileMultiUsbDrive()` that implements `MultiUsbDrive` using files on disk (analogous to the existing `MockFileUsbDrive`) - Add `addMockDrive()` / `listMockDrives()` / `removeMockDriveDir()` helpers to manage simulated drive slots in the filesystem mock root - Update `getMockFileUsbDriveHandler()` to accept an optional `diskName` to target a specific slot; defaults to the first slot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `createUsbDriveAdapter(multiUsbDrive, getDriveDevPath)` that implements the single-drive `UsbDrive` interface on top of `MultiUsbDrive`, enabling backward-compatible consumers (Exporter, listDirectoryOnUsbDrive, createSystemCallApi) without modification - Refactor `detectUsbDrive` to build on `detectMultiUsbDrive` + adapter; preserve identical observable behavior for all existing callers - Add `detectOrMockMultiUsbDrive` factory for callers that need the full multi-drive API with file-mock support in non-production environments - Update `cli.ts` watch command to use the new `onChange` option shape - Export `MultiUsbDrive`, `UsbDriveAdapter`, and multi-USB mocks from the package index Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Update `getUsbDriveStatus` to return `DevDockUsbDriveInfo[]` (with `devPath` and `status`) instead of a single `DevDockUsbDriveStatus` - Add `insertUsbDrive` and `removeUsbDrive` API methods keyed by `devPath` - Add `addUsbDriveSlot` and `removeUsbDriveSlot` API methods to manage the number of simulated drives - Initialize with a default drive slot on startup - Poll USB drive status every second in the dev-dock frontend - Add per-drive insert/eject controls in the dev-dock UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace single `UsbDrive` with `MultiUsbDrive` in backend `buildApp` - Replace `getUsbDriveStatus`/`ejectUsbDrive`/`formatUsbDrive` with multi-drive equivalents: `getUsbDrives`, `ejectUsbDrive(driveDevPath)`, `formatUsbDrive(driveDevPath)` - Add `get_usb_drive_status` frontend utility to derive per-component USB status from the list of `UsbDriveInfo` objects - Update navigation, settings, diagnostics, and export screens to use the new API; settings screen shows each drive with its own eject button - Update dev-dock mock client usage in frontend to match new API shape - Fix `FormatUsbButtonProps` to accept a simpler `formatUsbDriveMutation` type (removes the explicit `UseMutationResult` annotation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ileUsbDrive` factory Consistent naming: all four USB drive mocks are now factory functions. - `createMockUsbDrive`(): single-drive test mock (mockFunction-based) - `createMockMultiUsbDrive`(): multi-drive test mock (mockFunction-based) - `createMockFileUsbDrive`(): single-drive dev mock (file-backed) - `createMockFileMultiUsbDrive`(): multi-drive dev mock (file-backed) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getUsbDriveStatus was collapsing 'unmounted' and 'unmounting' into 'ejected'. Only 'unmounting' means an eject is in progress; a bare 'unmounted' partition has no eject history (the MultiUsbDrive API does not expose ejectState). Returning 'ejected' here caused non-FAT32 drives (which are never auto-mounted) to always display as ejected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The bad_format check only tested fstype !== 'vfat', so a FAT16 partition (fstype='vfat', fsver='FAT16') was not flagged. This mirrors the isFat32Partition check in multi_usb_drive.ts which requires both fstype === 'vfat' AND fsver === 'FAT32'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
getAllUsbDrives only iterated over disk-typed udev entries, silently dropping any partition that appeared without a corresponding disk entry. This is observed with some USB card readers. For such orphan partitions, derive the disk path by stripping the partition suffix and synthesize a UsbDiskDeviceInfo entry, matching the old getUsbDriveDeviceInfo behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ejectState only ever stored `true` values; a Set<string> expresses the intent more clearly and removes the possibility of a false value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling assert() in a UI event handler crashes the app if triggered without a devPath. Replace with a no-op guard that returns ok(), matching the pattern already used in navigation_screen.tsx for eject. Also fix render_in_app_context.tsx test helper: fsver was '32' instead of 'FAT32', which caused the new fsver check to flag mock mounted drives as bad_format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
….length)
The pattern `devPath.slice('/dev/'.length)` was repeated 6 times across
two files to extract the disk name from a device path. Using
`basename(devPath)` from node:path is both clearer and safe against
paths that don't start with '/dev/'.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lector The adapter was hardcoded to always select '/dev/sdb', so usbDrive.eject() and usbDrive.format() would unconditionally call ejectDrive/formatDrive even when no drive was inserted. Use drives[0]?.devPath instead so the adapter returns no_drive and is a no-op for eject/format when the drive list is empty, matching the real detectUsbDrive behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After the issue-1 fix (unmounted→no_drive), there was no way to distinguish
a newly-inserted-but-not-yet-mounted partition from one that was explicitly
ejected by the user. Add `{ type: 'ejected' }` to UsbPartitionMount and
update computeMount to return it when ejectState tracks the drive.
This makes getUsbDriveStatus correctly return `ejected` for user-ejected
drives while still returning `no_drive` for unmounted non-ejected ones.
Also fixes mock_api_client.ts using fsver '32' instead of 'FAT32', which
caused the FAT32 check added in issue 2 to misidentify mounted drives as
bad_format in tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…kagesOnUsbDrive query
Replace the full UsbDriveInfo[] array as the React Query key with a compact
string derived from the first drive's first partition mount type. Using large
objects as query keys is verbose and harder to debug. The derived mount type
string ('mounted', 'unmounted', 'ejected', 'no_drive') still captures all
state transitions that should trigger a refetch.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Now that MultiUsbDrive exposes { type: 'ejected' } on partitions
belonging to ejected drives, the adapter no longer needs a separate
boolean to remember whether eject was called. The explicit mount
type is unambiguous and avoids the per-adapter stale-state issue
noted in review.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ions This matches the behavior of UsbDriveAdapter, which also returns bad_format for drives with no partitions. A present drive with no partitions is unformatted, which the user should be prompted to format — not silently treated as absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously, a disk whose partitions all lived outside /media (or were LVM, etc.) was added to the result with partitions: []. Downstream code treats an empty-partition disk as unformatted and may prompt formatting. Now the disk is excluded entirely, consistent with the intent of isDataUsbDrive. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- fix(admin): update ClientAppRoot to use usbDrives instead of deprecated usbDriveStatus in AppContext (fixes TypeScript error that caused admin-frontend build and integration-testing to fail) - fix(usb-drive): add test for orphan partition that fails isDataUsbDrive to reach 100% branch coverage
…agesOnUsbDrive query key (#8122)
Can't test that something is unmounted if it gets auto-mounted.
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
Co-authored-by: eventualbuddha <1938+eventualbuddha@users.noreply.github.com>
42994dc to
0ae5690
Compare
Overview
Refs #7897
Adds a multi-USB drive API to
libs/usb-driveand migrates VxAdmin to use it. This is groundwork for supporting multiple simultaneous USB drives in VxAdmin, which will be needed for Election Archiving & Backup and multi-USB CVR imports.Kooha-2026-03-04-16-45-39.webm
Testing Plan