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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-30 11:27:52 -04:00
co-authored by Claude Opus 4.7
parent 3a579f0321
commit 0f6296759d
7 changed files with 186 additions and 18 deletions
+7 -5
View File
@@ -11,17 +11,19 @@
- [x] Add date filter to device event list method (from) - [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] 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) - [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 ## Frontend
- [ ] Rework the devices list into a list when the screen is wide enough - [ ] Review how we are using DIO and improve error handling (500 error page?)
- [ ] Display the device "name" in the list and details - [x] Rework the devices list into a list when the screen is wide enough
- [ ] When registering a device ask for the name - [x] Display the device "name" in the list and details
- [ ] Add a device edit feature for registered devices - [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] Add an mDNS/Bonjour scanner
- [x] Extract the select_interface method and interface logic from ARP scanner to a centralized utility file - [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) - [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 ## Frontend
+18 -3
View File
@@ -275,12 +275,13 @@ pub fn update(
owner: String, owner: String,
device_type: String, device_type: String,
vendor: String, vendor: String,
name: Option<String>,
) -> Result<(), DbError> { ) -> Result<(), DbError> {
let conn = db::get_db_connection(); let conn = db::get_db_connection();
match conn.execute( match conn.execute(
"UPDATE devices SET owner=?1, device_type=?2, vendor=?3 WHERE mac_address=?4", "UPDATE devices SET owner=?1, device_type=?2, vendor=?3, name=?4 WHERE mac_address=?5",
params![owner, device_type, vendor, mac_address], params![owner, device_type, vendor, name, mac_address],
) { ) {
Ok(_) => { Ok(_) => {
debug!("Device updated in database: {mac_address}"); debug!("Device updated in database: {mac_address}");
@@ -842,6 +843,7 @@ mod tests {
"Bob".to_string(), "Bob".to_string(),
"Laptop".to_string(), "Laptop".to_string(),
"New Vendor".to_string(), "New Vendor".to_string(),
Some("kitchen-pc".to_string()),
) )
.unwrap(); .unwrap();
@@ -849,11 +851,24 @@ mod tests {
assert_eq!(device.owner, "Bob".to_string()); assert_eq!(device.owner, "Bob".to_string());
assert_eq!(device.device_type, "Laptop".to_string()); assert_eq!(device.device_type, "Laptop".to_string());
assert_eq!(device.vendor, "New Vendor".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 // Sighting and registration fields are untouched
assert_eq!(device.ipv4_address, "192.168.250.1".to_string()); assert_eq!(device.ipv4_address, "192.168.250.1".to_string());
assert_eq!(device.last_seen, last_seen); assert_eq!(device.last_seen, last_seen);
assert_eq!(device.name, Some("host.local".to_string()));
assert!(device.is_registered); 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] #[tokio::test]
+4 -2
View File
@@ -166,8 +166,8 @@ pub async fn update(
Json(payload): Json<UpdateDevicePayload>, Json(payload): Json<UpdateDevicePayload>,
) -> impl IntoResponse { ) -> impl IntoResponse {
debug!( debug!(
"Device update received: mac_address={}, owner={}, device_type={}, vendor={}", "Device update received: mac_address={}, owner={}, device_type={}, vendor={}, name={:?}",
mac_address, payload.owner, payload.device_type, payload.vendor mac_address, payload.owner, payload.device_type, payload.vendor, payload.name
); );
let device = match db::devices::read(mac_address.clone()) { let device = match db::devices::read(mac_address.clone()) {
@@ -192,6 +192,7 @@ pub async fn update(
payload.owner, payload.owner,
payload.device_type, payload.device_type,
payload.vendor, payload.vendor,
payload.name,
) { ) {
Ok(_) => (axum::http::StatusCode::OK, "Device updated"), Ok(_) => (axum::http::StatusCode::OK, "Device updated"),
Err(err) => { Err(err) => {
@@ -283,4 +284,5 @@ pub struct UpdateDevicePayload {
owner: String, owner: String,
device_type: String, device_type: String,
vendor: String, vendor: String,
name: Option<String>,
} }
+115
View File
@@ -46,6 +46,121 @@ Future<void> confirmForgetDevice(
} }
} }
Future<void> showEditDeviceDialog(
BuildContext context,
Device device,
VoidCallback onRefresh,
) async {
final formKey = GlobalKey<FormState>();
String owner = device.owner;
String name = device.name ?? '';
String vendor = device.vendor;
DeviceType deviceType = device.deviceType;
final saved = await showDialog<bool>(
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<DeviceType>(
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<void> showRegisterDeviceDialog( Future<void> showRegisterDeviceDialog(
BuildContext context, BuildContext context,
Device device, Device device,
+19 -8
View File
@@ -199,14 +199,25 @@ class _DeviceActions extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
if (device.isRegistered) { if (device.isRegistered) {
return OutlinedButton.icon( return Wrap(
style: OutlinedButton.styleFrom( spacing: 12,
foregroundColor: colorScheme.error, runSpacing: 12,
side: BorderSide(color: colorScheme.error), children: [
), FilledButton.icon(
onPressed: () => confirmForgetDevice(context, device, onAction), onPressed: () => showEditDeviceDialog(context, device, onAction),
icon: const Icon(Icons.link_off), icon: const Icon(Icons.edit),
label: const Text('Forget Device'), 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( return FilledButton.icon(
@@ -333,6 +333,8 @@ class _DeviceActionsMenu extends StatelessWidget {
onSelected: (value) async { onSelected: (value) async {
if (value == 'details') { if (value == 'details') {
context.push('/devices/${device.macAddress}'); context.push('/devices/${device.macAddress}');
} else if (value == 'edit') {
await showEditDeviceDialog(context, device, onRefresh);
} else if (value == 'forget') { } else if (value == 'forget') {
await confirmForgetDevice(context, device, onRefresh); await confirmForgetDevice(context, device, onRefresh);
} else if (value == 'register') { } else if (value == 'register') {
@@ -341,6 +343,8 @@ class _DeviceActionsMenu extends StatelessWidget {
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
const PopupMenuItem(value: 'details', child: Text('View details')), const PopupMenuItem(value: 'details', child: Text('View details')),
if (device.isRegistered)
const PopupMenuItem(value: 'edit', child: Text('Edit')),
if (device.isRegistered) if (device.isRegistered)
const PopupMenuItem(value: 'forget', child: Text('Forget')), const PopupMenuItem(value: 'forget', child: Text('Forget')),
if (!device.isRegistered) if (!device.isRegistered)
+19
View File
@@ -150,6 +150,25 @@ class BackendAPI {
); );
} }
Future<void> 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<void> forgetDevice(String macAddress) async { Future<void> forgetDevice(String macAddress) async {
debugPrint('About to call DELETE /devices/$macAddress'); debugPrint('About to call DELETE /devices/$macAddress');
await _dio.delete('/devices/$macAddress'); await _dio.delete('/devices/$macAddress');