mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.7
parent
3a579f0321
commit
0f6296759d
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user