feat: add server-side device deletion
- add portal API and UI action to delete non-default devices - protect the configured portal default device from deletion - document that trip/history pages still scope to the single DEVICE_NAME Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -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
|
live portal UI itself, not just the backing APIs/helpers
|
||||||
- If copy/paste provisioning feels too clunky in practice, add a later QR /
|
- If copy/paste provisioning feels too clunky in practice, add a later QR /
|
||||||
claim-code pairing flow
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -699,6 +699,19 @@ def portal_device_request_sync(device_id):
|
|||||||
return jsonify(result)
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/portal/devices/<int:device_id>")
|
||||||
|
@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")
|
@app.get("/api/v1/portal/device-config")
|
||||||
@require_login
|
@require_login
|
||||||
def portal_device_config_get():
|
def portal_device_config_get():
|
||||||
|
|||||||
@@ -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):
|
def insert_tracking_points(device_id, points):
|
||||||
"""Bulk-insert tracking points, skipping any that already exist
|
"""Bulk-insert tracking points, skipping any that already exist
|
||||||
(device_id, recorded_at) so a client can safely re-send a batch after a
|
(device_id, recorded_at) so a client can safely re-send a batch after a
|
||||||
|
|||||||
@@ -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):
|
def acknowledge_sync(device_id, config_version):
|
||||||
db.acknowledge_device_sync(device_id, config_version)
|
db.acknowledge_device_sync(device_id, config_version)
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,9 @@
|
|||||||
<button class="btn btn-outline-primary btn-sm" id="request-sync-button" type="button" disabled>
|
<button class="btn btn-outline-primary btn-sm" id="request-sync-button" type="button" disabled>
|
||||||
<i class="fas fa-rotate me-1"></i>Request sync
|
<i class="fas fa-rotate me-1"></i>Request sync
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" id="delete-device-button" type="button" disabled>
|
||||||
|
<i class="fas fa-trash-alt me-1"></i>Delete device
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -144,6 +147,7 @@
|
|||||||
<li><strong>Request sync</strong> means “fetch this config on the Pi's next online contact”.</li>
|
<li><strong>Request sync</strong> means “fetch this config on the Pi's next online contact”.</li>
|
||||||
<li>The car can still be offline; the remote server cannot push directly into it.</li>
|
<li>The car can still be offline; the remote server cannot push directly into it.</li>
|
||||||
<li>Use the Pi's local <strong>Sync now</strong> button for immediate pull attempts.</li>
|
<li>Use the Pi's local <strong>Sync now</strong> button for immediate pull attempts.</li>
|
||||||
|
<li><strong>Delete device</strong> removes that device row and cascades its trips, tracking logs, and dashcam files from the server.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const createDeviceButton = document.getElementById('create-device-button');
|
|||||||
const newDeviceName = document.getElementById('new-device-name');
|
const newDeviceName = document.getElementById('new-device-name');
|
||||||
const rotateKeyButton = document.getElementById('rotate-key-button');
|
const rotateKeyButton = document.getElementById('rotate-key-button');
|
||||||
const requestSyncButton = document.getElementById('request-sync-button');
|
const requestSyncButton = document.getElementById('request-sync-button');
|
||||||
|
const deleteDeviceButton = document.getElementById('delete-device-button');
|
||||||
const copyCredentialsButton = document.getElementById('copy-credentials-button');
|
const copyCredentialsButton = document.getElementById('copy-credentials-button');
|
||||||
|
|
||||||
const selectedSummary = {
|
const selectedSummary = {
|
||||||
@@ -64,6 +65,7 @@ function setFormEnabled(enabled) {
|
|||||||
}
|
}
|
||||||
rotateKeyButton.disabled = !enabled;
|
rotateKeyButton.disabled = !enabled;
|
||||||
requestSyncButton.disabled = !enabled;
|
requestSyncButton.disabled = !enabled;
|
||||||
|
deleteDeviceButton.disabled = !enabled;
|
||||||
updateDirtyState();
|
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) {
|
async function saveConfig(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (selectedDeviceId === null) return;
|
if (selectedDeviceId === null) return;
|
||||||
@@ -284,6 +316,7 @@ form.addEventListener('submit', saveConfig);
|
|||||||
createDeviceButton.addEventListener('click', createDevice);
|
createDeviceButton.addEventListener('click', createDevice);
|
||||||
rotateKeyButton.addEventListener('click', rotateKey);
|
rotateKeyButton.addEventListener('click', rotateKey);
|
||||||
requestSyncButton.addEventListener('click', requestSync);
|
requestSyncButton.addEventListener('click', requestSync);
|
||||||
|
deleteDeviceButton.addEventListener('click', deleteDevice);
|
||||||
copyCredentialsButton.addEventListener('click', () => {
|
copyCredentialsButton.addEventListener('click', () => {
|
||||||
copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger'));
|
copyCredentials().catch(error => showMessage(`Could not copy credentials: ${error.message}`, 'danger'));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user