feat: portal-wide device selector — session-based, all pages now use selected device

- _portal_device_id() reads from session, falls back to DEVICE_NAME env var
- GET/POST /api/v1/portal/select-device for selector data + set
- Dropdown in server base.html topbar, JS populates from API, page reloads on change
- All portal pages (overview, trips, dashcam, DTC, device settings) now use selected device
This commit is contained in:
Mathieu Moran
2026-08-12 01:40:51 -04:00
parent 1556c65131
commit e30b85d218
6 changed files with 133 additions and 87 deletions
+46 -1
View File
@@ -74,12 +74,57 @@ login_manager.init_app(app)
def _portal_device_id():
"""Return the device id for the configured device name (single Pi setup)."""
"""Return the device id for the user's selected device, falling back to
the DEVICE_NAME env var default. Selection is stored in the Flask session
so it persists across pages without a URL parameter on every link."""
# Check session first (user explicitly picked a device)
session_id = session.get("selected_device_id")
if session_id is not None:
dev = db.get_device_by_id(session_id)
if dev is not None:
return dev["id"]
# Selected device was deleted — clear stale session entry
session.pop("selected_device_id", None)
# Fall back to configured default
name = os.environ.get("DEVICE_NAME", "yaris-2013")
dev = db.get_device_by_name(name)
return dev["id"] if dev else None
@app.get("/api/v1/portal/select-device")
@require_login
def portal_select_device_state():
"""Return current selection and available devices for the dropdown."""
devices = device_registry.list_devices()
configured_name = os.environ.get("DEVICE_NAME", "yaris-2013")
for device in devices:
device["portal_default"] = device["name"] == configured_name
current_id = session.get("selected_device_id")
return jsonify(
devices=devices,
selected_device_id=current_id,
configured_device_name=configured_name,
)
@app.post("/api/v1/portal/select-device")
@require_login
def portal_select_device():
"""Set the active device for the current session."""
body = request.get_json(silent=True) or {}
device_id = body.get("device_id")
if device_id is None:
session.pop("selected_device_id", None)
session.pop("selected_device_name", None)
return jsonify(status="ok", message="Device selection cleared.")
dev = db.get_device_by_id(int(device_id))
if dev is None:
return jsonify(error="Device not found"), 404
session["selected_device_id"] = dev["id"]
session["selected_device_name"] = dev["device_name"] or dev["name"]
return jsonify(status="ok", device_id=dev["id"], device_name=dev["device_name"] or dev["name"])
@app.get("/health")
def health():
"""Unauthenticated liveness check - used by docker-compose healthcheck
+34 -2
View File
@@ -86,7 +86,12 @@
<span class="fw-bold text-dark">Yaris OBD</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.6rem;">Remote</span>
</a>
<div class="ms-auto d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" id="portal-device-selector" style="width:auto;min-width:10rem;" aria-label="Select device">
<option value="">Loading…</option>
</select>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small d-none d-sm-inline">{{ current_user.username }}</span>
<a href="{{ url_for('logout') }}" class="btn btn-outline-secondary btn-sm" title="Logout">
<i class="fas fa-sign-out-alt fa-fw"></i>
@@ -136,7 +141,34 @@
<script src="{{ url_for('static', filename='js/bs-init.js') }}"></script>
<script src="{{ url_for('static', filename='js/theme.js') }}"></script>
{# ── Shared confirmation helper ────────────────────────────────────────── #}
{# ── Device selector ─────────────────────────────────────────────────── #}
<script>
(function () {
const sel = document.getElementById("portal-device-selector");
if (!sel) return;
async function load() {
const r = await fetch("/api/v1/portal/select-device");
if (!r.ok) return;
const d = await r.json();
sel.innerHTML = d.devices.map(dev =>
'<option value="' + dev.id + '"' + (dev.id === d.selected_device_id ? " selected" : "") + ">" +
(dev.device_name || dev.name) + (dev.portal_default ? " (default)" : "") +
"</option>"
).join("");
if (!d.devices.length) sel.innerHTML = '<option value="">No devices</option>';
}
load();
sel.addEventListener("change", async function () {
const id = sel.value ? parseInt(sel.value) : null;
await fetch("/api/v1/portal/select-device", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({device_id: id}),
});
location.reload();
});
})();
</script>
<script>
window.confirmDelete = function(message) {
return Promise.resolve(window.confirm(message));