From 0f6296759dc56c97c65c2d3c84a245990797db5e Mon Sep 17 00:00:00 2001 From: rzuasti Date: Sat, 30 May 2026 11:27:52 -0400 Subject: [PATCH] Add edit dialog for registered devices Extend PUT /api/devices/{mac} to accept an optional name and surface an Edit action from both the device detail screen and the per-row overflow menu, letting users modify owner, device type, vendor and name without forgetting and re-registering. Co-Authored-By: Claude Opus 4.7 --- TODO.md | 12 ++- backend/src/db/devices.rs | 21 +++- backend/src/web_server/devices.rs | 6 +- frontend/lib/devices/device_actions.dart | 115 +++++++++++++++++++++ frontend/lib/devices/device_detail.dart | 27 +++-- frontend/lib/devices/device_list_rows.dart | 4 + frontend/lib/utils/oott_api.dart | 19 ++++ 7 files changed, 186 insertions(+), 18 deletions(-) diff --git a/TODO.md b/TODO.md index 6912bc6..f55b03b 100644 --- a/TODO.md +++ b/TODO.md @@ -11,17 +11,19 @@ - [x] Add date filter to device event list method (from) - [x] Add a data set (json) to store maps from vendors -> device type; modify the device creation so that it uses it automatically - [x] Separate the devices "update" method into update and seen (one for API edit one for scanners) +- [ ] Add the scanner that triggered the event to the device_events table +- [ ] Improve notifications layout/text ## Frontend -- [ ] Rework the devices list into a list when the screen is wide enough -- [ ] Display the device "name" in the list and details -- [ ] When registering a device ask for the name -- [ ] Add a device edit feature for registered devices +- [ ] Review how we are using DIO and improve error handling (500 error page?) +- [x] Rework the devices list into a list when the screen is wide enough +- [x] Display the device "name" in the list and details +- [x] When registering a device ask for the name +- [x] Add a device edit feature for registered devices - [x] Add an mDNS/Bonjour scanner - [x] Extract the select_interface method and interface logic from ARP scanner to a centralized utility file - [x] Figure out if we can univocally identify devices that mask their MAC address (like apple) -- [ ] Add the scanner that triggered the event to the device_events table ## Frontend diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index 7cffe2c..2e94ae2 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -275,12 +275,13 @@ pub fn update( owner: String, device_type: String, vendor: String, + name: Option, ) -> Result<(), DbError> { let conn = db::get_db_connection(); match conn.execute( - "UPDATE devices SET owner=?1, device_type=?2, vendor=?3 WHERE mac_address=?4", - params![owner, device_type, vendor, mac_address], + "UPDATE devices SET owner=?1, device_type=?2, vendor=?3, name=?4 WHERE mac_address=?5", + params![owner, device_type, vendor, name, mac_address], ) { Ok(_) => { debug!("Device updated in database: {mac_address}"); @@ -842,6 +843,7 @@ mod tests { "Bob".to_string(), "Laptop".to_string(), "New Vendor".to_string(), + Some("kitchen-pc".to_string()), ) .unwrap(); @@ -849,11 +851,24 @@ mod tests { assert_eq!(device.owner, "Bob".to_string()); assert_eq!(device.device_type, "Laptop".to_string()); assert_eq!(device.vendor, "New Vendor".to_string()); + assert_eq!(device.name, Some("kitchen-pc".to_string())); // Sighting and registration fields are untouched assert_eq!(device.ipv4_address, "192.168.250.1".to_string()); assert_eq!(device.last_seen, last_seen); - assert_eq!(device.name, Some("host.local".to_string())); assert!(device.is_registered); + + // Passing None clears the name (edit dialog is an explicit user action — "what you see + // in the dialog is what gets saved", unlike register() which preserves on None). + update( + "mm:mm:mm:mm:mm:01".to_string(), + "Bob".to_string(), + "Laptop".to_string(), + "New Vendor".to_string(), + None, + ) + .unwrap(); + let device = read("mm:mm:mm:mm:mm:01".to_string()).unwrap(); + assert_eq!(device.name, None); } #[tokio::test] diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index 30583b8..ffd7426 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -166,8 +166,8 @@ pub async fn update( Json(payload): Json, ) -> impl IntoResponse { debug!( - "Device update received: mac_address={}, owner={}, device_type={}, vendor={}", - mac_address, payload.owner, payload.device_type, payload.vendor + "Device update received: mac_address={}, owner={}, device_type={}, vendor={}, name={:?}", + mac_address, payload.owner, payload.device_type, payload.vendor, payload.name ); let device = match db::devices::read(mac_address.clone()) { @@ -192,6 +192,7 @@ pub async fn update( payload.owner, payload.device_type, payload.vendor, + payload.name, ) { Ok(_) => (axum::http::StatusCode::OK, "Device updated"), Err(err) => { @@ -283,4 +284,5 @@ pub struct UpdateDevicePayload { owner: String, device_type: String, vendor: String, + name: Option, } diff --git a/frontend/lib/devices/device_actions.dart b/frontend/lib/devices/device_actions.dart index dd7f7d3..8f30de9 100644 --- a/frontend/lib/devices/device_actions.dart +++ b/frontend/lib/devices/device_actions.dart @@ -46,6 +46,121 @@ Future confirmForgetDevice( } } +Future showEditDeviceDialog( + BuildContext context, + Device device, + VoidCallback onRefresh, +) async { + final formKey = GlobalKey(); + String owner = device.owner; + String name = device.name ?? ''; + String vendor = device.vendor; + DeviceType deviceType = device.deviceType; + + final saved = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + void save() { + if (formKey.currentState?.validate() ?? false) { + formKey.currentState?.save(); + Navigator.of(context).pop(true); + } + } + + return AlertDialog( + title: const Text('Edit Device'), + content: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + initialValue: owner, + decoration: const InputDecoration(labelText: 'Owner'), + autofocus: true, + onFieldSubmitted: (_) => save(), + validator: (value) => value == null || value.trim().isEmpty + ? 'Owner is required' + : null, + onSaved: (value) => owner = value?.trim() ?? '', + ), + const SizedBox(height: 16), + TextFormField( + initialValue: name, + decoration: const InputDecoration( + labelText: 'Name (optional)', + ), + onFieldSubmitted: (_) => save(), + onSaved: (value) => name = value?.trim() ?? '', + ), + const SizedBox(height: 16), + TextFormField( + initialValue: vendor, + decoration: const InputDecoration( + labelText: 'Vendor (optional)', + ), + onFieldSubmitted: (_) => save(), + onSaved: (value) => vendor = value?.trim() ?? '', + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: deviceType, + decoration: const InputDecoration(labelText: 'Device Type'), + items: DeviceType.values + .map( + (t) => DropdownMenuItem( + value: t, + child: Row( + children: [ + Icon(t.icon, size: 16), + const SizedBox(width: 4), + Text(t.label), + ], + ), + ), + ) + .toList(), + onChanged: (value) => + setDialogState(() => deviceType = value ?? deviceType), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: save, + child: const Text('Save'), + ), + ], + ); + }, + ), + ); + + if (saved != true || !context.mounted) return; + + try { + await BackendAPI.instance.updateDevice( + device.macAddress, + owner, + deviceType.apiName, + vendor, + name: name.isEmpty ? null : name, + ); + if (!context.mounted) return; + UISnackbars.showSuccess(context, 'Device updated'); + onRefresh(); + } catch (e) { + if (!context.mounted) return; + UISnackbars.showError(context, 'Failed to update device: $e'); + } +} + Future showRegisterDeviceDialog( BuildContext context, Device device, diff --git a/frontend/lib/devices/device_detail.dart b/frontend/lib/devices/device_detail.dart index fc53415..7dbfe19 100644 --- a/frontend/lib/devices/device_detail.dart +++ b/frontend/lib/devices/device_detail.dart @@ -199,14 +199,25 @@ class _DeviceActions extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; if (device.isRegistered) { - return OutlinedButton.icon( - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - ), - onPressed: () => confirmForgetDevice(context, device, onAction), - icon: const Icon(Icons.link_off), - label: const Text('Forget Device'), + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: () => showEditDeviceDialog(context, device, onAction), + icon: const Icon(Icons.edit), + label: const Text('Edit'), + ), + OutlinedButton.icon( + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + ), + onPressed: () => confirmForgetDevice(context, device, onAction), + icon: const Icon(Icons.link_off), + label: const Text('Forget Device'), + ), + ], ); } return FilledButton.icon( diff --git a/frontend/lib/devices/device_list_rows.dart b/frontend/lib/devices/device_list_rows.dart index 94fc61a..c22cae9 100644 --- a/frontend/lib/devices/device_list_rows.dart +++ b/frontend/lib/devices/device_list_rows.dart @@ -333,6 +333,8 @@ class _DeviceActionsMenu extends StatelessWidget { onSelected: (value) async { if (value == 'details') { context.push('/devices/${device.macAddress}'); + } else if (value == 'edit') { + await showEditDeviceDialog(context, device, onRefresh); } else if (value == 'forget') { await confirmForgetDevice(context, device, onRefresh); } else if (value == 'register') { @@ -341,6 +343,8 @@ class _DeviceActionsMenu extends StatelessWidget { }, itemBuilder: (context) => [ const PopupMenuItem(value: 'details', child: Text('View details')), + if (device.isRegistered) + const PopupMenuItem(value: 'edit', child: Text('Edit')), if (device.isRegistered) const PopupMenuItem(value: 'forget', child: Text('Forget')), if (!device.isRegistered) diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index dd92de9..6c65ced 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -150,6 +150,25 @@ class BackendAPI { ); } + Future updateDevice( + String macAddress, + String owner, + String deviceType, + String vendor, { + String? name, + }) async { + debugPrint('About to call PUT /devices/$macAddress'); + await _dio.put( + '/devices/$macAddress', + data: { + 'owner': owner, + 'device_type': deviceType, + 'vendor': vendor, + 'name': name, + }, + ); + } + Future forgetDevice(String macAddress) async { debugPrint('About to call DELETE /devices/$macAddress'); await _dio.delete('/devices/$macAddress');