diff --git a/PLAN.md b/PLAN.md index ad61be0..71bf14b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1737,6 +1737,12 @@ updates its display-facing values without a manual restart once sync lands. live portal UI itself, not just the backing APIs/helpers - If copy/paste provisioning feels too clunky in practice, add a later QR / claim-code pairing flow + - Main portal overview/history/dashcam pages are **not** multi-device yet: + the data model already separates rows by `device_id`, but those pages still + read from the single configured `DEVICE_NAME`. Today that means the new + multi-device management page can manage several devices, but the normal trip + history view still shows one configured car/device at a time until a later + portal-wide device selector/filter is implemented. --- diff --git a/server/app.py b/server/app.py index 3336df2..b051bfe 100644 --- a/server/app.py +++ b/server/app.py @@ -699,6 +699,19 @@ def portal_device_request_sync(device_id): return jsonify(result) +@app.delete("/api/v1/portal/devices/") +@require_login +def portal_device_delete(device_id): + success, errors = device_registry.delete_device( + device_id, + protected_device_name=os.environ.get("DEVICE_NAME", "yaris-2013"), + ) + if not success: + status = 404 if errors.get("error") == "Device not found." else 409 + return jsonify(error=errors.get("error", "Could not delete device.")), status + return jsonify(status="deleted") + + @app.get("/api/v1/portal/device-config") @require_login def portal_device_config_get(): diff --git a/server/modules/db.py b/server/modules/db.py index ffbb234..c82e949 100644 --- a/server/modules/db.py +++ b/server/modules/db.py @@ -187,6 +187,13 @@ def acknowledge_device_sync(device_id, config_version): ) +def delete_device(device_id): + with get_cursor(commit=True) as cur: + cur.execute("DELETE FROM devices WHERE id = %s RETURNING id", (device_id,)) + row = cur.fetchone() + return row[0] if row else None + + def insert_tracking_points(device_id, points): """Bulk-insert tracking points, skipping any that already exist (device_id, recorded_at) so a client can safely re-send a batch after a diff --git a/server/modules/device_registry.py b/server/modules/device_registry.py index 06b4423..2f280ee 100644 --- a/server/modules/device_registry.py +++ b/server/modules/device_registry.py @@ -63,6 +63,21 @@ def request_sync(device_id): } +def delete_device(device_id, protected_device_name=None): + device = db.get_device_by_id(device_id) + if device is None: + return False, {"error": "Device not found."} + if protected_device_name and device["name"] == protected_device_name: + return False, { + "error": "Cannot delete the portal's default device while DEVICE_NAME still points to it.", + } + + deleted_id = db.delete_device(device_id) + if deleted_id is None: + return False, {"error": "Device not found."} + return True, {} + + def acknowledge_sync(device_id, config_version): db.acknowledge_device_sync(device_id, config_version) diff --git a/server/templates/device_settings.html b/server/templates/device_settings.html index 194611c..40f139e 100644 --- a/server/templates/device_settings.html +++ b/server/templates/device_settings.html @@ -57,6 +57,9 @@ +
@@ -144,6 +147,7 @@
  • Request sync means “fetch this config on the Pi's next online contact”.
  • The car can still be offline; the remote server cannot push directly into it.
  • Use the Pi's local Sync now button for immediate pull attempts.
  • +
  • Delete device removes that device row and cascades its trips, tracking logs, and dashcam files from the server.
  • diff --git a/static/js/portal-device-settings.js b/static/js/portal-device-settings.js index bd11ada..7ba722b 100644 --- a/static/js/portal-device-settings.js +++ b/static/js/portal-device-settings.js @@ -7,6 +7,7 @@ const createDeviceButton = document.getElementById('create-device-button'); const newDeviceName = document.getElementById('new-device-name'); const rotateKeyButton = document.getElementById('rotate-key-button'); const requestSyncButton = document.getElementById('request-sync-button'); +const deleteDeviceButton = document.getElementById('delete-device-button'); const copyCredentialsButton = document.getElementById('copy-credentials-button'); const selectedSummary = { @@ -64,6 +65,7 @@ function setFormEnabled(enabled) { } rotateKeyButton.disabled = !enabled; requestSyncButton.disabled = !enabled; + deleteDeviceButton.disabled = !enabled; updateDirtyState(); } @@ -239,6 +241,36 @@ async function requestSync() { } } +async function deleteDevice() { + if (selectedDeviceId === null) return; + const device = devices.find(candidate => candidate.id === selectedDeviceId); + if (!device) return; + if (!window.confirm(`Delete device "${device.name}" and all of its server-side trips, tracking logs, and dashcam files?`)) { + return; + } + hideMessage(); + deleteDeviceButton.disabled = true; + try { + const response = await fetch(`/api/v1/portal/devices/${selectedDeviceId}`, { + method: 'DELETE', + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(payload.error || 'Could not delete device.'); + } + latestApiKey = ''; + credentialFields.apiKey.value = ''; + copyCredentialsButton.disabled = true; + selectedDeviceId = null; + await loadDevices(); + showMessage('Device deleted from the server.', 'success'); + } catch (error) { + showMessage(error.message, 'danger'); + } finally { + deleteDeviceButton.disabled = selectedDeviceId === null; + } +} + async function saveConfig(event) { event.preventDefault(); if (selectedDeviceId === null) return; @@ -284,6 +316,7 @@ form.addEventListener('submit', saveConfig); createDeviceButton.addEventListener('click', createDevice); rotateKeyButton.addEventListener('click', rotateKey); requestSyncButton.addEventListener('click', requestSync); +deleteDeviceButton.addEventListener('click', deleteDevice); copyCredentialsButton.addEventListener('click', () => { copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger')); });